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.
549 lines
14 KiB
Go
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()
|
|
}
|