From 2db3d28d6d51c96376924dfca814f7de99775344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Wed, 14 Oct 2026 16:23:12 +0200 Subject: [PATCH] feat: lazysecrets list says expired or how soon, as the TUI does, in a field of its own --- cmd/lazysecrets/list.go | 65 ++++++++++++++++++++---------------- cmd/lazysecrets/list_test.go | 40 ++++++++++++++-------- internal/ui/app/app_test.go | 3 +- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 0872f40..8c0d29e 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -29,9 +29,10 @@ import ( // // lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 // -// Each line is the kind, a mark (★ and the provider's icon), the name, -// why it couldn't be read (empty unless it failed), the account and the -// link, tab-separated. The mark has a field of its own so a search can +// Each line is the kind, a mark (★, ◷ and the provider's icon), the name, +// why it couldn't be read (empty unless it failed), whether it expires +// soon or has expired (empty unless so), the account and the link, +// tab-separated. The mark has a field of its own so a search can // leave it out (fzf --nth), and ^prod finds names starting with prod. The // name is coloured from the theme, // with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS, @@ -45,12 +46,12 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t fs := flag.NewFlagSet("list", flag.ContinueOnError) cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks") maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again") - format := fs.String("format", "text", "text: kind, mark, name, error, account and link, tab-separated; json: an object per line") + format := fs.String("format", "text", "text: kind, mark, name, error, expiry, account and link, tab-separated; json: an object per line") color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi") iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)") stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)") recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)") - expiry := fs.Bool("expiry", true, "put ⚠ before names expiring within ui.expiryWarningDays, ✗ before expired ones (text)") + expiry := fs.Bool("expiry", true, "say which expire within ui.expiryWarningDays (⚠ 12d) or have expired (✗ expired), after the name (text)") kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")") if err := fs.Parse(args); err != nil { @@ -72,8 +73,8 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t if *color != "always" && *color != "never" { return fmt.Errorf("--color: %q is not always or never", *color) } - m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry, - color: *color == "always", warn: expiryWarn, now: time.Now()} + m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, color: *color == "always"} + now := time.Now() if *iconsOn { m.icons = icons.NerdFont } @@ -85,8 +86,12 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t mark := m.of(e) name := nameOf(e, theme, colored) why := errorOf(e, theme, colored) + var expires string + if *expiry { + expires = expiryOf(e, theme, expiryWarn, now, colored) + } // A tab or line break in a name would break the line up. - _, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(why), e.Account, e.Link) + _, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(why), expires, e.Account, e.Link) return err } case "json": @@ -141,6 +146,22 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") +// expiryOf says, as the TUI does, that a secret, key or certificate has +// expired (✗ expired) or will within warn (⚠ 12d), or is "". +func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string { + render := renderer(color) + left := e.Expires.Sub(now) + switch { + case e.Expires.IsZero() || left > warn: + return "" + case left <= 0: + return render(theme.Error, "✗ expired") + case left < 24*time.Hour: + return render(theme.Warning, fmt.Sprintf("⚠ %dh", max(1, int(left.Hours())))) + } + return render(theme.Warning, fmt.Sprintf("⚠ %dd", int(left.Hours()/24))) +} + // errorOf is why a list couldn't be read, or "": with colour, a red ✗ // before it, as the TUI marks lists it can't read. func errorOf(e listing.Entry, theme styles.Styles, color bool) string { @@ -168,17 +189,14 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string { } // marks says what goes before the name, in a field of its own so a -// search leaves it out: ★ for a starred entry, ◷ for one used lately, -// ⚠ for one expiring within warn and ✗ for an expired one, as the TUI -// marks them, then the provider's icon. The marks are right-aligned in -// room for every kind turned on, so they sit by the icon and names line -// up. +// search leaves it out: ★ for a starred entry and ◷ for one used lately, +// as the TUI marks them, then the provider's icon. The marks are +// right-aligned in room for every kind turned on, so they sit by the icon +// and names line up. type marks struct { - theme styles.Styles - icons icons.Set - stars, recent, expiry, color bool - warn time.Duration // ui.expiryWarningDays - now time.Time + theme styles.Styles + icons icons.Set + stars, recent, color bool } func (m marks) of(e listing.Entry) string { @@ -197,17 +215,6 @@ func (m marks) of(e listing.Entry) string { if m.recent { mark(e.Recent, m.theme.Muted, "◷") } - if m.expiry { - left := e.Expires.Sub(m.now) - switch { - case e.Expires.IsZero() || left > m.warn: - mark(false, m.theme.Muted, "") - case left <= 0: - mark(true, m.theme.Error, "✗") - default: - mark(true, m.theme.Warning, "⚠") - } - } pad := strings.Repeat(" ", room-len(parts)) // each mark and its space kind := provider.Kind("") for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index 005ef35..d8099f6 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -46,31 +46,43 @@ func TestListMarks(t *testing.T) { } } -func TestListRecentAndExpiryMarks(t *testing.T) { +func TestListRecentMarks(t *testing.T) { az := icons.NerdFont.Azure - now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) - m := marks{icons: icons.NerdFont, stars: true, recent: true, expiry: true, warn: 30 * 24 * time.Hour, now: now} + m := marks{icons: icons.NerdFont, stars: true, recent: true} for _, c := range []struct { e listing.Entry want string }{ - // Right-aligned in room for three: by the icon, names lined up. - {listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az}, - {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, - {listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az}, - {listing.Entry{Provider: "azure", Expires: now.Add(10 * 24 * time.Hour)}, " ⚠ " + az}, - {listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour)}, " " + az}, + // Right-aligned in room for two: by the icon, names lined up. + {listing.Entry{Provider: "azure", Starred: true, Recent: true}, "★ ◷ " + az}, + {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, + {listing.Entry{Provider: "azure"}, " " + az}, } { if got := m.of(c.e); got != c.want { t.Errorf("%+v: %q, want %q", c.e, got, c.want) } } - m.color, m.theme = true, styles.Default() - if got := m.of(listing.Entry{Expires: now.Add(-time.Hour)}); !strings.Contains(got, styles.Default().Error.Render("✗")) { - t.Errorf("expired isn't a red ✗: %q", got) +} + +func TestListExpiry(t *testing.T) { + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + warn := 30 * 24 * time.Hour + for _, c := range []struct { + expires time.Time + want string + }{ + {time.Time{}, ""}, + {now.Add(-time.Hour), "✗ expired"}, + {now.Add(12*24*time.Hour + time.Hour), "⚠ 12d"}, + {now.Add(5 * time.Hour), "⚠ 5h"}, + {now.Add(60 * 24 * time.Hour), ""}, + } { + if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want { + t.Errorf("expires %v: %q, want %q", c.expires, got, c.want) + } } - if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) { - t.Errorf("expiring isn't a ⚠ in the warning colour: %q", got) + if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("✗ expired") { + t.Errorf("expired isn't red: %q", got) } } diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 6e7929a..34b3e07 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1304,7 +1304,8 @@ func TestExpiringInStatus(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) d.keys("3", "]", "]") // load kv-app-prod's certificates - if !strings.Contains(d.screen(), "⚠ 2 expiring") { // old-tls, legacy-token + // old-tls and legacy-token. + if !strings.Contains(d.screen(), "⚠ 2 expiring") { t.Errorf("status line:\n%s", d.screen()) } d.keys("0")