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

72
internal/assets/assets.go Normal file
View file

@ -0,0 +1,72 @@
// Package assets hält die eingebetteten Presets und Custom-Scripte.
package assets
import (
"embed"
"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
}
// ScriptNames listet alle gebündelten Script-Vorlagen.
func ScriptNames() []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
}
// 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
}
// 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
}

View file

@ -0,0 +1,13 @@
{
"name": "minimal",
"description": "Ruhiger Login: nur Header-Zeile und dezenter Footer",
"scripts": [
{ "name": "00-header", "enabled": true },
{ "name": "10-help-text", "enabled": false },
{ "name": "50-landscape-sysinfo", "enabled": false },
{ "name": "50-motd-news", "enabled": false },
{ "name": "80-livepatch", "enabled": false },
{ "name": "91-release-upgrade", "enabled": false },
{ "name": "95-footer", "enabled": true, "install": "footer" }
]
}

View file

@ -0,0 +1,13 @@
{
"name": "server-dashboard",
"description": "Server-Übersicht: Branding-Header, Sysinfo-Balken und Update-Hinweis",
"scripts": [
{ "name": "00-header", "enabled": false },
{ "name": "10-help-text", "enabled": false },
{ "name": "50-motd-news", "enabled": false },
{ "name": "10-brand-header", "enabled": true, "install": "brand-header" },
{ "name": "20-sysinfo-panel", "enabled": true, "install": "sysinfo-panel" },
{ "name": "90-updates-available", "enabled": true },
{ "name": "95-footer", "enabled": true, "install": "footer" }
]
}

View file

@ -0,0 +1,16 @@
{
"name": "ubuntu-default",
"description": "Ubuntu-Standard: alle bekannten System-Scripte aktiviert",
"scripts": [
{ "name": "00-header", "enabled": true },
{ "name": "10-help-text", "enabled": true },
{ "name": "50-landscape-sysinfo", "enabled": true },
{ "name": "50-motd-news", "enabled": true },
{ "name": "80-livepatch", "enabled": true },
{ "name": "90-updates-available", "enabled": true },
{ "name": "91-release-upgrade", "enabled": true },
{ "name": "95-hwe-eol", "enabled": true },
{ "name": "97-overlayroot", "enabled": true },
{ "name": "98-fsck-at-reboot", "enabled": true }
]
}

View file

@ -0,0 +1,36 @@
#!/bin/sh
# motd-assist :: brand-header — Rahmen-Header mit Hostname und Betriebssystem
set -u
E=$(printf '\033')
BLUE="${E}[1;38;5;75m"
ORANGE="${E}[1;38;5;214m"
DIM="${E}[2m"
BOLD="${E}[1m"
R="${E}[0m"
HOST=$(hostname 2>/dev/null || echo localhost)
HOST=${HOST%%.*}
OS=Linux
if [ -r /etc/os-release ]; then
. /etc/os-release
OS=${PRETTY_NAME:-Linux}
fi
W=${COLUMNS:-72}
case $W in ''|*[!0-9]*) W=72 ;; esac
[ "$W" -gt 78 ] && W=78
[ "$W" -lt 44 ] && W=44
INNER=$((W - 2))
PLAIN=" ${HOST} - ${OS} "
PAD=$((INNER - ${#PLAIN}))
[ "$PAD" -lt 0 ] && PAD=0
RULE=$(awk -v n="$INNER" 'BEGIN{s="";for(i=0;i<n;i++)s=s "─";printf "%s",s}')
SPACES=$(printf '%*s' "$PAD" '')
printf '%b\n' "$BLUE╭$R$DIM$RULE$R$BLUE╮$R"
printf '%b\n' "$BLUE│$R $BOLD$BLUE$HOST$R $DIM·$R $ORANGE$OS$R$SPACES $BLUE│$R"
printf '%b\n' "$BLUE╰$R$DIM$RULE$R$BLUE╯$R"

View file

@ -0,0 +1,14 @@
#!/bin/sh
# motd-assist :: footer — dezente Kernel-/Uptime-Zeile
set -u
E=$(printf '\033')
DIM="${E}[2m"
R="${E}[0m"
K=$(uname -r 2>/dev/null || echo unknown)
UP=$(uptime -p 2>/dev/null)
[ -n "$UP" ] || UP=$(awk '{d=int($1/86400); h=int(($1%86400)/3600); printf "up %d days %d hours", d, h}' /proc/uptime 2>/dev/null)
[ -n "$UP" ] && printf '%b\n' "${DIM}${K} · ${UP}${R}"
exit 0

View file

@ -0,0 +1,66 @@
#!/bin/sh
# motd-assist :: sysinfo-panel — Load, Speicher, Disk und Updates als farbige Balken
set -u
E=$(printf '\033')
GREEN="${E}[38;5;114m"
YELLOW="${E}[38;5;221m"
RED="${E}[38;5;203m"
LBLUE="${E}[38;5;75m"
DIM="${E}[2m"
R="${E}[0m"
BAR=22
# bar <used> <total> — farbiger Balken mit Prozentanzeige
bar() {
awk -v u="$1" -v t="$2" -v b="$BAR" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" 'BEGIN{
if (t <= 0) { printf "%sn/a%s", d, rset; exit }
pct = u * 100 / t
if (pct > 100) pct = 100
f = int(pct / 100 * b + 0.5)
c = (pct < 60) ? g : ((pct < 85) ? y : r)
s = c
for (i = 0; i < f; i++) s = s "━"
s = s d
for (i = f; i < b; i++) s = s "·"
printf "%s%s%s %s%3d%%%s", s, rset, c, c, pct, rset
}'
}
mem=$(awk '/^MemTotal:/ {t=$2} /^MemAvailable:/ {a=$2} END {if (t>0) printf "%.0f %.0f", t-a, t}' /proc/meminfo 2>/dev/null)
[ -n "$mem" ] && printf '%b\n' "$LBLUE MEM $DIM│$R $(bar $mem)"
disk=$(df -P / 2>/dev/null | awk 'NR==2 {gsub("%","",$5); print $5}')
[ -n "$disk" ] && printf '%b\n' "$LBLUE DISK $DIM│$R $(bar $disk 100)"
if [ -r /proc/loadavg ]; then
load=$(awk '{print $1}' /proc/loadavg)
ncpu=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
case $ncpu in ''|*[!0-9]*) ncpu=1 ;; esac
awk -v l="$load" -v n="$ncpu" -v b="$BAR" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" -v lbl="$LBLUE" 'BEGIN{
pct = l * 100 / n
if (pct > 100) pct = 100
f = int(pct / 100 * b + 0.5)
c = (pct < 60) ? g : ((pct < 85) ? y : r)
s = c
for (i = 0; i < f; i++) s = s "━"
s = s d
for (i = f; i < b; i++) s = s "·"
printf "%s LOAD %s│%s %s%s%s %s%s/%s%s\n", lbl, d, rset, s, rset, c, c, l, n, rset
}'
fi
if [ -r /var/lib/update-notifier/updates-available ]; then
upd=$(awk 'NR==1 {print $1; exit}' /var/lib/update-notifier/updates-available 2>/dev/null)
case $upd in
''|*[!0-9]*) ;;
*)
awk -v n="$upd" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" -v lbl="$LBLUE" 'BEGIN{
c = (n == 0) ? g : ((n < 20) ? y : r)
txt = (n == 0) ? "System ist auf dem aktuellen Stand" : n " Paket-Updates verfügbar"
printf "%s UPD %s│%s %s%s%s\n", lbl, d, rset, c, txt, rset
}'
;;
esac
fi

344
internal/motd/apply.go Normal file
View file

