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

182
internal/ui/model.go Normal file
View file

@ -0,0 +1,182 @@
// Package ui implementiert die Bubbletea-TUI von motd-assist.
package ui
import (
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/assets"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
type pane int
const (
panePresets pane = iota
paneScripts
panePreview
)
type mode int
const (
modeNormal mode = iota
modeSaveName
modeHelp
)
// row ist die Arbeitskopie eines Scripts inkl. geplanter Installation.
type row struct {
Name string
Enabled bool
Install string // gebündelte Vorlage, solange noch nicht installiert
Bundled bool // installiert und entspricht einer Vorlage
Remove bool // Deinstallation geplant (Preset-Wechsel)
Exists bool // Datei existiert bereits
}
// Model ist das Bubbletea-Model der TUI.
type Model struct {
root string
host motd.HostInfo
current []motd.Script
working []row
presets []preset.Preset
// presetMatches markiert Presets, die dem aktiven Ist-Zustand entsprechen.
presetMatches map[string]bool
focus pane
mode mode
cursor int // Zeiger in der Script-Liste
presetIdx int
previewOff int
preview string
previewBusy bool
previewVersion int
status string
err error
applying bool
lastSummary string
width, height int
ready bool
input textinput.Model
spinner spinner.Model
}
// New erzeugt das Model für ein root-Dateisystem (normalerweise "/").
func New(root string) Model {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = dimStyle
ti := textinput.New()
ti.Placeholder = "preset-name"
ti.CharLimit = 48
return Model{
root: root,
focus: panePresets,
presetMatches: map[string]bool{},
spinner: sp,
input: ti,
}
}
// Init startet Discovery und Spinner.
func (m Model) Init() tea.Cmd {
return tea.Batch(discoverCmd(m.root), presetsCmd(), m.spinner.Tick)
}
// desiredEntries wandelt die Arbeitskopie in Soll-Einträge um.
func (m *Model) desiredEntries() []motd.DesiredEntry {
out := make([]motd.DesiredEntry, 0, len(m.working))
for _, r := range m.working {
out = append(out, motd.DesiredEntry{
Name: r.Name,
Enabled: r.Enabled,
Install: r.Install,
Remove: r.Remove,
})
}
return out
}
// currentPlan berechnet den ausstehenden Plan (Ist → Arbeitskopie).
func (m *Model) currentPlan() motd.Plan {
return motd.Diff(m.current, m.desiredEntries())
}
func rowsFromCurrent(scripts []motd.Script) []row {
rows := make([]row, len(scripts))
for i, s := range scripts {
rows[i] = row{Name: s.Name, Enabled: s.Enabled, Bundled: s.Bundled, Exists: true}
}
return rows
}
func rowsFromDesired(entries []motd.DesiredEntry, current []motd.Script) []row {
curByName := make(map[string]motd.Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
rows := make([]row, 0, len(entries))
for _, e := range entries {
r := row{Name: e.Name, Enabled: e.Enabled, Remove: e.Remove}
if c, exists := curByName[e.Name]; exists {
r.Exists = true
r.Bundled = c.Bundled
} else if e.Install != "" {
r.Install = e.Install
}
rows = append(rows, r)
}
return rows
}
// presetFromWorking serialisiert die Arbeitskopie in ein Preset; für
// installierte gebündelte Scripte wird die Vorlage als Install-Quelle
// hinterlegt, damit das Preset auf anderen Maschinen tragbar bleibt.
func (m *Model) presetFromWorking(name string) preset.Preset {
p := preset.Preset{Name: name}
for _, r := range m.working {
if r.Remove {
continue
}
e := preset.Entry{Name: r.Name, Enabled: r.Enabled}
switch {
case r.Install != "":
e.Install = r.Install
case r.Bundled:
if b, err := os.ReadFile(filepath.Join(motd.MotdDir(m.root), r.Name)); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
e.Install = src
}
}
}
p.Scripts = append(p.Scripts, e)
}
return p
}
// refreshPresetMatches prüft, welche Presets dem Ist-Zustand entsprechen.
func (m *Model) refreshPresetMatches() {
m.presetMatches = make(map[string]bool, len(m.presets))
for _, p := range m.presets {
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})
}
desired := motd.DesiredFromPreset(m.current, entries)
m.presetMatches[p.Name] = motd.Diff(m.current, desired).Empty()
}
}