feat: add motd-assist TUI for managing dynamic MOTDs

Initial Go project built with Bubble Tea and Lip Gloss:
- Live preview and management of /etc/update-motd.d scripts
- Embedded presets (ubuntu-default, minimal, server-dashboard)
- Bundled POSIX sh style scripts (brand header, sysinfo panel, footer)
- Snapshot/restore safety around applying changes
- CLI subcommands preview, apply, restore, list-presets
- Unit tests for detect, sanitize, and apply logic
This commit is contained in:
Tronax 2026-08-24 14:19:28 +02:00
commit d56659e21a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 3260 additions and 0 deletions

255
internal/ui/update.go Normal file
View file

@ -0,0 +1,255 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
// Update behandelt Fenstergröße, Ergebnisse der Hintergrundkommandos und
// Tastendrücke.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
if w := m.width - 20; w > 10 {
m.input.Width = w
}
if len(m.working) > 0 {
return m, m.previewCmd() // Breite geändert → Preview neu rendern
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case discoverMsg:
if msg.err != nil {
m.err = msg.err
m.current, m.working = nil, nil
return m, nil
}
m.err = nil
m.current = msg.scripts
m.host = msg.host
m.working = rowsFromCurrent(msg.scripts)
if m.cursor >= len(m.working) {
m.cursor = max(0, len(m.working)-1)
}
m.refreshPresetMatches()
m.previewOff = 0
return m, m.previewCmd()
case presetsMsg:
if msg.err == nil {
m.presets = msg.presets
if m.presetIdx >= len(m.presets) {
m.presetIdx = max(0, len(m.presets)-1)
}
m.refreshPresetMatches()
}
return m, nil
case previewMsg:
if msg.version < m.previewVersion {
return m, nil // veraltetes Ergebnis, neuere Preview läuft schon
}
m.previewBusy = false
m.preview = msg.text
m.previewOff = 0
return m, nil
case applyDoneMsg:
m.applying = false
if msg.err != nil {
m.status = "Anwenden fehlgeschlagen: " + msg.err.Error()
} else {
m.status = "Angewendet: " + m.lastSummary
}
return m, discoverCmd(m.root)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.mode {
case modeHelp:
m.mode = modeNormal
return m, nil
case modeSaveName:
switch msg.String() {
case "enter":
name := strings.TrimSpace(m.input.Value())
m.mode = modeNormal
m.input.Blur()
if name == "" {
return m, nil
}
if _, err := preset.SaveUser(m.presetFromWorking(name)); err != nil {
m.status = "Speichern fehlgeschlagen: " + err.Error()
} else {
m.status = "Preset \"" + name + "\" gespeichert"
}
return m, presetsCmd()
case "esc", "ctrl+c":
m.mode = modeNormal
m.input.Blur()
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
key := msg.String()
switch key {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.focus = (m.focus + 1) % 3
return m, nil
case "shift+tab":
m.focus = (m.focus + 2) % 3
case "?":
m.mode = modeHelp
return m, nil
case "r":
m.status = ""
return m, discoverCmd(m.root)
case "esc":
if m.err != nil {
return m, tea.Quit
}
m.working = rowsFromCurrent(m.current)
m.previewOff = 0
m.status = "Änderungen verworfen"
return m, m.previewCmd()
case "s":
if m.err != nil {
return m, nil
}
m.mode = modeSaveName
m.input.SetValue("")
m.input.Focus()
return m, textinput.Blink
case "a":
return m.applyWorking()
case "D":
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
if err := preset.DeleteUser(p.Name); err != nil {
m.status = err.Error()
} else {
m.status = "Preset \"" + p.Name + "\" gelöscht"
}
return m, presetsCmd()
}
switch m.focus {
case panePresets:
switch key {
case "j", "down":
if m.presetIdx < len(m.presets)-1 {
m.presetIdx++
}
case "k", "up":
if m.presetIdx > 0 {
m.presetIdx--
}
case "enter", " ":
return m.loadPreset()
}
case paneScripts:
switch key {
case "j", "down":
if m.cursor < len(m.working)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case " ":
if len(m.working) == 0 {
return m, nil
}
r := &m.working[m.cursor]
if r.Remove {
return m, nil
}
r.Enabled = !r.Enabled
return m, m.previewCmd()
case "u":
return m.moveRow(-1)
case "d":
return m.moveRow(1)
}
case panePreview:
lines := strings.Count(m.preview, "\n") + 1
switch key {
case "j", "down":
m.previewOff = min(m.previewOff+1, max(0, lines-1))
case "k", "up":
if m.previewOff > 0 {
m.previewOff--
}
}
}
return m, nil
}
func (m Model) loadPreset() (tea.Model, tea.Cmd) {
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
for _, e := range p.Scripts {
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
}
m.working = rowsFromDesired(motd.DesiredFromPreset(m.current, entries), m.current)
m.cursor = 0
m.previewOff = 0
m.status = "Preset \"" + p.Name + "\" geladen mit 'a' anwenden"
return m, m.previewCmd()
}
func (m Model) moveRow(delta int) (tea.Model, tea.Cmd) {
i, j := m.cursor, m.cursor+delta
if i < 0 || i >= len(m.working) || j < 0 || j >= len(m.working) {
return m, nil
}
m.working[i], m.working[j] = m.working[j], m.working[i]
m.cursor = j
return m, m.previewCmd()
}
func (m Model) applyWorking() (tea.Model, tea.Cmd) {
if m.applying || m.err != nil {
return m, nil
}
plan := m.currentPlan()
if plan.Empty() {
m.status = "Keine Änderungen zum Anwenden"
return m, nil
}
m.applying = true
m.lastSummary = plan.Summary()
return m, applyExec(plan)
}