@ -0,0 +1,344 @@
package motd
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"motd-assist/internal/assets"
)
const (
modeEnabled os.FileMode = 0o755
modeDisabled os.FileMode = 0o644
)
// Validate prüft einen Plan gegen das Dateisystem, bevor Root-Operationen
// ausgeführt werden. Alle Namen müssen einfach bleiben und im
// update-motd.d-Verzeichnis liegen; fremde Dateien sind tabu.
func Validate(root string, plan Plan) error {
dir := MotdDir(root)
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
return fmt.Errorf("%s existiert nicht", dir)
}
existing := make(map[string]bool)
listing, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range listing {
existing[e.Name()] = true
}
for _, op := range plan.Chmod {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if !existing[op.Name] {
return fmt.Errorf("chmod: %s existiert nicht", op.Name)
}
}
for _, op := range plan.Removes {
if !validName.MatchString(op.Name) {
return fmt.Errorf("ungültiger Name %q", op.Name)
}
if !existing[op.Name] {
return fmt.Errorf("remove: %s existiert nicht", op.Name)
}
if !isBundledFile(filepath.Join(dir, op.Name)) {
return fmt.Errorf("remove: %s ist kein motd-assist-Script", op.Name)
}
}
renamedAway := make(map[string]bool, len(plan.Renames))
for _, op := range plan.Renames {
if !validName.MatchString(op.From) || !validName.MatchString(op.To) {
return fmt.Errorf("ungültiger Name in Umbenennung %q → %q", op.From, op.To)
}
if !existing[op.From] {
return fmt.Errorf("rename: %s existiert nicht", op.From)
}
renamedAway[op.From] = true
}
// Zielnamen dürfen nur frei werden, wenn die Datei dort wegrename'd oder
// entfernt wird sonst Kollision.
for _, op := range plan.Renames {
if existing[op.To] && !renamedAway[op.To] {
isRemoved := false
for _, r := range plan.Removes {
if r.Name == op.To {
isRemoved = true
break
}
}
if !isRemoved {
return fmt.Errorf("rename: Ziel %s ist belegt", op.To)
}
}
}
for _, op := range plan.Installs {
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 existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name)
}
}
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 {
b, err := os.ReadFile(path)
if err != nil {
return false
}
_, ok := assets.MatchScriptBytes(b)
return ok
}
// Snapshot sichert den Zustand vor einem Apply, damit Restore möglich ist.
type Snapshot struct {
SavedAt time.Time `json:"saved_at"`
Scripts []SnapshotScript `json:"scripts"`
}
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
type SnapshotScript struct {
Name string `json:"name"`
Mode uint32 `json:"mode"`
Bundled bool `json:"bundled"`
Source string `json:"source,omitempty"`
}
// SnapshotPath liefert den Ablageort des letzten Snapshots.
func SnapshotPath(root string) string {
return filepath.Join(root, "var", "lib", "motd-assist", "last-snapshot.json")
}
// WriteFile schreibt den Plan als temporäre JSON-Datei, z.B. für die
// Übergabe an "motd-assist apply-state" unter sudo.
func (p Plan) WriteFile() (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", err
}
f, err := os.CreateTemp("", "motd-assist-plan-*.json")
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Write(b); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// ReadPlanFile liest eine mit WriteFile erzeugte Plandatei.
func ReadPlanFile(path string) (Plan, error) {
var plan Plan
b, err := os.ReadFile(path)
if err != nil {
return plan, err
}
if err := json.Unmarshal(b, &plan); err != nil {
return plan, fmt.Errorf("ungültige Plandatei: %w", err)
}
return plan, nil
}
func writeSnapshot(root string) error {
scripts, err := Discover(root)
if err != nil {
return err
}
snap := Snapshot{SavedAt: time.Now(), Scripts: make([]SnapshotScript, 0, len(scripts))}
for _, s := range scripts {
ss := SnapshotScript{Name: s.Name, Bundled: s.Bundled}
if fi, err := os.Stat(s.Path); err == nil {
ss.Mode = uint32(fi.Mode().Perm())
}
if s.Bundled {
if b, err := os.ReadFile(s.Path); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
ss.Source = src
}
}
}
snap.Scripts = append(snap.Scripts, ss)
}
path := SnapshotPath(root)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(b, '\n'), 0o644)
}
// Execute führt einen validierten Plan aus: Snapshot → Removes →
// Umbenennungen (zweiphasig gegen Kollisionen) → Installationen → Modi.
// Erfordert Root, wenn /etc/update-motd.d Root gehört.
func Execute(root string, plan Plan) error {
if err := Validate(root, plan); err != nil {
return err
}
if err := writeSnapshot(root); err != nil {
return fmt.Errorf("Snapshot fehlgeschlagen: %w", err)
}
dir := MotdDir(root)
// Reihenfolge: Removes → Chmods (alte Namen!) → Renames → Installs.
for _, op := range plan.Removes {
if err := os.Remove(filepath.Join(dir, op.Name)); err != nil {
return err
}
}
for _, op := range plan.Chmod {
mode := modeDisabled
if op.Enable {
mode = modeEnabled
}
if err := os.Chmod(filepath.Join(dir, op.Name), mode); err != nil {
return err
}
}
// Zweiphasige Umbenennung: erst alle nach .tmp-N (Discover ignoriert
// Punktnamen), dann auf die finalen Namen.
if len(plan.Renames) > 0 {
for i := range plan.Renames {
tmp := fmt.Sprintf(".ma-tmp-%d", i)
if err := os.Rename(filepath.Join(dir, plan.Renames[i].From), filepath.Join(dir, tmp)); err != nil {
return err
}
plan.Renames[i].From = tmp
}
for _, op := range plan.Renames {
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
return err
}
}
}
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 {
return err
}
}
return nil
}
// Restore setzt den letzten Snapshot bestmöglich zurück (Modi, Namen,
// gebündelte Installationen). Best-effort: Fehler werden gesammelt.
func Restore(root string) error {
b, err := os.ReadFile(SnapshotPath(root))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return errors.New("kein Snapshot vorhanden")
}
return err
}
var snap Snapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
current, err := Discover(root)
if err != nil {
return err
}
curBySuffix := make(map[string]Script, len(current))
for _, c := range current {
curBySuffix[suffixOf(c.Name)] = c
}
snapBySuffix := make(map[string]SnapshotScript, len(snap.Scripts))
for _, s := range snap.Scripts {
snapBySuffix[suffixOf(s.Name)] = s
}
var errs []error
dir := MotdDir(root)
// Dateien entfernen, die es vor dem Apply nicht gab (nur unsere).
for _, c := range current {
if _, ok := snapBySuffix[suffixOf(c.Name)]; !ok && c.Bundled {
if err := os.Remove(c.Path); err != nil {
errs = append(errs, err)
}
}
}
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
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))
continue
}
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
errs = append(errs, err)
}
}
}
// Namen und Modi wiederherstellen.
for _, c := range current {
s, ok := snapBySuffix[suffixOf(c.Name)]
if !ok {
continue
}
if c.Name != s.Name {
renames = append(renames, RenameOp{From: c.Name, To: s.Name})
}
if err := os.Chmod(c.Path, os.FileMode(s.Mode)); err != nil {
errs = append(errs, err)
}
}
if len(renames) > 0 {
for i := range renames {
tmp := fmt.Sprintf(".ma-tmp-%d", i)
if err := os.Rename(filepath.Join(dir, renames[i].From), filepath.Join(dir, tmp)); err != nil {
errs = append(errs, err)
continue
}
renames[i].From = tmp
}
for _, op := range renames {
if op.From == op.To {
continue
}
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
errs = append(errs, err)
}
}
}
if len(errs) > 0 {
return fmt.Errorf("Restore mit Fehlern: %w", errors.Join(errs...))
}
return nil
}

View file

@ -0,0 +1,73 @@
package motd
import (
"os"
"path/filepath"
"testing"
)
// setupDir legt ein update-motd.d-Verzeichnis unter einem Temp-Root an.
func setupDir(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
dir := MotdDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
return root
}
func TestValidateRejectsUnknownNames(t *testing.T) {
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho hi\n"})
err := Validate(root, Plan{Chmod: []ChmodOp{{Name: "../evil", Enable: true}}})
if err == nil {
t.Error("Pfad-Traversal sollte abgelehnt werden")
}
err = Validate(root, Plan{Chmod: []ChmodOp{{Name: "gibt-es-nicht", Enable: true}}})
if err == nil {
t.Error("chmod auf fehlende Datei sollte abgelehnt werden")
}
}
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}}})
if err == nil {
t.Error("Install über fremdes Script sollte abgelehnt werden")
}
}
func TestValidateRejectsForeignRemove(t *testing.T) {
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
err := Validate(root, Plan{Removes: []RemoveOp{{Name: "10-sysinfo"}}})
if err == nil {
t.Error("Remove eines fremden Scripts sollte abgelehnt werden")
}
}
func TestValidateRenameCollision(t *testing.T) {
root := setupDir(t, map[string]string{
"00-a": "#!/bin/sh\n",
"10-b": "#!/bin/sh\n",
})
// b wird nicht wegbewegt → Ziel 10-b ist belegt.
err := Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}}})
if err == nil {
t.Error("Kollision sollte abgelehnt werden")
}
// Beide tauschen → zweiphasige Ausführung macht das frei.
err = Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}, {From: "10-b", To: "00-a"}}})
if err != nil {
t.Errorf("Tausch sollte erlaubt sein: %v", err)
}
}

View file

