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.
This commit is contained in:
Tronax 2026-08-31 10:31:58 +02:00
parent d56659e21a
commit 303c49b484
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
10 changed files with 375 additions and 48 deletions

View file

@ -1,25 +1,72 @@
// Package assets hält die eingebetteten Presets und Custom-Scripte.
// Package assets hält die eingebetteten Presets und Custom-Scripte sowie
// den Drop-in-Ordner für eigene Vorlagen (~/.config/motd-assist/templates).
package assets
import (
"embed"
"os"
"path/filepath"
"sort"
"strings"
)
//go:embed presets scripts
var embedded embed.FS
// ScriptContent liefert den Inhalt einer gebündelten Script-Vorlage.
func ScriptContent(name string) (string, bool) {
b, err := embedded.ReadFile("scripts/" + name)
if err != nil {
return "", false
}
return string(b), true
// Template ist eine installierbare Script-Vorlage.
type Template struct {
Name string
Source string // "builtin" oder "user"
}
// ScriptNames listet alle gebündelten Script-Vorlagen.
func ScriptNames() []string {
// TemplatesDir liefert den Drop-in-Ordner für eigene Vorlagen (XDG-konform).
func TemplatesDir() (string, error) {
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "motd-assist", "templates"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "motd-assist", "templates"), nil
}
// EnsureTemplatesDir legt den Drop-in-Ordner an, falls er fehlt.
func EnsureTemplatesDir() (string, error) {
dir, err := TemplatesDir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// userTemplates liest den Drop-in-Ordner frisch bei jedem Aufruf, damit
// neue Dateien ohne Neustart erkannt werden (TUI: Taste r).
func userTemplates() map[string]string {
dir, err := TemplatesDir()
if err != nil {
return nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make(map[string]string, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if b, err := os.ReadFile(filepath.Join(dir, e.Name())); err == nil {
out[e.Name()] = string(b)
}
}
return out
}
func embeddedScriptNames() []string {
entries, err := embedded.ReadDir("scripts")
if err != nil {
return nil
@ -33,6 +80,78 @@ func ScriptNames() []string {
return names
}
func embeddedContent(name string) (string, bool) {
b, err := embedded.ReadFile("scripts/" + name)
if err != nil {
return "", false
}
return string(b), true
}
// Templates listet alle bekannten Vorlagen sortiert. Gleichnamige
// User-Vorlagen überdecken Built-ins.
func Templates() []Template {
user := userTemplates()
seen := make(map[string]string, len(user)+4)
for name := range user {
seen[name] = "user"
}
for _, name := range embeddedScriptNames() {
if _, ok := seen[name]; !ok {
seen[name] = "builtin"
}
}
out := make([]Template, 0, len(seen))
for name, source := range seen {
out = append(out, Template{Name: name, Source: source})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ScriptNames listet alle Vorlagen-Namen (Union aus Built-in und Drop-in).
func ScriptNames() []string {
names := make([]string, 0)
for _, t := range Templates() {
names = append(names, t.Name)
}
return names
}
// ScriptContent liefert den Inhalt einer Vorlage; User-Vorlagen aus dem
// Drop-in-Ordner haben Vorrang vor Built-ins.
func ScriptContent(name string) (string, bool) {
if c, ok := userTemplates()[name]; ok {
return c, true
}
return embeddedContent(name)
}
// MatchScript findet die Vorlage, deren Inhalt zu content passt, und
// erkennt damit bereits installierte motd-assist-Scripte.
func MatchScript(content string) (string, bool) {
return MatchScriptBytes([]byte(content))
}
// MatchScriptBytes ist die Byte-Variante von MatchScript.
func MatchScriptBytes(b []byte) (string, bool) {
content := strings.TrimSpace(string(b))
if content == "" {
return "", false
}
for name, c := range userTemplates() {
if strings.TrimSpace(c) == content {
return name, true
}
}
for _, name := range embeddedScriptNames() {
if c, ok := embeddedContent(name); ok && strings.TrimSpace(c) == content {
return name, true
}
}
return "", false
}
// PresetFiles liefert Dateiname → Inhalt aller eingebetteten Presets.
func PresetFiles() map[string]string {
entries, err := embedded.ReadDir("presets")
@ -50,23 +169,3 @@ func PresetFiles() map[string]string {
}
return files
}
// MatchScript findet die Vorlage, deren Inhalt zu content passt, und
// erkennt damit bereits installierte motd-assist-Scripte.
func MatchScript(content string) (string, bool) {
return MatchScriptBytes([]byte(content))
}
// MatchScriptBytes ist die Byte-Variante von MatchScript.
func MatchScriptBytes(b []byte) (string, bool) {
content := strings.TrimSpace(string(b))
if content == "" {
return "", false
}
for _, name := range ScriptNames() {
if c, ok := ScriptContent(name); ok && strings.TrimSpace(c) == content {
return name, true
}
}
return "", false
}

View file

@ -0,0 +1,77 @@
package assets
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDropInAndOverride(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir, err := EnsureTemplatesDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "mein-header"),
[]byte("#!/bin/sh\necho custom\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "footer"),
[]byte("#!/bin/sh\necho myfooter\n"), 0o644); err != nil {
t.Fatal(err)
}
// Drop-in wird gefunden.
c, ok := ScriptContent("mein-header")
if !ok || !strings.Contains(c, "custom") {
t.Errorf("ScriptContent(mein-header) = %q, %v", c, ok)
}
// Gleichnamige User-Vorlage überdeckt das Built-in.
c, ok = ScriptContent("footer")
if !ok || !strings.Contains(c, "myfooter") {
t.Errorf("Override greift nicht: %q, %v", c, ok)
}
// Übrige Built-ins bleiben erreichbar.
if c, ok = ScriptContent("brand-header"); !ok || !strings.Contains(c, "brand-header") {
t.Errorf("Built-in fehlt: %q, %v", c, ok)
}
// Namensliste enthält die Drop-in-Vorlage.
var found bool
for _, n := range ScriptNames() {
if n == "mein-header" {
found = true
}
}
if !found {
t.Error("mein-header fehlt in ScriptNames()")
}
// MatchScript erkennt Drop-in-Inhalt.
name, ok := MatchScript("#!/bin/sh\necho custom\n")
if !ok || name != "mein-header" {
t.Errorf("MatchScript = %q, %v", name, ok)
}
// Templates() markiert die Quellen richtig.
sources := map[string]string{}
for _, tpl := range Templates() {
sources[tpl.Name] = tpl.Source
}
if sources["mein-header"] != "user" || sources["footer"] != "user" || sources["brand-header"] != "builtin" {
t.Errorf("Quellen falsch: %+v", sources)
}
}
func TestDropInDirMissingIsNoError(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "leer"))
if _, ok := ScriptContent("gibt-es-nicht"); ok {
t.Error("unbekannte Vorlage darf nicht gefunden werden")
}
if len(Templates()) == 0 {
t.Error("Built-ins sollten ohne Drop-in-Ordner weiter existieren")
}
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"motd-assist/internal/assets"
@ -85,8 +86,8 @@ func Validate(root string, plan Plan) error {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if _, ok := assets.ScriptContent(op.Source); !ok {
return fmt.Errorf("install: unbekannte Vorlage %q", op.Source)
if strings.TrimSpace(op.Content) == "" {
return fmt.Errorf("install: Vorlage %q hat keinen Inhalt Plan ohne ResolveContents erstellt?", op.Source)
}
if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name)
@ -95,6 +96,29 @@ func Validate(root string, plan Plan) error {
return nil
}
// ResolveContents füllt die Vorlagen-Inhalte aller Installationen auf.
// Muss im unprivilegierten Kontext laufen (User-Vorlagen liegen im
// $HOME des aufrufenden Benutzers), bevor der Plan per sudo an Root
// übergeben wird der Root-Prozess kann die User-Konfiguration nicht lesen.
func (p *Plan) ResolveContents() error {
for i := range p.Installs {
op := &p.Installs[i]
if op.Content != "" {
continue
}
content, ok := assets.ScriptContent(op.Source)
if !ok {
dir, err := assets.TemplatesDir()
if err != nil || dir == "" {
dir = "~/.config/motd-assist/templates"
}
return fmt.Errorf("Vorlage %q nicht gefunden (eigene Vorlagen ablegen in: %s)", op.Source, dir)
}
op.Content = content
}
return nil
}
// isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage
// entspricht (oder nicht lesbar ist, dann false).
func isBundledFile(path string) bool {
@ -112,12 +136,15 @@ type Snapshot struct {
Scripts []SnapshotScript `json:"scripts"`
}
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
// SnapshotScript hält Name, Modus und (falls gebündelt) Vorlage und
// Inhalt fest, damit Restore ohne Zugriff auf die ursprüngliche
// Vorlage (auch User-Vorlagen unter sudo) auskommt.
type SnapshotScript struct {
Name string `json:"name"`
Mode uint32 `json:"mode"`
Bundled bool `json:"bundled"`
Source string `json:"source,omitempty"`
Content string `json:"content,omitempty"`
}
// SnapshotPath liefert den Ablageort des letzten Snapshots.
@ -170,9 +197,8 @@ func writeSnapshot(root string) error {
}
if s.Bundled {
if b, err := os.ReadFile(s.Path); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
ss.Source = src
}
ss.Content = string(b)
ss.Source, _ = assets.MatchScriptBytes(b)
}
}
snap.Scripts = append(snap.Scripts, ss)
@ -235,12 +261,11 @@ func Execute(root string, plan Plan) error {
}
for _, op := range plan.Installs {
content, _ := assets.ScriptContent(op.Source)
mode := modeDisabled
if op.Enabled {
mode = modeEnabled
}
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(content), mode); err != nil {
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(op.Content), mode); err != nil {
return err
}
}
@ -287,16 +312,20 @@ func Restore(root string) error {
}
}
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
// Fehlende gebündelte Scripte aus dem Snapshot-Inhalt nachinstallieren;
// als Fallback (alte Snapshots ohne Content) über den Vorlagennamen.
var renames []RenameOp
for _, s := range snap.Scripts {
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
continue
}
if s.Bundled && s.Source != "" {
content, ok := assets.ScriptContent(s.Source)
if !ok {
errs = append(errs, fmt.Errorf("Vorlage %s fehlt für %s", s.Source, s.Name))
if s.Bundled {
content := s.Content
if content == "" && s.Source != "" {
content, _ = assets.ScriptContent(s.Source)
}
if content == "" {
errs = append(errs, fmt.Errorf("Inhalt für %s fehlt im Snapshot", s.Name))
continue
}
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {

View file

@ -38,12 +38,39 @@ func TestValidateRejectsUnknownNames(t *testing.T) {
func TestValidateRejectsForeignOverwrite(t *testing.T) {
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho eigenes\n"})
err := Validate(root, Plan{Installs: []InstallOp{{Name: "00-header", Source: "footer", Enabled: true}}})
err := Validate(root, Plan{Installs: []InstallOp{{
Name: "00-header", Source: "footer", Enabled: true,
Content: "#!/bin/sh\necho neu\n",
}}})
if err == nil {
t.Error("Install über fremdes Script sollte abgelehnt werden")
}
}
func TestValidateRejectsEmptyInstallContent(t *testing.T) {
root := setupDir(t, nil)
err := Validate(root, Plan{Installs: []InstallOp{{Name: "10-x", Source: "unbekannt", Enabled: true}}})
if err == nil {
t.Error("Install ohne aufgelösten Inhalt sollte abgelehnt werden")
}
}
func TestResolveContentsEmbedsTemplates(t *testing.T) {
plan := Plan{Installs: []InstallOp{{Name: "10-x", Source: "brand-header", Enabled: true}}}
if err := plan.ResolveContents(); err != nil {
t.Fatalf("ResolveContents: %v", err)
}
if plan.Installs[0].Content == "" {
t.Error("Content wurde nicht befüllt")
}
missing := Plan{Installs: []InstallOp{{Name: "10-y", Source: "gibt-es-nicht", Enabled: true}}}
if err := missing.ResolveContents(); err == nil {
t.Error("unbekannte Vorlage sollte einen Fehler liefern")
}
}
func TestValidateRejectsForeignRemove(t *testing.T) {
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})

View file

@ -33,6 +33,9 @@ func TestExecuteAppliesPlanAndRestore(t *testing.T) {
if plan.Empty() {
t.Fatal("Plan sollte Änderungen enthalten")
}
if err := plan.ResolveContents(); err != nil {
t.Fatalf("ResolveContents: %v", err)
}
if err := Execute(root, plan); err != nil {
t.Fatalf("Execute: %v", err)
}
@ -93,7 +96,11 @@ func TestExecuteInstallAndRemove(t *testing.T) {
{Name: "00-header", Enabled: true},
{Name: "95-footer", Remove: true},
}
if err := Execute(root, Diff(current, desired)); err != nil {
plan := Diff(current, desired)
if err := plan.ResolveContents(); err != nil {
t.Fatalf("ResolveContents: %v", err)
}
if err := Execute(root, plan); err != nil {
t.Fatalf("Execute: %v", err)
}

View file

@ -31,7 +31,8 @@ type (
}
InstallOp struct {
Name string `json:"name"`
Source string `json:"source"`
Source string `json:"source,omitempty"`
Content string `json:"content,omitempty"`
Enabled bool `json:"enabled"`
}
RemoveOp struct {

View file

@ -249,6 +249,12 @@ func (m Model) applyWorking() (tea.Model, tea.Cmd) {
m.status = "Keine Änderungen zum Anwenden"
return m, nil
}
// Vorlagen-Inhalte jetzt auflösen (User-Templates liegen im eigenen
// $HOME); der sudo-Prozess kann sie später nicht mehr lesen.
if err := plan.ResolveContents(); err != nil {
m.status = err.Error()
return m, nil
}
m.applying = true
m.lastSummary = plan.Summary()
return m, applyExec(plan)