feat: add motd-assist TUI for managing dynamic MOTDs
Initial Go project built with Bubble Tea and Lip Gloss: - Live preview and management of /etc/update-motd.d scripts - Embedded presets (ubuntu-default, minimal, server-dashboard) - Bundled POSIX sh style scripts (brand header, sysinfo panel, footer) - Snapshot/restore safety around applying changes - CLI subcommands preview, apply, restore, list-presets - Unit tests for detect, sanitize, and apply logic
This commit is contained in:
commit
d56659e21a
34 changed files with 3260 additions and 0 deletions
72
internal/assets/assets.go
Normal file
72
internal/assets/assets.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Package assets hält die eingebetteten Presets und Custom-Scripte.
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed presets scripts
|
||||
var embedded embed.FS
|
||||
|
||||
// ScriptContent liefert den Inhalt einer gebündelten Script-Vorlage.
|
||||
func ScriptContent(name string) (string, bool) {
|
||||
b, err := embedded.ReadFile("scripts/" + name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// ScriptNames listet alle gebündelten Script-Vorlagen.
|
||||
func ScriptNames() []string {
|
||||
entries, err := embedded.ReadDir("scripts")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// PresetFiles liefert Dateiname → Inhalt aller eingebetteten Presets.
|
||||
func PresetFiles() map[string]string {
|
||||
entries, err := embedded.ReadDir("presets")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
files := make(map[string]string, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if b, err := embedded.ReadFile("presets/" + e.Name()); err == nil {
|
||||
files[e.Name()] = string(b)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// MatchScript findet die Vorlage, deren Inhalt zu content passt, und
|
||||
// erkennt damit bereits installierte motd-assist-Scripte.
|
||||
func MatchScript(content string) (string, bool) {
|
||||
return MatchScriptBytes([]byte(content))
|
||||
}
|
||||
|
||||
// MatchScriptBytes ist die Byte-Variante von MatchScript.
|
||||
func MatchScriptBytes(b []byte) (string, bool) {
|
||||
content := strings.TrimSpace(string(b))
|
||||
if content == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, name := range ScriptNames() {
|
||||
if c, ok := ScriptContent(name); ok && strings.TrimSpace(c) == content {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
13
internal/assets/presets/minimal.json
Normal file
13
internal/assets/presets/minimal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "minimal",
|
||||
"description": "Ruhiger Login: nur Header-Zeile und dezenter Footer",
|
||||
"scripts": [
|
||||
{ "name": "00-header", "enabled": true },
|
||||
{ "name": "10-help-text", "enabled": false },
|
||||
{ "name": "50-landscape-sysinfo", "enabled": false },
|
||||
{ "name": "50-motd-news", "enabled": false },
|
||||
{ "name": "80-livepatch", "enabled": false },
|
||||
{ "name": "91-release-upgrade", "enabled": false },
|
||||
{ "name": "95-footer", "enabled": true, "install": "footer" }
|
||||
]
|
||||
}
|
||||
13
internal/assets/presets/server-dashboard.json
Normal file
13
internal/assets/presets/server-dashboard.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "server-dashboard",
|
||||
"description": "Server-Übersicht: Branding-Header, Sysinfo-Balken und Update-Hinweis",
|
||||
"scripts": [
|
||||
{ "name": "00-header", "enabled": false },
|
||||
{ "name": "10-help-text", "enabled": false },
|
||||
{ "name": "50-motd-news", "enabled": false },
|
||||
{ "name": "10-brand-header", "enabled": true, "install": "brand-header" },
|
||||
{ "name": "20-sysinfo-panel", "enabled": true, "install": "sysinfo-panel" },
|
||||
{ "name": "90-updates-available", "enabled": true },
|
||||
{ "name": "95-footer", "enabled": true, "install": "footer" }
|
||||
]
|
||||
}
|
||||
16
internal/assets/presets/ubuntu-default.json
Normal file
16
internal/assets/presets/ubuntu-default.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "ubuntu-default",
|
||||
"description": "Ubuntu-Standard: alle bekannten System-Scripte aktiviert",
|
||||
"scripts": [
|
||||
{ "name": "00-header", "enabled": true },
|
||||
{ "name": "10-help-text", "enabled": true },
|
||||
{ "name": "50-landscape-sysinfo", "enabled": true },
|
||||
{ "name": "50-motd-news", "enabled": true },
|
||||
{ "name": "80-livepatch", "enabled": true },
|
||||
{ "name": "90-updates-available", "enabled": true },
|
||||
{ "name": "91-release-upgrade", "enabled": true },
|
||||
{ "name": "95-hwe-eol", "enabled": true },
|
||||
{ "name": "97-overlayroot", "enabled": true },
|
||||
{ "name": "98-fsck-at-reboot", "enabled": true }
|
||||
]
|
||||
}
|
||||
36
internal/assets/scripts/brand-header
Normal file
36
internal/assets/scripts/brand-header
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/bin/sh
|
||||
# motd-assist :: brand-header — Rahmen-Header mit Hostname und Betriebssystem
|
||||
set -u
|
||||
|
||||
E=$(printf '\033')
|
||||
BLUE="${E}[1;38;5;75m"
|
||||
ORANGE="${E}[1;38;5;214m"
|
||||
DIM="${E}[2m"
|
||||
BOLD="${E}[1m"
|
||||
R="${E}[0m"
|
||||
|
||||
HOST=$(hostname 2>/dev/null || echo localhost)
|
||||
HOST=${HOST%%.*}
|
||||
|
||||
OS=Linux
|
||||
if [ -r /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=${PRETTY_NAME:-Linux}
|
||||
fi
|
||||
|
||||
W=${COLUMNS:-72}
|
||||
case $W in ''|*[!0-9]*) W=72 ;; esac
|
||||
[ "$W" -gt 78 ] && W=78
|
||||
[ "$W" -lt 44 ] && W=44
|
||||
|
||||
INNER=$((W - 2))
|
||||
PLAIN=" ${HOST} - ${OS} "
|
||||
PAD=$((INNER - ${#PLAIN}))
|
||||
[ "$PAD" -lt 0 ] && PAD=0
|
||||
|
||||
RULE=$(awk -v n="$INNER" 'BEGIN{s="";for(i=0;i<n;i++)s=s "─";printf "%s",s}')
|
||||
SPACES=$(printf '%*s' "$PAD" '')
|
||||
|
||||
printf '%b\n' "$BLUE╭$R$DIM$RULE$R$BLUE╮$R"
|
||||
printf '%b\n' "$BLUE│$R $BOLD$BLUE$HOST$R $DIM·$R $ORANGE$OS$R$SPACES $BLUE│$R"
|
||||
printf '%b\n' "$BLUE╰$R$DIM$RULE$R$BLUE╯$R"
|
||||
14
internal/assets/scripts/footer
Normal file
14
internal/assets/scripts/footer
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
# motd-assist :: footer — dezente Kernel-/Uptime-Zeile
|
||||
set -u
|
||||
|
||||
E=$(printf '\033')
|
||||
DIM="${E}[2m"
|
||||
R="${E}[0m"
|
||||
|
||||
K=$(uname -r 2>/dev/null || echo unknown)
|
||||
UP=$(uptime -p 2>/dev/null)
|
||||
[ -n "$UP" ] || UP=$(awk '{d=int($1/86400); h=int(($1%86400)/3600); printf "up %d days %d hours", d, h}' /proc/uptime 2>/dev/null)
|
||||
|
||||
[ -n "$UP" ] && printf '%b\n' "${DIM}${K} · ${UP}${R}"
|
||||
exit 0
|
||||
66
internal/assets/scripts/sysinfo-panel
Normal file
66
internal/assets/scripts/sysinfo-panel
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/bin/sh
|
||||
# motd-assist :: sysinfo-panel — Load, Speicher, Disk und Updates als farbige Balken
|
||||
set -u
|
||||
|
||||
E=$(printf '\033')
|
||||
GREEN="${E}[38;5;114m"
|
||||
YELLOW="${E}[38;5;221m"
|
||||
RED="${E}[38;5;203m"
|
||||
LBLUE="${E}[38;5;75m"
|
||||
DIM="${E}[2m"
|
||||
R="${E}[0m"
|
||||
|
||||
BAR=22
|
||||
|
||||
# bar <used> <total> — farbiger Balken mit Prozentanzeige
|
||||
bar() {
|
||||
awk -v u="$1" -v t="$2" -v b="$BAR" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" 'BEGIN{
|
||||
if (t <= 0) { printf "%sn/a%s", d, rset; exit }
|
||||
pct = u * 100 / t
|
||||
if (pct > 100) pct = 100
|
||||
f = int(pct / 100 * b + 0.5)
|
||||
c = (pct < 60) ? g : ((pct < 85) ? y : r)
|
||||
s = c
|
||||
for (i = 0; i < f; i++) s = s "━"
|
||||
s = s d
|
||||
for (i = f; i < b; i++) s = s "·"
|
||||
printf "%s%s%s %s%3d%%%s", s, rset, c, c, pct, rset
|
||||
}'
|
||||
}
|
||||
|
||||
mem=$(awk '/^MemTotal:/ {t=$2} /^MemAvailable:/ {a=$2} END {if (t>0) printf "%.0f %.0f", t-a, t}' /proc/meminfo 2>/dev/null)
|
||||
[ -n "$mem" ] && printf '%b\n' "$LBLUE MEM $DIM│$R $(bar $mem)"
|
||||
|
||||
disk=$(df -P / 2>/dev/null | awk 'NR==2 {gsub("%","",$5); print $5}')
|
||||
[ -n "$disk" ] && printf '%b\n' "$LBLUE DISK $DIM│$R $(bar $disk 100)"
|
||||
|
||||
if [ -r /proc/loadavg ]; then
|
||||
load=$(awk '{print $1}' /proc/loadavg)
|
||||
ncpu=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
|
||||
case $ncpu in ''|*[!0-9]*) ncpu=1 ;; esac
|
||||
awk -v l="$load" -v n="$ncpu" -v b="$BAR" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" -v lbl="$LBLUE" 'BEGIN{
|
||||
pct = l * 100 / n
|
||||
if (pct > 100) pct = 100
|
||||
f = int(pct / 100 * b + 0.5)
|
||||
c = (pct < 60) ? g : ((pct < 85) ? y : r)
|
||||
s = c
|
||||
for (i = 0; i < f; i++) s = s "━"
|
||||
s = s d
|
||||
for (i = f; i < b; i++) s = s "·"
|
||||
printf "%s LOAD %s│%s %s%s%s %s%s/%s%s\n", lbl, d, rset, s, rset, c, c, l, n, rset
|
||||
}'
|
||||
fi
|
||||
|
||||
if [ -r /var/lib/update-notifier/updates-available ]; then
|
||||
upd=$(awk 'NR==1 {print $1; exit}' /var/lib/update-notifier/updates-available 2>/dev/null)
|
||||
case $upd in
|
||||
''|*[!0-9]*) ;;
|
||||
*)
|
||||
awk -v n="$upd" -v g="$GREEN" -v y="$YELLOW" -v r="$RED" -v d="$DIM" -v rset="$R" -v lbl="$LBLUE" 'BEGIN{
|
||||
c = (n == 0) ? g : ((n < 20) ? y : r)
|
||||
txt = (n == 0) ? "System ist auf dem aktuellen Stand" : n " Paket-Updates verfügbar"
|
||||
printf "%s UPD %s│%s %s%s%s\n", lbl, d, rset, c, txt, rset
|
||||
}'
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
344
internal/motd/apply.go
Normal file
344
internal/motd/apply.go
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
const (
|
||||
modeEnabled os.FileMode = 0o755
|
||||
modeDisabled os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// Validate prüft einen Plan gegen das Dateisystem, bevor Root-Operationen
|
||||
// ausgeführt werden. Alle Namen müssen einfach bleiben und im
|
||||
// update-motd.d-Verzeichnis liegen; fremde Dateien sind tabu.
|
||||
func Validate(root string, plan Plan) error {
|
||||
dir := MotdDir(root)
|
||||
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("%s existiert nicht", dir)
|
||||
}
|
||||
|
||||
existing := make(map[string]bool)
|
||||
listing, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range listing {
|
||||
existing[e.Name()] = true
|
||||
}
|
||||
|
||||
for _, op := range plan.Chmod {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if !existing[op.Name] {
|
||||
return fmt.Errorf("chmod: %s existiert nicht", op.Name)
|
||||
}
|
||||
}
|
||||
for _, op := range plan.Removes {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if !existing[op.Name] {
|
||||
return fmt.Errorf("remove: %s existiert nicht", op.Name)
|
||||
}
|
||||
if !isBundledFile(filepath.Join(dir, op.Name)) {
|
||||
return fmt.Errorf("remove: %s ist kein motd-assist-Script", op.Name)
|
||||
}
|
||||
}
|
||||
|
||||
renamedAway := make(map[string]bool, len(plan.Renames))
|
||||
for _, op := range plan.Renames {
|
||||
if !validName.MatchString(op.From) || !validName.MatchString(op.To) {
|
||||
return fmt.Errorf("ungültiger Name in Umbenennung %q → %q", op.From, op.To)
|
||||
}
|
||||
if !existing[op.From] {
|
||||
return fmt.Errorf("rename: %s existiert nicht", op.From)
|
||||
}
|
||||
renamedAway[op.From] = true
|
||||
}
|
||||
// Zielnamen dürfen nur frei werden, wenn die Datei dort wegrename'd oder
|
||||
// entfernt wird – sonst Kollision.
|
||||
for _, op := range plan.Renames {
|
||||
if existing[op.To] && !renamedAway[op.To] {
|
||||
isRemoved := false
|
||||
for _, r := range plan.Removes {
|
||||
if r.Name == op.To {
|
||||
isRemoved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRemoved {
|
||||
return fmt.Errorf("rename: Ziel %s ist belegt", op.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Installs {
|
||||
if !validName.MatchString(op.Name) {
|
||||
return fmt.Errorf("ungültiger Name %q", op.Name)
|
||||
}
|
||||
if _, ok := assets.ScriptContent(op.Source); !ok {
|
||||
return fmt.Errorf("install: unbekannte Vorlage %q", op.Source)
|
||||
}
|
||||
if existing[op.Name] && !isBundledFile(filepath.Join(dir, op.Name)) {
|
||||
return fmt.Errorf("install: %s wäre von einem fremden Script überschrieben", op.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBundledFile meldet, ob die Datei unter path einer gebündelten Vorlage
|
||||
// entspricht (oder nicht lesbar ist, dann false).
|
||||
func isBundledFile(path string) bool {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := assets.MatchScriptBytes(b)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Snapshot sichert den Zustand vor einem Apply, damit Restore möglich ist.
|
||||
type Snapshot struct {
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
Scripts []SnapshotScript `json:"scripts"`
|
||||
}
|
||||
|
||||
// SnapshotScript hält Name, Modus und (falls gebündelt) die Vorlage fest.
|
||||
type SnapshotScript struct {
|
||||
Name string `json:"name"`
|
||||
Mode uint32 `json:"mode"`
|
||||
Bundled bool `json:"bundled"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// SnapshotPath liefert den Ablageort des letzten Snapshots.
|
||||
func SnapshotPath(root string) string {
|
||||
return filepath.Join(root, "var", "lib", "motd-assist", "last-snapshot.json")
|
||||
}
|
||||
|
||||
// WriteFile schreibt den Plan als temporäre JSON-Datei, z.B. für die
|
||||
// Übergabe an "motd-assist apply-state" unter sudo.
|
||||
func (p Plan) WriteFile() (string, error) {
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.CreateTemp("", "motd-assist-plan-*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(b); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// ReadPlanFile liest eine mit WriteFile erzeugte Plandatei.
|
||||
func ReadPlanFile(path string) (Plan, error) {
|
||||
var plan Plan
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &plan); err != nil {
|
||||
return plan, fmt.Errorf("ungültige Plandatei: %w", err)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func writeSnapshot(root string) error {
|
||||
scripts, err := Discover(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap := Snapshot{SavedAt: time.Now(), Scripts: make([]SnapshotScript, 0, len(scripts))}
|
||||
for _, s := range scripts {
|
||||
ss := SnapshotScript{Name: s.Name, Bundled: s.Bundled}
|
||||
if fi, err := os.Stat(s.Path); err == nil {
|
||||
ss.Mode = uint32(fi.Mode().Perm())
|
||||
}
|
||||
if s.Bundled {
|
||||
if b, err := os.ReadFile(s.Path); err == nil {
|
||||
if src, ok := assets.MatchScript(string(b)); ok {
|
||||
ss.Source = src
|
||||
}
|
||||
}
|
||||
}
|
||||
snap.Scripts = append(snap.Scripts, ss)
|
||||
}
|
||||
path := SnapshotPath(root)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// Execute führt einen validierten Plan aus: Snapshot → Removes →
|
||||
// Umbenennungen (zweiphasig gegen Kollisionen) → Installationen → Modi.
|
||||
// Erfordert Root, wenn /etc/update-motd.d Root gehört.
|
||||
func Execute(root string, plan Plan) error {
|
||||
if err := Validate(root, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSnapshot(root); err != nil {
|
||||
return fmt.Errorf("Snapshot fehlgeschlagen: %w", err)
|
||||
}
|
||||
dir := MotdDir(root)
|
||||
|
||||
// Reihenfolge: Removes → Chmods (alte Namen!) → Renames → Installs.
|
||||
for _, op := range plan.Removes {
|
||||
if err := os.Remove(filepath.Join(dir, op.Name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Chmod {
|
||||
mode := modeDisabled
|
||||
if op.Enable {
|
||||
mode = modeEnabled
|
||||
}
|
||||
if err := os.Chmod(filepath.Join(dir, op.Name), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Zweiphasige Umbenennung: erst alle nach .tmp-N (Discover ignoriert
|
||||
// Punktnamen), dann auf die finalen Namen.
|
||||
if len(plan.Renames) > 0 {
|
||||
for i := range plan.Renames {
|
||||
tmp := fmt.Sprintf(".ma-tmp-%d", i)
|
||||
if err := os.Rename(filepath.Join(dir, plan.Renames[i].From), filepath.Join(dir, tmp)); err != nil {
|
||||
return err
|
||||
}
|
||||
plan.Renames[i].From = tmp
|
||||
}
|
||||
for _, op := range plan.Renames {
|
||||
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, op := range plan.Installs {
|
||||
content, _ := assets.ScriptContent(op.Source)
|
||||
mode := modeDisabled
|
||||
if op.Enabled {
|
||||
mode = modeEnabled
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, op.Name), []byte(content), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restore setzt den letzten Snapshot bestmöglich zurück (Modi, Namen,
|
||||
// gebündelte Installationen). Best-effort: Fehler werden gesammelt.
|
||||
func Restore(root string) error {
|
||||
b, err := os.ReadFile(SnapshotPath(root))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("kein Snapshot vorhanden")
|
||||
}
|
||||
return err
|
||||
}
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(b, &snap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
curBySuffix := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curBySuffix[suffixOf(c.Name)] = c
|
||||
}
|
||||
snapBySuffix := make(map[string]SnapshotScript, len(snap.Scripts))
|
||||
for _, s := range snap.Scripts {
|
||||
snapBySuffix[suffixOf(s.Name)] = s
|
||||
}
|
||||
|
||||
var errs []error
|
||||
dir := MotdDir(root)
|
||||
|
||||
// Dateien entfernen, die es vor dem Apply nicht gab (nur unsere).
|
||||
for _, c := range current {
|
||||
if _, ok := snapBySuffix[suffixOf(c.Name)]; !ok && c.Bundled {
|
||||
if err := os.Remove(c.Path); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fehlende gebündelte Scripte aus der Vorlage nachinstallieren.
|
||||
var renames []RenameOp
|
||||
for _, s := range snap.Scripts {
|
||||
if _, ok := curBySuffix[suffixOf(s.Name)]; ok {
|
||||
continue
|
||||
}
|
||||
if s.Bundled && s.Source != "" {
|
||||
content, ok := assets.ScriptContent(s.Source)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("Vorlage %s fehlt für %s", s.Source, s.Name))
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, s.Name), []byte(content), os.FileMode(s.Mode)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Namen und Modi wiederherstellen.
|
||||
for _, c := range current {
|
||||
s, ok := snapBySuffix[suffixOf(c.Name)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if c.Name != s.Name {
|
||||
renames = append(renames, RenameOp{From: c.Name, To: s.Name})
|
||||
}
|
||||
if err := os.Chmod(c.Path, os.FileMode(s.Mode)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(renames) > 0 {
|
||||
for i := range renames {
|
||||
tmp := fmt.Sprintf(".ma-tmp-%d", i)
|
||||
if err := os.Rename(filepath.Join(dir, renames[i].From), filepath.Join(dir, tmp)); err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
renames[i].From = tmp
|
||||
}
|
||||
for _, op := range renames {
|
||||
if op.From == op.To {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, op.From), filepath.Join(dir, op.To)); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("Restore mit Fehlern: %w", errors.Join(errs...))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
73
internal/motd/apply_test.go
Normal file
73
internal/motd/apply_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupDir legt ein update-motd.d-Verzeichnis unter einem Temp-Root an.
|
||||
func setupDir(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
dir := MotdDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownNames(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho hi\n"})
|
||||
|
||||
err := Validate(root, Plan{Chmod: []ChmodOp{{Name: "../evil", Enable: true}}})
|
||||
if err == nil {
|
||||
t.Error("Pfad-Traversal sollte abgelehnt werden")
|
||||
}
|
||||
err = Validate(root, Plan{Chmod: []ChmodOp{{Name: "gibt-es-nicht", Enable: true}}})
|
||||
if err == nil {
|
||||
t.Error("chmod auf fehlende Datei sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsForeignOverwrite(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"00-header": "#!/bin/sh\necho eigenes\n"})
|
||||
|
||||
err := Validate(root, Plan{Installs: []InstallOp{{Name: "00-header", Source: "footer", Enabled: true}}})
|
||||
if err == nil {
|
||||
t.Error("Install über fremdes Script sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsForeignRemove(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{"10-sysinfo": "#!/bin/sh\necho eigenes\n"})
|
||||
|
||||
err := Validate(root, Plan{Removes: []RemoveOp{{Name: "10-sysinfo"}}})
|
||||
if err == nil {
|
||||
t.Error("Remove eines fremden Scripts sollte abgelehnt werden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenameCollision(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-a": "#!/bin/sh\n",
|
||||
"10-b": "#!/bin/sh\n",
|
||||
})
|
||||
|
||||
// b wird nicht wegbewegt → Ziel 10-b ist belegt.
|
||||
err := Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}}})
|
||||
if err == nil {
|
||||
t.Error("Kollision sollte abgelehnt werden")
|
||||
}
|
||||
|
||||
// Beide tauschen → zweiphasige Ausführung macht das frei.
|
||||
err = Validate(root, Plan{Renames: []RenameOp{{From: "00-a", To: "10-b"}, {From: "10-b", To: "00-a"}}})
|
||||
if err != nil {
|
||||
t.Errorf("Tausch sollte erlaubt sein: %v", err)
|
||||
}
|
||||
}
|
||||
118
internal/motd/apply_unix_test.go
Normal file
118
internal/motd/apply_unix_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//go:build unix
|
||||
|
||||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
func TestExecuteAppliesPlanAndRestore(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-header": "#!/bin/sh\necho header\n",
|
||||
"10-sysinfo": "#!/bin/sh\necho sysinfo\n",
|
||||
})
|
||||
dir := MotdDir(root)
|
||||
if err := os.Chmod(filepath.Join(dir, "10-sysinfo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Ziel: sysinfo vor header, sysinfo aktiv, header aus.
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-sysinfo", Enabled: true},
|
||||
{Name: "00-header", Enabled: false},
|
||||
}
|
||||
plan := Diff(current, desired)
|
||||
if plan.Empty() {
|
||||
t.Fatal("Plan sollte Änderungen enthalten")
|
||||
}
|
||||
if err := Execute(root, plan); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
after, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(after) != 2 {
|
||||
t.Fatalf("nach Execute: %v", after)
|
||||
}
|
||||
if after[0].Name != "00-sysinfo" || !after[0].Enabled {
|
||||
t.Errorf("after[0] = %+v, want 00-sysinfo aktiv", after[0])
|
||||
}
|
||||
if after[1].Name != "10-header" || after[1].Enabled {
|
||||
t.Errorf("after[1] = %+v, want 10-header inaktiv", after[1])
|
||||
}
|
||||
if _, err := os.Stat(SnapshotPath(root)); err != nil {
|
||||
t.Errorf("Snapshot fehlt: %v", err)
|
||||
}
|
||||
|
||||
if err := Restore(root); err != nil {
|
||||
t.Fatalf("Restore: %v", err)
|
||||
}
|
||||
restored, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored[0].Name != "00-header" || !restored[0].Enabled {
|
||||
t.Errorf("restored[0] = %+v, want 00-header aktiv", restored[0])
|
||||
}
|
||||
if restored[1].Name != "10-sysinfo" || restored[1].Enabled {
|
||||
t.Errorf("restored[1] = %+v, want 10-sysinfo inaktiv", restored[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteInstallAndRemove(t *testing.T) {
|
||||
root := setupDir(t, map[string]string{
|
||||
"00-header": "#!/bin/sh\necho header\n",
|
||||
"95-footer": "#!/bin/sh\ntrue\n", // wird unten als gebündelt ersetzt
|
||||
})
|
||||
dir := MotdDir(root)
|
||||
// echtes gebündeltes Script einsetzen, damit Remove validiert
|
||||
content, ok := assets.ScriptContent("footer")
|
||||
if !ok {
|
||||
t.Fatal("footer-Vorlage nicht gefunden")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "95-footer"), []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
current, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "95-footer", Remove: true},
|
||||
}
|
||||
if err := Execute(root, Diff(current, desired)); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
after, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range after {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
want := []string{"00-brand-header", "10-header"}
|
||||
if len(names) != 2 || names[0] != want[0] || names[1] != want[1] {
|
||||
t.Errorf("names = %v, want %v", names, want)
|
||||
}
|
||||
if !after[0].Bundled {
|
||||
t.Error("brand-header sollte als gebündelt erkannt werden")
|
||||
}
|
||||
if !after[0].Enabled || !after[1].Enabled {
|
||||
t.Errorf("Modi falsch: %+v", after)
|
||||
}
|
||||
}
|
||||
74
internal/motd/detect.go
Normal file
74
internal/motd/detect.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HostInfo beschreibt den erkannten MOTD-Zustand des Systems.
|
||||
type HostInfo struct {
|
||||
Hostname string
|
||||
Distro string // PRETTY_NAME aus /etc/os-release
|
||||
PAM bool // pam_motd konfiguriert (dynamischer MOTD aktiv)
|
||||
PAMWhere []string // Dateien in /etc/pam.d mit pam_motd-Eintrag
|
||||
DynamicPath string // vorhandene Cache-Datei der dynamischen MOTD ("" = keine)
|
||||
StaticMOTD bool // /etc/motd vorhanden und nicht leer
|
||||
}
|
||||
|
||||
// DetectHost sammelt Host- und MOTD-Informationen unter root.
|
||||
// Fehler einzelner Erkennungen führen nicht zum Abbruch.
|
||||
func DetectHost(root string) HostInfo {
|
||||
info := HostInfo{Hostname: "unbekannt", Distro: "unbekannt"}
|
||||
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
info.Hostname = strings.SplitN(h, ".", 2)[0]
|
||||
}
|
||||
if pretty, ok := osReleaseValue(filepath.Join(root, "etc", "os-release"), "PRETTY_NAME"); ok {
|
||||
info.Distro = pretty
|
||||
}
|
||||
|
||||
for _, svc := range []string{"sshd", "login", "remote"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, "etc", "pam.d", svc))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, "pam_motd") {
|
||||
info.PAM = true
|
||||
info.PAMWhere = append(info.PAMWhere, svc)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, cand := range []string{"run/motd.dynamic", "run/motd"} {
|
||||
p := filepath.Join(root, cand)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
info.DynamicPath = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fi, err := os.Stat(filepath.Join(root, "etc", "motd")); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||
info.StaticMOTD = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func osReleaseValue(path, key string) (string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok {
|
||||
return strings.Trim(v, `"'`), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
40
internal/motd/discover_test.go
Normal file
40
internal/motd/discover_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiscoverListsValidNamesSorted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := MotdDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"10-sysinfo", "00-header", "notes.txt", ".hidden", "README"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
scripts, err := Discover(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, s := range scripts {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
// README ist ein valider run-parts-Name und bleibt dabei.
|
||||
if !reflect.DeepEqual(names, []string{"00-header", "10-sysinfo", "README"}) {
|
||||
t.Errorf("names = %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverMissingDir(t *testing.T) {
|
||||
if _, err := Discover(t.TempDir()); err == nil {
|
||||
t.Error("Fehler erwartet, wenn update-motd.d fehlt")
|
||||
}
|
||||
}
|
||||
107
internal/motd/preview.go
Normal file
107
internal/motd/preview.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
// scriptTimeout begrenzt die Laufzeit eines einzelnen Preview-Scripts
|
||||
// (langsame Netzwerk-Scripte wie 50-motd-news blockieren sonst die TUI).
|
||||
const scriptTimeout = 2 * time.Second
|
||||
|
||||
// PreviewScript ist ein Preview-Kandidat: entweder eine vorhandene Datei
|
||||
// (Path) oder eine noch nicht installierte gebündelte Vorlage (Source).
|
||||
type PreviewScript struct {
|
||||
Name string
|
||||
Path string
|
||||
Source string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// Render führt die aktivierten Scripte in Reihenfolge aus und liefert den
|
||||
// bereinigten MOTD-Output. Fehler einzelner Scripte werden als dezente
|
||||
// Hinweiszeile eingebaut statt die ganze Preview abbrechen.
|
||||
func Render(scripts []PreviewScript, width int) string {
|
||||
var parts []string
|
||||
for _, s := range scripts {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
path := s.Path
|
||||
if path == "" {
|
||||
if s.Source == "" {
|
||||
continue
|
||||
}
|
||||
staged, cleanup, err := stagePreview(s.Source, s.Name)
|
||||
if err != nil {
|
||||
parts = append(parts, dimLine(fmt.Sprintf("%s: %v", s.Name, err)))
|
||||
continue
|
||||
}
|
||||
path = staged
|
||||
defer cleanup()
|
||||
}
|
||||
addPart(&parts, runScript(path, width), width)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func addPart(parts *[]string, raw string, width int) {
|
||||
if text := strings.TrimSpace(Sanitize(raw, width)); text != "" {
|
||||
*parts = append(*parts, text)
|
||||
}
|
||||
}
|
||||
|
||||
func runScript(path string, width int) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, path)
|
||||
cmd.Env = append(os.Environ(),
|
||||
fmt.Sprintf("COLUMNS=%d", width),
|
||||
"TERM=xterm-256color",
|
||||
)
|
||||
cmd.Dir = "/"
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return dimLine(fmt.Sprintf("%s: Timeout", filepath.Base(path)))
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
return dimLine(fmt.Sprintf("%s: nicht ausführbar (%v)", filepath.Base(path), err))
|
||||
}
|
||||
// Exit-Status != 0 ist bei MOTD-Scripten üblich; Output behalten.
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// stagePreview kopiert eine gebündelte Vorlage in ein Temporärverzeichnis,
|
||||
// damit die Live-Vorschau sie vor der Installation zeigen kann.
|
||||
func stagePreview(source, name string) (string, func(), error) {
|
||||
content, ok := assets.ScriptContent(source)
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("unbekannte Vorlage %q", source)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "motd-assist-")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
return path, func() { os.RemoveAll(dir) }, nil
|
||||
}
|
||||
|
||||
func dimLine(s string) string { return "\x1b[2m" + s + "\x1b[0m" }
|
||||
6
internal/motd/root_other.go
Normal file
6
internal/motd/root_other.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
//go:build !unix
|
||||
|
||||
package motd
|
||||
|
||||
// IsRoot ist auf Nicht-Unix-Plattformen immer false (Zielsystem ist Linux).
|
||||
func IsRoot() bool { return false }
|
||||
8
internal/motd/root_unix.go
Normal file
8
internal/motd/root_unix.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
//go:build unix
|
||||
|
||||
package motd
|
||||
|
||||
import "os"
|
||||
|
||||
// IsRoot meldet, ob der Prozess mit EUID 0 läuft.
|
||||
func IsRoot() bool { return os.Geteuid() == 0 }
|
||||
120
internal/motd/sanitize.go
Normal file
120
internal/motd/sanitize.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
)
|
||||
|
||||
type escapeKind int
|
||||
|
||||
const (
|
||||
escNone escapeKind = iota
|
||||
escSGR
|
||||
)
|
||||
|
||||
// parseEscape klassifiziert eine Escape-Sequenz ab runes[0] == ESC und
|
||||
// liefert die konsumierte Rune-Anzahl. Nur SGR (Farben) ist relevant.
|
||||
func parseEscape(r []rune) (string, int, escapeKind) {
|
||||
if len(r) < 2 {
|
||||
return "", len(r), escNone
|
||||
}
|
||||
switch r[1] {
|
||||
case '[': // CSI: Parameter (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")
|
||||
}
|
||||
34
internal/motd/sanitize_test.go
Normal file
34
internal/motd/sanitize_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package motd
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeKeepsSGRStripsRest(t *testing.T) {
|
||||
in := "\x1b[1;32mgrün\x1b[0m\x1b[2J\x1b[?25lnormal\r\nnext\x1b]0;title\x07"
|
||||
want := "\x1b[1;32mgrün\x1b[0mnormal\x1b[0m\nnext"
|
||||
if got := Sanitize(in, 40); got != want {
|
||||
t.Errorf("Sanitize() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeTruncatesToWidth(t *testing.T) {
|
||||
if got := Sanitize("abcdefghij", 4); got != "abcd" {
|
||||
t.Errorf("plain: got %q, want %q", got, "abcd")
|
||||
}
|
||||
got := Sanitize("\x1b[31mabcdefghij\x1b[0m", 4)
|
||||
want := "\x1b[31mabcd\x1b[0m"
|
||||
if got != want {
|
||||
t.Errorf("colored: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeExpandsTabsAndStripsCharset(t *testing.T) {
|
||||
if got := Sanitize("\x1b(Ba\tb", 20); got != "a b" {
|
||||
t.Errorf("got %q, want %q", got, "a b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeDropsOtherControlChars(t *testing.T) {
|
||||
if got := Sanitize("a\x00\x07\x08\x0bb", 20); got != "ab" {
|
||||
t.Errorf("got %q, want %q", got, "ab")
|
||||
}
|
||||
}
|
||||
74
internal/motd/script.go
Normal file
74
internal/motd/script.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Package motd enthält die Domain-Logik: Discovery der update-motd.d-Scripte,
|
||||
// Diff/Plan von Soll-Zuständen, das Ausführen des Plans und die Preview.
|
||||
package motd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
// Script ist ein Eintrag in /etc/update-motd.d.
|
||||
type Script struct {
|
||||
Name string // Dateiname inkl. Nummern-Präfix, z.B. "00-header"
|
||||
Path string // Absoluter Pfad
|
||||
Enabled bool // Executable-Bit gesetzt (run-parts führt nur diese aus)
|
||||
Bundled bool // von motd-assist installiert (Inhalt entspricht einer Vorlage)
|
||||
}
|
||||
|
||||
// MotdDir liefert den Pfad des update-motd.d-Verzeichnisses unter root.
|
||||
func MotdDir(root string) string {
|
||||
return filepath.Join(root, "etc", "update-motd.d")
|
||||
}
|
||||
|
||||
// run-parts im pam_motd-Kontext ignoriert Dateien mit Punkten; wir halten
|
||||
// uns an dieselbe Namenskonvention ([A-Za-z0-9_-]).
|
||||
var validName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// Discover listet alle Scripte im update-motd.d-Verzeichnis in
|
||||
// run-parts-Reihenfolge (lexikalische Sortierung). Gebündelte Scripte
|
||||
// werden per Inhaltsvergleich erkannt.
|
||||
func Discover(root string) ([]Script, error) {
|
||||
dir := MotdDir(root)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update-motd.d nicht lesbar: %w", err)
|
||||
}
|
||||
scripts := make([]Script, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !validName.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
s := Script{
|
||||
Name: e.Name(),
|
||||
Path: path,
|
||||
Enabled: info.Mode()&0o111 != 0,
|
||||
}
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
_, s.Bundled = assets.MatchScript(string(b))
|
||||
}
|
||||
scripts = append(scripts, s)
|
||||
}
|
||||
sort.Slice(scripts, func(i, j int) bool { return scripts[i].Name < scripts[j].Name })
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// EnabledCount zählt die aktiven Scripte.
|
||||
func EnabledCount(scripts []Script) int {
|
||||
n := 0
|
||||
for _, s := range scripts {
|
||||
if s.Enabled {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
207
internal/motd/state.go
Normal file
207
internal/motd/state.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DesiredEntry beschreibt den Soll-Zustand eines Scripts.
|
||||
// Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation.
|
||||
type DesiredEntry struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Install string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
// Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen
|
||||
// geändert — niemals Inhalte fremder Scripte.
|
||||
type (
|
||||
ChmodOp struct {
|
||||
Name string `json:"name"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
RenameOp struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
InstallOp struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
RemoveOp struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
)
|
||||
|
||||
// Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll.
|
||||
type Plan struct {
|
||||
Chmod []ChmodOp `json:"chmod,omitempty"`
|
||||
Renames []RenameOp `json:"renames,omitempty"`
|
||||
Installs []InstallOp `json:"installs,omitempty"`
|
||||
Removes []RemoveOp `json:"removes,omitempty"`
|
||||
}
|
||||
|
||||
// Empty meldet, ob der Plan nichts tut.
|
||||
func (p Plan) Empty() bool { return p.NumOps() == 0 }
|
||||
|
||||
// NumOps zählt alle Operationen.
|
||||
func (p Plan) NumOps() int {
|
||||
return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes)
|
||||
}
|
||||
|
||||
// Summary liefert eine Kurzbeschreibung für Statusmeldungen.
|
||||
func (p Plan) Summary() string {
|
||||
var parts []string
|
||||
if n := len(p.Removes); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d entfernt", n))
|
||||
}
|
||||
if n := len(p.Installs); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d installiert", n))
|
||||
}
|
||||
if n := len(p.Renames); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d umbenannt", n))
|
||||
}
|
||||
if n := len(p.Chmod); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "keine Änderungen"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
var prefixRe = regexp.MustCompile(`^([0-9]+)-`)
|
||||
|
||||
// suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header").
|
||||
func suffixOf(name string) string {
|
||||
if loc := prefixRe.FindStringIndex(name); loc != nil {
|
||||
return name[loc[1]:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func prefixNum(name string) (float64, bool) {
|
||||
m := prefixRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseFloat(m[1], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// targetName baut den kanonischen Namen für Position i (Schrittweite 10).
|
||||
func targetName(i int, name string) string {
|
||||
return fmt.Sprintf("%02d-%s", i*10, suffixOf(name))
|
||||
}
|
||||
|
||||
// DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset:
|
||||
// Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte
|
||||
// behalten Zustand und Position (interleaved über ihren Nummern-Präfix).
|
||||
// Ungelistete gebündelte Scripte werden zur Deinstallation markiert.
|
||||
func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry {
|
||||
curByName := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
|
||||
type slot struct {
|
||||
key float64
|
||||
entry DesiredEntry
|
||||
}
|
||||
var slots []slot
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for i, e := range entries {
|
||||
if _, exists := curByName[e.Name]; e.Install == "" && !exists {
|
||||
continue // Script existiert auf diesem System nicht
|
||||
}
|
||||
if seen[e.Name] || e.Remove {
|
||||
continue
|
||||
}
|
||||
seen[e.Name] = true
|
||||
slots = append(slots, slot{float64(i)*10 + 0.5, e})
|
||||
}
|
||||
|
||||
for i, c := range current {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
key := float64(i) * 10
|
||||
if n, ok := prefixNum(c.Name); ok {
|
||||
key = n
|
||||
}
|
||||
e := DesiredEntry{Name: c.Name, Enabled: c.Enabled}
|
||||
if c.Bundled {
|
||||
e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset
|
||||
}
|
||||
slots = append(slots, slot{key, e})
|
||||
}
|
||||
|
||||
sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key })
|
||||
|
||||
out := make([]DesiredEntry, len(slots))
|
||||
for i, s := range slots {
|
||||
out[i] = s.entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Diff berechnet den Plan von current nach desired.
|
||||
func Diff(current []Script, desired []DesiredEntry) Plan {
|
||||
var plan Plan
|
||||
curByName := make(map[string]Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
|
||||
var final []string // Reihenfolge der Dateien nach dem Apply
|
||||
installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs
|
||||
for _, e := range desired {
|
||||
if e.Remove {
|
||||
if _, exists := curByName[e.Name]; exists {
|
||||
plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name})
|
||||
}
|
||||
continue
|
||||
}
|
||||
final = append(final, e.Name)
|
||||
|
||||
cur, exists := curByName[e.Name]
|
||||
if !exists {
|
||||
if e.Install != "" {
|
||||
installIdx[e.Name] = len(plan.Installs)
|
||||
plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if cur.Enabled != e.Enabled {
|
||||
plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled})
|
||||
}
|
||||
}
|
||||
|
||||
// Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen
|
||||
// entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle
|
||||
// Positionen neu nummeriert – vorhandene Dateien umbenannt und
|
||||
// Installationen direkt unter dem Zielnamen angelegt.
|
||||
if !slices.IsSorted(final) {
|
||||
for i, name := range final {
|
||||
target := targetName(i, name)
|
||||
if cur, exists := curByName[name]; exists {
|
||||
if target != cur.Name {
|
||||
plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target})
|
||||
}
|
||||
} else if idx, ok := installIdx[name]; ok {
|
||||
plan.Installs[idx].Name = target
|
||||
}
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
120
internal/motd/state_test.go
Normal file
120
internal/motd/state_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package motd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiffToggleOnly(t *testing.T) {
|
||||
current := []Script{{Name: "00-header", Enabled: true}, {Name: "10-sysinfo", Enabled: false}}
|
||||
desired := []DesiredEntry{{Name: "00-header", Enabled: false}, {Name: "10-sysinfo", Enabled: false}}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
|
||||
t.Errorf("Chmod = %+v, want 00-header disable", plan.Chmod)
|
||||
}
|
||||
if plan.Empty() {
|
||||
t.Error("Plan sollte eine Operation haben, ist aber leer")
|
||||
}
|
||||
if len(plan.Renames) != 0 {
|
||||
t.Errorf("Renames = %+v, want none", plan.Renames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffReorderRenumbers(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-a", Enabled: true},
|
||||
{Name: "10-b", Enabled: true},
|
||||
{Name: "20-c", Enabled: true},
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "10-b", Enabled: true},
|
||||
{Name: "00-a", Enabled: true},
|
||||
{Name: "20-c", Enabled: true},
|
||||
}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
want := []RenameOp{{From: "10-b", To: "00-b"}, {From: "00-a", To: "10-a"}}
|
||||
if !reflect.DeepEqual(plan.Renames, want) {
|
||||
t.Errorf("Renames = %+v, want %+v", plan.Renames, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffKeepsPrefixesWhenOrderUnchanged(t *testing.T) {
|
||||
current := []Script{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
|
||||
desired := []DesiredEntry{{Name: "05-a", Enabled: true}, {Name: "07-b", Enabled: true}}
|
||||
|
||||
if plan := Diff(current, desired); !plan.Empty() {
|
||||
t.Errorf("Plan = %+v, want leer", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffInstallAndRemove(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "95-footer", Enabled: true, Bundled: true},
|
||||
}
|
||||
desired := []DesiredEntry{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "95-footer", Remove: true},
|
||||
}
|
||||
|
||||
plan := Diff(current, desired)
|
||||
if len(plan.Installs) != 1 || plan.Installs[0].Source != "brand-header" {
|
||||
t.Errorf("Installs = %+v", plan.Installs)
|
||||
}
|
||||
if len(plan.Removes) != 1 || plan.Removes[0].Name != "95-footer" {
|
||||
t.Errorf("Removes = %+v", plan.Removes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredFromPresetInterleavesUnlisted(t *testing.T) {
|
||||
current := []Script{
|
||||
{Name: "00-header", Enabled: true},
|
||||
{Name: "50-motd-news", Enabled: true},
|
||||
{Name: "90-updates-available", Enabled: false},
|
||||
{Name: "95-footer", Enabled: true, Bundled: true},
|
||||
}
|
||||
entries := []DesiredEntry{
|
||||
{Name: "10-brand-header", Enabled: true, Install: "brand-header"},
|
||||
{Name: "00-header", Enabled: false},
|
||||
}
|
||||
|
||||
got := DesiredFromPreset(current, entries)
|
||||
var names []string
|
||||
var removes []string
|
||||
for _, e := range got {
|
||||
if e.Remove {
|
||||
removes = append(removes, e.Name)
|
||||
} else {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
}
|
||||
wantNames := []string{"10-brand-header", "00-header", "50-motd-news", "90-updates-available"}
|
||||
if !reflect.DeepEqual(names, wantNames) {
|
||||
t.Errorf("Reihenfolge = %v, want %v", names, wantNames)
|
||||
}
|
||||
if !reflect.DeepEqual(removes, []string{"95-footer"}) {
|
||||
t.Errorf("Removes = %v, want [95-footer]", removes)
|
||||
}
|
||||
// Ungelistete behalten ihren Zustand.
|
||||
for _, e := range got {
|
||||
if e.Name == "50-motd-news" && !e.Enabled {
|
||||
t.Error("50-motd-news sollte aktiviert bleiben")
|
||||
}
|
||||
if e.Name == "00-header" && e.Enabled {
|
||||
t.Error("00-header sollte laut Preset deaktiviert werden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesiredFromPresetSkipsMissing(t *testing.T) {
|
||||
current := []Script{{Name: "00-header", Enabled: true}}
|
||||
entries := []DesiredEntry{{Name: "10-gibt-es-nicht", Enabled: true}}
|
||||
|
||||
got := DesiredFromPreset(current, entries)
|
||||
if len(got) != 1 || got[0].Name != "00-header" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
127
internal/preset/preset.go
Normal file
127
internal/preset/preset.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Package preset definiert das Preset-Schema und das Laden/Speichern
|
||||
// von Built-in- und User-Presets.
|
||||
package preset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
)
|
||||
|
||||
// Entry beschreibt den Soll-Zustand eines Scripts innerhalb eines Presets.
|
||||
// Die Reihenfolge im Scripts-Array bestimmt die Ziel-Reihenfolge im MOTD.
|
||||
type Entry struct {
|
||||
Name string `json:"name"` // Zieldateiname, z.B. "10-brand-header"
|
||||
Enabled bool `json:"enabled"` // Executable-Bit gesetzt
|
||||
Install string `json:"install,omitempty"` // gebündelte Vorlage, leer = vorhandenes Script
|
||||
}
|
||||
|
||||
// Preset ist eine benannte MOTD-Konfiguration.
|
||||
type Preset struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Scripts []Entry `json:"scripts"`
|
||||
|
||||
Source string `json:"-"` // "builtin" oder "user"
|
||||
}
|
||||
|
||||
var safeName = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
// Dir liefert das User-Preset-Verzeichnis (XDG-konform).
|
||||
func Dir() (string, error) {
|
||||
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
|
||||
return filepath.Join(d, "motd-assist", "presets"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "motd-assist", "presets"), nil
|
||||
}
|
||||
|
||||
// LoadAll liefert alle Presets: Built-ins, durch User-Presets mit
|
||||
// gleichem Namen überschrieben. Sortiert nach Name.
|
||||
func LoadAll() ([]Preset, error) {
|
||||
byName := make(map[string]Preset)
|
||||
|
||||
for file, content := range assets.PresetFiles() {
|
||||
var p Preset
|
||||
if err := json.Unmarshal([]byte(content), &p); err != nil {
|
||||
return nil, fmt.Errorf("builtin preset %s: %w", file, err)
|
||||
}
|
||||
p.Source = "builtin"
|
||||
byName[p.Name] = p
|
||||
}
|
||||
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var p Preset
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
continue
|
||||
}
|
||||
p.Source = "user"
|
||||
byName[p.Name] = p
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
presets := make([]Preset, 0, len(byName))
|
||||
for _, p := range byName {
|
||||
presets = append(presets, p)
|
||||
}
|
||||
sort.Slice(presets, func(i, j int) bool { return presets[i].Name < presets[j].Name })
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// SaveUser schreibt ein Preset ins User-Verzeichnis (überschreibt Built-ins
|
||||
// mit gleichem Namen zur Laufzeit).
|
||||
func SaveUser(p Preset) (string, error) {
|
||||
if p.Name == "" {
|
||||
return "", errors.New("Preset-Name fehlt")
|
||||
}
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, safeName.ReplaceAllString(p.Name, "-")+".json")
|
||||
return path, os.WriteFile(path, append(b, '\n'), 0o644)
|
||||
}
|
||||
|
||||
// DeleteUser entfernt ein User-Preset. Built-ins können nicht gelöscht werden.
|
||||
func DeleteUser(name string) error {
|
||||
dir, err := Dir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, safeName.ReplaceAllString(name, "-")+".json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("nur User-Presets sind löschbar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
59
internal/preset/preset_test.go
Normal file
59
internal/preset/preset_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package preset
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinPresetsLoad(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
|
||||
presets, err := LoadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(presets) < 3 {
|
||||
t.Errorf("Built-in-Presets fehlen: %d geladen", len(presets))
|
||||
}
|
||||
for _, p := range presets {
|
||||
if p.Source != "builtin" {
|
||||
t.Errorf("Preset %s: Source = %s", p.Name, p.Source)
|
||||
}
|
||||
if len(p.Scripts) == 0 {
|
||||
t.Errorf("Preset %s hat keine Scripte", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndUserOverride(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
|
||||
p := Preset{
|
||||
Name: "minimal",
|
||||
Description: "eigene Variante",
|
||||
Scripts: []Entry{{Name: "00-header", Enabled: true}},
|
||||
}
|
||||
if _, err := SaveUser(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
presets, err := LoadAll()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, got := range presets {
|
||||
if got.Name == "minimal" {
|
||||
if got.Source != "user" || got.Description != "eigene Variante" {
|
||||
t.Errorf("User-Override greift nicht: %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("minimal nicht gefunden")
|
||||
}
|
||||
|
||||
func TestDeleteUserRejectsUnknown(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
if err := DeleteUser("gibt-es-nicht"); err == nil {
|
||||
t.Error("Löschen eines fehlenden Presets sollte fehlschlagen")
|
||||
}
|
||||
}
|
||||
110
internal/ui/commands.go
Normal file
110
internal/ui/commands.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"motd-assist/internal/motd"
|
||||
"motd-assist/internal/preset"
|
||||
)
|
||||
|
||||
type discoverMsg struct {
|
||||
scripts []motd.Script
|
||||
host motd.HostInfo
|
||||
err error
|
||||
}
|
||||
|
||||
type presetsMsg struct {
|
||||
presets []preset.Preset
|
||||
err error
|
||||
}
|
||||
|
||||
type previewMsg struct {
|
||||
version int
|
||||
text string
|
||||
}
|
||||
|
||||
type applyDoneMsg struct{ err error }
|
||||
|
||||
func discoverCmd(root string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
scripts, err := motd.Discover(root)
|
||||
return discoverMsg{scripts: scripts, host: motd.DetectHost(root), err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func presetsCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
presets, err := preset.LoadAll()
|
||||
return presetsMsg{presets: presets, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// previewCmd rendert die Arbeitskopie als Live-Vorschau (asynchron, damit
|
||||
// langsame Scripte die TUI nicht blockieren).
|
||||
func (m *Model) previewCmd() tea.Cmd {
|
||||
m.previewBusy = true
|
||||
m.previewVersion++
|
||||
version := m.previewVersion
|
||||
width := m.previewWidth()
|
||||
scripts := make([]motd.PreviewScript, 0, len(m.working))
|
||||
for _, r := range m.working {
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
ps := motd.PreviewScript{Name: r.Name, Enabled: r.Enabled}
|
||||
if r.Exists {
|
||||
ps.Path = filepath.Join(motd.MotdDir(m.root), r.Name)
|
||||
} else {
|
||||
ps.Source = r.Install
|
||||
}
|
||||
scripts = append(scripts, ps)
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return previewMsg{version: version, text: motd.Render(scripts, width)}
|
||||
}
|
||||
}
|
||||
|
||||
// previewWidth ist die nutzbare Breite innerhalb der Preview-Umrandung.
|
||||
func (m *Model) previewWidth() int {
|
||||
w := m.width - 4 // Rahmen (2) + Padding (2)
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
if w > 100 {
|
||||
w = 100
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// applyExec handelt das Terminal an sudo weiter (interaktive
|
||||
// Passwortabfrage) und startet danach die TUI weiter.
|
||||
func applyExec(plan motd.Plan) tea.Cmd {
|
||||
planFile, err := plan.WriteFile()
|
||||
if err != nil {
|
||||
return func() tea.Msg { return applyDoneMsg{err: err} }
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
os.Remove(planFile)
|
||||
return func() tea.Msg { return applyDoneMsg{err: err} }
|
||||
}
|
||||
var cmd *exec.Cmd
|
||||
if motd.IsRoot() {
|
||||
cmd = exec.Command(self, "apply-state", planFile)
|
||||
} else {
|
||||
sudo, err := exec.LookPath("sudo")
|
||||
if err != nil {
|
||||
os.Remove(planFile)
|
||||
return func() tea.Msg { return applyDoneMsg{err: err} }
|
||||
}
|
||||
cmd = exec.Command(sudo, "-p", "[motd-assist] Passwort für %p: ", self, "apply-state", planFile)
|
||||
}
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||
os.Remove(planFile)
|
||||
return applyDoneMsg{err: err}
|
||||
})
|
||||
}
|
||||
182
internal/ui/model.go
Normal file
182
internal/ui/model.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Package ui implementiert die Bubbletea-TUI von motd-assist.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
"motd-assist/internal/motd"
|
||||
"motd-assist/internal/preset"
|
||||
)
|
||||
|
||||
type pane int
|
||||
|
||||
const (
|
||||
panePresets pane = iota
|
||||
paneScripts
|
||||
panePreview
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNormal mode = iota
|
||||
modeSaveName
|
||||
modeHelp
|
||||
)
|
||||
|
||||
// row ist die Arbeitskopie eines Scripts inkl. geplanter Installation.
|
||||
type row struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Install string // gebündelte Vorlage, solange noch nicht installiert
|
||||
Bundled bool // installiert und entspricht einer Vorlage
|
||||
Remove bool // Deinstallation geplant (Preset-Wechsel)
|
||||
Exists bool // Datei existiert bereits
|
||||
}
|
||||
|
||||
// Model ist das Bubbletea-Model der TUI.
|
||||
type Model struct {
|
||||
root string
|
||||
|
||||
host motd.HostInfo
|
||||
current []motd.Script
|
||||
working []row
|
||||
presets []preset.Preset
|
||||
// presetMatches markiert Presets, die dem aktiven Ist-Zustand entsprechen.
|
||||
presetMatches map[string]bool
|
||||
|
||||
focus pane
|
||||
mode mode
|
||||
cursor int // Zeiger in der Script-Liste
|
||||
presetIdx int
|
||||
previewOff int
|
||||
|
||||
preview string
|
||||
previewBusy bool
|
||||
previewVersion int
|
||||
|
||||
status string
|
||||
err error
|
||||
applying bool
|
||||
lastSummary string
|
||||
|
||||
width, height int
|
||||
ready bool
|
||||
|
||||
input textinput.Model
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
// New erzeugt das Model für ein root-Dateisystem (normalerweise "/").
|
||||
func New(root string) Model {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = dimStyle
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "preset-name"
|
||||
ti.CharLimit = 48
|
||||
|
||||
return Model{
|
||||
root: root,
|
||||
focus: panePresets,
|
||||
presetMatches: map[string]bool{},
|
||||
spinner: sp,
|
||||
input: ti,
|
||||
}
|
||||
}
|
||||
|
||||
// Init startet Discovery und Spinner.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(discoverCmd(m.root), presetsCmd(), m.spinner.Tick)
|
||||
}
|
||||
|
||||
// desiredEntries wandelt die Arbeitskopie in Soll-Einträge um.
|
||||
func (m *Model) desiredEntries() []motd.DesiredEntry {
|
||||
out := make([]motd.DesiredEntry, 0, len(m.working))
|
||||
for _, r := range m.working {
|
||||
out = append(out, motd.DesiredEntry{
|
||||
Name: r.Name,
|
||||
Enabled: r.Enabled,
|
||||
Install: r.Install,
|
||||
Remove: r.Remove,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentPlan berechnet den ausstehenden Plan (Ist → Arbeitskopie).
|
||||
func (m *Model) currentPlan() motd.Plan {
|
||||
return motd.Diff(m.current, m.desiredEntries())
|
||||
}
|
||||
|
||||
func rowsFromCurrent(scripts []motd.Script) []row {
|
||||
rows := make([]row, len(scripts))
|
||||
for i, s := range scripts {
|
||||
rows[i] = row{Name: s.Name, Enabled: s.Enabled, Bundled: s.Bundled, Exists: true}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func rowsFromDesired(entries []motd.DesiredEntry, current []motd.Script) []row {
|
||||
curByName := make(map[string]motd.Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
rows := make([]row, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
r := row{Name: e.Name, Enabled: e.Enabled, Remove: e.Remove}
|
||||
if c, exists := curByName[e.Name]; exists {
|
||||
r.Exists = true
|
||||
r.Bundled = c.Bundled
|
||||
} else if e.Install != "" {
|
||||
r.Install = e.Install
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// presetFromWorking serialisiert die Arbeitskopie in ein Preset; für
|
||||
// installierte gebündelte Scripte wird die Vorlage als Install-Quelle
|
||||
// hinterlegt, damit das Preset auf anderen Maschinen tragbar bleibt.
|
||||
func (m *Model) presetFromWorking(name string) preset.Preset {
|
||||
p := preset.Preset{Name: name}
|
||||
for _, r := range m.working {
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
e := preset.Entry{Name: r.Name, Enabled: r.Enabled}
|
||||
switch {
|
||||
case r.Install != "":
|
||||
e.Install = r.Install
|
||||
case r.Bundled:
|
||||
if b, err := os.ReadFile(filepath.Join(motd.MotdDir(m.root), r.Name)); err == nil {
|
||||
if src, ok := assets.MatchScript(string(b)); ok {
|
||||
e.Install = src
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Scripts = append(p.Scripts, e)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// refreshPresetMatches prüft, welche Presets dem Ist-Zustand entsprechen.
|
||||
func (m *Model) refreshPresetMatches() {
|
||||
m.presetMatches = make(map[string]bool, len(m.presets))
|
||||
for _, p := range m.presets {
|
||||
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
|
||||
for _, e := range p.Scripts {
|
||||
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
|
||||
}
|
||||
desired := motd.DesiredFromPreset(m.current, entries)
|
||||
m.presetMatches[p.Name] = motd.Diff(m.current, desired).Empty()
|
||||
}
|
||||
}
|
||||
109
internal/ui/model_test.go
Normal file
109
internal/ui/model_test.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"motd-assist/internal/motd"
|
||||
"motd-assist/internal/preset"
|
||||
)
|
||||
|
||||
func mustUpdate(m Model, msg tea.Msg) Model {
|
||||
next, _ := m.Update(msg)
|
||||
return next.(Model)
|
||||
}
|
||||
|
||||
func newTestModel(t *testing.T) Model {
|
||||
t.Helper()
|
||||
m := New(t.TempDir())
|
||||
m = mustUpdate(m, tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
return m
|
||||
}
|
||||
|
||||
func TestViewRendersAllPanes(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
scripts := []motd.Script{
|
||||
{Name: "00-header", Path: "/x/00-header", Enabled: true},
|
||||
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
|
||||
}
|
||||
m = mustUpdate(m, discoverMsg{scripts: scripts, host: motd.HostInfo{Hostname: "myhost", Distro: "Ubuntu 24.04", PAM: true}})
|
||||
m = mustUpdate(m, previewMsg{version: m.previewVersion, text: "Willkommen auf myhost"})
|
||||
|
||||
view := m.View()
|
||||
for _, want := range []string{"Presets", "Scripte", "Live-Vorschau", "00-header", "10-sysinfo", "Willkommen auf myhost", "myhost"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("View enthält %q nicht", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleAndPlan(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
scripts := []motd.Script{
|
||||
{Name: "00-header", Path: "/x/00-header", Enabled: true},
|
||||
{Name: "10-sysinfo", Path: "/x/10-sysinfo", Enabled: false},
|
||||
}
|
||||
m = mustUpdate(m, discoverMsg{scripts: scripts})
|
||||
|
||||
// Fokus auf Scripte wechseln und erstes Script ausschalten.
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace})
|
||||
|
||||
plan := m.currentPlan()
|
||||
if len(plan.Chmod) != 1 || plan.Chmod[0].Name != "00-header" || plan.Chmod[0].Enable {
|
||||
t.Errorf("Chmod = %+v, want 00-header aus", plan.Chmod)
|
||||
}
|
||||
if markers := m.markersByRow(); markers["00-header"] != "aus" {
|
||||
t.Errorf("Marker = %q, want aus", markers["00-header"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRowChangesOrder(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
scripts := []motd.Script{
|
||||
{Name: "00-header", Path: "/x", Enabled: true},
|
||||
{Name: "10-sysinfo", Path: "/x", Enabled: true},
|
||||
}
|
||||
m = mustUpdate(m, discoverMsg{scripts: scripts})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) // nach unten
|
||||
|
||||
if m.working[0].Name != "10-sysinfo" || m.working[1].Name != "00-header" {
|
||||
t.Errorf("Reihenfolge = %v, %v", m.working[0].Name, m.working[1].Name)
|
||||
}
|
||||
if plan := m.currentPlan(); len(plan.Renames) != 2 {
|
||||
t.Errorf("Renames = %+v, want 2", plan.Renames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPresetMarksPending(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: false}}
|
||||
m = mustUpdate(m, discoverMsg{scripts: scripts})
|
||||
m = mustUpdate(m, presetsMsg{presets: []preset.Preset{{Name: "p1", Scripts: []preset.Entry{{Name: "00-header", Enabled: true}}}}})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEnter}) // erstes Preset laden
|
||||
|
||||
if plan := m.currentPlan(); len(plan.Chmod) != 1 || !plan.Chmod[0].Enable {
|
||||
t.Errorf("Plan = %+v, want 00-header an", plan)
|
||||
}
|
||||
if m.status == "" {
|
||||
t.Error("Statusmeldung nach Preset-Laden erwartet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscardResetsWorking(t *testing.T) {
|
||||
m := newTestModel(t)
|
||||
scripts := []motd.Script{{Name: "00-header", Path: "/x", Enabled: true}}
|
||||
m = mustUpdate(m, discoverMsg{scripts: scripts})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeySpace}) // ausschalten
|
||||
if m.currentPlan().Empty() {
|
||||
t.Fatal("Plan sollte Änderungen haben")
|
||||
}
|
||||
m = mustUpdate(m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if !m.currentPlan().Empty() {
|
||||
t.Errorf("nach esc sollte der Plan leer sein, ist %+v", m.currentPlan())
|
||||
}
|
||||
}
|
||||
56
internal/ui/styles.go
Normal file
56
internal/ui/styles.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
colorAccent = lipgloss.Color("75")
|
||||
colorBright = lipgloss.Color("81")
|
||||
colorGood = lipgloss.Color("114")
|
||||
colorWarn = lipgloss.Color("214")
|
||||
colorBad = lipgloss.Color("203")
|
||||
colorDim = lipgloss.Color("244")
|
||||
colorBorder = lipgloss.Color("240")
|
||||
|
||||
focusedTitle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
|
||||
dimStyle = lipgloss.NewStyle().Foreground(colorDim)
|
||||
goodStyle = lipgloss.NewStyle().Foreground(colorGood)
|
||||
badStyle = lipgloss.NewStyle().Foreground(colorBad)
|
||||
warnStyle = lipgloss.NewStyle().Foreground(colorWarn)
|
||||
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBright)
|
||||
helpStyle = lipgloss.NewStyle().Foreground(colorDim)
|
||||
)
|
||||
|
||||
// paneBox baut den Rahmen-Stil für ein Pane mit gegebener Außenbreite/-höhe.
|
||||
// lipgloss zählt Padding zur Breite, den Rahmen nicht – daher w-2/h-2.
|
||||
func paneBox(focused bool, w, h int) lipgloss.Style {
|
||||
s := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
PaddingLeft(1).
|
||||
PaddingRight(1)
|
||||
if focused {
|
||||
s = s.BorderForeground(colorAccent)
|
||||
}
|
||||
if w > 0 {
|
||||
s = s.Width(w - 2)
|
||||
}
|
||||
if h > 0 {
|
||||
s = s.Height(h - 2)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// truncate schneidet s Anzeigebreite auf w und hängt … an.
|
||||
func truncate(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= w {
|
||||
return s
|
||||
}
|
||||
if w == 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(r[:w-1]) + "…"
|
||||
}
|
||||
255
internal/ui/update.go
Normal file
255
internal/ui/update.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"motd-assist/internal/motd"
|
||||
"motd-assist/internal/preset"
|
||||
)
|
||||
|
||||
// Update behandelt Fenstergröße, Ergebnisse der Hintergrundkommandos und
|
||||
// Tastendrücke.
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m.ready = true
|
||||
if w := m.width - 20; w > 10 {
|
||||
m.input.Width = w
|
||||
}
|
||||
if len(m.working) > 0 {
|
||||
return m, m.previewCmd() // Breite geändert → Preview neu rendern
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case discoverMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.current, m.working = nil, nil
|
||||
return m, nil
|
||||
}
|
||||
m.err = nil
|
||||
m.current = msg.scripts
|
||||
m.host = msg.host
|
||||
m.working = rowsFromCurrent(msg.scripts)
|
||||
if m.cursor >= len(m.working) {
|
||||
m.cursor = max(0, len(m.working)-1)
|
||||
}
|
||||
m.refreshPresetMatches()
|
||||
m.previewOff = 0
|
||||
return m, m.previewCmd()
|
||||
|
||||
case presetsMsg:
|
||||
if msg.err == nil {
|
||||
m.presets = msg.presets
|
||||
if m.presetIdx >= len(m.presets) {
|
||||
m.presetIdx = max(0, len(m.presets)-1)
|
||||
}
|
||||
m.refreshPresetMatches()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case previewMsg:
|
||||
if msg.version < m.previewVersion {
|
||||
return m, nil // veraltetes Ergebnis, neuere Preview läuft schon
|
||||
}
|
||||
m.previewBusy = false
|
||||
m.preview = msg.text
|
||||
m.previewOff = 0
|
||||
return m, nil
|
||||
|
||||
case applyDoneMsg:
|
||||
m.applying = false
|
||||
if msg.err != nil {
|
||||
m.status = "Anwenden fehlgeschlagen: " + msg.err.Error()
|
||||
} else {
|
||||
m.status = "Angewendet: " + m.lastSummary
|
||||
}
|
||||
return m, discoverCmd(m.root)
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
case modeHelp:
|
||||
m.mode = modeNormal
|
||||
return m, nil
|
||||
|
||||
case modeSaveName:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
name := strings.TrimSpace(m.input.Value())
|
||||
m.mode = modeNormal
|
||||
m.input.Blur()
|
||||
if name == "" {
|
||||
return m, nil
|
||||
}
|
||||
if _, err := preset.SaveUser(m.presetFromWorking(name)); err != nil {
|
||||
m.status = "Speichern fehlgeschlagen: " + err.Error()
|
||||
} else {
|
||||
m.status = "Preset \"" + name + "\" gespeichert"
|
||||
}
|
||||
return m, presetsCmd()
|
||||
case "esc", "ctrl+c":
|
||||
m.mode = modeNormal
|
||||
m.input.Blur()
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
key := msg.String()
|
||||
switch key {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "tab":
|
||||
m.focus = (m.focus + 1) % 3
|
||||
return m, nil
|
||||
case "shift+tab":
|
||||
m.focus = (m.focus + 2) % 3
|
||||
case "?":
|
||||
m.mode = modeHelp
|
||||
return m, nil
|
||||
case "r":
|
||||
m.status = ""
|
||||
return m, discoverCmd(m.root)
|
||||
case "esc":
|
||||
if m.err != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.working = rowsFromCurrent(m.current)
|
||||
m.previewOff = 0
|
||||
m.status = "Änderungen verworfen"
|
||||
return m, m.previewCmd()
|
||||
case "s":
|
||||
if m.err != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.mode = modeSaveName
|
||||
m.input.SetValue("")
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
case "a":
|
||||
return m.applyWorking()
|
||||
case "D":
|
||||
if len(m.presets) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
p := m.presets[m.presetIdx]
|
||||
if err := preset.DeleteUser(p.Name); err != nil {
|
||||
m.status = err.Error()
|
||||
} else {
|
||||
m.status = "Preset \"" + p.Name + "\" gelöscht"
|
||||
}
|
||||
return m, presetsCmd()
|
||||
}
|
||||
|
||||
switch m.focus {
|
||||
case panePresets:
|
||||
switch key {
|
||||
case "j", "down":
|
||||
if m.presetIdx < len(m.presets)-1 {
|
||||
m.presetIdx++
|
||||
}
|
||||
case "k", "up":
|
||||
if m.presetIdx > 0 {
|
||||
m.presetIdx--
|
||||
}
|
||||
case "enter", " ":
|
||||
return m.loadPreset()
|
||||
}
|
||||
|
||||
case paneScripts:
|
||||
switch key {
|
||||
case "j", "down":
|
||||
if m.cursor < len(m.working)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "k", "up":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case " ":
|
||||
if len(m.working) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
r := &m.working[m.cursor]
|
||||
if r.Remove {
|
||||
return m, nil
|
||||
}
|
||||
r.Enabled = !r.Enabled
|
||||
return m, m.previewCmd()
|
||||
case "u":
|
||||
return m.moveRow(-1)
|
||||
case "d":
|
||||
return m.moveRow(1)
|
||||
}
|
||||
|
||||
case panePreview:
|
||||
lines := strings.Count(m.preview, "\n") + 1
|
||||
switch key {
|
||||
case "j", "down":
|
||||
m.previewOff = min(m.previewOff+1, max(0, lines-1))
|
||||
case "k", "up":
|
||||
if m.previewOff > 0 {
|
||||
m.previewOff--
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) loadPreset() (tea.Model, tea.Cmd) {
|
||||
if len(m.presets) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
p := m.presets[m.presetIdx]
|
||||
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
|
||||
for _, e := range p.Scripts {
|
||||
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
|
||||
}
|
||||
m.working = rowsFromDesired(motd.DesiredFromPreset(m.current, entries), m.current)
|
||||
m.cursor = 0
|
||||
m.previewOff = 0
|
||||
m.status = "Preset \"" + p.Name + "\" geladen – mit 'a' anwenden"
|
||||
return m, m.previewCmd()
|
||||
}
|
||||
|
||||
func (m Model) moveRow(delta int) (tea.Model, tea.Cmd) {
|
||||
i, j := m.cursor, m.cursor+delta
|
||||
if i < 0 || i >= len(m.working) || j < 0 || j >= len(m.working) {
|
||||
return m, nil
|
||||
}
|
||||
m.working[i], m.working[j] = m.working[j], m.working[i]
|
||||
m.cursor = j
|
||||
return m, m.previewCmd()
|
||||
}
|
||||
|
||||
func (m Model) applyWorking() (tea.Model, tea.Cmd) {
|
||||
if m.applying || m.err != nil {
|
||||
return m, nil
|
||||
}
|
||||
plan := m.currentPlan()
|
||||
if plan.Empty() {
|
||||
m.status = "Keine Änderungen zum Anwenden"
|
||||
return m, nil
|
||||
}
|
||||
m.applying = true
|
||||
m.lastSummary = plan.Summary()
|
||||
return m, applyExec(plan)
|
||||
}
|
||||
336
internal/ui/view.go
Normal file
336
internal/ui/view.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"motd-assist/internal/motd"
|
||||
)
|
||||
|
||||
// View rendert das komplette Layout: Preset- und Script-Pane oben,
|
||||
// Live-Vorschau unten, Statuszeile am unteren Rand.
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "motd-assist startet…"
|
||||
}
|
||||
switch m.mode {
|
||||
case modeHelp:
|
||||
return m.helpView()
|
||||
case modeSaveName:
|
||||
return m.saveView()
|
||||
}
|
||||
if m.err != nil {
|
||||
return m.errorView()
|
||||
}
|
||||
|
||||
const statusH = 1
|
||||
previewH := max(6, (m.height-statusH)*2/5)
|
||||
topH := m.height - statusH - previewH
|
||||
if topH < 5 {
|
||||
topH = max(3, m.height-statusH-3)
|
||||
previewH = m.height - statusH - topH
|
||||
}
|
||||
|
||||
presetsW := 26
|
||||
if m.width < 90 {
|
||||
presetsW = 22
|
||||
}
|
||||
scriptsW := m.width - presetsW - 1
|
||||
|
||||
top := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
m.presetsView(presetsW, topH),
|
||||
" ",
|
||||
m.scriptsView(scriptsW, topH),
|
||||
)
|
||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||
top,
|
||||
m.previewView(m.width, previewH),
|
||||
m.statusView(),
|
||||
)
|
||||
return body
|
||||
}
|
||||
|
||||
func (m Model) presetsView(w, h int) string {
|
||||
inner := w - 4 // Rahmen (2) + Padding (2)
|
||||
var b strings.Builder
|
||||
b.WriteString(focusedTitle.Render("Presets"))
|
||||
b.WriteString("\n")
|
||||
|
||||
visible := h - 3 // Rahmen (2) + Titel (1)
|
||||
start := 0
|
||||
if m.presetIdx >= visible {
|
||||
start = m.presetIdx - visible + 1
|
||||
}
|
||||
for i := start; i < len(m.presets) && i < start+visible; i++ {
|
||||
p := m.presets[i]
|
||||
cursor := " "
|
||||
name := truncate(p.Name, inner-5)
|
||||
if m.focus == panePresets && i == m.presetIdx {
|
||||
cursor = "▸ "
|
||||
b.WriteString(cursorStyle.Render(cursor + name))
|
||||
} else {
|
||||
b.WriteString(cursor + name)
|
||||
}
|
||||
if m.presetMatches[p.Name] {
|
||||
b.WriteString(" ")
|
||||
b.WriteString(goodStyle.Render("✓"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(m.presets) == 0 {
|
||||
b.WriteString(dimStyle.Render("(keine Presets)"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return paneBox(m.focus == panePresets, w, h).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) scriptsView(w, h int) string {
|
||||
inner := w - 4
|
||||
var b strings.Builder
|
||||
title := "Scripte · /etc/update-motd.d"
|
||||
b.WriteString(focusedTitle.Render(truncate(title, inner)))
|
||||
b.WriteString("\n")
|
||||
|
||||
markers := m.markersByRow()
|
||||
visible := h - 3
|
||||
start := 0
|
||||
if m.cursor >= visible {
|
||||
start = m.cursor - visible + 1
|
||||
}
|
||||
for i := start; i < len(m.working) && i < start+visible; i++ {
|
||||
b.WriteString(m.renderRow(m.working[i], markers[m.working[i].Name], inner, m.focus == paneScripts && i == m.cursor))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if len(m.working) == 0 {
|
||||
b.WriteString(dimStyle.Render("(leer)"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return paneBox(m.focus == paneScripts, w, h).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) renderRow(r row, marker string, inner int, selected bool) string {
|
||||
var icon string
|
||||
switch {
|
||||
case r.Remove:
|
||||
icon = badStyle.Render("✕")
|
||||
case r.Enabled:
|
||||
icon = goodStyle.Render("●")
|
||||
default:
|
||||
icon = dimStyle.Render("○")
|
||||
}
|
||||
|
||||
name := r.Name
|
||||
suffix := ""
|
||||
if r.Install != "" {
|
||||
suffix = " " + dimStyle.Render("+neu")
|
||||
} else if r.Bundled {
|
||||
suffix = " " + dimStyle.Render("·motd-assist")
|
||||
}
|
||||
if selected {
|
||||
name = cursorStyle.Render(name)
|
||||
} else if !r.Enabled && !r.Remove {
|
||||
name = dimStyle.Render(name)
|
||||
}
|
||||
|
||||
line := icon + " " + name + suffix
|
||||
// Marker rechtsbündig, ohne ANSI-Breiten zu verreißen: Marker ist
|
||||
// gefärbt, daher Breite ohne Escape-Codes einplanen.
|
||||
markerPlain := marker
|
||||
markerW := len([]rune(markerPlain))
|
||||
used := lipgloss.Width(line)
|
||||
pad := inner - used - markerW - 1
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
if marker != "" {
|
||||
style := warnStyle
|
||||
switch marker {
|
||||
case "neu", "an":
|
||||
style = goodStyle
|
||||
case "entfernt":
|
||||
style = badStyle
|
||||
default:
|
||||
style = dimStyle
|
||||
}
|
||||
line += strings.Repeat(" ", pad) + style.Render(marker)
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// markersByRow liefert die ausstehenden Änderungen pro Script-Name.
|
||||
func (m *Model) markersByRow() map[string]string {
|
||||
mk := make(map[string]string)
|
||||
plan := m.currentPlan()
|
||||
for _, op := range plan.Installs {
|
||||
if op.Enabled {
|
||||
mk[op.Name] = "neu"
|
||||
} else {
|
||||
mk[op.Name] = "neu (aus)"
|
||||
}
|
||||
}
|
||||
for _, op := range plan.Chmod {
|
||||
if op.Enable {
|
||||
mk[op.Name] = "an"
|
||||
} else {
|
||||
mk[op.Name] = "aus"
|
||||
}
|
||||
}
|
||||
for _, op := range plan.Renames {
|
||||
mk[op.From] = "→ " + op.To
|
||||
}
|
||||
for _, op := range plan.Removes {
|
||||
mk[op.Name] = "entfernt"
|
||||
}
|
||||
return mk
|
||||
}
|
||||
|
||||
func (m Model) previewView(w, h int) string {
|
||||
inner := w - 4
|
||||
var title string
|
||||
switch {
|
||||
case m.applying:
|
||||
title = fmt.Sprintf("Live-Vorschau %s sudo läuft…", m.spinner.View())
|
||||
case m.previewBusy:
|
||||
title = fmt.Sprintf("Live-Vorschau %s aktualisiere…", m.spinner.View())
|
||||
default:
|
||||
if n := m.currentPlan().NumOps(); n > 0 {
|
||||
title = fmt.Sprintf("Live-Vorschau · %s (a: anwenden, esc: verwerfen)",
|
||||
warnStyle.Render(fmt.Sprintf("%d Änderungen ausstehend", n)))
|
||||
} else {
|
||||
title = "Live-Vorschau · " + goodStyle.Render("entspricht dem System")
|
||||
}
|
||||
}
|
||||
|
||||
var content string
|
||||
if m.preview == "" {
|
||||
content = dimStyle.Render("(keine Ausgabe – Scripte aktivieren mit Leertaste)")
|
||||
} else {
|
||||
lines := strings.Split(m.preview, "\n")
|
||||
off := min(m.previewOff, max(0, len(lines)-1))
|
||||
end := off + h - 3
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
content = strings.Join(lines[off:end], "\n")
|
||||
}
|
||||
return paneBox(m.focus == panePreview, w, h).Render(
|
||||
truncateAnsi(title, inner) + "\n" + content,
|
||||
)
|
||||
}
|
||||
|
||||
// truncateAnsi kürzt einen stilisierten Titel auf Anzeigebreite w.
|
||||
func truncateAnsi(s string, w int) string {
|
||||
if lipgloss.Width(s) <= w {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
width := 0
|
||||
inEsc := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '\x1b':
|
||||
inEsc = true
|
||||
b.WriteRune(r)
|
||||
case inEsc:
|
||||
b.WriteRune(r)
|
||||
if r == 'm' {
|
||||
inEsc = false
|
||||
}
|
||||
default:
|
||||
if width >= w {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
width++
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) statusView() string {
|
||||
pam := warnStyle.Render("pam_motd ✗")
|
||||
if m.host.PAM {
|
||||
pam = goodStyle.Render("pam_motd ✓")
|
||||
}
|
||||
priv := warnStyle.Render("sudo nötig")
|
||||
if motd.IsRoot() {
|
||||
priv = goodStyle.Render("root")
|
||||
}
|
||||
enabled := 0
|
||||
for _, r := range m.working {
|
||||
if r.Enabled && !r.Remove {
|
||||
enabled++
|
||||
}
|
||||
}
|
||||
left := fmt.Sprintf("%s · %s · %s · %d/%d aktiv · %s",
|
||||
m.host.Hostname, m.host.Distro, pam, enabled, len(m.working), priv)
|
||||
|
||||
line := statusStyle(left)
|
||||
if m.status != "" {
|
||||
line += statusStyle(" │ ") + statusStyle(m.status)
|
||||
}
|
||||
return lipgloss.NewStyle().MaxWidth(m.width).Render(line)
|
||||
}
|
||||
|
||||
func statusStyle(s string) string {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Render(s)
|
||||
}
|
||||
|
||||
func (m Model) errorView() string {
|
||||
msg := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBad).
|
||||
Padding(1, 2).
|
||||
Width(min(60, m.width-4)).
|
||||
Render(
|
||||
badStyle.Render("MOTD-Verzeichnis nicht gefunden") + "\n\n" +
|
||||
m.err.Error() + "\n\n" +
|
||||
dimStyle.Render("motd-assist verwaltet /etc/update-motd.d (Ubuntu/Debian). "+
|
||||
"Auf diesem System liegt kein update-motd.d vor.") + "\n\n" +
|
||||
dimStyle.Render("q/esc: beenden · r: erneut versuchen"),
|
||||
)
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg)
|
||||
}
|
||||
|
||||
func (m Model) helpView() string {
|
||||
keys := [][2]string{
|
||||
{"tab / shift+tab", "Panes wechseln (Presets · Scripte · Vorschau)"},
|
||||
{"j / k, ↑ / ↓", "auswählen"},
|
||||
{"enter / space", "Preset laden · Script an/aus"},
|
||||
{"u / d", "Script in der Reihenfolge nach oben/unten"},
|
||||
{"a", "Änderungen anwenden (sudo-Passwortabfrage)"},
|
||||
{"esc", "Änderungen verwerfen"},
|
||||
{"s", "aktuellen Zustand als Preset speichern"},
|
||||
{"D", "ausgewähltes Preset löschen (nur eigene)"},
|
||||
{"r", "Systemzustand neu einlesen"},
|
||||
{"q", "beenden"},
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(focusedTitle.Render("Hilfe") + "\n\n")
|
||||
for _, k := range keys {
|
||||
b.WriteString(cursorStyle.Render(fmt.Sprintf("%-20s", k[0])) + " " + k[1] + "\n")
|
||||
}
|
||||
b.WriteString("\n" + dimStyle.Render("Presets: ~/.config/motd-assist/presets · Undo: motd-assist restore"))
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorAccent).
|
||||
Padding(1, 2).
|
||||
Render(b.String())
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
|
||||
}
|
||||
|
||||
func (m Model) saveView() string {
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorAccent).
|
||||
Padding(1, 2).
|
||||
Render(
|
||||
focusedTitle.Render("Preset speichern") + "\n\n" +
|
||||
m.input.View() + "\n\n" +
|
||||
helpStyle.Render("enter: speichern · esc: abbrechen"),
|
||||
)
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue