From 303c49b484a8f6d0468e09e0687befdc6bd1a233 Mon Sep 17 00:00:00 2001 From: Tronax Date: Mon, 31 Aug 2026 10:31:58 +0200 Subject: [PATCH] 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": "". 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. --- README.md | 28 +++++- internal/assets/assets.go | 159 +++++++++++++++++++++++++------ internal/assets/assets_test.go | 77 +++++++++++++++ internal/motd/apply.go | 55 ++++++++--- internal/motd/apply_test.go | 29 +++++- internal/motd/apply_unix_test.go | 9 +- internal/motd/state.go | 3 +- internal/ui/update.go | 6 ++ main.go | 23 +++++ scripts/e2e-wsl.sh | 34 +++++++ 10 files changed, 375 insertions(+), 48 deletions(-) create mode 100644 internal/assets/assets_test.go diff --git a/README.md b/README.md index 03de0cb..9b39a6d 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,32 @@ motd-assist restore # letzten Apply zurücksetzen Die Array-Reihenfolge bestimmt die MOTD-Reihenfolge. Nicht gelistete System-Scripte bleiben unverändert; nicht mehr benötigte gebündelte Scripte werden beim Preset-Wechsel -entfernt. `install` referenziert eine gebündelte Vorlage (`brand-header`, -`sysinfo-panel`, `footer`). +entfernt. `install` referenziert eine Vorlage – siehe nächster Abschnitt. + +## Eigene Vorlagen (Drop-in) + +Vorlagen, die Presets über `"install"` referenzieren, können ohne Rebuild ergänzt +werden: Datei in den Drop-in-Ordner legen, fertig. + +```sh +motd-assist templates # zeigt Ordner (legt ihn an) + bekannte Vorlagen +~/.config/motd-assist/templates/mein-header # eigene Vorlage (POSIX sh) +``` + +Anschließend im Preset referenzieren: + +```json +{ "name": "10-mein-header", "enabled": true, "install": "mein-header" } +``` + +- Der Dateiname im Drop-in-Ordner ist der Vorlagenname; gleichnamige Dateien + überdecken die Built-ins (`brand-header`, `sysinfo-panel`, `footer`). +- Neue/geänderte Vorlagen übernimmt die laufende TUI mit `r`. +- Installierte Scripte aus Vorlagen gelten als von motd-assist verwaltet + (Preset-Wechsel räumt sie wieder ab, `restore` kennt ihren Inhalt). +- Technisch: Der Apply-Plan bettet den Vorlageninhalt ein. Das Auflösen + passiert im unprivilegierten Kontext; der sudo-Prozess muss die + User-Konfiguration nicht lesen können. ## Entwicklung diff --git a/internal/assets/assets.go b/internal/assets/assets.go index e705454..a4c4736 100644 --- a/internal/assets/assets.go +++ b/internal/assets/assets.go @@ -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 -} diff --git a/internal/assets/assets_test.go b/internal/assets/assets_test.go new file mode 100644 index 0000000..72a632b --- /dev/null +++ b/internal/assets/assets_test.go @@ -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") + } +} diff --git a/internal/motd/apply.go b/internal/motd/apply.go index a9aa26b..bae3fa0 100644 --- a/internal/motd/apply.go +++ b/internal/motd/apply.go @@ -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 { diff --git a/internal/motd/apply_test.go b/internal/motd/apply_test.go index 48a9f9a..66df1f5 100644 --- a/internal/motd/apply_test.go +++ b/internal/motd/apply_test.go @@ -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"}) diff --git a/internal/motd/apply_unix_test.go b/internal/motd/apply_unix_test.go index 1ccb037..8340409 100644 --- a/internal/motd/apply_unix_test.go +++ b/internal/motd/apply_unix_test.go @@ -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) } diff --git a/internal/motd/state.go b/internal/motd/state.go index 0747fcb..3cf4d7c 100644 --- a/internal/motd/state.go +++ b/internal/motd/state.go @@ -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 { diff --git a/internal/ui/update.go b/internal/ui/update.go index ccb3b7f..aaff719 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -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) diff --git a/main.go b/main.go index 10a05fd..e999db6 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" + "motd-assist/internal/assets" "motd-assist/internal/motd" "motd-assist/internal/preset" "motd-assist/internal/ui" @@ -35,6 +36,8 @@ func main() { cmdList(root) case "presets": cmdPresets() + case "templates": + cmdTemplates() case "apply": cmdApply(root) case "restore": @@ -128,6 +131,19 @@ func cmdPresets() { } } +func cmdTemplates() { + dir, err := assets.EnsureTemplatesDir() + if err != nil { + fatal(err) + } + fmt.Println("Eigene Vorlagen: Datei in diesen Ordner legen und im Preset referenzieren:") + fmt.Printf(" { \"name\": \"10-mein-style\", \"enabled\": true, \"install\": \"\" }\n") + fmt.Printf("Drop-in-Ordner: %s\n\n", dir) + for _, t := range assets.Templates() { + fmt.Printf(" %-20s (%s)\n", t.Name, t.Source) + } +} + func cmdApply(root string) { if len(os.Args) < 3 { fatal(fmt.Errorf("Usage: motd-assist apply ")) @@ -163,6 +179,12 @@ func cmdApply(root string) { return } + // Vorlagen-Inhalte im User-Kontext auflösen, bevor der Plan + // (ggf. per sudo) an Root übergeben wird. + if err := plan.ResolveContents(); err != nil { + fatal(err) + } + if motd.IsRoot() { if err := motd.Execute(root, plan); err != nil { fatal(err) @@ -239,6 +261,7 @@ Usage: motd-assist preview [-w N] aktuellen MOTD rendern und ausgeben motd-assist list Scripte und Zustand auflisten motd-assist presets verfügbare Presets auflisten + motd-assist templates Vorlagen auflisten + Drop-in-Ordner anzeigen motd-assist apply Preset anwenden (fragt ggf. sudo-Passwort) motd-assist restore letzten Apply zurücksetzen motd-assist help diese Hilfe diff --git a/scripts/e2e-wsl.sh b/scripts/e2e-wsl.sh index 62f854f..6a60be2 100644 --- a/scripts/e2e-wsl.sh +++ b/scripts/e2e-wsl.sh @@ -45,6 +45,40 @@ echo echo "=== list (nach restore) ===" MOTD_ASSIST_ROOT=$R /tmp/motd-assist list +echo +echo "=== Drop-in-Vorlage anlegen (XDG) ===" +export XDG_CONFIG_HOME=/tmp/xdg-motd +rm -rf "$XDG_CONFIG_HOME" +mkdir -p "$XDG_CONFIG_HOME/motd-assist/templates" "$XDG_CONFIG_HOME/motd-assist/presets" +printf '#!/bin/sh\necho "== Mein eigenes Template =="\nuname -s\n' > "$XDG_CONFIG_HOME/motd-assist/templates/mein-header" +cat > "$XDG_CONFIG_HOME/motd-assist/presets/custom.json" <<'EOF' +{ + "name": "custom", + "description": "Drop-in-Preset mit eigener Vorlage", + "scripts": [ + { "name": "00-header", "enabled": false }, + { "name": "10-mein-header", "enabled": true, "install": "mein-header" } + ] +} +EOF + +echo +echo "=== templates (Drop-in muss auftauchen) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist templates + +echo +echo "=== apply custom (User-Vorlage via eingebetteten Plan-Inhalt) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist apply custom + +echo +echo "=== preview mit Drop-in-Template ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist preview -w 50 + +echo +echo "=== restore (Drop-in-Script muss weg) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist restore +MOTD_ASSIST_ROOT=$R /tmp/motd-assist list + echo echo "=== ls update-motd.d ===" ls -la "$R/etc/update-motd.d"