@ -0,0 +1,118 @@
//go:build unix
package motd
import (
"os"
"path/filepath"
"testing"
"motd-assist/internal/assets"
)
func TestExecuteAppliesPlanAndRestore(t *testing.T) {
root := setupDir(t, map[string]string{
"00-header": "#!/bin/sh\necho header\n",
"10-sysinfo": "#!/bin/sh\necho sysinfo\n",
})
dir := MotdDir(root)
if err := os.Chmod(filepath.Join(dir, "10-sysinfo"), 0o644); err != nil {
t.Fatal(err)
}
current, err := Discover(root)
if err != nil {
t.Fatal(err)
}
// Ziel: sysinfo vor header, sysinfo aktiv, header aus.
desired := []DesiredEntry{
{Name: "10-sysinfo", Enabled: true},
{Name: "00-header", Enabled: false},
}
plan := Diff(current, desired)
if plan.Empty() {
t.Fatal("Plan sollte Änderungen enthalten")
}
if err := Execute(root, plan); err != nil {
t.Fatalf("Execute: %v", err)
}
after, err := Discover(root)
if err != nil {
t.Fatal(err)
}
if len(after) != 2 {
t.Fatalf("nach Execute: %v", after)
}
if after[0].Name != "00-sysinfo" || !after[0].Enabled {
t.Errorf("after[0] = %+v, want 00-sysinfo aktiv", after[0])
}
if after[1].Name != "10-header" || after[1].Enabled {
t.Errorf("after[1] = %+v, want 10-header inaktiv", after[1])
}
if _, err := os.Stat(SnapshotPath(root)); err != nil {
t.Errorf("Snapshot fehlt: %v", err)
}
if err := Restore(root); err != nil {
t.Fatalf("Restore: %v", err)
}
restored, err := Discover(root)
if err != nil {
t.Fatal(err)
}
if restored[0].Name != "00-header" || !restored[0].Enabled {
t.Errorf("restored[0] = %+v, want 00-header aktiv", restored[0])
}
if restored[1].Name != "10-sysinfo" || restored[1].Enabled {
t.Errorf("restored[1] = %+v, want 10-sysinfo inaktiv", restored[1])
}
}
func TestExecuteInstallAndRemove(t *testing.T) {
root := setupDir(t, map[string]string{
"00-header": "#!/bin/sh\necho header\n",
"95-footer": "#!/bin/sh\ntrue\n", // wird unten als gebündelt ersetzt
})
dir := MotdDir(root)
// echtes gebündeltes Script einsetzen, damit Remove validiert
content, ok := assets.ScriptContent("footer")
if !ok {
t.Fatal("footer-Vorlage nicht gefunden")
}
if err := os.WriteFile(filepath.Join(dir, "95-footer"), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
current, err := Discover(root)
if err != nil {
t.Fatal(err)
}
desired := []DesiredEntry{
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "00-header", Enabled: true},
{Name: "95-footer", Remove: true},
}
if err := Execute(root, Diff(current, desired)); err != nil {
t.Fatalf("Execute: %v", err)
}
after, err := Discover(root)
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range after {
names = append(names, s.Name)
}
want := []string{"00-brand-header", "10-header"}
if len(names) != 2 || names[0] != want[0] || names[1] != want[1] {
t.Errorf("names = %v, want %v", names, want)
}
if !after[0].Bundled {
t.Error("brand-header sollte als gebündelt erkannt werden")
}
if !after[0].Enabled || !after[1].Enabled {
t.Errorf("Modi falsch: %+v", after)
}
}

74
internal/motd/detect.go Normal file
View file

@ -0,0 +1,74 @@
package motd
import (
"os"
"path/filepath"
"strings"
)
// HostInfo beschreibt den erkannten MOTD-Zustand des Systems.
type HostInfo struct {
Hostname string
Distro string // PRETTY_NAME aus /etc/os-release
PAM bool // pam_motd konfiguriert (dynamischer MOTD aktiv)
PAMWhere []string // Dateien in /etc/pam.d mit pam_motd-Eintrag
DynamicPath string // vorhandene Cache-Datei der dynamischen MOTD ("" = keine)
StaticMOTD bool // /etc/motd vorhanden und nicht leer
}
// DetectHost sammelt Host- und MOTD-Informationen unter root.
// Fehler einzelner Erkennungen führen nicht zum Abbruch.
func DetectHost(root string) HostInfo {
info := HostInfo{Hostname: "unbekannt", Distro: "unbekannt"}
if h, err := os.Hostname(); err == nil && h != "" {
info.Hostname = strings.SplitN(h, ".", 2)[0]
}
if pretty, ok := osReleaseValue(filepath.Join(root, "etc", "os-release"), "PRETTY_NAME"); ok {
info.Distro = pretty
}
for _, svc := range []string{"sshd", "login", "remote"} {
b, err := os.ReadFile(filepath.Join(root, "etc", "pam.d", svc))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.Contains(line, "pam_motd") {
info.PAM = true
info.PAMWhere = append(info.PAMWhere, svc)
break
}
}
}
for _, cand := range []string{"run/motd.dynamic", "run/motd"} {
p := filepath.Join(root, cand)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
info.DynamicPath = p
break
}
}
if fi, err := os.Stat(filepath.Join(root, "etc", "motd")); err == nil && !fi.IsDir() && fi.Size() > 0 {
info.StaticMOTD = true
}
return info
}
func osReleaseValue(path, key string) (string, bool) {
b, err := os.ReadFile(path)
if err != nil {
return "", false
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok {
return strings.Trim(v, `"'`), true
}
}
return "", false
}

View file

@ -0,0 +1,40 @@
package motd
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestDiscoverListsValidNamesSorted(t *testing.T) {
root := t.TempDir()
dir := MotdDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"10-sysinfo", "00-header", "notes.txt", ".hidden", "README"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o644); err != nil {
t.Fatal(err)
}
}
scripts, err := Discover(root)
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range scripts {
names = append(names, s.Name)
}
// README ist ein valider run-parts-Name und bleibt dabei.
if !reflect.DeepEqual(names, []string{"00-header", "10-sysinfo", "README"}) {
t.Errorf("names = %v", names)
}
}
func TestDiscoverMissingDir(t *testing.T) {
if _, err := Discover(t.TempDir()); err == nil {
t.Error("Fehler erwartet, wenn update-motd.d fehlt")
}
}

107
internal/motd/preview.go Normal file
View file

@ -0,0 +1,107 @@
package motd
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"motd-assist/internal/assets"
)
// scriptTimeout begrenzt die Laufzeit eines einzelnen Preview-Scripts
// (langsame Netzwerk-Scripte wie 50-motd-news blockieren sonst die TUI).
const scriptTimeout = 2 * time.Second
// PreviewScript ist ein Preview-Kandidat: entweder eine vorhandene Datei
// (Path) oder eine noch nicht installierte gebündelte Vorlage (Source).
type PreviewScript struct {
Name string
Path string
Source string
Enabled bool
}
// Render führt die aktivierten Scripte in Reihenfolge aus und liefert den
// bereinigten MOTD-Output. Fehler einzelner Scripte werden als dezente
// Hinweiszeile eingebaut statt die ganze Preview abbrechen.
func Render(scripts []PreviewScript, width int) string {
var parts []string
for _, s := range scripts {
if !s.Enabled {
continue
}
path := s.Path
if path == "" {
if s.Source == "" {
continue
}
staged, cleanup, err := stagePreview(s.Source, s.Name)
if err != nil {
parts = append(parts, dimLine(fmt.Sprintf("%s: %v", s.Name, err)))
continue
}
path = staged
defer cleanup()
}
addPart(&parts, runScript(path, width), width)
}
return strings.Join(parts, "\n\n")
}
func addPart(parts *[]string, raw string, width int) {
if text := strings.TrimSpace(Sanitize(raw, width)); text != "" {
*parts = append(*parts, text)
}
}
func runScript(path string, width int) string {
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, path)
cmd.Env = append(os.Environ(),
fmt.Sprintf("COLUMNS=%d", width),
"TERM=xterm-256color",
)
cmd.Dir = "/"
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return dimLine(fmt.Sprintf("%s: Timeout", filepath.Base(path)))
}
if buf.Len() == 0 {
return dimLine(fmt.Sprintf("%s: nicht ausführbar (%v)", filepath.Base(path), err))
}
// Exit-Status != 0 ist bei MOTD-Scripten üblich; Output behalten.
}
return buf.String()
}
// stagePreview kopiert eine gebündelte Vorlage in ein Temporärverzeichnis,
// damit die Live-Vorschau sie vor der Installation zeigen kann.
func stagePreview(source, name string) (string, func(), error) {
content, ok := assets.ScriptContent(source)
if !ok {
return "", nil, fmt.Errorf("unbekannte Vorlage %q", source)
}
dir, err := os.MkdirTemp("", "motd-assist-")
if err != nil {
return "", nil, err
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
os.RemoveAll(dir)
return "", nil, err
}
return path, func() { os.RemoveAll(dir) }, nil
}
func dimLine(s string) string { return "\x1b[2m" + s + "\x1b[0m" }

