motd-assist/internal/motd/state.go
Tronax 303c49b484
feat: drop-in support for custom templates
User templates can now be added without rebuilding: drop a script into
~/.config/motd-assist/templates/ and reference it from a preset via
"install": "<filename>". Same-name files override built-ins; new files
are picked up by the running TUI via 'r'. `motd-assist templates` lists
known templates and creates the drop-in directory.

Apply plans now embed the resolved template content (Plan.ResolveContents)
so the sudo/root process never needs to read user config; snapshots store
installed script content so restore works under sudo as well. Validation
rejects plans with unresolved content.
2026-08-31 10:31:58 +02:00

208 lines
5.5 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,omitempty"`
Content string `json:"content,omitempty"`
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
}