feat(vault-tui): implement KV client and service for managing secrets

- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
// Package theme turns config.Theme (the YAML-facing schema) into concrete
// lipgloss.Style values the TUI renders with.
package theme
import (
"image/color"
"os"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/term"
"git.morlana.online/f.weber/vault-tui/internal/config"
)
// Styles is the materialised set of lipgloss styles and colors the whole
// TUI renders with. Built once at startup from config.Theme plus a
// light/dark decision, and passed down by value (styles are cheap,
// immutable value types) to every screen.
type Styles struct {
// Raw colors, exposed for the few call sites that need to compose a
// style lipgloss.Style alone can't express (e.g. list.Styles).
Bg color.Color
Surface color.Color
SurfaceRaised color.Color
Overlay color.Color
Text color.Color
TextMuted color.Color
TextFaint color.Color
TextInverted color.Color
Border color.Color
BorderSubtle color.Color
BorderActive color.Color
Header color.Color
Selection color.Color
SelectionBg color.Color
Accent color.Color
Error color.Color
Warning color.Color
Success color.Color
Info color.Color
Muted color.Color
Masked color.Color
Directory color.Color
Leaf color.Color
MaskChar string
Mono bool // true when colors are suppressed (--no-color / NO_COLOR)
// Chrome
HeaderBar lipgloss.Style
AppBadge lipgloss.Style
Crumb lipgloss.Style
CrumbSep lipgloss.Style
CrumbActive lipgloss.Style
Pill lipgloss.Style
PillOK lipgloss.Style
PillWarn lipgloss.Style
PillDanger lipgloss.Style
Footer lipgloss.Style
StatusBar lipgloss.Style
ReadOnly lipgloss.Style
// Panels
Panel lipgloss.Style
PanelActive lipgloss.Style
PanelTitle lipgloss.Style
PanelSubtle lipgloss.Style
SidePanel lipgloss.Style
EmptyState lipgloss.Style
// Rows / tables
Row lipgloss.Style
RowSelected lipgloss.Style
RowDim lipgloss.Style
Badge lipgloss.Style
TableHeader lipgloss.Style
KeyCell lipgloss.Style
ValueCell lipgloss.Style
// Text roles
Title lipgloss.Style
Help lipgloss.Style
ErrorText lipgloss.Style
WarnText lipgloss.Style
SuccessText lipgloss.Style
MaskedText lipgloss.Style
DirText lipgloss.Style
LeafText lipgloss.Style
Selected lipgloss.Style
// Overlays
Modal lipgloss.Style
ModalDanger lipgloss.Style
ModalTitle lipgloss.Style
Backdrop lipgloss.Style
// Toasts
ToastInfo lipgloss.Style
ToastSuccess lipgloss.Style
ToastWarn lipgloss.Style
ToastError lipgloss.Style
// Inputs
InputFocused lipgloss.Style
InputBlurred lipgloss.Style
isDark bool
}
// defaultPalette is the built-in fallback: an adaptive light/dark set for
// every named color config.Theme.Colors can override.
func defaultPalette(dark bool) map[string]string {
if dark {
return map[string]string{
"bg": "#0E1015", "surface": "#151821", "surface_raised": "#1C2130", "overlay": "#20263A",
"text": "#E4E7F1", "text_muted": "#9AA2B8", "text_faint": "#5B6272", "text_inverted": "#0E1015",
"border": "#2A3040", "border_subtle": "#1C2130", "border_active": "#A78BFA", "header": "#9BA3C4",
"selection": "#0E1015", "selection_bg": "#A78BFA", "accent": "#A78BFA",
"error": "#F87171", "warning": "#FBBF24", "success": "#4ADE80", "info": "#6EC1FF",
"muted": "#9AA2B8", "masked": "#5B6272", "directory": "#6EC1FF", "leaf": "#E4E7F1",
}
}
return map[string]string{
"bg": "#FFFFFF", "surface": "#F6F7FB", "surface_raised": "#EDEFF7", "overlay": "#E3E6F3",
"text": "#1F2430", "text_muted": "#6C7086", "text_faint": "#9AA0AE", "text_inverted": "#FFFFFF",
"border": "#C9CCD6", "border_subtle": "#DEE1EA", "border_active": "#5B34D6", "header": "#3A3F58",
"selection": "#FFFFFF", "selection_bg": "#5B34D6", "accent": "#5B34D6",
"error": "#B3261E", "warning": "#8A6100", "success": "#1B6B3A", "info": "#0B5FA5",
"muted": "#6C7086", "masked": "#9AA0AE", "directory": "#0B5FA5", "leaf": "#1F2430",
}
}
// IsDark decides light vs. dark once at startup, synchronously — a
// deliberate simplification versus wiring bubbletea's async
// BackgroundColorMsg round trip, since the terminal's background rarely
// changes mid-session. cfg.Appearance can force it.
func IsDark(cfg config.Theme, appearance string) bool {
switch appearance {
case "dark":
return true
case "light":
return false
default:
return lipgloss.HasDarkBackground(term.File(os.Stdin), term.File(os.Stdout))
}
}
// NoColor reports whether ANSI color should be suppressed: the NO_COLOR
// convention (https://no-color.org) or an explicit request.
func NoColor(explicit bool) bool {
if explicit {
return true
}
_, set := os.LookupEnv("NO_COLOR")
return set
}
func colorOf(cfg config.Theme, defaults map[string]string, dark bool, key string) color.Color {
if c, ok := cfg.Colors[key]; ok {
if dark && c.Dark != "" {
return lipgloss.Color(c.Dark)
}
if !dark && c.Light != "" {
return lipgloss.Color(c.Light)
}
}
return lipgloss.Color(defaults[key])
}
// Build materialises Styles from cfg and the light/dark decision. When mono
// is true (NO_COLOR / --no-color), every color collapses to the terminal's
// default foreground and hierarchy is carried by weight/reverse instead.
func Build(cfg config.Theme, dark bool, mono bool) *Styles {
def := defaultPalette(dark)
c := func(key string) color.Color { return colorOf(cfg, def, dark, key) }
s := &Styles{isDark: dark, Mono: mono}
s.Bg, s.Surface, s.SurfaceRaised, s.Overlay = c("bg"), c("surface"), c("surface_raised"), c("overlay")
s.Text, s.TextMuted, s.TextFaint, s.TextInverted = c("text"), c("text_muted"), c("text_faint"), c("text_inverted")
s.Border, s.BorderSubtle, s.BorderActive, s.Header = c("border"), c("border_subtle"), c("border_active"), c("header")
s.Selection, s.SelectionBg, s.Accent = c("selection"), c("selection_bg"), c("accent")
s.Error, s.Warning, s.Success, s.Info = c("error"), c("warning"), c("success"), c("info")
s.Muted, s.Masked, s.Directory, s.Leaf = c("muted"), c("masked"), c("directory"), c("leaf")
s.MaskChar = cfg.MaskChar
if s.MaskChar == "" {
s.MaskChar = "•"
}
if mono {
s.flatten()
}
border := borderFor(cfg.BorderStyle)
pillFG := s.TextInverted
s.HeaderBar = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Padding(0, 1)
s.AppBadge = lipgloss.NewStyle().Foreground(pillFG).Background(s.Accent).Bold(true).Padding(0, 1)
s.Crumb = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface)
s.CrumbSep = lipgloss.NewStyle().Foreground(s.TextFaint).Background(s.Surface)
s.CrumbActive = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Bold(true)
s.Pill = lipgloss.NewStyle().Foreground(s.Text).Background(s.SurfaceRaised).Padding(0, 1)
s.PillOK = s.Pill.Foreground(pillFG).Background(s.Success)
s.PillWarn = s.Pill.Foreground(pillFG).Background(s.Warning)
s.PillDanger = s.Pill.Foreground(pillFG).Background(s.Error)
s.Footer = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface).Padding(0, 1)
s.StatusBar = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface)
s.ReadOnly = s.PillWarn.Bold(true)
s.Panel = lipgloss.NewStyle().Border(border).BorderForeground(s.Border).Padding(0, 1)
s.PanelActive = s.Panel.BorderForeground(s.BorderActive)
s.PanelTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
s.PanelSubtle = lipgloss.NewStyle().Foreground(s.TextMuted)
s.SidePanel = lipgloss.NewStyle().Border(border).BorderForeground(s.BorderSubtle).Padding(0, 1)
s.EmptyState = lipgloss.NewStyle().Foreground(s.TextFaint).Italic(true)
s.Row = lipgloss.NewStyle().Foreground(s.Text)
s.RowSelected = lipgloss.NewStyle().Foreground(s.Selection).Background(s.SelectionBg).Bold(true)
s.RowDim = lipgloss.NewStyle().Foreground(s.TextFaint)
s.Badge = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.SurfaceRaised).Padding(0, 1)
s.TableHeader = lipgloss.NewStyle().Foreground(s.TextMuted).Bold(true)
s.KeyCell = lipgloss.NewStyle().Foreground(s.Text).Bold(true)
s.ValueCell = lipgloss.NewStyle().Foreground(s.TextMuted)
s.Title = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
s.Help = lipgloss.NewStyle().Foreground(s.TextMuted)
s.ErrorText = lipgloss.NewStyle().Foreground(s.Error).Bold(true)
s.WarnText = lipgloss.NewStyle().Foreground(s.Warning)
s.SuccessText = lipgloss.NewStyle().Foreground(s.Success)
s.MaskedText = lipgloss.NewStyle().Foreground(s.Masked)
s.DirText = lipgloss.NewStyle().Foreground(s.Directory)
s.LeafText = lipgloss.NewStyle().Foreground(s.Leaf)
s.Selected = s.RowSelected
s.Modal = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.Accent).
Background(s.Overlay).Foreground(s.Text).Padding(1, 2)
s.ModalDanger = s.Modal.BorderForeground(s.Error)
s.ModalTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
s.Backdrop = lipgloss.NewStyle().Foreground(s.TextFaint)
s.ToastInfo = lipgloss.NewStyle().Foreground(pillFG).Background(s.Info).Padding(0, 1)
s.ToastSuccess = lipgloss.NewStyle().Foreground(pillFG).Background(s.Success).Padding(0, 1)
s.ToastWarn = lipgloss.NewStyle().Foreground(pillFG).Background(s.Warning).Padding(0, 1)
s.ToastError = lipgloss.NewStyle().Foreground(pillFG).Background(s.Error).Padding(0, 1)
s.InputFocused = lipgloss.NewStyle().Foreground(s.Accent)
s.InputBlurred = lipgloss.NewStyle().Foreground(s.TextMuted)
return s
}
// flatten drops every color to the terminal's default foreground (mono
// mode). Called before styles are built so every lipgloss.Style below
// inherits plain colors and relies on Bold/Italic/Reverse for hierarchy.
func (s *Styles) flatten() {
none := lipgloss.Color("")
*s = Styles{
isDark: s.isDark, Mono: true, MaskChar: s.MaskChar,
Bg: none, Surface: none, SurfaceRaised: none, Overlay: none,
Text: none, TextMuted: none, TextFaint: none, TextInverted: none,
Border: none, BorderSubtle: none, BorderActive: none, Header: none,
Selection: none, SelectionBg: none, Accent: none,
Error: none, Warning: none, Success: none, Info: none,
Muted: none, Masked: none, Directory: none, Leaf: none,
}
}
func borderFor(style string) lipgloss.Border {
switch style {
case "normal":
return lipgloss.NormalBorder()
case "thick":
return lipgloss.ThickBorder()
case "double":
return lipgloss.DoubleBorder()
case "hidden":
return lipgloss.HiddenBorder()
case "ascii":
return lipgloss.Border{Top: "-", Bottom: "-", Left: "|", Right: "|", TopLeft: "+", TopRight: "+", BottomLeft: "+", BottomRight: "+"}
default:
return lipgloss.RoundedBorder()
}
}
// HelpStyles adapts bubbles/help's palette to the theme.
func (s *Styles) HelpStyles() help.Styles {
hs := help.DefaultStyles(s.isDark)
hs.ShortKey = lipgloss.NewStyle().Foreground(s.Accent)
hs.ShortDesc = lipgloss.NewStyle().Foreground(s.TextMuted)
hs.ShortSeparator = lipgloss.NewStyle().Foreground(s.TextFaint)
hs.FullKey = hs.ShortKey
hs.FullDesc = hs.ShortDesc
hs.FullSeparator = hs.ShortSeparator
hs.Ellipsis = lipgloss.NewStyle().Foreground(s.TextFaint)
return hs
}
// TextInputStyles adapts bubbles/textinput's palette to the theme.
func (s *Styles) TextInputStyles() textinput.Styles {
ti := textinput.DefaultStyles(s.isDark)
ti.Focused.Text = lipgloss.NewStyle().Foreground(s.Text)
ti.Focused.Prompt = lipgloss.NewStyle().Foreground(s.Accent)
ti.Focused.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint)
ti.Blurred.Text = lipgloss.NewStyle().Foreground(s.TextMuted)
ti.Blurred.Prompt = lipgloss.NewStyle().Foreground(s.TextFaint)
ti.Blurred.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint)
ti.Cursor.Color = s.Accent
return ti
}
// ListStyles adapts bubbles/list's chrome (filter prompt/cursor, "no
// items", help line) to the theme. The list's per-row rendering is handled
// entirely by our own list.ItemDelegate, not by this.
func (s *Styles) ListStyles() list.Styles {
ls := list.DefaultStyles(s.isDark)
ls.Filter = s.TextInputStyles()
ls.NoItems = s.EmptyState
ls.StatusEmpty = s.EmptyState
ls.HelpStyle = s.Help
ls.StatusBarActiveFilter = lipgloss.NewStyle().Foreground(s.Text)
ls.StatusBarFilterCount = lipgloss.NewStyle().Foreground(s.TextFaint)
ls.PaginationStyle = lipgloss.NewStyle().Foreground(s.TextFaint)
ls.DefaultFilterCharacterMatch = lipgloss.NewStyle().Foreground(s.Accent).Underline(true)
return ls
}