View file

@ -0,0 +1,6 @@
//go:build !unix
package motd
// IsRoot ist auf Nicht-Unix-Plattformen immer false (Zielsystem ist Linux).
func IsRoot() bool { return false }

View file

@ -0,0 +1,8 @@
//go:build unix
package motd
import "os"
// IsRoot meldet, ob der Prozess mit EUID 0 läuft.
func IsRoot() bool { return os.Geteuid() == 0 }

120
internal/motd/sanitize.go Normal file
View file

@ -0,0 +1,120 @@
package motd
import (
"strings"
"github.com/mattn/go-runewidth"
)
type escapeKind int
const (
escNone escapeKind = iota
escSGR
)
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
func parseEscape(r []rune) (string, int, escapeKind) {
if len(r) < 2 {
return "", len(r), escNone
}
switch r[1] {
case '[': // CSI: Parameter (0x300x3F), Zwischenzeichen (0x200x2F), Final
j := 2
for j < len(r) && r[j] >= 0x30 && r[j] <= 0x3f {
j++
}
for j < len(r) && r[j] >= 0x20 && r[j] <= 0x2f {
j++
}
if j >= len(r) {
return "", len(r), escNone // unvollständig
}
if r[j] == 'm' {
return string(r[:j+1]), j + 1, escSGR
}
return "", j + 1, escNone
case ']', 'P', 'X', '^', '_': // OSC/DCS/SOS/PM/APC: bis BEL oder ST
for j := 2; j < len(r); j++ {
if r[j] == 0x07 {
return "", j + 1, escNone
}
if r[j] == '\x1b' && j+1 < len(r) && r[j+1] == '\\' {
return "", j + 2, escNone
}
}
return "", len(r), escNone
case '(', ')', '*', '+', '#', '%': // Charset-/Zeichensatz-Wechsel
if len(r) >= 3 {
return "", 3, escNone
}
return "", len(r), escNone
default:
return "", 2, escNone
}
}
// Sanitize bereinigt Script-Output für die Anzeige in der TUI: behalten
// werden SGR-Farbsequenzen und Zeilenumbrüche; Cursor-Steuerung, OSC,
// Charset-Switches und andere Steuerzeichen werden entfernt. Jede Zeile
// wird auf width Spellen begrenzt und bei Farbnutzung mit Reset
// abgeschlossen, damit die Pane-Umrandung nicht verfärbt wird.
func Sanitize(s string, width int) string {
if width <= 0 {
width = 80
}
var b strings.Builder
b.Grow(len(s))
lineW, hasSGR := 0, false
runes := []rune(s)
for i := 0; i < len(runes); i++ {
r := runes[i]
switch {
case r == '\x1b':
seq, n, kind := parseEscape(runes[i:])
i += n - 1
if kind == escSGR {
b.WriteString(seq)
hasSGR = true
}
case r == '\n':
if hasSGR && !endsWithReset(&b) {
b.WriteString("\x1b[0m")
}
b.WriteByte('\n')
lineW, hasSGR = 0, false
case r == '\r', r == '\a', r == '\b', r == '\v', r == '\f':
// ignorieren
case r == '\t':
for k := 0; k < 4 && lineW < width; k++ {
b.WriteByte(' ')
lineW++
}
case r < 0x20 || r == 0x7f:
// übrige Steuerzeichen entfernen
default:
if lineW >= width {
continue // Zeile abgeschnitten
}
w := runewidth.RuneWidth(r)
if lineW+w > width {
lineW = width // breites Zeichen passt nicht mehr → Schnittstelle
continue
}
b.WriteRune(r)
lineW += w
}
}
out := b.String()
if hasSGR && !strings.HasSuffix(out, "\x1b[0m") {
out += "\x1b[0m" // letzte Zeile ohne Newline: offene Farbe schließen
}
return strings.TrimRight(out, "\n")
}
// endsWithReset prüft, ob der Builder bereits auf Farbe zurückgesetzt ist.
func endsWithReset(b *strings.Builder) bool {
return strings.HasSuffix(b.String(), "\x1b[0m")
}

View file

@ -0,0 +1,34 @@
package motd
import "testing"
func TestSanitizeKeepsSGRStripsRest(t *testing.T) {
in := "\x1b[1;32mgrün\x1b[0m\x1b[2J\x1b[?25lnormal\r\nnext\x1b]0;title\x07"
want := "\x1b[1;32mgrün\x1b[0mnormal\x1b[0m\nnext"
if got := Sanitize(in, 40); got != want {
t.Errorf("Sanitize() = %q, want %q", got, want)
}
}
func TestSanitizeTruncatesToWidth(t *testing.T) {
if got := Sanitize("abcdefghij", 4); got != "abcd" {
t.Errorf("plain: got %q, want %q", got, "abcd")
}
got := Sanitize("\x1b[31mabcdefghij\x1b[0m", 4)
want := "\x1b[31mabcd\x1b[0m"
if got != want {
t.Errorf("colored: got %q, want %q", got, want)
}
}
func TestSanitizeExpandsTabsAndStripsCharset(t *testing.T) {
if got := Sanitize("\x1b(Ba\tb", 20); got != "a b" {
t.Errorf("got %q, want %q", got, "a b")
}
}
func TestSanitizeDropsOtherControlChars(t *testing.T) {
if got := Sanitize("a\x00\x07\x08\x0bb", 20); got != "ab" {
t.Errorf("got %q, want %q", got, "ab")
}
}

74
internal/motd/script.go Normal file
View file

@ -0,0 +1,74 @@
// Package motd enthält die Domain-Logik: Discovery der update-motd.d-Scripte,
// Diff/Plan von Soll-Zuständen, das Ausführen des Plans und die Preview.
package motd
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"motd-assist/internal/assets"
)
// Script ist ein Eintrag in /etc/update-motd.d.
type Script struct {
Name string // Dateiname inkl. Nummern-Präfix, z.B. "00-header"
Path string // Absoluter Pfad
Enabled bool // Executable-Bit gesetzt (run-parts führt nur diese aus)
Bundled bool // von motd-assist installiert (Inhalt entspricht einer Vorlage)
}
// MotdDir liefert den Pfad des update-motd.d-Verzeichnisses unter root.
func MotdDir(root string) string {
return filepath.Join(root, "etc", "update-motd.d")
}
// run-parts im pam_motd-Kontext ignoriert Dateien mit Punkten; wir halten
// uns an dieselbe Namenskonvention ([A-Za-z0-9_-]).
var validName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Discover listet alle Scripte im update-motd.d-Verzeichnis in
// run-parts-Reihenfolge (lexikalische Sortierung). Gebündelte Scripte
// werden per Inhaltsvergleich erkannt.
func Discover(root string) ([]Script, error) {
dir := MotdDir(root)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("update-motd.d nicht lesbar: %w", err)
}
scripts := make([]Script, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !validName.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
path := filepath.Join(dir, e.Name())
s := Script{
Name: e.Name(),
Path: path,
Enabled: info.Mode()&0o111 != 0,
}
if b, err := os.ReadFile(path); err == nil {
_, s.Bundled = assets.MatchScript(string(b))
}
scripts = append(scripts, s)
}
sort.Slice(scripts, func(i, j int) bool { return scripts[i].Name < scripts[j].Name })
return scripts, nil
}
// EnabledCount zählt die aktiven Scripte.
func EnabledCount(scripts []Script) int {
n := 0
for _, s := range scripts {
if s.Enabled {
n++
}
}
return n
}

207
internal/motd/state.go Normal file
View file

