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

207
internal/motd/state.go Normal file
View file

@ -0,0 +1,207 @@
package motd
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
)
// DesiredEntry beschreibt den Soll-Zustand eines Scripts.
// Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation.
type DesiredEntry struct {
Name string
Enabled bool
Install string
Remove bool
}
// Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen
// geändert — niemals Inhalte fremder Scripte.
type (
ChmodOp struct {
Name string `json:"name"`
Enable bool `json:"enable"`
}
RenameOp struct {
From string `json:"from"`
To string `json:"to"`
}
InstallOp struct {
Name string `json:"name"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
}
RemoveOp struct {
Name string `json:"name"`
}
)
// Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll.
type Plan struct {
Chmod []ChmodOp `json:"chmod,omitempty"`
Renames []RenameOp `json:"renames,omitempty"`
Installs []InstallOp `json:"installs,omitempty"`
Removes []RemoveOp `json:"removes,omitempty"`
}
// Empty meldet, ob der Plan nichts tut.
func (p Plan) Empty() bool { return p.NumOps() == 0 }
// NumOps zählt alle Operationen.
func (p Plan) NumOps() int {
return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes)
}
// Summary liefert eine Kurzbeschreibung für Statusmeldungen.
func (p Plan) Summary() string {
var parts []string
if n := len(p.Removes); n > 0 {
parts = append(parts, fmt.Sprintf("%d entfernt", n))
}
if n := len(p.Installs); n > 0 {
parts = append(parts, fmt.Sprintf("%d installiert", n))
}
if n := len(p.Renames); n > 0 {
parts = append(parts, fmt.Sprintf("%d umbenannt", n))
}
if n := len(p.Chmod); n > 0 {
parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n))
}
if len(parts) == 0 {
return "keine Änderungen"
}
return strings.Join(parts, ", ")
}
var prefixRe = regexp.MustCompile(`^([0-9]+)-`)
// suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header").
func suffixOf(name string) string {
if loc := prefixRe.FindStringIndex(name); loc != nil {
return name[loc[1]:]
}
return name
}
func prefixNum(name string) (float64, bool) {
m := prefixRe.FindStringSubmatch(name)
if m == nil {
return 0, false
}
n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, false
}
return n, true
}
// targetName baut den kanonischen Namen für Position i (Schrittweite 10).
func targetName(i int, name string) string {
return fmt.Sprintf("%02d-%s", i*10, suffixOf(name))
}
// DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset:
// Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte
// behalten Zustand und Position (interleaved über ihren Nummern-Präfix).
// Ungelistete gebündelte Scripte werden zur Deinstallation markiert.
func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry {
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
type slot struct {
key float64
entry DesiredEntry
}
var slots []slot
seen := make(map[string]bool)
for i, e := range entries {
if _, exists := curByName[e.Name]; e.Install == "" && !exists {
continue // Script existiert auf diesem System nicht
}
if seen[e.Name] || e.Remove {
continue
}
seen[e.Name] = true
slots = append(slots, slot{float64(i)*10 + 0.5, e})
}
for i, c := range current {
if seen[c.Name] {
continue
}
key := float64(i) * 10
if n, ok := prefixNum(c.Name); ok {
key = n
}
e := DesiredEntry{Name: c.Name, Enabled: c.Enabled}
if c.Bundled {
e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset
}
slots = append(slots, slot{key, e})
}
sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key })
out := make([]DesiredEntry, len(slots))
for i, s := range slots {
out[i] = s.entry
}
return out
}
// Diff berechnet den Plan von current nach desired.
func Diff(current []Script, desired []DesiredEntry) Plan {
var plan Plan
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
var final []string // Reihenfolge der Dateien nach dem Apply
installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs
for _, e := range desired {
if e.Remove {
if _, exists := curByName[e.Name]; exists {
plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name})
}
continue
}
final = append(final, e.Name)
cur, exists := curByName[e.Name]
if !exists {
if e.Install != "" {
installIdx[e.Name] = len(plan.Installs)
plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled})
}
continue
}
if cur.Enabled != e.Enabled {
plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled})
}
}
// Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen
// entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle
// Positionen neu nummeriert vorhandene Dateien umbenannt und
// Installationen direkt unter dem Zielnamen angelegt.
if !slices.IsSorted(final) {
for i, name := range final {
target := targetName(i, name)
if cur, exists := curByName[name]; exists {
if target != cur.Name {
plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target})
}
} else if idx, ok := installIdx[name]; ok {
plan.Installs[idx].Name = target
}
}
}
return plan
}