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:
commit
d56659e21a
34 changed files with 3260 additions and 0 deletions
182
internal/ui/model.go
Normal file
182
internal/ui/model.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Package ui implementiert die Bubbletea-TUI von motd-assist.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"motd-assist/internal/assets"
|
||||
"motd-assist/internal/motd"
|
||||
"motd-assist/internal/preset"
|
||||
)
|
||||
|
||||
type pane int
|
||||
|
||||
const (
|
||||
panePresets pane = iota
|
||||
paneScripts
|
||||
panePreview
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeNormal mode = iota
|
||||
modeSaveName
|
||||
modeHelp
|
||||
)
|
||||
|
||||
// row ist die Arbeitskopie eines Scripts inkl. geplanter Installation.
|
||||
type row struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
Install string // gebündelte Vorlage, solange noch nicht installiert
|
||||
Bundled bool // installiert und entspricht einer Vorlage
|
||||
Remove bool // Deinstallation geplant (Preset-Wechsel)
|
||||
Exists bool // Datei existiert bereits
|
||||
}
|
||||
|
||||
// Model ist das Bubbletea-Model der TUI.
|
||||
type Model struct {
|
||||
root string
|
||||
|
||||
host motd.HostInfo
|
||||
current []motd.Script
|
||||
working []row
|
||||
presets []preset.Preset
|
||||
// presetMatches markiert Presets, die dem aktiven Ist-Zustand entsprechen.
|
||||
presetMatches map[string]bool
|
||||
|
||||
focus pane
|
||||
mode mode
|
||||
cursor int // Zeiger in der Script-Liste
|
||||
presetIdx int
|
||||
previewOff int
|
||||
|
||||
preview string
|
||||
previewBusy bool
|
||||
previewVersion int
|
||||
|
||||
status string
|
||||
err error
|
||||
applying bool
|
||||
lastSummary string
|
||||
|
||||
width, height int
|
||||
ready bool
|
||||
|
||||
input textinput.Model
|
||||
spinner spinner.Model
|
||||
}
|
||||
|
||||
// New erzeugt das Model für ein root-Dateisystem (normalerweise "/").
|
||||
func New(root string) Model {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Dot
|
||||
sp.Style = dimStyle
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "preset-name"
|
||||
ti.CharLimit = 48
|
||||
|
||||
return Model{
|
||||
root: root,
|
||||
focus: panePresets,
|
||||
presetMatches: map[string]bool{},
|
||||
spinner: sp,
|
||||
input: ti,
|
||||
}
|
||||
}
|
||||
|
||||
// Init startet Discovery und Spinner.
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(discoverCmd(m.root), presetsCmd(), m.spinner.Tick)
|
||||
}
|
||||
|
||||
// desiredEntries wandelt die Arbeitskopie in Soll-Einträge um.
|
||||
func (m *Model) desiredEntries() []motd.DesiredEntry {
|
||||
out := make([]motd.DesiredEntry, 0, len(m.working))
|
||||
for _, r := range m.working {
|
||||
out = append(out, motd.DesiredEntry{
|
||||
Name: r.Name,
|
||||
Enabled: r.Enabled,
|
||||
Install: r.Install,
|
||||
Remove: r.Remove,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// currentPlan berechnet den ausstehenden Plan (Ist → Arbeitskopie).
|
||||
func (m *Model) currentPlan() motd.Plan {
|
||||
return motd.Diff(m.current, m.desiredEntries())
|
||||
}
|
||||
|
||||
func rowsFromCurrent(scripts []motd.Script) []row {
|
||||
rows := make([]row, len(scripts))
|
||||
for i, s := range scripts {
|
||||
rows[i] = row{Name: s.Name, Enabled: s.Enabled, Bundled: s.Bundled, Exists: true}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func rowsFromDesired(entries []motd.DesiredEntry, current []motd.Script) []row {
|
||||
curByName := make(map[string]motd.Script, len(current))
|
||||
for _, c := range current {
|
||||
curByName[c.Name] = c
|
||||
}
|
||||
rows := make([]row, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
r := row{Name: e.Name, Enabled: e.Enabled, Remove: e.Remove}
|
||||
if c, exists := curByName[e.Name]; exists {
|
||||
r.Exists = true
|
||||
r.Bundled = c.Bundled
|
||||
} else if e.Install != "" {
|
||||
r.Install = e.Install
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// presetFromWorking serialisiert die Arbeitskopie in ein Preset; für
|
||||
// installierte gebündelte Scripte wird die Vorlage als Install-Quelle
|
||||
// hinterlegt, damit das Preset auf anderen Maschinen tragbar bleibt.
|
||||
func (m *Model) presetFromWorking(name string) preset.Preset {
|
||||
p := preset.Preset{Name: name}
|
||||
for _, r := range m.working {
|
||||
if r.Remove {
|
||||
continue
|
||||
}
|
||||
e := preset.Entry{Name: r.Name, Enabled: r.Enabled}
|
||||
switch {
|
||||
case r.Install != "":
|
||||
e.Install = r.Install
|
||||
case r.Bundled:
|
||||
if b, err := os.ReadFile(filepath.Join(motd.MotdDir(m.root), r.Name)); err == nil {
|
||||
if src, ok := assets.MatchScript(string(b)); ok {
|
||||
e.Install = src
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Scripts = append(p.Scripts, e)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// refreshPresetMatches prüft, welche Presets dem Ist-Zustand entsprechen.
|
||||
func (m *Model) refreshPresetMatches() {
|
||||
m.presetMatches = make(map[string]bool, len(m.presets))
|
||||
for _, p := range m.presets {
|
||||
entries := make([]motd.DesiredEntry, 0, len(p.Scripts))
|
||||
for _, e := range p.Scripts {
|
||||
entries = append(entries, motd.DesiredEntry{Name: e.Name, Enabled: e.Enabled, Install: e.Install})
|
||||
}
|
||||
desired := motd.DesiredFromPreset(m.current, entries)
|
||||
m.presetMatches[p.Name] = motd.Diff(m.current, desired).Empty()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue