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:
commit
d56659e21a
34 changed files with 3260 additions and 0 deletions
207
internal/motd/state.go
Normal file
207
internal/motd/state.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue