commit d56659e21ac9db4149761b16af9be462250ae77e Author: Tronax Date: Mon Aug 24 14:19:28 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39c9826 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +.zcode/ +*.exe +motd-assist +motd-assist-linux diff --git a/README.md b/README.md new file mode 100644 index 0000000..03de0cb --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# motd-assist + +TUI-Tool zur Verwaltung dynamischer MOTDs auf Ubuntu/Debian (`/etc/update-motd.d`) +— geschrieben in Go mit [Bubble Tea](https://github.com/charmbracelet/bubbletea) +und [Lip Gloss](https://github.com/charmbracelet/lipgloss). + +## Features + +- **Presets**: schnell zwischen MOTD-Stilen wechseln (mitgeliefert: `ubuntu-default`, + `minimal`, `server-dashboard`); eigene Presets landen in `~/.config/motd-assist/presets/` +- **Live-Vorschau**: die aktivierten Scripte werden ausgeführt und farbig in der TUI + angezeigt – inklusive noch nicht installierter Preset-Scripte und Breitenanpassung +- **Dynamische Erkennung**: welche Scripte existieren, welche aktiv sind (Executable-Bit), + ob `pam_motd` konfiguriert ist, Distro/Hostname +- **Gebündelte Style-Scripte**: Branding-Header, Sysinfo-Panel (MEM/DISK/LOAD-Balken), + dezenter Footer – reines POSIX `sh`, keine Abhängigkeiten +- **Sicherheiten**: Script-Inhalte werden nie verändert (nur chmod/rename/install), + vor jedem Apply wird ein Snapshot geschrieben, `motd-assist restore` macht ihn rückgängig + +## Installation + +```sh +go build -o motd-assist . # oder: go install motd-assist@latest +sudo cp motd-assist /usr/local/bin/ +``` + +## Bedienung (TUI) + +`motd-assist` startet die TUI. Lesen und Vorschauen geht ohne Root; erst beim +Anwenden (`a`) erscheint die sudo-Passwortabfrage. + +| Taste | Funktion | +| --- | --- | +| `tab` / `shift+tab` | zwischen Presets · Scripten · Vorschau wechseln | +| `j`/`k`, `↑`/`↓` | auswählen | +| `enter` / `space` | Preset laden · Script an/aus | +| `u` / `d` | Script in der MOTD-Reihenfolge nach oben/unten | +| `a` | Änderungen anwenden (sudo) | +| `esc` | ausstehende Änderungen verwerfen | +| `s` | aktuellen Zustand als Preset speichern | +| `D` | ausgewähltes Preset löschen (nur eigene) | +| `r` | Systemzustand neu einlesen | +| `?` | Hilfe · `q` beenden | + +## CLI + +```sh +motd-assist preview [-w 72] # aktuellen MOTD rendern und ausgeben +motd-assist list # Scripte + Zustand anzeigen +motd-assist presets # verfügbare Presets auflisten +motd-assist apply # Preset anwenden (fragt ggf. sudo-Passwort) +motd-assist restore # letzten Apply zurücksetzen +``` + +## Preset-Format (JSON) + +```json +{ + "name": "mein-preset", + "description": "Kurze Beschreibung", + "scripts": [ + { "name": "00-header", "enabled": false }, + { "name": "10-brand-header", "enabled": true, "install": "brand-header" }, + { "name": "95-footer", "enabled": true, "install": "footer" } + ] +} +``` + +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 +entfernt. `install` referenziert eine gebündelte Vorlage (`brand-header`, +`sysinfo-panel`, `footer`). + +## Entwicklung + +```sh +go test ./... # Unit-Tests (Logik plattformneutral) +MOTD_ASSIST_ROOT= go run . # gegen ein Test-Root laufen lassen +``` + +Das Tool zielt auf Linux; Windows-Builds kompilieren, haben aber kein +Executable-Bit-Konzept (alles Lesen/Planen funktioniert, Ausführen der Scripte nicht). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..471e2df --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module motd-assist + +go 1.26.2 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mattn/go-runewidth v0.0.19 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e92ed90 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/assets/assets.go b/internal/assets/assets.go new file mode 100644 index 0000000..e705454 --- /dev/null +++ b/internal/assets/assets.go @@ -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 +} diff --git a/internal/assets/presets/minimal.json b/internal/assets/presets/minimal.json new file mode 100644 index 0000000..2a8d38e --- /dev/null +++ b/internal/assets/presets/minimal.json @@ -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" } + ] +} diff --git a/internal/assets/presets/server-dashboard.json b/internal/assets/presets/server-dashboard.json new file mode 100644 index 0000000..81866ed --- /dev/null +++ b/internal/assets/presets/server-dashboard.json @@ -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" } + ] +} diff --git a/internal/assets/presets/ubuntu-default.json b/internal/assets/presets/ubuntu-default.json new file mode 100644 index 0000000..acfd104 --- /dev/null +++ b/internal/assets/presets/ubuntu-default.json @@ -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 } + ] +} diff --git a/internal/assets/scripts/brand-header b/internal/assets/scripts/brand-header new file mode 100644 index 0000000..a8ba2e7 --- /dev/null +++ b/internal/assets/scripts/brand-header @@ -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/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 diff --git a/internal/assets/scripts/sysinfo-panel b/internal/assets/scripts/sysinfo-panel new file mode 100644 index 0000000..4a1bf1c --- /dev/null +++ b/internal/assets/scripts/sysinfo-panel @@ -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 — 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 diff --git a/internal/motd/apply.go b/internal/motd/apply.go new file mode 100644 index 0000000..a9aa26b --- /dev/null +++ b/internal/motd/apply.go @@ -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 +} diff --git a/internal/motd/apply_test.go b/internal/motd/apply_test.go new file mode 100644 index 0000000..48a9f9a --- /dev/null +++ b/internal/motd/apply_test.go @@ -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) + } +} diff --git a/internal/motd/apply_unix_test.go b/internal/motd/apply_unix_test.go new file mode 100644 index 0000000..1ccb037 --- /dev/null +++ b/internal/motd/apply_unix_test.go @@ -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) + } +} diff --git a/internal/motd/detect.go b/internal/motd/detect.go new file mode 100644 index 0000000..d21a6d8 --- /dev/null +++ b/internal/motd/detect.go @@ -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 +} diff --git a/internal/motd/discover_test.go b/internal/motd/discover_test.go new file mode 100644 index 0000000..3019524 --- /dev/null +++ b/internal/motd/discover_test.go @@ -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") + } +} diff --git a/internal/motd/preview.go b/internal/motd/preview.go new file mode 100644 index 0000000..26aae47 --- /dev/null +++ b/internal/motd/preview.go @@ -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" } diff --git a/internal/motd/root_other.go b/internal/motd/root_other.go new file mode 100644 index 0000000..3588c12 --- /dev/null +++ b/internal/motd/root_other.go @@ -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 } diff --git a/internal/motd/root_unix.go b/internal/motd/root_unix.go new file mode 100644 index 0000000..acc4ac3 --- /dev/null +++ b/internal/motd/root_unix.go @@ -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 } diff --git a/internal/motd/sanitize.go b/internal/motd/sanitize.go new file mode 100644 index 0000000..4634299 --- /dev/null +++ b/internal/motd/sanitize.go @@ -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 (0x30–0x3F), Zwischenzeichen (0x20–0x2F), 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") +} diff --git a/internal/motd/sanitize_test.go b/internal/motd/sanitize_test.go new file mode 100644 index 0000000..c036a53 --- /dev/null +++ b/internal/motd/sanitize_test.go @@ -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") + } +} diff --git a/internal/motd/script.go b/internal/motd/script.go new file mode 100644 index 0000000..d43004d --- /dev/null +++ b/internal/motd/script.go @@ -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 +} diff --git a/internal/motd/state.go b/internal/motd/state.go new file mode 100644 index 0000000..0747fcb --- /dev/null +++ b/internal/motd/state.go @@ -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 +} diff --git a/internal/motd/state_test.go b/internal/motd/state_test.go new file mode 100644 index 0000000..d0f5549 --- /dev/null +++ b/internal/motd/state_test.go @@ -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) + } +} diff --git a/internal/preset/preset.go b/internal/preset/preset.go new file mode 100644 index 0000000..d51f5f6 --- /dev/null +++ b/internal/preset/preset.go @@ -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 +} diff --git a/internal/preset/preset_test.go b/internal/preset/preset_test.go new file mode 100644 index 0000000..162adbf --- /dev/null +++ b/internal/preset/preset_test.go @@ -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") + } +} diff --git a/internal/ui/commands.go b/internal/ui/commands.go new file mode 100644 index 0000000..930a52e --- /dev/null +++ b/internal/ui/commands.go @@ -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} + }) +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..ca95b49 --- /dev/null +++ b/internal/ui/model.go @@ -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() + } +} diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go new file mode 100644 index 0000000..f475c99 --- /dev/null +++ b/internal/ui/model_test.go @@ -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()) + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..609950d --- /dev/null +++ b/internal/ui/styles.go @@ -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]) + "…" +} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..ccb3b7f --- /dev/null +++ b/internal/ui/update.go @@ -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) +} diff --git a/internal/ui/view.go b/internal/ui/view.go new file mode 100644 index 0000000..f38128b --- /dev/null +++ b/internal/ui/view.go @@ -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) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..10a05fd --- /dev/null +++ b/main.go @@ -0,0 +1,249 @@ +// motd-assist verwaltet dynamische MOTDs (Ubuntu/Debian, /etc/update-motd.d): +// Presets wählen, Scripte schalten, Live-Vorschau, geführtes Anwenden per sudo. +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + + tea "github.com/charmbracelet/bubbletea" + + "motd-assist/internal/motd" + "motd-assist/internal/preset" + "motd-assist/internal/ui" +) + +const version = "0.1.0" + +func main() { + root := os.Getenv("MOTD_ASSIST_ROOT") + if root == "" { + root = "/" + } + + if len(os.Args) < 2 { + runTUI(root) + return + } + + switch os.Args[1] { + case "preview": + cmdPreview(root) + case "list": + cmdList(root) + case "presets": + cmdPresets() + case "apply": + cmdApply(root) + case "restore": + cmdRestore(root) + case "apply-state": // intern: wird über sudo aufgerufen + cmdApplyState(root) + case "version", "--version", "-v": + fmt.Println("motd-assist", version) + case "help", "--help", "-h": + usage() + default: + fmt.Fprintln(os.Stderr, "unbekannter Befehl:", os.Args[1]) + usage() + os.Exit(1) + } +} + +func runTUI(root string) { + p := tea.NewProgram(ui.New(root), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "TUI-Fehler:", err) + os.Exit(1) + } +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "Fehler:", err) + os.Exit(1) +} + +func cmdPreview(root string) { + width := 72 + args := os.Args[2:] + for i := 0; i < len(args); i++ { + if (args[i] == "--width" || args[i] == "-w") && i+1 < len(args) { + if n, err := strconv.Atoi(args[i+1]); err == nil { + width = n + } + i++ + } + } + scripts, err := motd.Discover(root) + if err != nil { + fatal(err) + } + preview := make([]motd.PreviewScript, 0, len(scripts)) + for _, s := range scripts { + preview = append(preview, motd.PreviewScript{Name: s.Name, Path: s.Path, Enabled: s.Enabled}) + } + fmt.Println(motd.Render(preview, width)) +} + +func cmdList(root string) { + host := motd.DetectHost(root) + fmt.Printf("%s · %s · dynamischer MOTD: %s\n\n", + host.Hostname, host.Distro, + boolText(host.PAM)) + + scripts, err := motd.Discover(root) + if err != nil { + fatal(err) + } + fmt.Printf("%d Scripte, %d aktiv:\n", len(scripts), motd.EnabledCount(scripts)) + for _, s := range scripts { + mark := "○ aus" + if s.Enabled { + mark = "● an" + } + note := "" + if s.Bundled { + note = " (motd-assist)" + } + fmt.Printf(" %s %s%s\n", mark, s.Name, note) + } +} + +func boolText(b bool) string { + if b { + return "aktiv" + } + return "inaktiv" +} + +func cmdPresets() { + presets, err := preset.LoadAll() + if err != nil { + fatal(err) + } + for _, p := range presets { + fmt.Printf(" %-18s %s (%s)\n", p.Name, p.Description, p.Source) + } +} + +func cmdApply(root string) { + if len(os.Args) < 3 { + fatal(fmt.Errorf("Usage: motd-assist apply ")) + } + name := os.Args[2] + + presets, err := preset.LoadAll() + if err != nil { + fatal(err) + } + var chosen *preset.Preset + for i := range presets { + if presets[i].Name == name { + chosen = &presets[i] + break + } + } + if chosen == nil { + fatal(fmt.Errorf("Preset %q nicht gefunden (siehe: motd-assist presets)", name)) + } + + current, err := motd.Discover(root) + if err != nil { + fatal(err) + } + entries := make([]motd.DesiredEntry, 0, len(chosen.Scripts)) + for _, e := range chosen.Scripts { + entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install}) + } + plan := motd.Diff(current, motd.DesiredFromPreset(current, entries)) + if plan.Empty() { + fmt.Println("System entspricht bereits dem Preset.") + return + } + + if motd.IsRoot() { + if err := motd.Execute(root, plan); err != nil { + fatal(err) + } + fmt.Println("Angewendet:", plan.Summary()) + fmt.Println("Undo: motd-assist restore") + return + } + + planFile, err := plan.WriteFile() + if err != nil { + fatal(err) + } + defer os.Remove(planFile) + if err := relaySudo("apply-state", planFile); err != nil { + fatal(err) + } +} + +func cmdRestore(root string) { + if motd.IsRoot() { + if err := motd.Restore(root); err != nil { + fatal(err) + } + fmt.Println("Letzter Apply wieder zurückgesetzt.") + return + } + if err := relaySudo("restore"); err != nil { + fatal(err) + } +} + +func cmdApplyState(root string) { + if !motd.IsRoot() { + fatal(fmt.Errorf("apply-state benötigt Root – bitte mit sudo starten")) + } + if len(os.Args) < 3 { + fatal(fmt.Errorf("Usage: motd-assist apply-state ")) + } + plan, err := motd.ReadPlanFile(os.Args[2]) + if err != nil { + fatal(err) + } + if err := motd.Execute(root, plan); err != nil { + fatal(err) + } + fmt.Println("Angewendet:", plan.Summary()) + fmt.Println("Undo: motd-assist restore") +} + +// relaySudo führt dieses Binary mit sudo und den Argumenten args aus +// (Terminal-Ein-/Ausgabe bleibt erhalten, Passwortabfrage möglich). +func relaySudo(args ...string) error { + self, err := os.Executable() + if err != nil { + return err + } + sudo, err := exec.LookPath("sudo") + if err != nil { + return fmt.Errorf("sudo nicht gefunden: %w", err) + } + cmd := exec.Command(sudo, append([]string{self}, args...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func usage() { + fmt.Print(`motd-assist ` + version + ` – MOTD-Verwaltung für /etc/update-motd.d + +Usage: + motd-assist TUI starten (Standard) + motd-assist preview [-w N] aktuellen MOTD rendern und ausgeben + motd-assist list Scripte und Zustand auflisten + motd-assist presets verfügbare Presets auflisten + motd-assist apply Preset anwenden (fragt ggf. sudo-Passwort) + motd-assist restore letzten Apply zurücksetzen + motd-assist help diese Hilfe + +TUI-Kürzel: tab Panes wechseln · enter/space Preset laden/Script schalten · +u/d Reihenfolge · a anwenden · s als Preset speichern · r neu einlesen · ? Hilfe +`) +} diff --git a/scripts/e2e-wsl.sh b/scripts/e2e-wsl.sh new file mode 100644 index 0000000..62f854f --- /dev/null +++ b/scripts/e2e-wsl.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# End-to-End-Test für motd-assist in einem Fake-Root. +set -euo pipefail +cd /mnt/c/Users/janik.dietz/Coding/motd-assist +go build -o /tmp/motd-assist . + +R=/tmp/motdroot +rm -rf "$R" +mkdir -p "$R/etc/update-motd.d" "$R/etc/pam.d" +printf 'NAME="Ubuntu"\nPRETTY_NAME="Ubuntu 24.04.1 LTS"\n' > "$R/etc/os-release" +printf 'session optional pam_motd.so motd=/run/motd.dynamic\n' > "$R/etc/pam.d/sshd" +printf '#!/bin/sh\necho Welcome to Ubuntu \\e[1m %s\\e[0m\n' "$(hostname)" > "$R/etc/update-motd.d/00-header" +printf '#!/bin/sh\necho " * Documentation: man handbook"\n' > "$R/etc/update-motd.d/10-help-text" +chmod 755 "$R/etc/update-motd.d/00-header" +chmod 644 "$R/etc/update-motd.d/10-help-text" + +echo "=== list (vorher) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist list + +echo +echo "=== preview (vorher, nur 00-header aktiv) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist preview -w 60 + +echo +echo "=== apply server-dashboard (als root) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist apply server-dashboard + +echo +echo "=== list (nachher) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist list + +echo +echo "=== preview (nachher, Breite 60) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist preview -w 60 + +echo +echo "=== preview (nachher, Breite 40 — schmaler) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist preview -w 40 | cat -v | head -6 + +echo +echo "=== restore ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist restore + +echo +echo "=== list (nach restore) ===" +MOTD_ASSIST_ROOT=$R /tmp/motd-assist list + +echo +echo "=== ls update-motd.d ===" +ls -la "$R/etc/update-motd.d"