motd-assist/main.go
Tronax d56659e21a
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
2026-08-24 14:19:28 +02:00

249 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
`)
}