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.
208 lines
5.5 KiB
Go
208 lines
5.5 KiB
Go
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
|
||
}
|