once-add/internal/tui/tui.go
28allday 68c64265a6 once-add: deploy + remove Once apps at <name>.local
A small Go tool that wraps the `/etc/hosts` + `once deploy --disable-tls`
recipe needed to host a Once app on a single-label `<name>.local`. Adds
the hosts entry, runs the deploy, polls /up, and on removal cleans the
hosts line that the Once UI orphans (including the orphan-only case,
where the app is already gone).

Bubble Tea TUI for interactive use; non-interactive CLI for scripts.
Curated apps live in /etc/once-add/apps.toml; seeded with Writebook +
Campfire on first run.
2026-05-28 07:23:07 +01:00

549 lines
14 KiB
Go

// Package tui implements the once-add wizard.
//
// Add path: menu → select app → (custom image) → name → deploying → done/error
// Remove path: menu → pick app → confirm (+ data toggle) → removing → done/error
package tui
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"once-add/internal/config"
"once-add/internal/deploy"
)
type state int
const (
stateMenu state = iota
stateSelectApp
stateCustomImage
stateName
stateDeploying
stateRemovePick
stateRemoveConfirm
stateRemoving
stateDone
stateError
)
// op tracks which recipe produced a terminal Done event, so viewDone can word
// itself correctly.
type op int
const (
opAdd op = iota
opRemove
)
// maxLogLines caps the streamed output kept on screen.
const maxLogLines = 12
type model struct {
state state
op op
menuCursor int // 0 = add, 1 = remove
// add path
apps []config.App
cursor int // index into apps; len(apps) == the "Custom image…" row
customInput textinput.Model
nameInput textinput.Model
fromCustom bool
image string
name string
nameErr error
// remove path
removeHosts []string
removeCursor int
removeTarget string
removeData bool
// shared
spinner spinner.Model
logLines []string
events <-chan deploy.Event
cancel context.CancelFunc
resultURL string // add: the reachable URL
doneMsg string // remove: the done line
err error
}
func newBase() model {
custom := textinput.New()
custom.Placeholder = "ghcr.io/org/image:tag"
custom.CharLimit = 256
name := textinput.New()
name.Placeholder = "book"
name.CharLimit = 63
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = okStyle
return model{state: stateMenu, customInput: custom, nameInput: name, spinner: sp}
}
// New builds the wizard starting at the Add/Remove menu.
func New() model { return newBase() }
// NewRemove builds the wizard jumping straight to the remove picker (for
// `once-add remove` with no name).
func NewRemove() model {
m := newBase()
return m.loadRemove()
}
func (m model) Init() tea.Cmd { return nil }
// --- streaming plumbing -----------------------------------------------------
type eventMsg deploy.Event
func waitEvent(ch <-chan deploy.Event) tea.Cmd {
return func() tea.Msg {
ev, ok := <-ch
if !ok {
return nil
}
return eventMsg(ev)
}
}
func (m *model) startDeploy() tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = deploy.Run(ctx, m.name, m.image)
m.op = opAdd
m.state = stateDeploying
m.logLines = nil
return tea.Batch(m.spinner.Tick, waitEvent(m.events))
}
func (m *model) startRemove() tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.events = deploy.RunRemove(ctx, m.removeTarget, m.removeData)
m.op = opRemove
m.state = stateRemoving
m.logLines = nil
return tea.Batch(m.spinner.Tick, waitEvent(m.events))
}
// loadRemove enumerates deployed .local hosts and enters the picker (or the
// error state if docker can't be reached).
func (m model) loadRemove() model {
hosts, err := deploy.DeployedHosts()
if err != nil {
m.err = fmt.Errorf("listing deployed apps: %w", err)
m.state = stateError
return m
}
m.removeHosts = hosts
m.removeCursor = 0
m.removeData = false
m.state = stateRemovePick
return m
}
func (m model) loadAdd() (model, tea.Cmd) {
apps, err := config.Load()
if err != nil {
m.err = fmt.Errorf("loading %s: %w", config.Path, err)
m.state = stateError
return m, nil
}
m.apps = apps
m.cursor = 0
m.state = stateSelectApp
return m, nil
}
func (m *model) quit() (tea.Model, tea.Cmd) {
if m.cancel != nil {
m.cancel()
}
return m, tea.Quit
}
// --- update -----------------------------------------------------------------
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case eventMsg:
return m.handleEvent(deploy.Event(msg))
case spinner.TickMsg:
if m.state != stateDeploying && m.state != stateRemoving {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
return m.quit()
}
switch m.state {
case stateMenu:
return m.updateMenu(msg)
case stateSelectApp:
return m.updateSelect(msg)
case stateCustomImage:
return m.updateCustom(msg)
case stateName:
return m.updateName(msg)
case stateRemovePick:
return m.updateRemovePick(msg)
case stateRemoveConfirm:
return m.updateRemoveConfirm(msg)
case stateDeploying, stateRemoving:
return m, nil // in progress; ctrl+c handled above
case stateDone, stateError:
return m.quit()
}
}
return m, nil
}
func (m model) handleEvent(ev deploy.Event) (tea.Model, tea.Cmd) {
switch {
case ev.Err != nil:
m.err = ev.Err
m.state = stateError
return m, nil
case ev.Done:
if m.op == opAdd {
m.resultURL = ev.Line
} else {
m.doneMsg = ev.Line
}
m.state = stateDone
return m, nil
default:
m.logLines = append(m.logLines, ev.Line)
if len(m.logLines) > maxLogLines {
m.logLines = m.logLines[len(m.logLines)-maxLogLines:]
}
return m, waitEvent(m.events)
}
}
func (m model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
return m.quit()
case "up", "k":
m.menuCursor = 0
case "down", "j":
m.menuCursor = 1
case "enter":
if m.menuCursor == 0 {
return m.loadAdd()
}
return m.loadRemove(), nil
}
return m, nil
}
func (m model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
last := len(m.apps) // index of the "Custom image…" row
switch msg.String() {
case "q":
return m.quit()
case "esc":
m.state = stateMenu
return m, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < last {
m.cursor++
}
case "enter":
if m.cursor == last {
m.fromCustom = true
m.customInput.SetValue("")
m.customInput.Focus()
m.state = stateCustomImage
return m, textinput.Blink
}
app := m.apps[m.cursor]
m.image = app.Image
m.fromCustom = false
return m.enterNameScreen(app.Suggested)
}
return m, nil
}
func (m model) updateCustom(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.state = stateSelectApp
return m, nil
case "enter":
img := strings.TrimSpace(m.customInput.Value())
if img == "" {
return m, nil
}
m.image = img
return m.enterNameScreen("")
}
var cmd tea.Cmd
m.customInput, cmd = m.customInput.Update(msg)
return m, cmd
}
func (m model) enterNameScreen(suggested string) (tea.Model, tea.Cmd) {
m.nameInput.SetValue(suggested)
m.nameInput.CursorEnd()
m.nameInput.Focus()
m.nameErr = nil
m.state = stateName
return m, textinput.Blink
}
func (m model) updateName(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.fromCustom {
m.customInput.Focus()
m.state = stateCustomImage
return m, textinput.Blink
}
m.state = stateSelectApp
return m, nil
case "enter":
name := strings.TrimSpace(m.nameInput.Value())
if err := deploy.ValidateName(name); err != nil {
m.nameErr = err
return m, nil
}
if hosts, err := deploy.InUseHosts(); err == nil && hosts[deploy.Host(name)] {
m.nameErr = fmt.Errorf("%s is already in use — pick another", deploy.Host(name))
return m, nil
}
m.name = name
return m, m.startDeploy()
}
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
if v := strings.TrimSpace(m.nameInput.Value()); v != "" {
m.nameErr = deploy.ValidateName(v)
} else {
m.nameErr = nil
}
return m, cmd
}
func (m model) updateRemovePick(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q":
return m.quit()
case "esc":
m.state = stateMenu
return m, nil
case "up", "k":
if m.removeCursor > 0 {
m.removeCursor--
}
case "down", "j":
if m.removeCursor < len(m.removeHosts)-1 {
m.removeCursor++
}
case "enter":
if len(m.removeHosts) == 0 {
return m, nil
}
m.removeTarget = m.removeHosts[m.removeCursor]
m.removeData = false
m.state = stateRemoveConfirm
}
return m, nil
}
func (m model) updateRemoveConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "n":
m.state = stateRemovePick
return m, nil
case " ":
m.removeData = !m.removeData
return m, nil
case "enter", "y":
return m, m.startRemove()
}
return m, nil
}
// --- view -------------------------------------------------------------------
func (m model) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("once-add") + descStyle.Render(" — manage LAN Once sites (<name>.local)") + "\n\n")
switch m.state {
case stateMenu:
b.WriteString(m.viewMenu())
case stateSelectApp:
b.WriteString(m.viewSelect())
case stateCustomImage:
b.WriteString(m.viewCustom())
case stateName:
b.WriteString(m.viewName())
case stateRemovePick:
b.WriteString(m.viewRemovePick())
case stateRemoveConfirm:
b.WriteString(m.viewRemoveConfirm())
case stateDeploying:
b.WriteString(m.viewProgress("Deploying " + deploy.Host(m.name)))
case stateRemoving:
b.WriteString(m.viewProgress("Removing " + m.removeTarget))
case stateDone:
b.WriteString(m.viewDone())
case stateError:
b.WriteString(m.viewError())
}
return b.String()
}
func (m model) viewMenu() string {
var b strings.Builder
b.WriteString("What do you want to do?\n\n")
b.WriteString(m.menuRow(0, "Add a site", "deploy an app as <name>.local"))
b.WriteString(m.menuRow(1, "Remove a site", "tear down a deployed app + its hosts entry"))
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · q quit"))
return b.String()
}
func (m model) menuRow(i int, name, desc string) string {
cursor, label := " ", name
if m.menuCursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(name)
}
return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc))
}
func (m model) viewSelect() string {
var b strings.Builder
b.WriteString("Which app do you want to add?\n\n")
for i, app := range m.apps {
b.WriteString(m.row(i, app.Name, app.Description))
}
b.WriteString(m.row(len(m.apps), "Custom image…", "deploy any container image"))
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit"))
return b.String()
}
func (m model) row(i int, name, desc string) string {
cursor, label := " ", name
if m.cursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(name)
}
return fmt.Sprintf("%s%s %s\n", cursor, label, descStyle.Render(desc))
}
func (m model) viewCustom() string {
var b strings.Builder
b.WriteString("Container image to deploy:\n\n")
b.WriteString(" " + m.customInput.View() + "\n\n")
b.WriteString(helpStyle.Render("enter continue · esc back · ctrl+c quit"))
return b.String()
}
func (m model) viewName() string {
var b strings.Builder
b.WriteString("Name for this site (single label):\n\n")
b.WriteString(" " + m.nameInput.View() + "\n")
if name := strings.TrimSpace(m.nameInput.Value()); name != "" && m.nameErr == nil {
b.WriteString(" " + okStyle.Render("→ http://"+deploy.Host(name)) + "\n")
}
if m.nameErr != nil {
b.WriteString(" " + errStyle.Render("✗ "+m.nameErr.Error()) + "\n")
}
b.WriteString("\n" + descStyle.Render("image: "+m.image) + "\n")
b.WriteString(helpStyle.Render("enter deploy · esc back · ctrl+c quit"))
return b.String()
}
func (m model) viewRemovePick() string {
var b strings.Builder
if len(m.removeHosts) == 0 {
b.WriteString(descStyle.Render("No deployed .local apps to remove.") + "\n\n")
b.WriteString(helpStyle.Render("esc back · q quit"))
return b.String()
}
b.WriteString("Which site do you want to remove?\n\n")
for i, h := range m.removeHosts {
cursor, label := " ", h
if m.removeCursor == i {
cursor = selectedStyle.Render("➜ ")
label = selectedStyle.Render(h)
}
b.WriteString(fmt.Sprintf("%s%s\n", cursor, label))
}
b.WriteString("\n" + helpStyle.Render("↑/↓ move · enter select · esc back · q quit"))
return b.String()
}
func (m model) viewRemoveConfirm() string {
var b strings.Builder
b.WriteString("Remove " + selectedStyle.Render(m.removeTarget) + "? This stops and deletes the app.\n\n")
box := "[ ]"
if m.removeData {
box = okStyle.Render("[x]")
}
b.WriteString(" " + box + " also delete its data volume " + errStyle.Render("(irreversible)") + "\n\n")
if m.removeData {
b.WriteString(errStyle.Render(" Data will be permanently deleted.") + "\n\n")
} else {
b.WriteString(hintStyle.Render(" Data is kept; re-deploying the same name restores it.") + "\n\n")
}
b.WriteString(helpStyle.Render("space toggle data · enter/y remove · esc/n back · ctrl+c quit"))
return b.String()
}
func (m model) viewProgress(title string) string {
var b strings.Builder
b.WriteString(m.spinner.View() + title + "\n\n")
for _, line := range m.logLines {
b.WriteString(descStyle.Render(" "+line) + "\n")
}
b.WriteString("\n" + helpStyle.Render("ctrl+c cancel"))
return b.String()
}
func (m model) viewDone() string {
var b strings.Builder
if m.op == opAdd {
b.WriteString(okStyle.Render("✓ Added.") + " Reachable at " + urlStyle.Render(m.resultURL) + "\n\n")
b.WriteString(hintStyle.Render("Published on the LAN within ~20s (once-mdns-sync).") + "\n")
b.WriteString(hintStyle.Render("Manage it in the Once web interface.") + "\n\n")
} else {
b.WriteString(okStyle.Render("✓ "+m.doneMsg) + "\n\n")
b.WriteString(hintStyle.Render("once-mdns-sync stops publishing it on the LAN automatically.") + "\n\n")
}
b.WriteString(helpStyle.Render("enter/q quit"))
return b.String()
}
func (m model) viewError() string {
var b strings.Builder
b.WriteString(errStyle.Render("✗ "+m.err.Error()) + "\n\n")
b.WriteString(hintStyle.Render("Inspect: journalctl -u once-mdns-sync -f") + "\n")
b.WriteString(hintStyle.Render(" docker logs once-proxy") + "\n\n")
b.WriteString(helpStyle.Render("enter/q quit"))
return b.String()
}