motd-assist/internal/assets/assets.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

171 lines
4.1 KiB
Go

// 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
// Template ist eine installierbare Script-Vorlage.
type Template struct {
Name string
Source string // "builtin" oder "user"
}
// 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
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
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")
if err != nil {
return nil
}
files := make(map[string]string, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
if b, err := embedded.ReadFile("presets/" + e.Name()); err == nil {
files[e.Name()] = string(b)
}
}
return files
}