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

@ -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 {