feat: add motd-assist TUI for managing dynamic MOTDs

Initial Go project built with Bubble Tea and Lip Gloss:
- Live preview and management of /etc/update-motd.d scripts
- Embedded presets (ubuntu-default, minimal, server-dashboard)
- Bundled POSIX sh style scripts (brand header, sysinfo panel, footer)
- Snapshot/restore safety around applying changes
- CLI subcommands preview, apply, restore, list-presets
- Unit tests for detect, sanitize, and apply logic
This commit is contained in:
Tronax 2026-08-24 14:19:28 +02:00
commit d56659e21a
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
34 changed files with 3260 additions and 0 deletions

127
internal/preset/preset.go Normal file
View file

@ -0,0 +1,127 @@
// Package preset definiert das Preset-Schema und das Laden/Speichern
// von Built-in- und User-Presets.
package preset
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"motd-assist/internal/assets"
)
// Entry beschreibt den Soll-Zustand eines Scripts innerhalb eines Presets.
// Die Reihenfolge im Scripts-Array bestimmt die Ziel-Reihenfolge im MOTD.
type Entry struct {
Name string `json:"name"` // Zieldateiname, z.B. "10-brand-header"
Enabled bool `json:"enabled"` // Executable-Bit gesetzt
Install string `json:"install,omitempty"` // gebündelte Vorlage, leer = vorhandenes Script
}
// Preset ist eine benannte MOTD-Konfiguration.
type Preset struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Scripts []Entry `json:"scripts"`
Source string `json:"-"` // "builtin" oder "user"
}
var safeName = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
// Dir liefert das User-Preset-Verzeichnis (XDG-konform).
func Dir() (string, error) {
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "motd-assist", "presets"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "motd-assist", "presets"), nil
}
// LoadAll liefert alle Presets: Built-ins, durch User-Presets mit
// gleichem Namen überschrieben. Sortiert nach Name.
func LoadAll() ([]Preset, error) {
byName := make(map[string]Preset)
for file, content := range assets.PresetFiles() {
var p Preset
if err := json.Unmarshal([]byte(content), &p); err != nil {
return nil, fmt.Errorf("builtin preset %s: %w", file, err)
}
p.Source = "builtin"
byName[p.Name] = p
}
dir, err := Dir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err == nil {
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
continue
}
p.Source = "user"
byName[p.Name] = p
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
presets := make([]Preset, 0, len(byName))
for _, p := range byName {
presets = append(presets, p)
}
sort.Slice(presets, func(i, j int) bool { return presets[i].Name < presets[j].Name })
return presets, nil
}
// SaveUser schreibt ein Preset ins User-Verzeichnis (überschreibt Built-ins
// mit gleichem Namen zur Laufzeit).
func SaveUser(p Preset) (string, error) {
if p.Name == "" {
return "", errors.New("Preset-Name fehlt")
}
dir, err := Dir()
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
b, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "", err
}
path := filepath.Join(dir, safeName.ReplaceAllString(p.Name, "-")+".json")
return path, os.WriteFile(path, append(b, '\n'), 0o644)
}
// DeleteUser entfernt ein User-Preset. Built-ins können nicht gelöscht werden.
func DeleteUser(name string) error {
dir, err := Dir()
if err != nil {
return err
}
path := filepath.Join(dir, safeName.ReplaceAllString(name, "-")+".json")
if err := os.Remove(path); err != nil {
return fmt.Errorf("nur User-Presets sind löschbar: %w", err)
}
return nil
}

View file

@ -0,0 +1,59 @@
package preset
import (
"testing"
)
func TestBuiltinPresetsLoad(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
presets, err := LoadAll()
if err != nil {
t.Fatal(err)
}
if len(presets) < 3 {
t.Errorf("Built-in-Presets fehlen: %d geladen", len(presets))
}
for _, p := range presets {
if p.Source != "builtin" {
t.Errorf("Preset %s: Source = %s", p.Name, p.Source)
}
if len(p.Scripts) == 0 {
t.Errorf("Preset %s hat keine Scripte", p.Name)
}
}
}
func TestSaveAndUserOverride(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
p := Preset{
Name: "minimal",
Description: "eigene Variante",
Scripts: []Entry{{Name: "00-header", Enabled: true}},
}
if _, err := SaveUser(p); err != nil {
t.Fatal(err)
}
presets, err := LoadAll()
if err != nil {
t.Fatal(err)
}
for _, got := range presets {
if got.Name == "minimal" {
if got.Source != "user" || got.Description != "eigene Variante" {
t.Errorf("User-Override greift nicht: %+v", got)
}
return
}
}
t.Fatal("minimal nicht gefunden")
}
func TestDeleteUserRejectsUnknown(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := DeleteUser("gibt-es-nicht"); err == nil {
t.Error("Löschen eines fehlenden Presets sollte fehlschlagen")
}
}