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

336
internal/ui/view.go Normal file
View file

@ -0,0 +1,336 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"motd-assist/internal/motd"
)
// View rendert das komplette Layout: Preset- und Script-Pane oben,
// Live-Vorschau unten, Statuszeile am unteren Rand.
func (m Model) View() string {
if !m.ready {
return "motd-assist startet…"
}
switch m.mode {
case modeHelp:
return m.helpView()
case modeSaveName:
return m.saveView()
}
if m.err != nil {
return m.errorView()
}
const statusH = 1
previewH := max(6, (m.height-statusH)*2/5)
topH := m.height - statusH - previewH
if topH < 5 {
topH = max(3, m.height-statusH-3)
previewH = m.height - statusH - topH
}
presetsW := 26
if m.width < 90 {
presetsW = 22
}
scriptsW := m.width - presetsW - 1
top := lipgloss.JoinHorizontal(lipgloss.Top,
m.presetsView(presetsW, topH),
" ",
m.scriptsView(scriptsW, topH),
)
body := lipgloss.JoinVertical(lipgloss.Left,
top,
m.previewView(m.width, previewH),
m.statusView(),
)
return body
}
func (m Model) presetsView(w, h int) string {
inner := w - 4 // Rahmen (2) + Padding (2)
var b strings.Builder
b.WriteString(focusedTitle.Render("Presets"))
b.WriteString("\n")
visible := h - 3 // Rahmen (2) + Titel (1)
start := 0
if m.presetIdx >= visible {
start = m.presetIdx - visible + 1
}
for i := start; i < len(m.presets) && i < start+visible; i++ {
p := m.presets[i]
cursor := " "
name := truncate(p.Name, inner-5)
if m.focus == panePresets && i == m.presetIdx {
cursor = "▸ "
b.WriteString(cursorStyle.Render(cursor + name))
} else {
b.WriteString(cursor + name)
}
if m.presetMatches[p.Name] {
b.WriteString(" ")
b.WriteString(goodStyle.Render("✓"))
}
b.WriteString("\n")
}
if len(m.presets) == 0 {
b.WriteString(dimStyle.Render("(keine Presets)"))
b.WriteString("\n")
}
return paneBox(m.focus == panePresets, w, h).Render(b.String())
}
func (m Model) scriptsView(w, h int) string {
inner := w - 4
var b strings.Builder
title := "Scripte · /etc/update-motd.d"
b.WriteString(focusedTitle.Render(truncate(title, inner)))
b.WriteString("\n")
markers := m.markersByRow()
visible := h - 3
start := 0
if m.cursor >= visible {
start = m.cursor - visible + 1
}
for i := start; i < len(m.working) && i < start+visible; i++ {
b.WriteString(m.renderRow(m.working[i], markers[m.working[i].Name], inner, m.focus == paneScripts && i == m.cursor))
b.WriteString("\n")
}
if len(m.working) == 0 {
b.WriteString(dimStyle.Render("(leer)"))
b.WriteString("\n")
}
return paneBox(m.focus == paneScripts, w, h).Render(b.String())
}
func (m Model) renderRow(r row, marker string, inner int, selected bool) string {
var icon string
switch {
case r.Remove:
icon = badStyle.Render("✕")
case r.Enabled:
icon = goodStyle.Render("●")
default:
icon = dimStyle.Render("○")
}
name := r.Name
suffix := ""
if r.Install != "" {
suffix = " " + dimStyle.Render("+neu")
} else if r.Bundled {
suffix = " " + dimStyle.Render("·motd-assist")
}
if selected {
name = cursorStyle.Render(name)
} else if !r.Enabled && !r.Remove {
name = dimStyle.Render(name)
}
line := icon + " " + name + suffix
// Marker rechtsbündig, ohne ANSI-Breiten zu verreißen: Marker ist
// gefärbt, daher Breite ohne Escape-Codes einplanen.
markerPlain := marker
markerW := len([]rune(markerPlain))
used := lipgloss.Width(line)
pad := inner - used - markerW - 1
if pad < 0 {
pad = 0
}
if marker != "" {
style := warnStyle
switch marker {
case "neu", "an":
style = goodStyle
case "entfernt":
style = badStyle
default:
style = dimStyle
}
line += strings.Repeat(" ", pad) + style.Render(marker)
}
return line
}
// markersByRow liefert die ausstehenden Änderungen pro Script-Name.
func (m *Model) markersByRow() map[string]string {
mk := make(map[string]string)
plan := m.currentPlan()
for _, op := range plan.Installs {
if op.Enabled {
mk[op.Name] = "neu"
} else {
mk[op.Name] = "neu (aus)"
}
}
for _, op := range plan.Chmod {
if op.Enable {
mk[op.Name] = "an"
} else {
mk[op.Name] = "aus"
}
}
for _, op := range plan.Renames {
mk[op.From] = "→ " + op.To
}
for _, op := range plan.Removes {
mk[op.Name] = "entfernt"
}
return mk
}
func (m Model) previewView(w, h int) string {
inner := w - 4
var title string
switch {
case m.applying:
title = fmt.Sprintf("Live-Vorschau %s sudo läuft…", m.spinner.View())
case m.previewBusy:
title = fmt.Sprintf("Live-Vorschau %s aktualisiere…", m.spinner.View())
default:
if n := m.currentPlan().NumOps(); n > 0 {
title = fmt.Sprintf("Live-Vorschau · %s (a: anwenden, esc: verwerfen)",
warnStyle.Render(fmt.Sprintf("%d Änderungen ausstehend", n)))
} else {
title = "Live-Vorschau · " + goodStyle.Render("entspricht dem System")
}
}
var content string
if m.preview == "" {
content = dimStyle.Render("(keine Ausgabe Scripte aktivieren mit Leertaste)")
} else {
lines := strings.Split(m.preview, "\n")
off := min(m.previewOff, max(0, len(lines)-1))
end := off + h - 3
if end > len(lines) {
end = len(lines)
}
content = strings.Join(lines[off:end], "\n")
}
return paneBox(m.focus == panePreview, w, h).Render(
truncateAnsi(title, inner) + "\n" + content,
)
}
// truncateAnsi kürzt einen stilisierten Titel auf Anzeigebreite w.
func truncateAnsi(s string, w int) string {
if lipgloss.Width(s) <= w {
return s
}
var b strings.Builder
width := 0
inEsc := false
for _, r := range s {
switch {
case r == '\x1b':
inEsc = true
b.WriteRune(r)
case inEsc:
b.WriteRune(r)
if r == 'm' {
inEsc = false
}
default:
if width >= w {
continue
}
b.WriteRune(r)
width++
}
}
return b.String()
}
func (m Model) statusView() string {
pam := warnStyle.Render("pam_motd ✗")
if m.host.PAM {
pam = goodStyle.Render("pam_motd ✓")
}
priv := warnStyle.Render("sudo nötig")
if motd.IsRoot() {
priv = goodStyle.Render("root")
}
enabled := 0
for _, r := range m.working {
if r.Enabled && !r.Remove {
enabled++
}
}
left := fmt.Sprintf("%s · %s · %s · %d/%d aktiv · %s",
m.host.Hostname, m.host.Distro, pam, enabled, len(m.working), priv)
line := statusStyle(left)
if m.status != "" {
line += statusStyle(" │ ") + statusStyle(m.status)
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(line)
}
func statusStyle(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(s)
}
func (m Model) errorView() string {
msg := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBad).
Padding(1, 2).
Width(min(60, m.width-4)).
Render(
badStyle.Render("MOTD-Verzeichnis nicht gefunden") + "\n\n" +
m.err.Error() + "\n\n" +
dimStyle.Render("motd-assist verwaltet /etc/update-motd.d (Ubuntu/Debian). "+
"Auf diesem System liegt kein update-motd.d vor.") + "\n\n" +
dimStyle.Render("q/esc: beenden · r: erneut versuchen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) helpView() string {
keys := [][2]string{
{"tab / shift+tab", "Panes wechseln (Presets · Scripte · Vorschau)"},
{"j / k, ↑ / ↓", "auswählen"},
{"enter / space", "Preset laden · Script an/aus"},
{"u / d", "Script in der Reihenfolge nach oben/unten"},
{"a", "Änderungen anwenden (sudo-Passwortabfrage)"},
{"esc", "Änderungen verwerfen"},
{"s", "aktuellen Zustand als Preset speichern"},
{"D", "ausgewähltes Preset löschen (nur eigene)"},
{"r", "Systemzustand neu einlesen"},
{"q", "beenden"},
}
var b strings.Builder
b.WriteString(focusedTitle.Render("Hilfe") + "\n\n")
for _, k := range keys {
b.WriteString(cursorStyle.Render(fmt.Sprintf("%-20s", k[0])) + " " + k[1] + "\n")
}
b.WriteString("\n" + dimStyle.Render("Presets: ~/.config/motd-assist/presets · Undo: motd-assist restore"))
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(b.String())
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
func (m Model) saveView() string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(
focusedTitle.Render("Preset speichern") + "\n\n" +
m.input.View() + "\n\n" +
helpStyle.Render("enter: speichern · esc: abbrechen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}