motd-assist/main.go
Tronax 303c49b484
feat: drop-in support for custom templates
User templates can now be added without rebuilding: drop a script into
~/.config/motd-assist/templates/ and reference it from a preset via
"install": "<filename>". Same-name files override built-ins; new files
are picked up by the running TUI via 'r'. `motd-assist templates` lists
known templates and creates the drop-in directory.

Apply plans now embed the resolved template content (Plan.ResolveContents)
so the sudo/root process never needs to read user config; snapshots store
installed script content so restore works under sudo as well. Validation
rejects plans with unresolved content.
2026-08-31 10:31:58 +02:00

272 lines
6.3 KiB
Go
Raw Permalink 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/assets"
"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 "templates":
cmdTemplates()
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 cmdTemplates() {
dir, err := assets.EnsureTemplatesDir()
if err != nil {
fatal(err)
}
fmt.Println("Eigene Vorlagen: Datei in diesen Ordner legen und im Preset referenzieren:")
fmt.Printf(" { \"name\": \"10-mein-style\", \"enabled\": true, \"install\": \"<dateiname>\" }\n")
fmt.Printf("Drop-in-Ordner: %s\n\n", dir)
for _, t := range assets.Templates() {
fmt.Printf(" %-20s (%s)\n", t.Name, t.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
}
// Vorlagen-Inhalte im User-Kontext auflösen, bevor der Plan
// (ggf. per sudo) an Root übergeben wird.
if err := plan.ResolveContents(); err != nil {
fatal(err)
}
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 templates Vorlagen auflisten + Drop-in-Ordner anzeigen
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
`)
}