refactor(deploy): improved TUI walkthrough
This commit is contained in:
20 files changed
+1580
-935
No files matched your search
+14
-8
@@ -125,7 +125,7 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, accountableOSUser(), adminExists)
|
||||
res, err := runBreakGlassTUI(ctx, repo, cfg.Database.URL, cfg.Server.RootDomain, cfg.Auth.AdminHostname, cfg.Auth.PanelHostname, cfg.Auth.AccessJWTAud, accountableOSUser(), adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis breakGlass: %v\n", err)
|
||||
return 1
|
||||
@@ -411,7 +411,13 @@ type breakGlassResult struct {
|
||||
adminHostname string
|
||||
panelURL string
|
||||
|
||||
// optional Cloudflare edge outcome (independent of provisioned)
|
||||
// connection outcome (independent of provisioned)
|
||||
connectMethod connectMethod
|
||||
connectConfigured bool
|
||||
panelHostname string
|
||||
reverseProxyGuide string
|
||||
|
||||
// Cloudflare-specific edge detail (set only when connectMethod is Cloudflare)
|
||||
edgeConfigured bool
|
||||
edgeAud string
|
||||
edgeRoutedHosts []string
|
||||
@@ -438,16 +444,16 @@ const (
|
||||
cloudflareAPITokenDocsURL = "https://developers.cloudflare.com/fundamentals/api/how-to/account-owned-token-template/"
|
||||
)
|
||||
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeBreakGlass)
|
||||
func runBreakGlassTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeBreakGlass)
|
||||
}
|
||||
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, consoleModeSetup)
|
||||
func runSetupTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool) (breakGlassResult, error) {
|
||||
return runConsoleTUI(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, consoleModeSetup)
|
||||
}
|
||||
|
||||
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
|
||||
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, osUser, adminExists, mode)
|
||||
func runConsoleTUI(ctx context.Context, s ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) (breakGlassResult, error) {
|
||||
rm := newRootModel(ctx, s, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser, adminExists, mode)
|
||||
final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run()
|
||||
if err != nil {
|
||||
return breakGlassResult{}, err
|
||||
|
||||
+18
-11
@@ -84,7 +84,7 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
defer setup.drv.Close()
|
||||
|
||||
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, accountableOSUser(), setup.adminExists)
|
||||
res, err := runSetupTUI(ctx, setup.repo, setup.cfg.Database.URL, setup.cfg.Server.RootDomain, setup.cfg.Auth.AdminHostname, setup.cfg.Auth.PanelHostname, setup.cfg.Auth.AccessJWTAud, accountableOSUser(), setup.adminExists)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "felis setup: %v\n", err)
|
||||
return 1
|
||||
@@ -94,9 +94,9 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
panelURL = localPanelURL(setup.cfg.Server.RootDomain)
|
||||
}
|
||||
|
||||
if !res.provisioned && !res.edgeConfigured {
|
||||
if !res.provisioned && !res.connectConfigured {
|
||||
if bootstrapped {
|
||||
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/edge setup skipped.")
|
||||
fmt.Fprintln(stdout, "felis setup: host bootstrap completed; Owner/connection setup skipped.")
|
||||
if panelURL != "" {
|
||||
fmt.Fprintf(stdout, "Panel: %s\n", panelURL)
|
||||
fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
|
||||
@@ -127,15 +127,22 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
}
|
||||
|
||||
if res.edgeConfigured {
|
||||
fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access edge configured.\n")
|
||||
if len(res.edgeRoutedHosts) > 0 {
|
||||
fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", "))
|
||||
if res.connectConfigured {
|
||||
switch res.connectMethod {
|
||||
case connectCloudflare:
|
||||
fmt.Fprintf(stdout, "\nfelis setup: Cloudflare Tunnel + Access configured.\n")
|
||||
if len(res.edgeRoutedHosts) > 0 {
|
||||
fmt.Fprintf(stdout, "Routed web hostnames: %s\n", strings.Join(res.edgeRoutedHosts, ", "))
|
||||
}
|
||||
if res.edgeConfigPath != "" {
|
||||
fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath)
|
||||
}
|
||||
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.")
|
||||
case connectReverseProxy:
|
||||
fmt.Fprintf(stdout, "\nfelis setup: reverse-proxy front configured. Point your proxy at the origin:\n\n")
|
||||
fmt.Fprintln(stdout, res.reverseProxyGuide)
|
||||
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret and API rollout were updated.")
|
||||
}
|
||||
if res.edgeConfigPath != "" {
|
||||
fmt.Fprintf(stdout, "Wrote tunnel config: %s\n", res.edgeConfigPath)
|
||||
}
|
||||
fmt.Fprintln(stdout, "Felis config, Kubernetes Secret, API rollout and cloudflared service were updated.")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (m *hostBootstrapModel) View() string {
|
||||
b.WriteString(tuiInfo("The terminal is handed to the installer until it finishes.") + "\n")
|
||||
case hostBootstrapDone:
|
||||
b.WriteString(tuiSuccessBanner("Host bootstrap completed.") + "\n\n")
|
||||
b.WriteString(tuiInfo("Continue to create the Owner account and optional Cloudflare edge.") + "\n")
|
||||
b.WriteString(tuiInfo("Continue to create the Owner account and choose how the panel is reached.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "continue", "esc", "continue"))
|
||||
case hostBootstrapError:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (m *edgeModel) View() string {
|
||||
|
||||
switch m.step {
|
||||
case egIntro:
|
||||
b.WriteString(tuiHint.Render("Publish web faces securely via Cloudflare Tunnel + Access.") + "\n\n")
|
||||
b.WriteString(tuiHint.Render("Publish the panel via Cloudflare Tunnel + Access — no open ports, TLS and admin identity handled by Cloudflare. Press esc to pick a different method.") + "\n\n")
|
||||
b.WriteString(tuiLabel.Render("Status") + "\n")
|
||||
if m.cloudflaredPath == "" {
|
||||
b.WriteString(" " + tuiErr.Render("✗ cloudflared not installed") + " — press i to install\n\n")
|
||||
@@ -276,7 +276,7 @@ func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
}
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
return m, goBack()
|
||||
case "enter":
|
||||
if m.step == egDone {
|
||||
return m, m.sendEdgeResult()
|
||||
@@ -297,7 +297,7 @@ func (m *edgeModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
func (m *edgeModel) handleIntroKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
return m, goBack()
|
||||
case "i", "I":
|
||||
if m.cloudflaredPath == "" {
|
||||
m.installing = true
|
||||
@@ -531,8 +531,9 @@ func (m *edgeModel) runEdgeSetup(runner cfsetup.Runner, p cfsetup.Params) tea.Cm
|
||||
|
||||
func (m *edgeModel) sendEdgeResult() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return edgeResultMsg{
|
||||
result: m.result,
|
||||
return connectResultMsg{
|
||||
method: connectCloudflare,
|
||||
edge: m.result,
|
||||
panelHostname: m.panelSet,
|
||||
adminHostname: m.adminSet,
|
||||
}
|
||||
|
||||
@@ -24,10 +24,8 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
|
||||
if adminHost == "" {
|
||||
return fmt.Errorf("admin hostname is required")
|
||||
}
|
||||
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
||||
if err := updateAuthConfig(path, panelHost, adminHost, result.AccessAud); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeConnectionConfig(panelHost, adminHost, result.AccessAud); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyFelisConfigSecret(ctx); err != nil {
|
||||
return err
|
||||
@@ -41,6 +39,38 @@ func applyCloudflareEdge(ctx context.Context, result *cfsetup.Result, panelHost,
|
||||
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
||||
}
|
||||
|
||||
// applyReverseProxy records the operator's chosen public hostnames and rolls the
|
||||
// API so the panel serves them. No Access audience is set: the admin console is
|
||||
// gated by the Owner's local-password session, and the operator's own reverse
|
||||
// proxy (Caddy/nginx/Traefik/…) terminates TLS in front of the NodePort origin.
|
||||
func applyReverseProxy(ctx context.Context, panelHost, adminHost string) error {
|
||||
if adminHost == "" {
|
||||
return fmt.Errorf("admin hostname is required")
|
||||
}
|
||||
if err := writeConnectionConfig(panelHost, adminHost, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyFelisConfigSecret(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := kubectl(ctx, "-n", "felis", "rollout", "restart", "deployment/felis-api"); err != nil {
|
||||
return err
|
||||
}
|
||||
return kubectl(ctx, "-n", "felis", "rollout", "status", "deployment/felis-api", "--timeout=180s")
|
||||
}
|
||||
|
||||
// writeConnectionConfig stamps the chosen hostnames (and optional Access audience)
|
||||
// into both the host and pod config files. An empty aud clears any prior
|
||||
// Cloudflare audience, which is correct when switching to a non-Access front.
|
||||
func writeConnectionConfig(panelHost, adminHost, aud string) error {
|
||||
for _, path := range []string{hostSetupConfigPath, podSetupConfigPath} {
|
||||
if err := updateAuthConfig(path, panelHost, adminHost, aud); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateAuthConfig(path, panelHost, adminHost, aud string) error {
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-119
@@ -6,131 +6,31 @@ import (
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type migRunMsg struct {
|
||||
n int
|
||||
total int
|
||||
err error
|
||||
}
|
||||
|
||||
type migrationModel struct {
|
||||
dbURL string
|
||||
osUser string
|
||||
|
||||
state string
|
||||
applied int
|
||||
total int
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func newMigrationModel(dbURL, osUser string) *migrationModel {
|
||||
return &migrationModel{
|
||||
dbURL: dbURL,
|
||||
osUser: osUser,
|
||||
state: "checking",
|
||||
// applyMigrations runs any pending schema migrations and returns the resulting
|
||||
// applied count. Used by the preflight stage to self-heal a freshly bootstrapped
|
||||
// (or upgraded) database.
|
||||
func applyMigrations(dbURL string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *migrationModel) Init() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
n, err := countMigrations(m.dbURL)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
total, err := totalMigrations()
|
||||
return migRunMsg{n: n, total: total, err: err}
|
||||
defer drv.Close()
|
||||
migrations, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
func (m *migrationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case migRunMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.applied = msg.n
|
||||
m.total = msg.total
|
||||
if m.applied < m.total {
|
||||
m.state = "pending"
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return migrationDoneMsg{n: msg.n} }
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "enter":
|
||||
if m.state == "pending" {
|
||||
m.state = "running"
|
||||
return m, runMigrationsCmd(m.dbURL)
|
||||
}
|
||||
}
|
||||
if _, err := store.Up(ctx, drv, migrations); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *migrationModel) View() string {
|
||||
var b string
|
||||
b += tuiHeader("Database Migrations") + "\n"
|
||||
|
||||
switch m.state {
|
||||
case "checking":
|
||||
b += tuiHint.Render("Checking migration status…") + "\n"
|
||||
case "pending":
|
||||
b += tuiWarn.Render(fmt.Sprintf("%d of %d migrations applied. %d pending.", m.applied, m.total, m.total-m.applied)) + "\n\n"
|
||||
b += tuiInfo("Press enter to run pending migrations.") + "\n"
|
||||
case "running":
|
||||
b += tuiHint.Render("Running migrations…") + "\n"
|
||||
b += tuiInfo("This should take only a moment.") + "\n"
|
||||
case "error":
|
||||
b += tuiErr.Render("Migration check failed:") + "\n"
|
||||
b += tuiHint.Render(m.lastErr.Error()) + "\n"
|
||||
default:
|
||||
b += tuiOK.Render(fmt.Sprintf("✓ %d migrations applied.", m.applied)) + "\n"
|
||||
}
|
||||
|
||||
b += "\n"
|
||||
b += tuiSeparator() + "\n"
|
||||
switch m.state {
|
||||
case "pending":
|
||||
b += tuiAction("enter", "run", "esc", "back")
|
||||
case "running":
|
||||
b += tuiAction("esc", "cancel")
|
||||
default:
|
||||
b += tuiAction("esc", "back")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func runMigrationsCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
drv, err := store.Open(ctx, dbURL)
|
||||
if err != nil {
|
||||
return migRunMsg{err: fmt.Errorf("open db: %w", err)}
|
||||
}
|
||||
defer drv.Close()
|
||||
migrations, err := store.LoadMigrations()
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
_, err = store.Up(ctx, drv, migrations)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
applied, err := drv.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return migRunMsg{err: err}
|
||||
}
|
||||
return migRunMsg{n: len(applied), total: len(migrations)}
|
||||
applied, err := drv.AppliedVersions(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(applied), nil
|
||||
}
|
||||
|
||||
func totalMigrations() (int, error) {
|
||||
|
||||
+246
-239
@@ -2,11 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
)
|
||||
|
||||
type owAuthMsg struct {
|
||||
@@ -31,6 +33,10 @@ const (
|
||||
owError
|
||||
)
|
||||
|
||||
// ownerModel collects the owner account. The input phases (admin auth, root
|
||||
// override, owner details) are huh forms; the async phases (verifying,
|
||||
// provisioning) show a spinner; the done phase shows the credential card. The
|
||||
// outward contract is unchanged: it emits an ownerResultMsg when finished.
|
||||
type ownerModel struct {
|
||||
ctx context.Context
|
||||
store ownerStore
|
||||
@@ -38,13 +44,23 @@ type ownerModel struct {
|
||||
adminExists bool
|
||||
mode string // "bootstrap", "recovery", "root_override"
|
||||
accountable string
|
||||
attempt string
|
||||
|
||||
step owStep
|
||||
inputs []textinput.Model
|
||||
focus int
|
||||
formErr string
|
||||
form *huh.Form
|
||||
sp spinner.Model
|
||||
working string
|
||||
attempt string
|
||||
|
||||
width, height int
|
||||
|
||||
// huh-bound form values
|
||||
authUser string
|
||||
authPass string
|
||||
overrideTok string
|
||||
ownerUser string
|
||||
ownerEmail string
|
||||
ownerPass string
|
||||
ownerConfirm string
|
||||
|
||||
username string
|
||||
displayPassword string
|
||||
@@ -52,49 +68,72 @@ type ownerModel struct {
|
||||
}
|
||||
|
||||
func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = tuiLabel
|
||||
|
||||
m := &ownerModel{
|
||||
ctx: ctx,
|
||||
store: store,
|
||||
osUser: osUser,
|
||||
adminExists: adminExists,
|
||||
sp: sp,
|
||||
ownerUser: "owner",
|
||||
}
|
||||
if adminExists {
|
||||
m.step = owAuth
|
||||
m.form = m.buildAuthForm()
|
||||
} else {
|
||||
m.mode = "bootstrap"
|
||||
m.accountable = osUser
|
||||
m.step = owProvision
|
||||
m.form = m.buildProvisionForm()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *ownerModel) Init() tea.Cmd {
|
||||
if m.step == owAuth {
|
||||
return m.buildAuth()
|
||||
func (m *ownerModel) Init() tea.Cmd { return m.form.Init() }
|
||||
|
||||
func (m *ownerModel) setSize(w, h int) {
|
||||
m.width, m.height = w, h
|
||||
if m.form != nil {
|
||||
m.form = m.form.WithWidth(w).WithHeight(h)
|
||||
}
|
||||
return m.buildProvision(true)
|
||||
}
|
||||
|
||||
// sized applies the current terminal area to a freshly built form so phase
|
||||
// transitions don't reset back to huh's default 80-column layout.
|
||||
func (m *ownerModel) sized(f *huh.Form) *huh.Form {
|
||||
if m.width > 0 {
|
||||
return f.WithWidth(m.width).WithHeight(m.height)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (m *ownerModel) isFormStep() bool {
|
||||
return m.step == owAuth || m.step == owOverride || m.step == owProvision
|
||||
}
|
||||
|
||||
func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case owAuthMsg:
|
||||
m.working = ""
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} }
|
||||
return m, m.failCmd(msg.err)
|
||||
}
|
||||
if msg.ok {
|
||||
m.mode = "recovery"
|
||||
m.accountable = msg.matched
|
||||
m.step = owProvision
|
||||
return m, m.buildProvision(false)
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
m.step = owOverride
|
||||
m.formErr = ""
|
||||
return m, m.buildOverride()
|
||||
m.form = m.sized(m.buildOverrideForm())
|
||||
return m, m.form.Init()
|
||||
|
||||
case owProvisionMsg:
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return ownerResultMsg{err: msg.err} }
|
||||
return m, m.failCmd(msg.err)
|
||||
}
|
||||
m.step = owDone
|
||||
m.displayPassword = msg.outcome.displayPassword
|
||||
@@ -103,157 +142,107 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.step == owWorking {
|
||||
var cmd tea.Cmd
|
||||
m.sp, cmd = m.sp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.step {
|
||||
case owDone, owError:
|
||||
case owDone:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc", "enter":
|
||||
if m.step == owDone {
|
||||
return m, m.ownerResultCmd()
|
||||
}
|
||||
return m, nil
|
||||
return m, m.ownerResultCmd()
|
||||
}
|
||||
return m, nil
|
||||
case owWorking:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
return m.handleFormKey(msg)
|
||||
default: // form steps — intercept cancel/back, let huh handle the rest
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
if m.step == owOverride {
|
||||
m.step = owAuth
|
||||
m.form = m.sized(m.buildAuthForm())
|
||||
return m, m.form.Init()
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
// Forward to inputs.
|
||||
if m.step != owWorking && m.step != owDone && m.step != owError {
|
||||
return m, m.updateInputs(msg)
|
||||
|
||||
// Drive the active form.
|
||||
if m.isFormStep() && m.form != nil {
|
||||
form, cmd := m.form.Update(msg)
|
||||
if f, ok := form.(*huh.Form); ok {
|
||||
m.form = f
|
||||
}
|
||||
switch m.form.State {
|
||||
case huh.StateCompleted:
|
||||
return m.onFormComplete()
|
||||
case huh.StateAborted:
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ownerModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Owner Account"))
|
||||
|
||||
func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
|
||||
switch m.step {
|
||||
case owAuth:
|
||||
b.WriteString(tuiHint.Render("A staff account exists. Identify yourself before proceeding.") + "\n\n")
|
||||
b.WriteString(tuiWizardCard("Admin Authentication", "",
|
||||
tuiFormField("Admin username", m.inputs[0])+"\n\n"+
|
||||
tuiFormField("Admin password", m.inputs[1])))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "verify", "esc", "cancel"))
|
||||
|
||||
m.attempt = strings.TrimSpace(m.authUser)
|
||||
m.step = owWorking
|
||||
m.working = "Verifying admin credential…"
|
||||
user, pass := m.authUser, m.authPass
|
||||
return m, tea.Batch(m.sp.Tick, func() tea.Msg {
|
||||
matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass)
|
||||
return owAuthMsg{matched: matched, ok: ok, err: err}
|
||||
})
|
||||
case owOverride:
|
||||
b.WriteString(tuiErrorBanner("That credential did not match.") + "\n\n")
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("You can proceed as OS user %q with root authority.", m.osUser)) + "\n\n")
|
||||
b.WriteString(tuiWizardCard("Root Override", "",
|
||||
tuiFormField("Type "+breakGlassOverrideToken+" to confirm", m.inputs[0])))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter", "confirm", "esc", "go back"))
|
||||
|
||||
m.mode = "root_override"
|
||||
m.accountable = m.osUser
|
||||
m.step = owProvision
|
||||
m.form = m.sized(m.buildProvisionForm())
|
||||
return m, m.form.Init()
|
||||
case owProvision:
|
||||
if m.mode == "bootstrap" {
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("Creating the first Owner. Recorded as OS user %q.", m.osUser)) + "\n\n")
|
||||
} else if m.mode == "root_override" {
|
||||
b.WriteString(tuiWarn.Render("Root override — a one-time password will be generated.") + "\n\n")
|
||||
} else {
|
||||
b.WriteString(tuiHint.Render(fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)) + "\n\n")
|
||||
}
|
||||
var fields string
|
||||
fields = tuiFormField("Owner username", m.inputs[0]) + "\n\n"
|
||||
fields += tuiFormField("Owner email (optional)", m.inputs[1])
|
||||
if m.mode == "bootstrap" {
|
||||
fields += "\n\n" + tuiFormField("Owner password", m.inputs[2])
|
||||
fields += "\n\n" + tuiFormField("Confirm password", m.inputs[3])
|
||||
}
|
||||
b.WriteString(tuiWizardCard("Account Details", "", fields))
|
||||
if m.formErr != "" {
|
||||
b.WriteString("\n" + tuiErrorBanner(m.formErr) + "\n")
|
||||
}
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("tab/↑↓", "move", "enter", "provision", "esc", "cancel"))
|
||||
|
||||
case owWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Working…"
|
||||
}
|
||||
b.WriteString(tuiHint.Render(msg) + "\n")
|
||||
|
||||
case owDone:
|
||||
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
|
||||
var box strings.Builder
|
||||
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
|
||||
if m.displayPassword != "" {
|
||||
box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n")
|
||||
box.WriteString(tuiWarn.Render("Record this password — it is shown only once.") + "\n")
|
||||
} else {
|
||||
box.WriteString(tuiHint.Render("Log in with the password you entered.") + "\n")
|
||||
}
|
||||
if m.auditWarning != "" {
|
||||
box.WriteString("\n" + tuiWarn.Render("Audit warning: "+m.auditWarning) + "\n")
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
|
||||
b.WriteString(tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("enter/esc", "back"))
|
||||
|
||||
case owError:
|
||||
b.WriteString(tuiErrorBanner("Owner provisioning failed.") + "\n")
|
||||
b.WriteString("\n" + tuiSeparator() + "\n")
|
||||
b.WriteString(tuiAction("esc", "exit"))
|
||||
m.username = strings.TrimSpace(m.ownerUser)
|
||||
m.step = owWorking
|
||||
m.working = "Provisioning Owner account…"
|
||||
return m, tea.Batch(m.sp.Tick, m.provisionCmd())
|
||||
}
|
||||
return b.String()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ownerModel) handleFormKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
if m.step == owOverride {
|
||||
m.step, m.formErr = owAuth, ""
|
||||
return m, m.buildAuth()
|
||||
}
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "tab", "down":
|
||||
m.focus = m.focus + 1
|
||||
if m.focus >= len(m.inputs) {
|
||||
m.focus = 0
|
||||
}
|
||||
return m, m.focusInput(m.focus)
|
||||
case "shift+tab", "up":
|
||||
m.focus = m.focus - 1
|
||||
if m.focus < 0 {
|
||||
m.focus = len(m.inputs) - 1
|
||||
}
|
||||
return m, m.focusInput(m.focus)
|
||||
case "enter":
|
||||
return m.submit()
|
||||
func (m *ownerModel) provisionCmd() tea.Cmd {
|
||||
password := ""
|
||||
if m.mode == "bootstrap" {
|
||||
password = m.ownerPass
|
||||
}
|
||||
op := breakGlassOp{
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
osUser: m.osUser,
|
||||
ownerUsername: m.username,
|
||||
ownerEmail: m.ownerEmail,
|
||||
ownerPassword: password,
|
||||
attemptedAdmin: m.attempt,
|
||||
}
|
||||
return func() tea.Msg {
|
||||
out, err := performBreakGlass(m.ctx, m.store, op)
|
||||
return owProvisionMsg{outcome: out, err: err}
|
||||
}
|
||||
return m, m.updateInputs(msg)
|
||||
}
|
||||
|
||||
func (m *ownerModel) focusInput(i int) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
for j := range m.inputs {
|
||||
if j == i {
|
||||
cmd = m.inputs[j].Focus()
|
||||
} else {
|
||||
m.inputs[j].Blur()
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *ownerModel) updateInputs(msg tea.Msg) tea.Cmd {
|
||||
cmds := make([]tea.Cmd, len(m.inputs))
|
||||
for i := range m.inputs {
|
||||
m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
func (m *ownerModel) failCmd(err error) tea.Cmd {
|
||||
return func() tea.Msg { return ownerResultMsg{err: err} }
|
||||
}
|
||||
|
||||
func (m *ownerModel) ownerResultCmd() tea.Cmd {
|
||||
@@ -268,113 +257,131 @@ func (m *ownerModel) ownerResultCmd() tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) setInputs(ins []textinput.Model) tea.Cmd {
|
||||
m.inputs = ins
|
||||
m.focus = 0
|
||||
return m.focusInput(0)
|
||||
// ---- form builders ----
|
||||
|
||||
func (m *ownerModel) buildAuthForm() *huh.Form {
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Admin authentication").
|
||||
Description("A staff account already exists. Identify yourself to continue."),
|
||||
huh.NewInput().
|
||||
Title("Admin username").
|
||||
Value(&m.authUser).
|
||||
Validate(requiredField("admin username")),
|
||||
huh.NewInput().
|
||||
Title("Admin password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&m.authPass).
|
||||
Validate(requiredField("admin password")),
|
||||
)))
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildAuth() tea.Cmd {
|
||||
user := tuiInput("admin username", 64, false)
|
||||
pass := tuiInput("admin password", 128, true)
|
||||
return m.setInputs([]textinput.Model{user, pass})
|
||||
func (m *ownerModel) buildOverrideForm() *huh.Form {
|
||||
return m.sized(newFelisForm(huh.NewGroup(
|
||||
huh.NewNote().
|
||||
Title("Root override").
|
||||
Description(fmt.Sprintf("That credential did not match. Proceed as OS user %q with root authority by typing the confirmation token.", m.osUser)),
|
||||
huh.NewInput().
|
||||
Title("Type "+breakGlassOverrideToken+" to confirm").
|
||||
Value(&m.overrideTok).
|
||||
Validate(func(s string) error {
|
||||
if s != breakGlassOverrideToken {
|
||||
return errors.New("type " + breakGlassOverrideToken + " exactly to proceed")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)))
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildOverride() tea.Cmd {
|
||||
confirm := tuiInput("type "+breakGlassOverrideToken, 16, false)
|
||||
return m.setInputs([]textinput.Model{confirm})
|
||||
}
|
||||
|
||||
func (m *ownerModel) buildProvision(withPassword bool) tea.Cmd {
|
||||
user := tuiInput("owner", 64, false)
|
||||
user.SetValue("owner")
|
||||
email := tuiInput("(optional)", 254, false)
|
||||
ins := []textinput.Model{user, email}
|
||||
if withPassword {
|
||||
ins = append(ins, tuiInput("at least 8 characters", 128, true))
|
||||
ins = append(ins, tuiInput("re-enter password", 128, true))
|
||||
func (m *ownerModel) buildProvisionForm() *huh.Form {
|
||||
desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
|
||||
switch m.mode {
|
||||
case "recovery":
|
||||
desc = fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable)
|
||||
case "root_override":
|
||||
desc = "Root override — a one-time password will be generated."
|
||||
}
|
||||
return m.setInputs(ins)
|
||||
}
|
||||
|
||||
func (m *ownerModel) submit() (tea.Model, tea.Cmd) {
|
||||
switch m.step {
|
||||
case owAuth:
|
||||
return m.submitAuth()
|
||||
case owOverride:
|
||||
return m.submitOverride()
|
||||
case owProvision:
|
||||
return m.submitProvision()
|
||||
fields := []huh.Field{
|
||||
huh.NewNote().Title("Owner account").Description(desc),
|
||||
huh.NewInput().
|
||||
Title("Owner username").
|
||||
Value(&m.ownerUser).
|
||||
Validate(requiredField("owner username")),
|
||||
huh.NewInput().
|
||||
Title("Owner email").
|
||||
Description("optional").
|
||||
Placeholder("[email protected]").
|
||||
Value(&m.ownerEmail),
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ownerModel) submitAuth() (tea.Model, tea.Cmd) {
|
||||
user := strings.TrimSpace(m.inputs[0].Value())
|
||||
pass := m.inputs[1].Value()
|
||||
if user == "" || pass == "" {
|
||||
m.formErr = "enter the username and password of an existing admin"
|
||||
return m, nil
|
||||
}
|
||||
m.attempt = user
|
||||
m.formErr, m.working = "", "Verifying admin credential…"
|
||||
m.step = owWorking
|
||||
return m, func() tea.Msg {
|
||||
matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass)
|
||||
return owAuthMsg{matched: matched, ok: ok, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) submitOverride() (tea.Model, tea.Cmd) {
|
||||
if m.inputs[0].Value() != breakGlassOverrideToken {
|
||||
m.formErr = "type " + breakGlassOverrideToken + " exactly to proceed"
|
||||
return m, nil
|
||||
}
|
||||
m.mode = "root_override"
|
||||
m.accountable = m.osUser
|
||||
m.step = owProvision
|
||||
return m, m.buildProvision(false)
|
||||
}
|
||||
|
||||
func (m *ownerModel) submitProvision() (tea.Model, tea.Cmd) {
|
||||
owner := strings.TrimSpace(m.inputs[0].Value())
|
||||
if owner == "" {
|
||||
m.formErr = "owner username is required"
|
||||
return m, m.focusForField(0)
|
||||
}
|
||||
email := m.inputs[1].Value()
|
||||
password := ""
|
||||
if m.mode == "bootstrap" {
|
||||
pw := m.inputs[2].Value()
|
||||
confirm := m.inputs[3].Value()
|
||||
if err := validateOwnerPassword(pw); err != nil {
|
||||
m.formErr = err.Error()
|
||||
return m, m.focusForField(2)
|
||||
}
|
||||
if pw != confirm {
|
||||
m.formErr = "the two passwords do not match"
|
||||
return m, m.focusForField(3)
|
||||
}
|
||||
password = pw
|
||||
fields = append(fields,
|
||||
huh.NewInput().
|
||||
Title("Owner password").
|
||||
Description("at least 8 characters").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&m.ownerPass).
|
||||
Validate(validateOwnerPassword),
|
||||
huh.NewInput().
|
||||
Title("Confirm password").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&m.ownerConfirm).
|
||||
Validate(func(s string) error {
|
||||
if s != m.ownerPass {
|
||||
return errors.New("the two passwords do not match")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
m.username = owner
|
||||
m.formErr, m.working = "", "Provisioning Owner account…"
|
||||
m.step = owWorking
|
||||
return m, func() tea.Msg {
|
||||
out, err := performBreakGlass(m.ctx, m.store, breakGlassOp{
|
||||
mode: m.mode,
|
||||
accountable: m.accountable,
|
||||
osUser: m.osUser,
|
||||
ownerUsername: owner,
|
||||
ownerEmail: email,
|
||||
ownerPassword: password,
|
||||
attemptedAdmin: m.attempt,
|
||||
})
|
||||
return owProvisionMsg{outcome: out, err: err}
|
||||
return m.sized(newFelisForm(huh.NewGroup(fields...)))
|
||||
}
|
||||
|
||||
func requiredField(name string) func(string) error {
|
||||
return func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New(name + " is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) focusForField(i int) tea.Cmd {
|
||||
m.focus = i
|
||||
return m.focusInput(i)
|
||||
// ---- views ----
|
||||
|
||||
func (m *ownerModel) View() string {
|
||||
switch m.step {
|
||||
case owWorking:
|
||||
msg := m.working
|
||||
if msg == "" {
|
||||
msg = "Working…"
|
||||
}
|
||||
return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n"
|
||||
case owDone:
|
||||
return m.doneView()
|
||||
default:
|
||||
if m.form == nil {
|
||||
return ""
|
||||
}
|
||||
return m.form.View()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ownerModel) doneView() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n")
|
||||
|
||||
var box strings.Builder
|
||||
box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
|
||||
if m.displayPassword != "" {
|
||||
box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n")
|
||||
box.WriteString(tuiWarn.Render("Record this password — it is shown only once."))
|
||||
} else {
|
||||
box.WriteString(tuiHint.Render("Log in with the password you entered."))
|
||||
}
|
||||
if m.auditWarning != "" {
|
||||
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
|
||||
}
|
||||
b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n")
|
||||
b.WriteString(tuiAction("enter", "continue"))
|
||||
return b.String()
|
||||
}
|
||||
+3
-172
@@ -4,138 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/store"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type pgCheckMsg struct {
|
||||
running bool
|
||||
reachable bool
|
||||
err error
|
||||
}
|
||||
|
||||
type pgInstallMsg struct{ err error }
|
||||
|
||||
type pgCreateMsg struct{ err error }
|
||||
|
||||
type postgresModel struct {
|
||||
dbURL string
|
||||
osUser string
|
||||
|
||||
state string // "checking", "missing", "installing", "creating", "done", "error"
|
||||
lastErr error
|
||||
pgExists bool
|
||||
}
|
||||
|
||||
func newPostgresModel(dbURL, osUser string) *postgresModel {
|
||||
return &postgresModel{
|
||||
dbURL: dbURL,
|
||||
osUser: osUser,
|
||||
state: "checking",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *postgresModel) Init() tea.Cmd {
|
||||
return checkPostgresCmd(m.dbURL)
|
||||
}
|
||||
|
||||
func (m *postgresModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case pgCheckMsg:
|
||||
if msg.err != nil || !msg.reachable {
|
||||
m.state = "missing"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pgDoneMsg{} }
|
||||
|
||||
case pgInstallMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.state = "creating"
|
||||
return m, createDatabaseCmd(m.dbURL)
|
||||
|
||||
case pgCreateMsg:
|
||||
if msg.err != nil {
|
||||
m.state = "error"
|
||||
m.lastErr = msg.err
|
||||
return m, nil
|
||||
}
|
||||
return m, func() tea.Msg { return pgDoneMsg{} }
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, func() tea.Msg { return switchToDashboard{} }
|
||||
case "i", "I":
|
||||
if m.state == "missing" {
|
||||
m.state = "installing"
|
||||
return m, installPostgresCmd()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *postgresModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiHeader("Database Setup"))
|
||||
|
||||
switch m.state {
|
||||
case "checking":
|
||||
b.WriteString(tuiHint.Render("Checking PostgreSQL status…") + "\n")
|
||||
case "missing":
|
||||
b.WriteString(tuiWarn.Render("PostgreSQL is not reachable.") + "\n\n")
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n\n")
|
||||
}
|
||||
b.WriteString(tuiInfo("Press i to install PostgreSQL, or esc to skip.") + "\n")
|
||||
case "installing":
|
||||
b.WriteString(tuiHint.Render("Installing PostgreSQL via apt…") + "\n")
|
||||
b.WriteString(tuiInfo("This may take up to a minute.") + "\n")
|
||||
case "creating":
|
||||
b.WriteString(tuiHint.Render("PostgreSQL installed. Creating database…") + "\n")
|
||||
case "done":
|
||||
b.WriteString(tuiOK.Render("✓ Database is ready.") + "\n")
|
||||
case "error":
|
||||
b.WriteString(tuiErr.Render("Failed to set up PostgreSQL:") + "\n")
|
||||
if m.lastErr != nil {
|
||||
b.WriteString(tuiHint.Render(m.lastErr.Error()) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(tuiSeparator())
|
||||
b.WriteString("\n")
|
||||
switch m.state {
|
||||
case "missing":
|
||||
b.WriteString(tuiAction("i", "install", "esc", "back"))
|
||||
case "done", "error":
|
||||
b.WriteString(tuiAction("esc", "back"))
|
||||
default:
|
||||
b.WriteString(tuiAction("esc", "cancel"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func checkPostgresCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := checkPostgres(dbURL)
|
||||
if err != nil {
|
||||
return pgCheckMsg{reachable: false, err: err}
|
||||
}
|
||||
return pgCheckMsg{reachable: true}
|
||||
}
|
||||
}
|
||||
|
||||
// checkPostgres proves the configured database is reachable and accepts a
|
||||
// connection. Host bootstrap provisions PostgreSQL, so in the normal setup flow
|
||||
// this succeeds immediately; the preflight stage uses it to fail fast otherwise.
|
||||
func checkPostgres(dbURL string) error {
|
||||
cfg, err := parseDBURL(dbURL)
|
||||
if err != nil {
|
||||
@@ -157,52 +34,6 @@ func checkPostgres(dbURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPostgresCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "apt-get", "install", "-y", "postgresql")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return pgInstallMsg{err: fmt.Errorf("%w: %s", err, string(out))}
|
||||
}
|
||||
// Start the service.
|
||||
start := exec.CommandContext(ctx, "systemctl", "restart", "postgresql")
|
||||
start.CombinedOutput()
|
||||
return pgInstallMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func createDatabaseCmd(dbURL string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cfg, err := parseDBURL(dbURL)
|
||||
if err != nil {
|
||||
return pgCreateMsg{err: err}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// Create user and database via PostgreSQL command line.
|
||||
cmds := [][]string{
|
||||
{"psql", "-c", fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s';", cfg.user, cfg.pass)},
|
||||
{"psql", "-c", fmt.Sprintf("CREATE DATABASE %s OWNER %s;", cfg.db, cfg.user)},
|
||||
{"psql", "-c", fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s;", cfg.db, cfg.user)},
|
||||
}
|
||||
for _, args := range cmds {
|
||||
cmd := exec.CommandContext(ctx, "su", append([]string{"-", "postgres", "-c"}, strings.Join(args, " "))...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Ignore "already exists" errors.
|
||||
s := string(out)
|
||||
if strings.Contains(s, "already exists") {
|
||||
continue
|
||||
}
|
||||
return pgCreateMsg{err: fmt.Errorf("%w: %s", err, s)}
|
||||
}
|
||||
}
|
||||
return pgCreateMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
type dbCfg struct {
|
||||
addr string
|
||||
user string
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+204
-148
@@ -2,21 +2,45 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"felis.lolicon.best/internal/cfsetup"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// sizeable is implemented by screens that need to know the terminal area
|
||||
// available to them (after the root reserves space for the step rail). huh-based
|
||||
// screens forward this to form.WithWidth/WithHeight; custom screens use it to
|
||||
// avoid overflowing the frame.
|
||||
type sizeable interface {
|
||||
setSize(width, height int)
|
||||
}
|
||||
|
||||
// ---- Connection methods ----
|
||||
|
||||
type connectMethod int
|
||||
|
||||
const (
|
||||
connectLocal connectMethod = iota
|
||||
connectCloudflare
|
||||
connectReverseProxy
|
||||
)
|
||||
|
||||
func connectMethodLabel(m connectMethod) string {
|
||||
switch m {
|
||||
case connectCloudflare:
|
||||
return "Cloudflare Tunnel + Access"
|
||||
case connectReverseProxy:
|
||||
return "Reverse proxy (your own front)"
|
||||
default:
|
||||
return "Local only (NodePort + self-signed TLS)"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Messages: sub-model → root ----
|
||||
|
||||
type pgDoneMsg struct{ err error }
|
||||
|
||||
type migrationDoneMsg struct {
|
||||
n int
|
||||
err error
|
||||
}
|
||||
type preflightDoneMsg struct{}
|
||||
|
||||
type ownerResultMsg struct {
|
||||
username string
|
||||
@@ -27,25 +51,45 @@ type ownerResultMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type edgeResultMsg struct {
|
||||
result *cfsetup.Result
|
||||
// connectResultMsg is emitted by every connection method (the chooser for
|
||||
// local, the edge model for Cloudflare, the reverse-proxy model for BYO). It is
|
||||
// the single, method-agnostic outcome the root advances on.
|
||||
type connectResultMsg struct {
|
||||
method connectMethod
|
||||
panelHostname string
|
||||
adminHostname string
|
||||
err error
|
||||
edge *cfsetup.Result // Cloudflare only
|
||||
guide string // reverse-proxy only
|
||||
}
|
||||
|
||||
type panelCheckMsg struct{ result panelAccessResult }
|
||||
// goBackMsg returns from a connection sub-screen to the chooser.
|
||||
type goBackMsg struct{}
|
||||
|
||||
// switchToDashboard tells the root to show the dashboard.
|
||||
type switchToDashboard struct{}
|
||||
func goBack() tea.Cmd { return func() tea.Msg { return goBackMsg{} } }
|
||||
|
||||
// reconfigureConnectMsg is sent from the re-run status screen to re-enter the
|
||||
// connection chooser.
|
||||
type reconfigureConnectMsg struct{}
|
||||
|
||||
// ---- rootModel: top-level session ----
|
||||
|
||||
type wizardStage int
|
||||
|
||||
const (
|
||||
stagePreflight wizardStage = iota
|
||||
stageOwner
|
||||
stageConnect
|
||||
stageSummary
|
||||
)
|
||||
|
||||
type rootModel struct {
|
||||
ctx context.Context
|
||||
|
||||
screen tea.Model // current active screen
|
||||
dashboard *dashboardModel // always preserved
|
||||
screen tea.Model
|
||||
stage wizardStage
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
result breakGlassResult
|
||||
err error
|
||||
@@ -57,10 +101,11 @@ type rootModel struct {
|
||||
rootDomain string
|
||||
adminHost string
|
||||
panelHost string
|
||||
accessAud string
|
||||
adminExists bool
|
||||
}
|
||||
|
||||
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
||||
func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, adminHostname, panelHostname, accessAud, osUser string, adminExists bool, mode consoleMode) *rootModel {
|
||||
rm := &rootModel{
|
||||
ctx: ctx,
|
||||
dbURL: dbURL,
|
||||
@@ -69,70 +114,48 @@ func newRootModel(ctx context.Context, store ownerStore, dbURL, rootDomain, admi
|
||||
rootDomain: rootDomain,
|
||||
adminHost: adminHostname,
|
||||
panelHost: panelHostname,
|
||||
accessAud: accessAud,
|
||||
adminExists: adminExists,
|
||||
mode: mode,
|
||||
dashboard: newDashboardModel(ctx, store, dbURL, osUser, rootDomain, adminHostname, panelHostname),
|
||||
result: breakGlassResult{
|
||||
osUser: osUser,
|
||||
rootDomain: rootDomain,
|
||||
adminHostname: adminHostname,
|
||||
},
|
||||
}
|
||||
rm.dashboard.adminExists = adminExists
|
||||
rm.refreshDashboard()
|
||||
if mode == consoleModeBreakGlass {
|
||||
rm.stage = stageOwner
|
||||
rm.screen = newOwnerModel(ctx, store, osUser, adminExists)
|
||||
} else {
|
||||
rm.screen = rm.dashboard
|
||||
rm.stage = stagePreflight
|
||||
rm.screen = newPreflightModel(dbURL, rootDomain)
|
||||
}
|
||||
return rm
|
||||
}
|
||||
|
||||
func (m *rootModel) Init() tea.Cmd {
|
||||
if m.mode == consoleModeSetup {
|
||||
return m.checkStatus()
|
||||
}
|
||||
return m.screen.Init()
|
||||
}
|
||||
|
||||
func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case pgDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.dashboard.pgStatus = statusFailed
|
||||
m.dashboard.pgDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
}
|
||||
m.dashboard.pgStatus = statusDone
|
||||
m.dashboard.pgDetail = "ready"
|
||||
m.showDashboard()
|
||||
return m, m.checkMigrations()
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m.pushSize()
|
||||
return m, nil
|
||||
|
||||
case migrationDoneMsg:
|
||||
if msg.err == nil {
|
||||
m.dashboard.mgStatus = statusDone
|
||||
m.dashboard.mgDetail = fmt.Sprintf("%d applied", msg.n)
|
||||
} else {
|
||||
m.dashboard.mgStatus = statusFailed
|
||||
m.dashboard.mgDetail = msg.err.Error()
|
||||
case preflightDoneMsg:
|
||||
if m.adminExists {
|
||||
// Re-run: setup already happened. Land on the status screen.
|
||||
return m.showStatus()
|
||||
}
|
||||
m.showDashboard()
|
||||
if msg.err != nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, m.checkPanel()
|
||||
m.stage = stageOwner
|
||||
return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
|
||||
|
||||
case ownerResultMsg:
|
||||
if msg.err != nil {
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.owStatus = statusFailed
|
||||
m.dashboard.owDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.result.provisioned = true
|
||||
m.result.username = msg.username
|
||||
@@ -144,122 +167,155 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.adminExists = true
|
||||
m.dashboard.adminExists = true
|
||||
m.dashboard.owStatus = statusDone
|
||||
m.dashboard.owDetail = msg.username
|
||||
m.showDashboard()
|
||||
return m, m.checkPanel()
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
|
||||
case panelCheckMsg:
|
||||
m.result.panelURL = msg.result.url
|
||||
if msg.result.err != nil {
|
||||
m.dashboard.paStatus = statusFailed
|
||||
m.dashboard.paDetail = msg.result.err.Error()
|
||||
} else {
|
||||
m.dashboard.paStatus = statusDone
|
||||
m.dashboard.paDetail = msg.result.url
|
||||
}
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
case connectResultMsg:
|
||||
m.applyConnectResult(msg)
|
||||
return m.showSummary()
|
||||
|
||||
case edgeResultMsg:
|
||||
if msg.err != nil {
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.egStatus = statusFailed
|
||||
m.dashboard.egDetail = msg.err.Error()
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
}
|
||||
m.result.edgeConfigured = true
|
||||
m.result.edgeAud = msg.result.AccessAud
|
||||
m.result.edgeRoutedHosts = msg.result.RoutedHostnames
|
||||
m.result.edgeConfigPath = msg.result.ConfigPath
|
||||
m.result.edgePanelHostname = msg.panelHostname
|
||||
m.result.edgeAdminHostname = msg.adminHostname
|
||||
if m.mode == consoleModeBreakGlass {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.dashboard.egStatus = statusDone
|
||||
m.dashboard.egDetail = "configured"
|
||||
m.showDashboard()
|
||||
return m, nil
|
||||
|
||||
case switchToDashboard:
|
||||
m.refreshDashboard()
|
||||
return m, m.checkStatus()
|
||||
case goBackMsg:
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
|
||||
case reconfigureConnectMsg:
|
||||
m.stage = stageConnect
|
||||
return m.adopt(newConnectChooserModel(m.rootDomain, m.adminHost, m.panelHost))
|
||||
}
|
||||
|
||||
if m.screen != nil {
|
||||
newScreen, cmd := m.screen.Update(msg)
|
||||
if newScreen != nil {
|
||||
if newScreen != m.screen {
|
||||
m.screen = newScreen
|
||||
return m, tea.Batch(cmd, m.screen.Init())
|
||||
}
|
||||
if newScreen != nil && newScreen != m.screen {
|
||||
adopted, initCmd := m.adopt(newScreen)
|
||||
return adopted, tea.Batch(cmd, initCmd)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *rootModel) View() string {
|
||||
if m.screen != nil {
|
||||
return m.screen.View()
|
||||
}
|
||||
return ""
|
||||
// adopt installs a new screen, hands it the current size, and returns its Init.
|
||||
// Centralizing screen swaps here guarantees every screen is sized before its
|
||||
// first View — the fix for the rail being clipped off the top of the frame.
|
||||
func (m *rootModel) adopt(s tea.Model) (tea.Model, tea.Cmd) {
|
||||
m.screen = s
|
||||
m.pushSize()
|
||||
return m, s.Init()
|
||||
}
|
||||
|
||||
func (m *rootModel) showDashboard() {
|
||||
m.screen = m.dashboard
|
||||
}
|
||||
|
||||
func (m *rootModel) refreshDashboard() {
|
||||
d := m.dashboard
|
||||
d.pgStatus = statusPending
|
||||
d.mgStatus = statusPending
|
||||
d.owStatus = statusOptional
|
||||
d.paStatus = statusPending
|
||||
d.egStatus = statusOptional
|
||||
if m.adminExists {
|
||||
d.owStatus = statusDone
|
||||
d.owDetail = "already exists (use breakGlass to reset)"
|
||||
// pushSize gives the active screen the area left after the step rail.
|
||||
func (m *rootModel) pushSize() {
|
||||
if m.height == 0 {
|
||||
return
|
||||
}
|
||||
if m.result.provisioned {
|
||||
d.owStatus = statusDone
|
||||
d.owDetail = m.result.username
|
||||
}
|
||||
if m.result.edgeConfigured {
|
||||
d.egStatus = statusDone
|
||||
d.egDetail = "configured"
|
||||
}
|
||||
if m.result.panelURL != "" {
|
||||
d.paStatus = statusDone
|
||||
d.paDetail = m.result.panelURL
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rootModel) checkStatus() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := checkPostgres(m.dbURL); err != nil {
|
||||
return pgDoneMsg{err: err}
|
||||
if s, ok := m.screen.(sizeable); ok {
|
||||
// Reserve one extra row: huh renders a hair taller than its WithHeight
|
||||
// budget (the help line sits outside it). Clipping the rail off the top is
|
||||
// the bug we're fixing, so we round the budget down — a blank bottom row is
|
||||
// invisible, an overflowed top is not.
|
||||
avail := m.height - m.chromeHeight() - 1
|
||||
if avail < 1 {
|
||||
avail = 1
|
||||
}
|
||||
return pgDoneMsg{}
|
||||
s.setSize(m.width, avail)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *rootModel) checkMigrations() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
n, err := countMigrations(m.dbURL)
|
||||
return migrationDoneMsg{n: n, err: err}
|
||||
// chromeHeight is the number of rows the root paints around the screen — the
|
||||
// rail plus its blank separator, or 0 when the rail is hidden.
|
||||
func (m *rootModel) chromeHeight() int {
|
||||
if m.mode != consoleModeSetup || m.adminExistsAtStart() {
|
||||
return 0
|
||||
}
|
||||
return lipgloss.Height(m.rail()) + 1
|
||||
}
|
||||
|
||||
func (m *rootModel) checkPanel() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return panelCheckMsg{result: checkPanelAccess(m.rootDomain)}
|
||||
func (m *rootModel) View() string {
|
||||
if m.screen == nil {
|
||||
return ""
|
||||
}
|
||||
if m.mode == consoleModeSetup && !m.adminExistsAtStart() {
|
||||
return m.rail() + "\n\n" + m.screen.View()
|
||||
}
|
||||
return m.screen.View()
|
||||
}
|
||||
|
||||
// adminExistsAtStart reports whether this run began with an Owner already
|
||||
// present (a re-run). The rail only makes sense for the first-run linear wizard.
|
||||
func (m *rootModel) adminExistsAtStart() bool {
|
||||
// adminExists flips true once we provision the Owner mid-run; the rail should
|
||||
// keep showing through the connect/summary stages of that same first run. So
|
||||
// only suppress the rail when the Owner pre-existed AND we never provisioned.
|
||||
return m.adminExists && !m.result.provisioned
|
||||
}
|
||||
|
||||
func (m *rootModel) rail() string {
|
||||
return tuiStepRail([]string{"Preflight", "Owner", "Connection", "Done"}, int(m.stage))
|
||||
}
|
||||
|
||||
// applyConnectResult records the chosen connection outcome onto the result.
|
||||
func (m *rootModel) applyConnectResult(msg connectResultMsg) {
|
||||
m.result.connectMethod = msg.method
|
||||
if msg.panelHostname != "" {
|
||||
m.result.panelHostname = msg.panelHostname
|
||||
}
|
||||
if msg.adminHostname != "" {
|
||||
m.result.adminHostname = msg.adminHostname
|
||||
}
|
||||
switch msg.method {
|
||||
case connectCloudflare:
|
||||
m.result.connectConfigured = true
|
||||
m.result.edgeConfigured = true
|
||||
if msg.edge != nil {
|
||||
m.result.edgeAud = msg.edge.AccessAud
|
||||
m.result.edgeRoutedHosts = msg.edge.RoutedHostnames
|
||||
m.result.edgeConfigPath = msg.edge.ConfigPath
|
||||
}
|
||||
case connectReverseProxy:
|
||||
m.result.connectConfigured = true
|
||||
m.result.reverseProxyGuide = msg.guide
|
||||
}
|
||||
m.result.panelURL = panelURLFor(msg.method, msg.panelHostname, m.rootDomain)
|
||||
}
|
||||
|
||||
func (m *rootModel) showSummary() (tea.Model, tea.Cmd) {
|
||||
m.stage = stageSummary
|
||||
routed := m.result.edgeRoutedHosts
|
||||
if len(routed) == 0 && m.result.connectMethod == connectReverseProxy && m.result.panelHostname != "" {
|
||||
routed = []string{m.result.panelHostname}
|
||||
}
|
||||
return m.adopt(&summaryModel{
|
||||
panelURL: m.result.panelURL,
|
||||
ownerUsername: m.result.username,
|
||||
ownerPassword: m.result.displayPassword,
|
||||
accessLabel: connectMethodLabel(m.result.connectMethod),
|
||||
routedHosts: routed,
|
||||
localHint: m.result.connectMethod == connectLocal,
|
||||
})
|
||||
}
|
||||
|
||||
// showStatus is the re-run landing: prove the backend is up, then point the
|
||||
// operator at the panel without forcing any reconfiguration.
|
||||
func (m *rootModel) showStatus() (tea.Model, tea.Cmd) {
|
||||
m.stage = stageSummary
|
||||
method := connectLocal
|
||||
accessLabel := "configured (manage in panel)"
|
||||
if m.accessAud != "" {
|
||||
method = connectCloudflare
|
||||
accessLabel = connectMethodLabel(connectCloudflare)
|
||||
}
|
||||
m.result.panelURL = panelURLFor(method, m.panelHost, m.rootDomain)
|
||||
return m.adopt(&summaryModel{
|
||||
panelURL: m.result.panelURL,
|
||||
accessLabel: accessLabel,
|
||||
alreadySetUp: true,
|
||||
localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "",
|
||||
})
|
||||
}
|
||||
|
||||
func panelURLFor(method connectMethod, panelHostname, rootDomain string) string {
|
||||
if method != connectLocal && panelHostname != "" {
|
||||
return "https://" + panelHostname
|
||||
}
|
||||
return localPanelURL(rootDomain)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+16
-27
@@ -46,7 +46,7 @@ var (
|
||||
|
||||
// Hint / help text
|
||||
tuiHint = lipgloss.NewStyle().
|
||||
Foreground(cWhite)
|
||||
Foreground(cWhite)
|
||||
|
||||
// Success text
|
||||
tuiOK = lipgloss.NewStyle().
|
||||
@@ -76,36 +76,25 @@ var (
|
||||
Padding(0, 1)
|
||||
|
||||
// Status icon styles
|
||||
tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓")
|
||||
tuiIconInPro = lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("→")
|
||||
tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○")
|
||||
tuiIconErr = lipgloss.NewStyle().Bold(true).Foreground(cError).Render("✗")
|
||||
tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳")
|
||||
|
||||
// Step card dimensions
|
||||
tuiStepWidth = 60
|
||||
tuiStepHeight = 5
|
||||
tuiIconOK = lipgloss.NewStyle().Bold(true).Foreground(cSuccess).Render("✓")
|
||||
tuiIconOpt = lipgloss.NewStyle().Foreground(cWhite).Render("○")
|
||||
tuiIconSpin = lipgloss.NewStyle().Bold(true).Foreground(cWarning).Render("⟳")
|
||||
|
||||
// Info box — subtle background
|
||||
tuiInfoBox = lipgloss.NewStyle().
|
||||
Background(cBgDark).
|
||||
Padding(1, 2).
|
||||
Width(60)
|
||||
)
|
||||
|
||||
func tuiIcon(status stepStatus) string {
|
||||
switch status {
|
||||
case statusDone:
|
||||
return tuiIconOK
|
||||
case statusPending:
|
||||
return tuiIconInPro
|
||||
case statusOptional:
|
||||
return tuiIconOpt
|
||||
case statusFailed:
|
||||
return tuiIconErr
|
||||
case statusRunning:
|
||||
return tuiIconSpin
|
||||
default:
|
||||
return " "
|
||||
}
|
||||
}
|
||||
// Step rail (breadcrumb) — shows where you are in the linear wizard.
|
||||
tuiRailDone = lipgloss.NewStyle().Foreground(cSuccess)
|
||||
tuiRailActive = lipgloss.NewStyle().Bold(true).Foreground(cWhite).Background(cPrimary).Padding(0, 1)
|
||||
tuiRailTodo = lipgloss.NewStyle().Foreground(cDim)
|
||||
tuiRailSep = lipgloss.NewStyle().Foreground(cDim)
|
||||
|
||||
// Code/guide block — monospace-ish snippet on a subtle background.
|
||||
tuiCodeBox = lipgloss.NewStyle().
|
||||
Foreground(cWhite).
|
||||
Background(cBgInput).
|
||||
Padding(0, 1)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+30
-56
@@ -8,24 +8,8 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type stepStatus int
|
||||
|
||||
const (
|
||||
statusDone stepStatus = iota
|
||||
statusPending
|
||||
statusOptional
|
||||
statusRunning
|
||||
statusFailed
|
||||
)
|
||||
|
||||
type dashboardStep struct {
|
||||
title string
|
||||
status stepStatus
|
||||
detail string
|
||||
}
|
||||
|
||||
func tuiHeader(title string) string {
|
||||
return tuiTitle.Render("🐾 " + title) + "\n\n"
|
||||
return tuiTitle.Render("🐾 "+title) + "\n\n"
|
||||
}
|
||||
|
||||
func tuiAction(pairs ...string) string {
|
||||
@@ -36,17 +20,6 @@ func tuiAction(pairs ...string) string {
|
||||
return tuiActionBar.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
func tuiStatusLine(icon, label, detail string, focused bool) string {
|
||||
line := fmt.Sprintf(" %s %s", icon, tuiLabel.Render(label))
|
||||
if detail != "" {
|
||||
line += "\n " + tuiHint.Render(detail)
|
||||
}
|
||||
if focused {
|
||||
return tuiCardFocusedStyle.Width(tuiStepWidth).Render(line)
|
||||
}
|
||||
return tuiCardStyle.Width(tuiStepWidth).Render(line)
|
||||
}
|
||||
|
||||
func tuiFormField(label string, input textinput.Model) string {
|
||||
return fmt.Sprintf("%s\n%s",
|
||||
tuiLabel.Render(label),
|
||||
@@ -57,25 +30,6 @@ func tuiInfo(text string) string {
|
||||
return tuiInfoBox.Render(tuiHint.Render("ℹ " + text))
|
||||
}
|
||||
|
||||
type progressStep struct {
|
||||
label string
|
||||
done bool
|
||||
}
|
||||
|
||||
func tuiProgress(steps []progressStep) string {
|
||||
var b strings.Builder
|
||||
for _, s := range steps {
|
||||
if s.done {
|
||||
b.WriteString(" " + tuiIconOK + " " + s.label + "\n")
|
||||
} else if len(steps) > 0 && s == steps[0] {
|
||||
continue
|
||||
} else {
|
||||
b.WriteString(" " + tuiIconOpt + " " + s.label + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tuiWizardCard(title, desc, body string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiSection.Render(title))
|
||||
@@ -88,15 +42,6 @@ func tuiWizardCard(title, desc, body string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func tuiResultCard(title string, pairs ...string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(tuiOK.Render(title) + "\n\n")
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
b.WriteString(tuiLabel.Render(pairs[i]) + " " + tuiPassword.Render(pairs[i+1]) + "\n")
|
||||
}
|
||||
return tuiCardStyle.Render(b.String())
|
||||
}
|
||||
|
||||
func tuiErrorBanner(msg string) string {
|
||||
return tuiCardFocusedStyle.Render(tuiErr.Render("✗ " + msg))
|
||||
}
|
||||
@@ -122,3 +67,32 @@ func tuiInput(placeholder string, charLimit int, password bool) textinput.Model
|
||||
func tuiSeparator() string {
|
||||
return tuiHint.Render(strings.Repeat("─", 70))
|
||||
}
|
||||
|
||||
// tuiStepRail renders a breadcrumb of wizard stages. Steps before `current`
|
||||
// render as done, `current` is highlighted, and later steps are dimmed.
|
||||
func tuiStepRail(steps []string, current int) string {
|
||||
var parts []string
|
||||
for i, s := range steps {
|
||||
switch {
|
||||
case i < current:
|
||||
parts = append(parts, tuiRailDone.Render("✓ "+s))
|
||||
case i == current:
|
||||
parts = append(parts, tuiRailActive.Render(fmt.Sprintf("%d. %s", i+1, s)))
|
||||
default:
|
||||
parts = append(parts, tuiRailTodo.Render(fmt.Sprintf("%d. %s", i+1, s)))
|
||||
}
|
||||
}
|
||||
return tuiRailSep.Render(" ") + strings.Join(parts, tuiRailSep.Render(" → "))
|
||||
}
|
||||
|
||||
// tuiGuideBlock renders a titled, copy-pasteable snippet (e.g. a Caddyfile).
|
||||
func tuiGuideBlock(title, body string) string {
|
||||
var b strings.Builder
|
||||
if title != "" {
|
||||
b.WriteString(tuiLabel.Render(title) + "\n")
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
b.WriteString(tuiCodeBox.Render(line) + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/jackc/pgx/v5 v5.7.1
|
||||
@@ -21,15 +22,18 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
||||
@@ -56,6 +60,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
@@ -74,10 +79,10 @@ require (
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.24.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
@@ -14,14 +20,28 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
@@ -29,10 +49,14 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
@@ -109,6 +133,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -187,8 +213,8 @@ golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -200,8 +226,8 @@ golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
|
||||
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
Reference in new issue
Block a user