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

249
main.go Normal file
View file

@ -0,0 +1,249 @@
// motd-assist verwaltet dynamische MOTDs (Ubuntu/Debian, /etc/update-motd.d):
// Presets wählen, Scripte schalten, Live-Vorschau, geführtes Anwenden per sudo.
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
tea "github.com/charmbracelet/bubbletea"
"motd-assist/internal/motd"
"motd-assist/internal/preset"
"motd-assist/internal/ui"
)
const version = "0.1.0"
func main() {
root := os.Getenv("MOTD_ASSIST_ROOT")
if root == "" {
root = "/"
}
if len(os.Args) < 2 {
runTUI(root)
return
}
switch os.Args[1] {
case "preview":
cmdPreview(root)
case "list":
cmdList(root)
case "presets":
cmdPresets()
case "apply":
cmdApply(root)
case "restore":
cmdRestore(root)
case "apply-state": // intern: wird über sudo aufgerufen
cmdApplyState(root)
case "version", "--version", "-v":
fmt.Println("motd-assist", version)
case "help", "--help", "-h":
usage()
default:
fmt.Fprintln(os.Stderr, "unbekannter Befehl:", os.Args[1])
usage()
os.Exit(1)
}
}
func runTUI(root string) {
p := tea.NewProgram(ui.New(root), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, "TUI-Fehler:", err)
os.Exit(1)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "Fehler:", err)
os.Exit(1)
}
func cmdPreview(root string) {
width := 72
args := os.Args[2:]
for i := 0; i < len(args); i++ {
if (args[i] == "--width" || args[i] == "-w") && i+1 < len(args) {
if n, err := strconv.Atoi(args[i+1]); err == nil {
width = n
}
i++
}
}
scripts, err := motd.Discover(root)
if err != nil {
fatal(err)
}
preview := make([]motd.PreviewScript, 0, len(scripts))
for _, s := range scripts {
preview = append(preview, motd.PreviewScript{Name: s.Name, Path: s.Path, Enabled: s.Enabled})
}
fmt.Println(motd.Render(preview, width))
}
func cmdList(root string) {
host := motd.DetectHost(root)
fmt.Printf("%s · %s · dynamischer MOTD: %s\n\n",
host.Hostname, host.Distro,
boolText(host.PAM))
scripts, err := motd.Discover(root)
if err != nil {
fatal(err)
}
fmt.Printf("%d Scripte, %d aktiv:\n", len(scripts), motd.EnabledCount(scripts))
for _, s := range scripts {
mark := "○ aus"
if s.Enabled {
mark = "● an"
}
note := ""
if s.Bundled {
note = " (motd-assist)"
}
fmt.Printf(" %s %s%s\n", mark, s.Name, note)
}
}
func boolText(b bool) string {
if b {
return "aktiv"
}
return "inaktiv"
}
func cmdPresets() {
presets, err := preset.LoadAll()
if err != nil {
fatal(err)
}
for _, p := range presets {
fmt.Printf(" %-18s %s (%s)\n", p.Name, p.Description, p.Source)
}
}
func cmdApply(root string) {
if len(os.Args) < 3 {
fatal(fmt.Errorf("Usage: motd-assist apply <preset>"))
}
name := os.Args[2]
presets, err := preset.LoadAll()
if err != nil {
fatal(err)
}
var chosen *preset.Preset
for i := range presets {
if presets[i].Name == name {
chosen = &presets[i]
break
}
}
if chosen == nil {
fatal(fmt.Errorf("Preset %q nicht gefunden (siehe: motd-assist presets)", name))
}
current, err := motd.Discover(root)
if err != nil {
fatal(err)
}
entries := make([]motd.DesiredEntry, 0, len(chosen.Scripts))
for _, e := range chosen.Scripts {
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
}
plan := motd.Diff(current, motd.DesiredFromPreset(current, entries))
if plan.Empty() {
fmt.Println("System entspricht bereits dem Preset.")
return
}
if motd.IsRoot() {
if err := motd.Execute(root, plan); err != nil {
fatal(err)
}
fmt.Println("Angewendet:", plan.Summary())
fmt.Println("Undo: motd-assist restore")
return
}
planFile, err := plan.WriteFile()
if err != nil {
fatal(err)
}
defer os.Remove(planFile)
if err := relaySudo("apply-state", planFile); err != nil {
fatal(err)
}
}
func cmdRestore(root string) {
if motd.IsRoot() {
if err := motd.Restore(root); err != nil {
fatal(err)
}
fmt.Println("Letzter Apply wieder zurückgesetzt.")
return
}
if err := relaySudo("restore"); err != nil {
fatal(err)
}
}
func cmdApplyState(root string) {
if !motd.IsRoot() {
fatal(fmt.Errorf("apply-state benötigt Root bitte mit sudo starten"))
}
if len(os.Args) < 3 {
fatal(fmt.Errorf("Usage: motd-assist apply-state <plandatei>"))
}
plan, err := motd.ReadPlanFile(os.Args[2])
if err != nil {
fatal(err)
}
if err := motd.Execute(root, plan); err != nil {
fatal(err)
}
fmt.Println("Angewendet:", plan.Summary())
fmt.Println("Undo: motd-assist restore")
}
// relaySudo führt dieses Binary mit sudo und den Argumenten args aus
// (Terminal-Ein-/Ausgabe bleibt erhalten, Passwortabfrage möglich).
func relaySudo(args ...string) error {
self, err := os.Executable()
if err != nil {
return err
}
sudo, err := exec.LookPath("sudo")
if err != nil {
return fmt.Errorf("sudo nicht gefunden: %w", err)
}
cmd := exec.Command(sudo, append([]string{self}, args...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func usage() {
fmt.Print(`motd-assist ` + version + ` MOTD-Verwaltung für /etc/update-motd.d
Usage:
motd-assist TUI starten (Standard)
motd-assist preview [-w N] aktuellen MOTD rendern und ausgeben
motd-assist list Scripte und Zustand auflisten
motd-assist presets verfügbare Presets auflisten
motd-assist apply <preset> Preset anwenden (fragt ggf. sudo-Passwort)
motd-assist restore letzten Apply zurücksetzen
motd-assist help diese Hilfe
TUI-Kürzel: tab Panes wechseln · enter/space Preset laden/Script schalten ·
u/d Reihenfolge · a anwenden · s als Preset speichern · r neu einlesen · ? Hilfe
`)
}