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

109
internal/ui/model_test.go Normal file
View file

@ -0,0 +1,109 @@
package ui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
func mustUpdate(m Model, msg tea.Msg) Model {
next, _ := m.Update(msg)
return next.(Model)
}
func newTestModel(t *testing.T) Model {
t.Helper()
m := New(t.TempDir())
m = mustUpdate(m, tea.WindowSizeMsg{Width: 120, Height: 40})
return m
}
func TestViewRendersAllPanes(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts, host: motd.HostInfo{Hostname: "myhost", Distro: "Ubuntu 24.04", PAM: true}})
m = mustUpdate(m, previewMsg{version: m.previewVersion, text: "Willkommen auf myhost"})
view := m.View()
for _, want := range []string{"Presets", "Scripte", "Live-Vorschau", "00-header", "10-sysinfo", "Willkommen auf myhost", "myhost"} {
if !strings.Contains(view, want) {
t.Errorf("View enthält %q nicht", want)
}
}
}
func TestToggleAndPlan(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
// Fokus auf Scripte wechseln und erstes Script ausschalten.
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace})
plan := m.currentPlan()
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
t.Errorf("Chmod = %+v, want 00-header aus", plan.Chmod)
}
if markers := m.markersByRow(); markers["00-header"] != "aus" {
t.Errorf("Marker = %q, want aus", markers["00-header"])
}
}
func TestMoveRowChangesOrder(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x", Enabled: true},
{Name: "10-sysinfo", Path: "/x", Enabled: true},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) // nach unten
if m.working[0].Name != "10-sysinfo" || m.working[1].Name != "00-header" {
t.Errorf("Reihenfolge = %v, %v", m.working[0].Name, m.working[1].Name)
}
if plan := m.currentPlan(); len(plan.Renames) != 2 {
t.Errorf("Renames = %+v, want 2", plan.Renames)
}
}
func TestLoadPresetMarksPending(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: false}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, presetsMsg{presets: []preset.Preset{{Name: "p1", Scripts: []preset.Entry{{Name: "00-header", Enabled: true}}}}})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) // erstes Preset laden
if plan := m.currentPlan(); len(plan.Chmod) != 1 || !plan.Chmod[0].Enable {
t.Errorf("Plan = %+v, want 00-header an", plan)
}
if m.status == "" {
t.Error("Statusmeldung nach Preset-Laden erwartet")
}
}
func TestDiscardResetsWorking(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: true}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace}) // ausschalten
if m.currentPlan().Empty() {
t.Fatal("Plan sollte Änderungen haben")
}
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEsc})
if !m.currentPlan().Empty() {
t.Errorf("nach esc sollte der Plan leer sein, ist %+v", m.currentPlan())
}
}