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.
272 lines
6.3 KiB
Go
272 lines
6.3 KiB
Go
// 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
|
||
`)
|
||
}
|