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:
parent
d56659e21a
commit
303c49b484
10 changed files with 375 additions and 48 deletions
28
README.md
28
README.md
|
|
@ -68,8 +68,32 @@ motd-assist restore # letzten Apply zurücksetzen
|
||||||
|
|
||||||
Die Array-Reihenfolge bestimmt die MOTD-Reihenfolge. Nicht gelistete System-Scripte
|
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
|
bleiben unverändert; nicht mehr benötigte gebündelte Scripte werden beim Preset-Wechsel
|
||||||
entfernt. `install` referenziert eine gebündelte Vorlage (`brand-header`,
|
entfernt. `install` referenziert eine Vorlage – siehe nächster Abschnitt.
|
||||||
`sysinfo-panel`, `footer`).
|
|
||||||
|
## 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
|
## Entwicklung
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
package assets
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed presets scripts
|
//go:embed presets scripts
|
||||||
var embedded embed.FS
|
var embedded embed.FS
|
||||||
|
|
||||||
// ScriptContent liefert den Inhalt einer gebündelten Script-Vorlage.
|
// Template ist eine installierbare Script-Vorlage.
|
||||||
func ScriptContent(name string) (string, bool) {
|
type Template struct {
|
||||||
b, err := embedded.ReadFile("scripts/" + name)
|
Name string
|
||||||
if err != nil {
|
Source string // "builtin" oder "user"
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return string(b), true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScriptNames listet alle gebündelten Script-Vorlagen.
|
// TemplatesDir liefert den Drop-in-Ordner für eigene Vorlagen (XDG-konform).
|
||||||
func ScriptNames() []string {
|
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")
|
entries, err := embedded.ReadDir("scripts")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -33,6 +80,78 @@ func ScriptNames() []string {
|
||||||
return names
|
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.
|
// PresetFiles liefert Dateiname → Inhalt aller eingebetteten Presets.
|
||||||
func PresetFiles() map[string]string {
|
func PresetFiles() map[string]string {
|
||||||
entries, err := embedded.ReadDir("presets")
|
entries, err := embedded.ReadDir("presets")
|
||||||
|
|
@ -50,23 +169,3 @@ func PresetFiles() map[string]string {
|
||||||
}
|
}
|
||||||
return files
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
77
internal/assets/assets_test.go
Normal file
77
internal/assets/assets_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"motd-assist/internal/assets"
|
"motd-assist/internal/assets"
|
||||||
|
|
@ -85,8 +86,8 @@ func Validate(root string, plan Plan) error {
|
||||||
if !validName.MatchString(op.Name) {
|
if !validName.MatchString(op.Name) {
|
||||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||||
}
|
}
|
||||||
if _, ok := assets.ScriptContent(op.Source); !ok {
|
if strings.TrimSpace(op.Content) == "" {
|
||||||
return fmt.Errorf("install: unbekannte Vorlage %q", op.Source)
|
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)) {
|
if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
|
||||||
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", 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
|
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
|
// isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage
|
||||||
// entspricht (oder nicht lesbar ist, dann false).
|
// entspricht (oder nicht lesbar ist, dann false).
|
||||||
func isBundledFile(path string) bool {
|
func isBundledFile(path string) bool {
|
||||||
|
|
@ -112,12 +136,15 @@ type Snapshot struct {
|
||||||
Scripts []SnapshotScript `json:"scripts"`
|
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 {
|
type SnapshotScript struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Mode uint32 `json:"mode"`
|
Mode uint32 `json:"mode"`
|
||||||
Bundled bool `json:"bundled"`
|
Bundled bool `json:"bundled"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotPath liefert den Ablageort des letzten Snapshots.
|
// SnapshotPath liefert den Ablageort des letzten Snapshots.
|
||||||
|
|
@ -170,9 +197,8 @@ func writeSnapshot(root string) error {
|
||||||
}
|
}
|
||||||
if s.Bundled {
|
if s.Bundled {
|
||||||
if b, err := os.ReadFile(s.Path); err == nil {
|
if b, err := os.ReadFile(s.Path); err == nil {
|
||||||
if src, ok := assets.MatchScript(string(b)); ok {
|
ss.Content = string(b)
|
||||||
ss.Source = src
|
ss.Source, _ = assets.MatchScriptBytes(b)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
snap.Scripts = append(snap.Scripts, ss)
|
snap.Scripts = append(snap.Scripts, ss)
|
||||||
|
|
@ -235,12 +261,11 @@ func Execute(root string, plan Plan) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, op := range plan.Installs {
|
for _, op := range plan.Installs {
|
||||||
content, _ := assets.ScriptContent(op.Source)
|
|
||||||
mode := modeDisabled
|
mode := modeDisabled
|
||||||
if op.Enabled {
|
if op.Enabled {
|
||||||
mode = modeEnabled
|
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
|
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
|
var renames []RenameOp
|
||||||
for _, s := range snap.Scripts {
|
for _, s := range snap.Scripts {
|
||||||
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
|
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.Bundled && s.Source != "" {
|
if s.Bundled {
|
||||||
content, ok := assets.ScriptContent(s.Source)
|
content := s.Content
|
||||||
if !ok {
|
if content == "" && s.Source != "" {
|
||||||
errs = append(errs, fmt.Errorf("Vorlage %s fehlt für %s", s.Source, s.Name))
|
content, _ = assets.ScriptContent(s.Source)
|
||||||
|
}
|
||||||
|
if content == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("Inhalt für %s fehlt im Snapshot", s.Name))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,39 @@ func TestValidateRejectsUnknownNames(t *testing.T) {
|
||||||
func TestValidateRejectsForeignOverwrite(t *testing.T) {
|
func TestValidateRejectsForeignOverwrite(t *testing.T) {
|
||||||
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho eigenes\n"})
|
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 {
|
if err == nil {
|
||||||
t.Error("Install über fremdes Script sollte abgelehnt werden")
|
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) {
|
func TestValidateRejectsForeignRemove(t *testing.T) {
|
||||||
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
|
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ func TestExecuteAppliesPlanAndRestore(t *testing.T) {
|
||||||
if plan.Empty() {
|
if plan.Empty() {
|
||||||
t.Fatal("Plan sollte Änderungen enthalten")
|
t.Fatal("Plan sollte Änderungen enthalten")
|
||||||
}
|
}
|
||||||
|
if err := plan.ResolveContents(); err != nil {
|
||||||
|
t.Fatalf("ResolveContents: %v", err)
|
||||||
|
}
|
||||||
if err := Execute(root, plan); err != nil {
|
if err := Execute(root, plan); err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -93,7 +96,11 @@ func TestExecuteInstallAndRemove(t *testing.T) {
|
||||||
{Name: "00-header", Enabled: true},
|
{Name: "00-header", Enabled: true},
|
||||||
{Name: "95-footer", Remove: 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)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ type (
|
||||||
}
|
}
|
||||||
InstallOp struct {
|
InstallOp struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
RemoveOp struct {
|
RemoveOp struct {
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,12 @@ func (m Model) applyWorking() (tea.Model, tea.Cmd) {
|
||||||
m.status = "Keine Änderungen zum Anwenden"
|
m.status = "Keine Änderungen zum Anwenden"
|
||||||
return m, nil
|
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.applying = true
|
||||||
m.lastSummary = plan.Summary()
|
m.lastSummary = plan.Summary()
|
||||||
return m, applyExec(plan)
|
return m, applyExec(plan)
|
||||||
|
|
|
||||||
23
main.go
23
main.go
|
|
@ -10,6 +10,7 @@ import (
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"motd-assist/internal/assets"
|
||||||
"motd-assist/internal/motd"
|
"motd-assist/internal/motd"
|
||||||
"motd-assist/internal/preset"
|
"motd-assist/internal/preset"
|
||||||
"motd-assist/internal/ui"
|
"motd-assist/internal/ui"
|
||||||
|
|
@ -35,6 +36,8 @@ func main() {
|
||||||
cmdList(root)
|
cmdList(root)
|
||||||
case "presets":
|
case "presets":
|
||||||
cmdPresets()
|
cmdPresets()
|
||||||
|
case "templates":
|
||||||
|
cmdTemplates()
|
||||||
case "apply":
|
case "apply":
|
||||||
cmdApply(root)
|
cmdApply(root)
|
||||||
case "restore":
|
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\": \"<dateiname>\" }\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) {
|
func cmdApply(root string) {
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fatal(fmt.Errorf("Usage: motd-assist apply <preset>"))
|
fatal(fmt.Errorf("Usage: motd-assist apply <preset>"))
|
||||||
|
|
@ -163,6 +179,12 @@ func cmdApply(root string) {
|
||||||
return
|
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 motd.IsRoot() {
|
||||||
if err := motd.Execute(root, plan); err != nil {
|
if err := motd.Execute(root, plan); err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
|
|
@ -239,6 +261,7 @@ Usage:
|
||||||
motd-assist preview [-w N] aktuellen MOTD rendern und ausgeben
|
motd-assist preview [-w N] aktuellen MOTD rendern und ausgeben
|
||||||
motd-assist list Scripte und Zustand auflisten
|
motd-assist list Scripte und Zustand auflisten
|
||||||
motd-assist presets verfügbare Presets auflisten
|
motd-assist presets verfügbare Presets auflisten
|
||||||
|
motd-assist templates Vorlagen auflisten + Drop-in-Ordner anzeigen
|
||||||
motd-assist apply <preset> Preset anwenden (fragt ggf. sudo-Passwort)
|
motd-assist apply <preset> Preset anwenden (fragt ggf. sudo-Passwort)
|
||||||
motd-assist restore letzten Apply zurücksetzen
|
motd-assist restore letzten Apply zurücksetzen
|
||||||
motd-assist help diese Hilfe
|
motd-assist help diese Hilfe
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,40 @@ echo
|
||||||
echo "=== list (nach restore) ==="
|
echo "=== list (nach restore) ==="
|
||||||
MOTD_ASSIST_ROOT=$R /tmp/motd-assist list
|
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
|
||||||
echo "=== ls update-motd.d ==="
|
echo "=== ls update-motd.d ==="
|
||||||
ls -la "$R/etc/update-motd.d"
|
ls -la "$R/etc/update-motd.d"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue