Merge remote-tracking branch 'origin/main' into feat/demo-repos
CI / Checks (pull_request) Successful in 22s
PR title / Conventional title (pull_request) Successful in 2s

# Conflicts:
#	README.md
This commit is contained in:
keb committed 2026-10-11 23:28:14 +02:00
commit 5cff381fc3
12 files changed
+72 -285

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleages. A colleague opens it - **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
with their own access, from the terminal or with a click in the browser with their own access, from the terminal or with a click in the browser
or chat. or chat.
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
-192
View File
@@ -1,192 +0,0 @@
package main
import (
"cmp"
"context"
"errors"
"flag"
"fmt"
"io"
"maps"
"slices"
"strings"
"sync"
"text/tabwriter"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// lazysecrets expiring lists what has expired, or will within --days,
// in every vault of every account: for a pipeline or a cron job. Expiry
// dates come with the lists, so it's one request per vault and type,
// a few at a time. Only Key Vault has expiry dates.
// The exit status is grep's: 1 when anything was listed (errExpiring),
// else 2 when some lists couldn't be read (errIncomplete), else 0.
var (
errExpiring = errors.New("expiring")
errIncomplete = errors.New("incomplete")
)
// listLimit is how many lists load at once.
const listLimit = 8
type expiringOpts struct {
days int
timeout time.Duration
now time.Time
}
func parseExpiring(args []string, days int) (int, error) {
fs := flag.NewFlagSet("expiring", flag.ContinueOnError)
n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)")
if err := fs.Parse(args); err != nil {
return 0, err
}
if fs.NArg() > 0 || *n < 0 {
return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]")
}
return *n, nil
}
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
var (
mu sync.Mutex
found []provider.Object
seen = map[string]bool{}
failed int
vaults int
wg sync.WaitGroup
limit = make(chan struct{}, listLimit)
within = o.now.AddDate(0, 0, o.days)
// A vault's lists that couldn't be read, said once per vault.
denied = map[string][]string{}
why = map[string]string{}
warnErr = func(what string, err error) {
mu.Lock()
defer mu.Unlock()
failed++
fmt.Fprintf(errs, "lazysecrets: %s: %s\n", what, briefly(err))
}
)
run := func(f func(ctx context.Context)) {
wg.Add(1)
go func() {
defer wg.Done()
limit <- struct{}{}
defer func() { <-limit }()
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
defer cancel()
f(ctx)
}()
}
var vaultsDone sync.WaitGroup
for _, p := range providers {
if p.Kind() != provider.KindAzure {
continue
}
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
srcs, err := p.Sources(ctx)
if err != nil {
warnErr(p.Label(), err)
return
}
for _, src := range srcs {
vaultsDone.Add(1)
run(func(ctx context.Context) {
defer vaultsDone.Done()
cs, err := p.Containers(ctx, src)
if err != nil {
warnErr(src.Name, err)
return
}
for _, c := range cs {
mu.Lock()
fresh := !seen[c.ID]
seen[c.ID] = true
if fresh {
vaults++
}
mu.Unlock()
if !fresh { // the same vault through another account
continue
}
for _, t := range c.ObjectTypes() {
run(func(ctx context.Context) {
objs, err := p.Objects(ctx, c, t)
mu.Lock()
defer mu.Unlock()
if err != nil {
failed++
denied[c.Name] = append(denied[c.Name], strings.ToLower(string(t))+"s")
why[c.Name] = cmp.Or(why[c.Name], briefly(err))
return
}
for _, o := range objs {
if !o.Expires.IsZero() && o.Expires.Before(within) {
found = append(found, o)
}
}
})
}
}
})
}
})
}
// Listing vaults queues the lists of their secrets, so wait for both.
vaultsDone.Wait()
wg.Wait()
for _, name := range slices.Sorted(maps.Keys(denied)) {
fmt.Fprintf(errs, "lazysecrets: %s: can't list %s: %s\n", name, strings.Join(denied[name], ", "), why[name])
}
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
if len(found) == 0 {
vault := "vaults"
if vaults == 1 {
vault = "vault"
}
fmt.Fprintf(errs, "nothing expires within %d days (checked %d of %d %s)\n", o.days, vaults-len(denied), vaults, vault)
if failed > 0 {
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: they weren't checked\n", failed)
return errIncomplete
}
return nil
}
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK")
for _, x := range found {
left := "expired"
if d := x.Expires.Sub(o.now); d > 0 {
left = fmt.Sprintf("%dd", int(d.Hours()/24))
}
link := ""
if r, err := ref.ForObject(x, ""); err == nil {
link = r.String()
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"),
strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link)
}
if err := tw.Flush(); err != nil {
return err
}
if failed > 0 {
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed)
}
return errExpiring
}
// briefly is err on one line: for a sign-in, the command that signs in.
func briefly(err error) string {
var auth *provider.AuthError
if errors.As(err, &auth) && len(auth.Login) > 0 {
return "sign-in required: " + strings.Join(auth.Login, " ")
}
line, _, _ := strings.Cut(err.Error(), "\n")
return line
}
-48
View File
@@ -1,48 +0,0 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
func TestExpiring(t *testing.T) {
providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)}
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
var out, errs bytes.Buffer
err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs)
if !errors.Is(err, errExpiring) {
t.Fatalf("err %v (want errExpiring: something listed)", err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") ||
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
t.Errorf("out:\n%s", out.String())
}
if !strings.Contains(errs.String(), "kv-restricted: can't list ") || strings.Count(errs.String(), "kv-restricted") != 1 {
t.Errorf("unreadable vaults not said:\n%s", errs.String())
}
// Further ahead, the soonest first.
out.Reset()
expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs)
if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") {
t.Errorf("400 days:\n%s", out.String())
}
// Nothing listed, but not everything checked.
errs.Reset()
err = expiring(providers, expiringOpts{days: 0, timeout: time.Minute, now: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)}, &out, &errs)
if !errors.Is(err, errIncomplete) {
t.Errorf("err %v, want errIncomplete", err)
}
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
t.Error("negative days accepted")
}
}
+1 -23
View File
@@ -4,7 +4,6 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -34,12 +33,7 @@ import (
) )
func main() { func main() {
switch err := run(); { if err := run(); err != nil {
case errors.Is(err, errExpiring):
os.Exit(1) // listed: a pipeline fails on it
case errors.Is(err, errIncomplete):
os.Exit(2)
case err != nil:
fmt.Fprintln(os.Stderr, "lazysecrets:", err) fmt.Fprintln(os.Stderr, "lazysecrets:", err)
os.Exit(1) os.Exit(1)
} }
@@ -124,10 +118,6 @@ func run() error {
@Microsoft.KeyVault(...) or {{secrets/scope/key}} @Microsoft.KeyVault(...) or {{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] expiring [--days N]
list what has expired or will within N days
(default 30) in every vault; exits 1 if any,
2 if it couldn't check everything
lazysecrets [flags] keybindings print every action and its keys (with your lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -150,7 +140,6 @@ Flags:
} }
var open, getRef *ref.Ref var open, getRef *ref.Ref
var expiringArgs []string
printKeys := false printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
@@ -161,8 +150,6 @@ Flags:
return err return err
} }
getRef = &r getRef = &r
case len(args) > 0 && args[0] == "expiring":
expiringArgs = append([]string{}, args[1:]...)
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -312,15 +299,6 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if expiringArgs != nil {
days, err := parseExpiring(expiringArgs, expiryDays)
if err != nil {
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
return errIncomplete // 2, not 1: nothing was checked
}
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
}
if getRef != nil { if getRef != nil {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel() defer cancel()
-15
View File
@@ -71,21 +71,6 @@ on one goes to it. That makes no requests of its own: expiry dates come
with the lists. It says how many vaults it covers; `ctrl+a` in search with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest. loads the rest.
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
vault of every account, loading each vault's lists (a few at a time):
```sh
$ lazysecrets expiring --days 30
LEFT EXPIRES TYPE VAULT NAME LINK
expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
```
`--days` defaults to `expiryWarningDays`. Accounts that need a sign-in and
lists it can't read are named on stderr, one line each. Like grep, it
exits 0 when nothing expires, 1 when it lists something, and 2 when it
couldn't check everything.
On a vault, the right pane shows its settings: resource group, location, On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and SKU, whether access is by Azure roles or access policies, soft delete and
purge protection, which networks can reach it, private endpoints and tags. purge protection, which networks can reach it, private endpoints and tags.
+1 -1
View File
@@ -56,7 +56,7 @@ theme:
activeBorderColor: ["#ff966c", bold] # focused pane activeBorderColor: ["#ff966c", bold] # focused pane
inactiveBorderColor: ["#589ed7"] inactiveBorderColor: ["#589ed7"]
selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8) selectedLineBgColor: ["#2d3f76"] # cursor row background (default: palette 8)
inactiveViewSelectedLineBgColor: ["#2f334d"] inactiveViewSelectedLineBgColor: ["#2f334d"] # in the other panes (default: palette 8, not bold)
defaultFgColor: ["#c8d3f5"] defaultFgColor: ["#c8d3f5"]
optionsTextColor: ["#82aaff"] # keys and labels optionsTextColor: ["#82aaff"] # keys and labels
searchMatchColor: ["#ffc777", bold] searchMatchColor: ["#ffc777", bold]
+1 -1
View File
@@ -55,7 +55,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil return out, nil
} }
if last, ok := git("log", "-1", "--format=%h · %ar · %an: %s", "--", rel); ok && last != "" { if last, ok := git("log", "-1", "--format=%h · %ar", "--", rel); ok && last != "" {
out = append(out, provider.Attr{Label: "last commit", Value: last}) out = append(out, provider.Attr{Label: "last commit", Value: last})
} }
return out, nil return out, nil
+1 -1
View File
@@ -505,7 +505,7 @@ func TestFileDetails(t *testing.T) {
d := details("secrets/app.yaml") d := details("secrets/app.yaml")
if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" || if d["encryption"].Value != "sops · age" || d["keys"].Value != "age age1xxxx" || d["git"].Value != "tracked" ||
!strings.Contains(d["last commit"].Value, "t: files") || d["encrypted"].Value == "" { !strings.HasSuffix(d["last commit"].Value, " ago") || strings.Contains(d["last commit"].Value, ":") || d["encrypted"].Value == "" {
t.Errorf("sops yaml: %+v", d) t.Errorf("sops yaml: %+v", d)
} }
if d := details("prod.env"); d["keys"].Value != "age age1xxxx" { if d := details("prod.env"); d["keys"].Value != "age age1xxxx" {
+4 -1
View File
@@ -489,7 +489,7 @@ func (m *Model) setVersions(items []provider.Object) {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()} cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard(), tea.RequestBackgroundColor}
if m.cachePath != "" || m.placeDir != "" { if m.cachePath != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })) cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
} }
@@ -514,6 +514,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.w, m.h = msg.Width, msg.Height m.w, m.h = msg.Width, msg.Height
+31 -2
View File
@@ -3,6 +3,8 @@
package styles package styles
import ( import (
"image/color"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -32,6 +34,10 @@ type Styles struct {
KindAzure lipgloss.Style KindAzure lipgloss.Style
KindDatabricks lipgloss.Style KindDatabricks lipgloss.Style
KindRepo lipgloss.Style KindRepo lipgloss.Style
// themed marks the selection colours as set by the theme, so
// OnBackground leaves them.
themed, themedBlurred bool
} }
func Default() Styles { func Default() Styles {
@@ -43,9 +49,12 @@ func Default() Styles {
TitleBlurred: lipgloss.NewStyle(), TitleBlurred: lipgloss.NewStyle(),
Footer: lipgloss.NewStyle().Faint(true), Footer: lipgloss.NewStyle().Faint(true),
// A background rather than reverse, so the row keeps its colours. // A background rather than reverse, so the row keeps its colours;
// in every pane, so each shows what's selected in it. The focused
// pane's is bold too. Palette 8 until the terminal says what its
// background is (OnBackground).
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true), Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
SelectedBlurred: lipgloss.NewStyle().Bold(true), SelectedBlurred: lipgloss.NewStyle().Background(lipgloss.BrightBlack),
Muted: lipgloss.NewStyle().Faint(true), Muted: lipgloss.NewStyle().Faint(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Red), Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green), Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
@@ -74,3 +83,23 @@ func (s Styles) Kind(k provider.Kind) lipgloss.Style {
} }
return s.KindAzure return s.KindAzure
} }
// OnBackground shades the selection colours from the terminal's
// background, a little lighter on a dark one and darker on a light one:
// a quiet band that keeps the row's colours readable, where palette 8 is
// often too bright. Colours the theme sets are kept.
func (s Styles) OnBackground(bg color.Color, dark bool) Styles {
shade := func(p float64) color.Color {
if dark {
return lipgloss.Lighten(bg, p)
}
return lipgloss.Darken(bg, p)
}
if !s.themed {
s.Selected = s.Selected.Background(shade(0.09))
}
if !s.themedBlurred {
s.SelectedBlurred = s.SelectedBlurred.Background(shade(0.05))
}
return s
}
+2
View File
@@ -70,8 +70,10 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st.BorderBlurred = v.fg(lipgloss.NewStyle()) st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor": case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true) st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
st.themed = true
case "inactiveViewSelectedLineBgColor": case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true) st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
st.themedBlurred = true
case "defaultFgColor": case "defaultFgColor":
fg = v.color fg = v.color
case "optionsTextColor": case "optionsTextColor":
+30
View File
@@ -1,6 +1,7 @@
package styles package styles
import ( import (
"fmt"
"strings" "strings"
"testing" "testing"
@@ -62,3 +63,32 @@ func TestEmptyThemeIsDefault(t *testing.T) {
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground()) t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
} }
} }
func TestOnBackground(t *testing.T) {
hex := func(c any) string {
r, g, b, _ := c.(interface{ RGBA() (r, g, b, a uint32) }).RGBA()
return fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
}
st := Default().OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#3e424a" {
t.Errorf("dark: selected %s", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("dark: blurred %s", got)
}
if got := hex(Default().OnBackground(lipgloss.Color("#ffffff"), false).Selected.GetBackground()); got != "#e8e8e8" {
t.Errorf("light: selected %s", got)
}
themed, _, err := FromTheme(map[string][]string{"selectedLineBgColor": {"#2d3f76"}})
if err != nil {
t.Fatal(err)
}
st = themed.OnBackground(lipgloss.Color("#282c34"), true)
if got := hex(st.Selected.GetBackground()); got != "#2d3f76" {
t.Errorf("themed: selected %s (want the theme's)", got)
}
if got := hex(st.SelectedBlurred.GetBackground()); got != "#343840" {
t.Errorf("themed: blurred %s (not themed: shaded)", got)
}
}