@ -0,0 +1,207 @@
package motd
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
)
// DesiredEntry beschreibt den Soll-Zustand eines Scripts.
// Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation.
type DesiredEntry struct {
Name string
Enabled bool
Install string
Remove bool
}
// Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen
// geändert — niemals Inhalte fremder Scripte.
type (
ChmodOp struct {
Name string `json:"name"`
Enable bool `json:"enable"`
}
RenameOp struct {
From string `json:"from"`
To string `json:"to"`
}
InstallOp struct {
Name string `json:"name"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
}
RemoveOp struct {
Name string `json:"name"`
}
)
// Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll.
type Plan struct {
Chmod []ChmodOp `json:"chmod,omitempty"`
Renames []RenameOp `json:"renames,omitempty"`
Installs []InstallOp `json:"installs,omitempty"`
Removes []RemoveOp `json:"removes,omitempty"`
}
// Empty meldet, ob der Plan nichts tut.
func (p Plan) Empty() bool { return p.NumOps() == 0 }
// NumOps zählt alle Operationen.
func (p Plan) NumOps() int {
return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes)
}
// Summary liefert eine Kurzbeschreibung für Statusmeldungen.
func (p Plan) Summary() string {
var parts []string
if n := len(p.Removes); n > 0 {
parts = append(parts, fmt.Sprintf("%d entfernt", n))
}
if n := len(p.Installs); n > 0 {
parts = append(parts, fmt.Sprintf("%d installiert", n))
}
if n := len(p.Renames); n > 0 {
parts = append(parts, fmt.Sprintf("%d umbenannt", n))
}
if n := len(p.Chmod); n > 0 {
parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n))
}
if len(parts) == 0 {
return "keine Änderungen"
}
return strings.Join(parts, ", ")
}
var prefixRe = regexp.MustCompile(`^([0-9]+)-`)
// suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header").
func suffixOf(name string) string {
if loc := prefixRe.FindStringIndex(name); loc != nil {
return name[loc[1]:]
}
return name
}
func prefixNum(name string) (float64, bool) {
m := prefixRe.FindStringSubmatch(name)
if m == nil {
return 0, false
}
n, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return 0, false
}
return n, true
}
// targetName baut den kanonischen Namen für Position i (Schrittweite 10).
func targetName(i int, name string) string {
return fmt.Sprintf("%02d-%s", i*10, suffixOf(name))
}
// DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset:
// Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte
// behalten Zustand und Position (interleaved über ihren Nummern-Präfix).
// Ungelistete gebündelte Scripte werden zur Deinstallation markiert.
func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry {
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
type slot struct {
key float64
entry DesiredEntry
}
var slots []slot
seen := make(map[string]bool)
for i, e := range entries {
if _, exists := curByName[e.Name]; e.Install == "" && !exists {
continue // Script existiert auf diesem System nicht
}
if seen[e.Name] || e.Remove {
continue
}
seen[e.Name] = true
slots = append(slots, slot{float64(i)*10 + 0.5, e})
}
for i, c := range current {
if seen[c.Name] {
continue
}
key := float64(i) * 10
if n, ok := prefixNum(c.Name); ok {
key = n
}
e := DesiredEntry{Name: c.Name, Enabled: c.Enabled}
if c.Bundled {
e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset
}
slots = append(slots, slot{key, e})
}
sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key })
out := make([]DesiredEntry, len(slots))
for i, s := range slots {
out[i] = s.entry
}
return out
}
// Diff berechnet den Plan von current nach desired.
func Diff(current []Script, desired []DesiredEntry) Plan {
var plan Plan
curByName := make(map[string]Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
var final []string // Reihenfolge der Dateien nach dem Apply
installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs
for _, e := range desired {
if e.Remove {
if _, exists := curByName[e.Name]; exists {
plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name})
}
continue
}
final = append(final, e.Name)
cur, exists := curByName[e.Name]
if !exists {
if e.Install != "" {
installIdx[e.Name] = len(plan.Installs)
plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled})
}
continue
}
if cur.Enabled != e.Enabled {
plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled})
}
}
// Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen
// entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle
// Positionen neu nummeriert vorhandene Dateien umbenannt und
// Installationen direkt unter dem Zielnamen angelegt.
if !slices.IsSorted(final) {
for i, name := range final {
target := targetName(i, name)
if cur, exists := curByName[name]; exists {
if target != cur.Name {
plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target})
}
} else if idx, ok := installIdx[name]; ok {
plan.Installs[idx].Name = target
}
}
}
return plan
}

120
internal/motd/state_test.go Normal file
View file

@ -0,0 +1,120 @@
package motd
import (
"reflect"
"testing"
)
func TestDiffToggleOnly(t *testing.T) {
current := []Script{{Name: "00-header", Enabled: true}, {Name: "10-sysinfo", Enabled: false}}
desired := []DesiredEntry{{Name: "00-header", Enabled: false}, {Name: "10-sysinfo", Enabled: false}}
plan := Diff(current, desired)
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
t.Errorf("Chmod = %+v, want 00-header disable", plan.Chmod)
}
if plan.Empty() {
t.Error("Plan sollte eine Operation haben, ist aber leer")
}
if len(plan.Renames) != 0 {
t.Errorf("Renames = %+v, want none", plan.Renames)
}
}
func TestDiffReorderRenumbers(t *testing.T) {
current := []Script{
{Name: "00-a", Enabled: true},
{Name: "10-b", Enabled: true},
{Name: "20-c", Enabled: true},
}
desired := []DesiredEntry{
{Name: "10-b", Enabled: true},
{Name: "00-a", Enabled: true},
{Name: "20-c", Enabled: true},
}
plan := Diff(current, desired)
want := []RenameOp{{From: "10-b", To: "00-b"}, {From: "00-a", To: "10-a"}}
if !reflect.DeepEqual(plan.Renames, want) {
t.Errorf("Renames = %+v, want %+v", plan.Renames, want)
}
}
func TestDiffKeepsPrefixesWhenOrderUnchanged(t *testing.T) {
current := []Script{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
desired := []DesiredEntry{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
if plan := Diff(current, desired); !plan.Empty() {
t.Errorf("Plan = %+v, want leer", plan)
}
}
func TestDiffInstallAndRemove(t *testing.T) {
current := []Script{
{Name: "00-header", Enabled: true},
{Name: "95-footer", Enabled: true, Bundled: true},
}
desired := []DesiredEntry{
{Name: "00-header", Enabled: true},
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "95-footer", Remove: true},
}
plan := Diff(current, desired)
if len(plan.Installs) != 1 || plan.Installs[0].Source != "brand-header" {
t.Errorf("Installs = %+v", plan.Installs)
}
if len(plan.Removes) != 1 || plan.Removes[0].Name != "95-footer" {
t.Errorf("Removes = %+v", plan.Removes)
}
}
func TestDesiredFromPresetInterleavesUnlisted(t *testing.T) {
current := []Script{
{Name: "00-header", Enabled: true},
{Name: "50-motd-news", Enabled: true},
{Name: "90-updates-available", Enabled: false},
{Name: "95-footer", Enabled: true, Bundled: true},
}
entries := []DesiredEntry{
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
{Name: "00-header", Enabled: false},
}
got := DesiredFromPreset(current, entries)
var names []string
var removes []string
for _, e := range got {
if e.Remove {
removes = append(removes, e.Name)
} else {
names = append(names, e.Name)
}
}
wantNames := []string{"10-brand-header", "00-header", "50-motd-news", "90-updates-available"}
if !reflect.DeepEqual(names, wantNames) {
t.Errorf("Reihenfolge = %v, want %v", names, wantNames)
}
if !reflect.DeepEqual(removes, []string{"95-footer"}) {
t.Errorf("Removes = %v, want [95-footer]", removes)
}
// Ungelistete behalten ihren Zustand.
for _, e := range got {
if e.Name == "50-motd-news" && !e.Enabled {
t.Error("50-motd-news sollte aktiviert bleiben")
}
if e.Name == "00-header" && e.Enabled {
t.Error("00-header sollte laut Preset deaktiviert werden")
}
}
}
func TestDesiredFromPresetSkipsMissing(t *testing.T) {
current := []Script{{Name: "00-header", Enabled: true}}
entries := []DesiredEntry{{Name: "10-gibt-es-nicht", Enabled: true}}
got := DesiredFromPreset(current, entries)
if len(got) != 1 || got[0].Name != "00-header" {
t.Errorf("got %+v", got)
}
}

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")
}
}

110
internal/ui/commands.go Normal file
View file

@ -0,0 +1,110 @@
package ui
import (
"os"
"os/exec"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
type discoverMsg struct {
scripts []motd.Script
host motd.HostInfo
err error
}
type presetsMsg struct {
presets []preset.Preset
err error
}
type previewMsg struct {
version int
text string
}
type applyDoneMsg struct{ err error }
func discoverCmd(root string) tea.Cmd {
return func() tea.Msg {
scripts, err := motd.Discover(root)
return discoverMsg{scripts: scripts, host: motd.DetectHost(root), err: err}
}
}
func presetsCmd() tea.Cmd {
return func() tea.Msg {
presets, err := preset.LoadAll()
return presetsMsg{presets: presets, err: err}
}
}
// previewCmd rendert die Arbeitskopie als Live-Vorschau (asynchron, damit
// langsame Scripte die TUI nicht blockieren).
func (m *Model) previewCmd() tea.Cmd {
m.previewBusy = true
m.previewVersion++
version := m.previewVersion
width := m.previewWidth()
scripts := make([]motd.PreviewScript, 0, len(m.working))
for _, r := range m.working {
if r.Remove {
continue
}
ps := motd.PreviewScript{Name: r.Name, Enabled: r.Enabled}
if r.Exists {
ps.Path = filepath.Join(motd.MotdDir(m.root), r.Name)
} else {
ps.Source = r.Install
}
scripts = append(scripts, ps)
}
return func() tea.Msg {
return previewMsg{version: version, text: motd.Render(scripts, width)}
}
}
// previewWidth ist die nutzbare Breite innerhalb der Preview-Umrandung.
func (m *Model) previewWidth() int {
w := m.width - 4 // Rahmen (2) + Padding (2)
if w < 40 {
w = 40
}
if w > 100 {
w = 100
}
return w
}
// applyExec handelt das Terminal an sudo weiter (interaktive
// Passwortabfrage) und startet danach die TUI weiter.
func applyExec(plan motd.Plan) tea.Cmd {
planFile, err := plan.WriteFile()
if err != nil {
return func() tea.Msg { return applyDoneMsg{err: err} }
}
self, err := os.Executable()
if err != nil {
os.Remove(planFile)
return func() tea.Msg { return applyDoneMsg{err: err} }
}
var cmd *exec.Cmd
if motd.IsRoot() {
cmd = exec.Command(self, "apply-state", planFile)
} else {
sudo, err := exec.LookPath("sudo")
if err != nil {
os.Remove(planFile)
return func() tea.Msg { return applyDoneMsg{err: err} }
}
cmd = exec.Command(sudo, "-p", "[motd-assist] Passwort für %p: ", self, "apply-state", planFile)
}
return tea.ExecProcess(cmd, func(err error) tea.Msg {
os.Remove(planFile)
return applyDoneMsg{err: err}
})
}

182
internal/ui/model.go Normal file
View file

@ -0,0 +1,182 @@
// Package ui implementiert die Bubbletea-TUI von motd-assist.
package ui
import (
"os"
"path/filepath"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/assets"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
type pane int
const (
panePresets pane = iota
paneScripts
panePreview
)
type mode int
const (
modeNormal mode = iota
modeSaveName
modeHelp
)
// row ist die Arbeitskopie eines Scripts inkl. geplanter Installation.
type row struct {
Name string
Enabled bool
Install string // gebündelte Vorlage, solange noch nicht installiert
Bundled bool // installiert und entspricht einer Vorlage
Remove bool // Deinstallation geplant (Preset-Wechsel)
Exists bool // Datei existiert bereits
}
// Model ist das Bubbletea-Model der TUI.
type Model struct {
root string
host motd.HostInfo
current []motd.Script
working []row
presets []preset.Preset
// presetMatches markiert Presets, die dem aktiven Ist-Zustand entsprechen.
presetMatches map[string]bool
focus pane
mode mode
cursor int // Zeiger in der Script-Liste
presetIdx int
previewOff int
preview string
previewBusy bool
previewVersion int
status string
err error
applying bool
lastSummary string
width, height int
ready bool
input textinput.Model
spinner spinner.Model
}
// New erzeugt das Model für ein root-Dateisystem (normalerweise "/").
func New(root string) Model {
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = dimStyle
ti := textinput.New()
ti.Placeholder = "preset-name"
ti.CharLimit = 48
return Model{
root: root,
focus: panePresets,
presetMatches: map[string]bool{},
spinner: sp,
input: ti,
}
}
// Init startet Discovery und Spinner.
func (m Model) Init() tea.Cmd {
return tea.Batch(discoverCmd(m.root), presetsCmd(), m.spinner.Tick)
}
// desiredEntries wandelt die Arbeitskopie in Soll-Einträge um.
func (m *Model) desiredEntries() []motd.DesiredEntry {
out := make([]motd.DesiredEntry, 0, len(m.working))
for _, r := range m.working {
out = append(out, motd.DesiredEntry{
Name: r.Name,
Enabled: r.Enabled,
Install: r.Install,
Remove: r.Remove,
})
}
return out
}
// currentPlan berechnet den ausstehenden Plan (Ist → Arbeitskopie).
func (m *Model) currentPlan() motd.Plan {
return motd.Diff(m.current, m.desiredEntries())
}
func rowsFromCurrent(scripts []motd.Script) []row {
rows := make([]row, len(scripts))
for i, s := range scripts {
rows[i] = row{Name: s.Name, Enabled: s.Enabled, Bundled: s.Bundled, Exists: true}
}
return rows
}
func rowsFromDesired(entries []motd.DesiredEntry, current []motd.Script) []row {
curByName := make(map[string]motd.Script, len(current))
for _, c := range current {
curByName[c.Name] = c
}
rows := make([]row, 0, len(entries))
for _, e := range entries {
r := row{Name: e.Name, Enabled: e.Enabled, Remove: e.Remove}
if c, exists := curByName[e.Name]; exists {
r.Exists = true
r.Bundled = c.Bundled
} else if e.Install != "" {
r.Install = e.Install
}
rows = append(rows, r)
}
return rows
}
// presetFromWorking serialisiert die Arbeitskopie in ein Preset; für
// installierte gebündelte Scripte wird die Vorlage als Install-Quelle
// hinterlegt, damit das Preset auf anderen Maschinen tragbar bleibt.
func (m *Model) presetFromWorking(name string) preset.Preset {
p := preset.Preset{Name: name}
for _, r := range m.working {
if r.Remove {
continue
}
e := preset.Entry{Name: r.Name, Enabled: r.Enabled}
switch {
case r.Install != "":
e.Install = r.Install
case r.Bundled:
if b, err := os.ReadFile(filepath.Join(motd.MotdDir(m.root), r.Name)); err == nil {
if src, ok := assets.MatchScript(string(b)); ok {
e.Install = src
}
}
}
p.Scripts = append(p.Scripts, e)
}
return p
}
// refreshPresetMatches prüft, welche Presets dem Ist-Zustand entsprechen.
func (m *Model) refreshPresetMatches() {
m.presetMatches = make(map[string]bool, len(m.presets))
for _, p := range m.presets {
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
for _, e := range p.Scripts {
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
}
desired := motd.DesiredFromPreset(m.current, entries)
m.presetMatches[p.Name] = motd.Diff(m.current, desired).Empty()
}
}

109
internal/ui/model_test.go Normal file
View file

@ -0,0 +1,109 @@
package ui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
func mustUpdate(m Model, msg tea.Msg) Model {
next, _ := m.Update(msg)
return next.(Model)
}
func newTestModel(t *testing.T) Model {
t.Helper()
m := New(t.TempDir())
m = mustUpdate(m, tea.WindowSizeMsg{Width: 120, Height: 40})
return m
}
func TestViewRendersAllPanes(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts, host: motd.HostInfo{Hostname: "myhost", Distro: "Ubuntu 24.04", PAM: true}})
m = mustUpdate(m, previewMsg{version: m.previewVersion, text: "Willkommen auf myhost"})
view := m.View()
for _, want := range []string{"Presets", "Scripte", "Live-Vorschau", "00-header", "10-sysinfo", "Willkommen auf myhost", "myhost"} {
if !strings.Contains(view, want) {
t.Errorf("View enthält %q nicht", want)
}
}
}
func TestToggleAndPlan(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x/00-header", Enabled: true},
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
// Fokus auf Scripte wechseln und erstes Script ausschalten.
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace})
plan := m.currentPlan()
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
t.Errorf("Chmod = %+v, want 00-header aus", plan.Chmod)
}
if markers := m.markersByRow(); markers["00-header"] != "aus" {
t.Errorf("Marker = %q, want aus", markers["00-header"])
}
}
func TestMoveRowChangesOrder(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{
{Name: "00-header", Path: "/x", Enabled: true},
{Name: "10-sysinfo", Path: "/x", Enabled: true},
}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) // nach unten
if m.working[0].Name != "10-sysinfo" || m.working[1].Name != "00-header" {
t.Errorf("Reihenfolge = %v, %v", m.working[0].Name, m.working[1].Name)
}
if plan := m.currentPlan(); len(plan.Renames) != 2 {
t.Errorf("Renames = %+v, want 2", plan.Renames)
}
}
func TestLoadPresetMarksPending(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: false}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, presetsMsg{presets: []preset.Preset{{Name: "p1", Scripts: []preset.Entry{{Name: "00-header", Enabled: true}}}}})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) // erstes Preset laden
if plan := m.currentPlan(); len(plan.Chmod) != 1 || !plan.Chmod[0].Enable {
t.Errorf("Plan = %+v, want 00-header an", plan)
}
if m.status == "" {
t.Error("Statusmeldung nach Preset-Laden erwartet")
}
}
func TestDiscardResetsWorking(t *testing.T) {
m := newTestModel(t)
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: true}}
m = mustUpdate(m, discoverMsg{scripts: scripts})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace}) // ausschalten
if m.currentPlan().Empty() {
t.Fatal("Plan sollte Änderungen haben")
}
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEsc})
if !m.currentPlan().Empty() {
t.Errorf("nach esc sollte der Plan leer sein, ist %+v", m.currentPlan())
}
}

56
internal/ui/styles.go Normal file
View file

@ -0,0 +1,56 @@
package ui
import "github.com/charmbracelet/lipgloss"
var (
colorAccent = lipgloss.Color("75")
colorBright = lipgloss.Color("81")
colorGood = lipgloss.Color("114")
colorWarn = lipgloss.Color("214")
colorBad = lipgloss.Color("203")
colorDim = lipgloss.Color("244")
colorBorder = lipgloss.Color("240")
focusedTitle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
dimStyle = lipgloss.NewStyle().Foreground(colorDim)
goodStyle = lipgloss.NewStyle().Foreground(colorGood)
badStyle = lipgloss.NewStyle().Foreground(colorBad)
warnStyle = lipgloss.NewStyle().Foreground(colorWarn)
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
helpStyle = lipgloss.NewStyle().Foreground(colorDim)
)
// paneBox baut den Rahmen-Stil für ein Pane mit gegebener Außenbreite/-höhe.
// lipgloss zählt Padding zur Breite, den Rahmen nicht daher w-2/h-2.
func paneBox(focused bool, w, h int) lipgloss.Style {
s := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
PaddingLeft(1).
PaddingRight(1)
if focused {
s = s.BorderForeground(colorAccent)
}
if w > 0 {
s = s.Width(w - 2)
}
if h > 0 {
s = s.Height(h - 2)
}
return s
}
// truncate schneidet s Anzeigebreite auf w und hängt … an.
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w == 1 {
return "…"
}
return string(r[:w-1]) + "…"
}

255
internal/ui/update.go Normal file
View file

@ -0,0 +1,255 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
)
// Update behandelt Fenstergröße, Ergebnisse der Hintergrundkommandos und
// Tastendrücke.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.ready = true
if w := m.width - 20; w > 10 {
m.input.Width = w
}
if len(m.working) > 0 {
return m, m.previewCmd() // Breite geändert → Preview neu rendern
}
return m, nil
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case discoverMsg:
if msg.err != nil {
m.err = msg.err
m.current, m.working = nil, nil
return m, nil
}
m.err = nil
m.current = msg.scripts
m.host = msg.host
m.working = rowsFromCurrent(msg.scripts)
if m.cursor >= len(m.working) {
m.cursor = max(0, len(m.working)-1)
}
m.refreshPresetMatches()
m.previewOff = 0
return m, m.previewCmd()
case presetsMsg:
if msg.err == nil {
m.presets = msg.presets
if m.presetIdx >= len(m.presets) {
m.presetIdx = max(0, len(m.presets)-1)
}
m.refreshPresetMatches()
}
return m, nil
case previewMsg:
if msg.version < m.previewVersion {
return m, nil // veraltetes Ergebnis, neuere Preview läuft schon
}
m.previewBusy = false
m.preview = msg.text
m.previewOff = 0
return m, nil
case applyDoneMsg:
m.applying = false
if msg.err != nil {
m.status = "Anwenden fehlgeschlagen: " + msg.err.Error()
} else {
m.status = "Angewendet: " + m.lastSummary
}
return m, discoverCmd(m.root)
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.mode {
case modeHelp:
m.mode = modeNormal
return m, nil
case modeSaveName:
switch msg.String() {
case "enter":
name := strings.TrimSpace(m.input.Value())
m.mode = modeNormal
m.input.Blur()
if name == "" {
return m, nil
}
if _, err := preset.SaveUser(m.presetFromWorking(name)); err != nil {
m.status = "Speichern fehlgeschlagen: " + err.Error()
} else {
m.status = "Preset \"" + name + "\" gespeichert"
}
return m, presetsCmd()
case "esc", "ctrl+c":
m.mode = modeNormal
m.input.Blur()
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
key := msg.String()
switch key {
case "q", "ctrl+c":
return m, tea.Quit
case "tab":
m.focus = (m.focus + 1) % 3
return m, nil
case "shift+tab":
m.focus = (m.focus + 2) % 3
case "?":
m.mode = modeHelp
return m, nil
case "r":
m.status = ""
return m, discoverCmd(m.root)
case "esc":
if m.err != nil {
return m, tea.Quit
}
m.working = rowsFromCurrent(m.current)
m.previewOff = 0
m.status = "Änderungen verworfen"
return m, m.previewCmd()
case "s":
if m.err != nil {
return m, nil
}
m.mode = modeSaveName
m.input.SetValue("")
m.input.Focus()
return m, textinput.Blink
case "a":
return m.applyWorking()
case "D":
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
if err := preset.DeleteUser(p.Name); err != nil {
m.status = err.Error()
} else {
m.status = "Preset \"" + p.Name + "\" gelöscht"
}
return m, presetsCmd()
}
switch m.focus {
case panePresets:
switch key {
case "j", "down":
if m.presetIdx < len(m.presets)-1 {
m.presetIdx++
}
case "k", "up":
if m.presetIdx > 0 {
m.presetIdx--
}
case "enter", " ":
return m.loadPreset()
}
case paneScripts:
switch key {
case "j", "down":
if m.cursor < len(m.working)-1 {
m.cursor++
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
}
case " ":
if len(m.working) == 0 {
return m, nil
}
r := &m.working[m.cursor]
if r.Remove {
return m, nil
}
r.Enabled = !r.Enabled
return m, m.previewCmd()
case "u":
return m.moveRow(-1)
case "d":
return m.moveRow(1)
}
case panePreview:
lines := strings.Count(m.preview, "\n") + 1
switch key {
case "j", "down":
m.previewOff = min(m.previewOff+1, max(0, lines-1))
case "k", "up":
if m.previewOff > 0 {
m.previewOff--
}
}
}
return m, nil
}
func (m Model) loadPreset() (tea.Model, tea.Cmd) {
if len(m.presets) == 0 {
return m, nil
}
p := m.presets[m.presetIdx]
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
for _, e := range p.Scripts {
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
}
m.working = rowsFromDesired(motd.DesiredFromPreset(m.current, entries), m.current)
m.cursor = 0
m.previewOff = 0
m.status = "Preset \"" + p.Name + "\" geladen mit 'a' anwenden"
return m, m.previewCmd()
}
func (m Model) moveRow(delta int) (tea.Model, tea.Cmd) {
i, j := m.cursor, m.cursor+delta
if i < 0 || i >= len(m.working) || j < 0 || j >= len(m.working) {
return m, nil
}
m.working[i], m.working[j] = m.working[j], m.working[i]
m.cursor = j
return m, m.previewCmd()
}
func (m Model) applyWorking() (tea.Model, tea.Cmd) {
if m.applying || m.err != nil {
return m, nil
}
plan := m.currentPlan()
if plan.Empty() {
m.status = "Keine Änderungen zum Anwenden"
return m, nil
}
m.applying = true
m.lastSummary = plan.Summary()
return m, applyExec(plan)
}

336
internal/ui/view.go Normal file
View file

@ -0,0 +1,336 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"motd-assist/internal/motd"
)
// View rendert das komplette Layout: Preset- und Script-Pane oben,
// Live-Vorschau unten, Statuszeile am unteren Rand.
func (m Model) View() string {
if !m.ready {
return "motd-assist startet…"
}
switch m.mode {
case modeHelp:
return m.helpView()
case modeSaveName:
return m.saveView()
}
if m.err != nil {
return m.errorView()
}
const statusH = 1
previewH := max(6, (m.height-statusH)*2/5)
topH := m.height - statusH - previewH
if topH < 5 {
topH = max(3, m.height-statusH-3)
previewH = m.height - statusH - topH
}
presetsW := 26
if m.width < 90 {
presetsW = 22
}
scriptsW := m.width - presetsW - 1
top := lipgloss.JoinHorizontal(lipgloss.Top,
m.presetsView(presetsW, topH),
" ",
m.scriptsView(scriptsW, topH),
)
body := lipgloss.JoinVertical(lipgloss.Left,
top,
m.previewView(m.width, previewH),
m.statusView(),
)
return body
}
func (m Model) presetsView(w, h int) string {
inner := w - 4 // Rahmen (2) + Padding (2)
var b strings.Builder
b.WriteString(focusedTitle.Render("Presets"))
b.WriteString("\n")
visible := h - 3 // Rahmen (2) + Titel (1)
start := 0
if m.presetIdx >= visible {
start = m.presetIdx - visible + 1
}
for i := start; i < len(m.presets) && i < start+visible; i++ {
p := m.presets[i]
cursor := " "
name := truncate(p.Name, inner-5)
if m.focus == panePresets && i == m.presetIdx {
cursor = "▸ "
b.WriteString(cursorStyle.Render(cursor + name))
} else {
b.WriteString(cursor + name)
}
if m.presetMatches[p.Name] {
b.WriteString(" ")
b.WriteString(goodStyle.Render("✓"))
}
b.WriteString("\n")
}
if len(m.presets) == 0 {
b.WriteString(dimStyle.Render("(keine Presets)"))
b.WriteString("\n")
}
return paneBox(m.focus == panePresets, w, h).Render(b.String())
}
func (m Model) scriptsView(w, h int) string {
inner := w - 4
var b strings.Builder
title := "Scripte · /etc/update-motd.d"
b.WriteString(focusedTitle.Render(truncate(title, inner)))
b.WriteString("\n")
markers := m.markersByRow()
visible := h - 3
start := 0
if m.cursor >= visible {
start = m.cursor - visible + 1
}
for i := start; i < len(m.working) && i < start+visible; i++ {
b.WriteString(m.renderRow(m.working[i], markers[m.working[i].Name], inner, m.focus == paneScripts && i == m.cursor))
b.WriteString("\n")
}
if len(m.working) == 0 {
b.WriteString(dimStyle.Render("(leer)"))
b.WriteString("\n")
}
return paneBox(m.focus == paneScripts, w, h).Render(b.String())
}
func (m Model) renderRow(r row, marker string, inner int, selected bool) string {
var icon string
switch {
case r.Remove:
icon = badStyle.Render("✕")
case r.Enabled:
icon = goodStyle.Render("●")
default:
icon = dimStyle.Render("○")
}
name := r.Name
suffix := ""
if r.Install != "" {
suffix = " " + dimStyle.Render("+neu")
} else if r.Bundled {
suffix = " " + dimStyle.Render("·motd-assist")
}
if selected {
name = cursorStyle.Render(name)
} else if !r.Enabled && !r.Remove {
name = dimStyle.Render(name)
}
line := icon + " " + name + suffix
// Marker rechtsbündig, ohne ANSI-Breiten zu verreißen: Marker ist
// gefärbt, daher Breite ohne Escape-Codes einplanen.
markerPlain := marker
markerW := len([]rune(markerPlain))
used := lipgloss.Width(line)
pad := inner - used - markerW - 1
if pad < 0 {
pad = 0
}
if marker != "" {
style := warnStyle
switch marker {
case "neu", "an":
style = goodStyle
case "entfernt":
style = badStyle
default:
style = dimStyle
}
line += strings.Repeat(" ", pad) + style.Render(marker)
}
return line
}
// markersByRow liefert die ausstehenden Änderungen pro Script-Name.
func (m *Model) markersByRow() map[string]string {
mk := make(map[string]string)
plan := m.currentPlan()
for _, op := range plan.Installs {
if op.Enabled {
mk[op.Name] = "neu"
} else {
mk[op.Name] = "neu (aus)"
}
}
for _, op := range plan.Chmod {
if op.Enable {
mk[op.Name] = "an"
} else {
mk[op.Name] = "aus"
}
}
for _, op := range plan.Renames {
mk[op.From] = "→ " + op.To
}
for _, op := range plan.Removes {
mk[op.Name] = "entfernt"
}
return mk
}
func (m Model) previewView(w, h int) string {
inner := w - 4
var title string
switch {
case m.applying:
title = fmt.Sprintf("Live-Vorschau %s sudo läuft…", m.spinner.View())
case m.previewBusy:
title = fmt.Sprintf("Live-Vorschau %s aktualisiere…", m.spinner.View())
default:
if n := m.currentPlan().NumOps(); n > 0 {
title = fmt.Sprintf("Live-Vorschau · %s (a: anwenden, esc: verwerfen)",
warnStyle.Render(fmt.Sprintf("%d Änderungen ausstehend", n)))
} else {
title = "Live-Vorschau · " + goodStyle.Render("entspricht dem System")
}
}
var content string
if m.preview == "" {
content = dimStyle.Render("(keine Ausgabe Scripte aktivieren mit Leertaste)")
} else {
lines := strings.Split(m.preview, "\n")
off := min(m.previewOff, max(0, len(lines)-1))
end := off + h - 3
if end > len(lines) {
end = len(lines)
}
content = strings.Join(lines[off:end], "\n")
}
return paneBox(m.focus == panePreview, w, h).Render(
truncateAnsi(title, inner) + "\n" + content,
)
}
// truncateAnsi kürzt einen stilisierten Titel auf Anzeigebreite w.
func truncateAnsi(s string, w int) string {
if lipgloss.Width(s) <= w {
return s
}
var b strings.Builder
width := 0
inEsc := false
for _, r := range s {
switch {
case r == '\x1b':
inEsc = true
b.WriteRune(r)
case inEsc:
b.WriteRune(r)
if r == 'm' {
inEsc = false
}
default:
if width >= w {
continue
}
b.WriteRune(r)
width++
}
}
return b.String()
}
func (m Model) statusView() string {
pam := warnStyle.Render("pam_motd ✗")
if m.host.PAM {
pam = goodStyle.Render("pam_motd ✓")
}
priv := warnStyle.Render("sudo nötig")
if motd.IsRoot() {
priv = goodStyle.Render("root")
}
enabled := 0
for _, r := range m.working {
if r.Enabled && !r.Remove {
enabled++
}
}
left := fmt.Sprintf("%s · %s · %s · %d/%d aktiv · %s",
m.host.Hostname, m.host.Distro, pam, enabled, len(m.working), priv)
line := statusStyle(left)
if m.status != "" {
line += statusStyle(" │ ") + statusStyle(m.status)
}
return lipgloss.NewStyle().MaxWidth(m.width).Render(line)
}
func statusStyle(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(s)
}
func (m Model) errorView() string {
msg := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBad).
Padding(1, 2).
Width(min(60, m.width-4)).
Render(
badStyle.Render("MOTD-Verzeichnis nicht gefunden") + "\n\n" +
m.err.Error() + "\n\n" +
dimStyle.Render("motd-assist verwaltet /etc/update-motd.d (Ubuntu/Debian). "+
"Auf diesem System liegt kein update-motd.d vor.") + "\n\n" +
dimStyle.Render("q/esc: beenden · r: erneut versuchen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
}
func (m Model) helpView() string {
keys := [][2]string{
{"tab / shift+tab", "Panes wechseln (Presets · Scripte · Vorschau)"},
{"j / k, ↑ / ↓", "auswählen"},
{"enter / space", "Preset laden · Script an/aus"},
{"u / d", "Script in der Reihenfolge nach oben/unten"},
{"a", "Änderungen anwenden (sudo-Passwortabfrage)"},
{"esc", "Änderungen verwerfen"},
{"s", "aktuellen Zustand als Preset speichern"},
{"D", "ausgewähltes Preset löschen (nur eigene)"},
{"r", "Systemzustand neu einlesen"},
{"q", "beenden"},
}
var b strings.Builder
b.WriteString(focusedTitle.Render("Hilfe") + "\n\n")
for _, k := range keys {
b.WriteString(cursorStyle.Render(fmt.Sprintf("%-20s", k[0])) + " " + k[1] + "\n")
}
b.WriteString("\n" + dimStyle.Render("Presets: ~/.config/motd-assist/presets · Undo: motd-assist restore"))
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(b.String())
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
func (m Model) saveView() string {
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorAccent).
Padding(1, 2).
Render(
focusedTitle.Render("Preset speichern") + "\n\n" +
m.input.View() + "\n\n" +
helpStyle.Render("enter: speichern · esc: abbrechen"),
)
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}