feat: add motd-assist TUI for managing dynamic MOTDs

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

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

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