motd-assist/internal/motd/state.go
Tronax d56659e21a
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
2026-08-24 14:19:28 +02:00

207 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}