feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers (#70)
`lazysecrets list` prints every source, vault and secret lazysecrets can find, one per line, for fzf, editor pickers and scripts: what Go to searches, without the TUI. What's cached prints at once; what's missing or older than `--max-age` loads in the background and streams in. It stops when the reader goes (a pick in fzf), saving what it loaded. - Text: kind, mark (✗/⚠, ★, ◷ and the provider icon), name, status (why it couldn't be read, or expired / expires in 12d), note, account and link, tab-separated; kind first and account and link last, so new fields go between. Coloured from the theme; `--color never`, `--icons=false`, `--stars=false`, `--recent`, `--notes=false`, `--expiry=false`. - `--format json`: an object per line, with starred, recent, lastUsed and uses, expires, enabled, note, reference and url too. - `--kind` lists only some kinds, and doesn't load lists only other kinds need; `--cached` loads nothing. - `lazysecrets open --action go|copy|show` skips the "what to do" menu and ends in the list, like Go to; `open` and `get` take `--account`, the account list printed, rather than asking which. - The cache file moved to `internal/cachefile`: saves merge with the file under a lock, so list and the TUI don't undo each other. - `ui.recentIcon` marks items used lately with ◷ in the TUI's lists too; Recent now means the last 90 days. - --demo has stars, notes, recent use, and expired, expiring and disabled secrets. - docs/integration.md documents get, list and an fzf picker; docs/links.md is docs/sharing.md. Reviewed-on: #70 Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #70.
This commit is contained in:
1 parent
dbb0eb2bbd
commit
39454d47a8
45 files changed
+2107
-222
No files matched your search
+38
-13
@@ -131,8 +131,15 @@ type Options struct {
|
||||
HandlerSetup *HandlerSetup
|
||||
// NoHistory stops counting uses of items for the Recent list.
|
||||
NoHistory bool
|
||||
// RecentIcon marks items used lately with ◷ in the lists
|
||||
// (ui.recentIcon).
|
||||
RecentIcon bool
|
||||
// Open is a reference to open at startup (lazysecrets open <ref>).
|
||||
Open *ref.Ref
|
||||
// OpenAction, if set, is what to do with Open, rather than asking:
|
||||
// go to it, and maybe copy or show its value (OpenActions). It ends in
|
||||
// the list, like Go to, rather than on the details.
|
||||
OpenAction string
|
||||
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
||||
// DefaultFg the terminal's default text colour.
|
||||
Styles *styles.Styles
|
||||
@@ -206,9 +213,12 @@ type Model struct {
|
||||
statusW int // the status pane's width: the left column's, zoomed or not
|
||||
changelog []panes.Field
|
||||
cacheDirty bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
// cacheDropped are cache keys deleted since the last save, so saving
|
||||
// deletes them from the file too.
|
||||
cacheDropped map[string]bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
|
||||
// A pending clear of a copied value, counted down in the footer.
|
||||
// clipToken identifies the latest copy so older ticks are ignored.
|
||||
@@ -283,6 +293,8 @@ type Model struct {
|
||||
prompt *modal // popup that has the keyboard
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
recentIcon bool // mark items used lately (◷)
|
||||
openAction string // and what to do with it, or ask
|
||||
|
||||
shareKey string // the config's sharing.key
|
||||
shareRules share.Rules // and sharing.keyRules
|
||||
@@ -415,7 +427,8 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
if m.store == nil {
|
||||
m.store, _ = store.Load("")
|
||||
}
|
||||
m.sources = newSourcesList(iconSet, m.store)
|
||||
m.recentIcon = opts.RecentIcon
|
||||
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
|
||||
m.sources.Empty = "no sources found"
|
||||
m.collapsed = map[string]bool{}
|
||||
m.flatSources = opts.FlatSources
|
||||
@@ -447,7 +460,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
}
|
||||
m.defaultFg = opts.DefaultFg
|
||||
m.help = newHelp(m.st)
|
||||
m.open = opts.Open
|
||||
m.open, m.openAction = opts.Open, opts.OpenAction
|
||||
saved := m.store.Layout()
|
||||
m.split, m.heights = saved.Split, saved.Heights
|
||||
|
||||
@@ -521,18 +534,26 @@ func (m Model) Init() tea.Cmd {
|
||||
cmds = append(cmds, m.fetchLatest(false))
|
||||
}
|
||||
if m.open != nil {
|
||||
r := *m.open
|
||||
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
|
||||
msg := openRefMsg{ref: *m.open, action: m.openAction}
|
||||
cmds = append(cmds, func() tea.Msg { return msg })
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// openRefMsg starts opening a reference from outside the key handler.
|
||||
type openRefMsg struct{ ref ref.Ref }
|
||||
// openRefMsg starts opening a reference from outside the key handler:
|
||||
// asking what to do with it, or doing action (OpenActions).
|
||||
type openRefMsg struct {
|
||||
ref ref.Ref
|
||||
action string
|
||||
}
|
||||
|
||||
// OpenActions are what lazysecrets open --action takes, the Open link
|
||||
// menu's choices that go there.
|
||||
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
|
||||
|
||||
// OpenLink is a message offering r in the Open link popup, for links
|
||||
// handed over by the link handler (Program.Send).
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
@@ -589,7 +610,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case openRefMsg:
|
||||
m.deferHandlerSetup() // the link first
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
|
||||
cmds = append(cmds, m.findRef(msg.ref, act))
|
||||
} else {
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
}
|
||||
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
@@ -666,7 +691,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.logErrOf(src.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.containers, msg.key)
|
||||
uncache(&m, m.cache.containers, msg.key)
|
||||
m.containers.SetError(msg.err)
|
||||
} else {
|
||||
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
||||
@@ -687,7 +712,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.logErrOf(c.Name, msg.err)
|
||||
}
|
||||
delete(m.cache.objects, msg.key)
|
||||
uncache(&m, m.cache.objects, msg.key)
|
||||
m.secrets.SetError(msg.err)
|
||||
} else {
|
||||
if msg.bound != nil {
|
||||
|
||||
+17
-15
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
|
||||
}
|
||||
// While typing a filter, backspace deletes a character instead.
|
||||
d.keys("/")
|
||||
d.typeText("apx")
|
||||
d.typeText("api-x")
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
|
||||
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api")
|
||||
d.typeText("api-k")
|
||||
if d.m.secrets.Len() != 1 {
|
||||
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
|
||||
}
|
||||
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
|
||||
}
|
||||
d.keys("esc") // leave filter mode, clearing the query
|
||||
d.keys("/")
|
||||
d.typeText("api")
|
||||
d.typeText("api-k")
|
||||
d.keys("enter", "esc") // apply, then esc clears the applied filter
|
||||
if d.m.secrets.Len() != 3 {
|
||||
if d.m.secrets.Len() != 4 {
|
||||
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("a") // all three secrets contain an a
|
||||
if d.m.secrets.Len() != 3 {
|
||||
d.typeText("a") // all four secrets contain an a
|
||||
if d.m.secrets.Len() != 4 {
|
||||
t.Fatalf("visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
_, _, initial := d.selected() // kept from before filtering: the middle row
|
||||
var seen []string
|
||||
for _, k := range []rune{'j', 'j', 'k'} {
|
||||
for _, k := range []rune{'j', 'j', 'j', 'k'} {
|
||||
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
|
||||
_, _, s := d.selected()
|
||||
seen = append(seen, s)
|
||||
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
if !d.m.secrets.Filtering() {
|
||||
t.Fatal("ctrl+j/k left filter mode")
|
||||
}
|
||||
// down to the bottom, stay there, back up to where it started
|
||||
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
|
||||
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
|
||||
// down to the bottom, stay there, back up one
|
||||
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
|
||||
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "ctrl+j/n down") {
|
||||
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
|
||||
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api")
|
||||
d.typeText("api-k")
|
||||
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
|
||||
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
|
||||
t.Errorf("ctrl+y copied %q", d.clip().content)
|
||||
@@ -1187,7 +1187,8 @@ func TestDetailsFollowFocus(t *testing.T) {
|
||||
func TestStatusLineCountsRepos(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, fake.Azure(0), fake.Repos())
|
||||
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
||||
// The status line, not the pane, which an expired secret's ⚠ fills.
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
|
||||
t.Errorf("status line: %q", line)
|
||||
}
|
||||
d = newDriver(t, fake.Azure(0))
|
||||
@@ -1202,8 +1203,8 @@ func TestStatusPane(t *testing.T) {
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
||||
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), signedOut{fake.Azure(0)})
|
||||
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line: %q", line)
|
||||
}
|
||||
d.keys("0")
|
||||
if d.m.focus != paneStatus {
|
||||
@@ -1303,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(), "⚠ 1 expiring") {
|
||||
// old-tls and legacy-token.
|
||||
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
|
||||
@@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
m.detail.Refresh(s)
|
||||
delete(m.cache.details, detailKey(s))
|
||||
uncache(m, m.cache.details, detailKey(s))
|
||||
return m.describe(s)
|
||||
}
|
||||
|
||||
// uncache deletes key from a cache, and, at the next save, from the file.
|
||||
func uncache[T any](m *Model, c map[string]entry[T], key string) {
|
||||
if _, ok := c[key]; !ok {
|
||||
return
|
||||
}
|
||||
delete(c, key)
|
||||
if m.cacheDropped == nil {
|
||||
m.cacheDropped = map[string]bool{}
|
||||
}
|
||||
m.cacheDropped[key] = true
|
||||
m.cacheDirty = true
|
||||
}
|
||||
|
||||
// sameListing reports whether a and b list the same: what a list
|
||||
// says of an object, not what describing it adds.
|
||||
func sameListing(a, b provider.Object) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
@@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -11,28 +12,18 @@ import (
|
||||
// list once they have expired (✗ expired) or will within the configured
|
||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||
|
||||
type expiryState int
|
||||
type expiryState = expiry.State
|
||||
|
||||
const (
|
||||
notExpiring expiryState = iota
|
||||
expiresSoon
|
||||
expired
|
||||
notExpiring = expiry.None
|
||||
expiresSoon = expiry.Soon
|
||||
expired = expiry.Expired
|
||||
)
|
||||
|
||||
// expiry tells whether o has expired or will within the warning period,
|
||||
// and how long it has left.
|
||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
||||
if o.Expires.IsZero() {
|
||||
return notExpiring, 0
|
||||
}
|
||||
left := o.Expires.Sub(m.now())
|
||||
switch {
|
||||
case left <= 0:
|
||||
return expired, 0
|
||||
case left <= m.expiryWarning:
|
||||
return expiresSoon, left
|
||||
}
|
||||
return notExpiring, left
|
||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||
}
|
||||
|
||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||
@@ -47,9 +38,4 @@ func (m Model) expiryWord(o provider.Object) string {
|
||||
}
|
||||
|
||||
// daysLeft is a short "12d", or "5h" on the last day.
|
||||
func daysLeft(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
func daysLeft(d time.Duration) string { return expiry.Left(d) }
|
||||
@@ -3,9 +3,11 @@ package app
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
@@ -19,6 +21,22 @@ import (
|
||||
|
||||
const star = "★ "
|
||||
|
||||
// recentMark marks items used lately (Recent), with ui.recentIcon.
|
||||
const recentMark = "◷ "
|
||||
|
||||
// marks is what goes before a list row's name: ★ when it's starred for
|
||||
// account and, with recent, ◷ when it was used lately.
|
||||
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
|
||||
var s string
|
||||
if n.StarredFor(account) {
|
||||
s = st.Favourite.Render(star)
|
||||
}
|
||||
if recent && n.UsedLately(now) {
|
||||
s += st.Muted.Render(recentMark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sourceKey(s provider.Source) string {
|
||||
if r, err := ref.ForSource(s); err == nil {
|
||||
return r.String()
|
||||
@@ -94,9 +112,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
||||
}
|
||||
line += sty.Muted.Render(" " + c.Detail)
|
||||
}
|
||||
if n.StarredFor(c.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||
@@ -116,9 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
if o.Enabled != nil && !*o.Enabled {
|
||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||
}
|
||||
if n.StarredFor(o.Container.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||
switch state, left := m.expiry(o); state {
|
||||
case expired:
|
||||
line += " " + sty.Error.Render("✗ expired")
|
||||
@@ -167,7 +181,7 @@ func (m *Model) rebind(c provider.Container) string {
|
||||
m.linked[i].Source = c.Source
|
||||
}
|
||||
}
|
||||
key := containerKey(c) + "|" + string(m.objType)
|
||||
key := cachefile.ObjectsKey(c, m.objType)
|
||||
if m.onLinks() {
|
||||
m.containers.SetItems(m.linked)
|
||||
m.containers.Select(containerKey(c))
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -394,7 +395,7 @@ func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api" {
|
||||
for _, r := range "api-k" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
@@ -434,3 +435,20 @@ func TestStarIsPerAccount(t *testing.T) {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentIcon(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
|
||||
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
|
||||
d.keys("3")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
|
||||
t.Errorf("want ◷ on api-key only:\n%s", scr)
|
||||
}
|
||||
off := newDriverOpts(t, Options{Store: mem})
|
||||
off.keys("3")
|
||||
if strings.Contains(off.screen(), "◷") {
|
||||
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
@@ -160,7 +161,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("web", c.URL)
|
||||
for _, t := range c.ObjectTypes() {
|
||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
||||
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
if nodes[k].UsedLately(m.now()) {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ type resolvedMsg struct {
|
||||
act linkAction
|
||||
sourceRow string
|
||||
others []string // other accounts' rows that also found the vault
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
// find ends in the list, like Go to, not on the details: opened by a
|
||||
// tool that found it (lazysecrets open --action, from fzf).
|
||||
find bool
|
||||
choices []choice // several candidates: ask
|
||||
linked *provider.Container
|
||||
err error
|
||||
}
|
||||
|
||||
type choice struct {
|
||||
@@ -218,6 +221,9 @@ func (m *Model) openSource(r ref.Ref) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if row, ok := preferredRow(r, choices); ok {
|
||||
choices = []choice{{sourceRow: row}}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
|
||||
@@ -404,18 +410,59 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
for i := range msg.choices {
|
||||
msg.choices[i].label = "Open in " + msg.choices[i].label
|
||||
}
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
|
||||
case len(msg.others) > 0:
|
||||
var choices []choice
|
||||
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
||||
if row, ok := preferredRow(msg.ref, choices); ok {
|
||||
m.navResolved(msg, row)
|
||||
return
|
||||
}
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
||||
m.navResolved(msg, linksRowID)
|
||||
default:
|
||||
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
||||
m.navResolved(msg, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
// preferredRow is the choice under the account the reference asks for
|
||||
// (ref.Ref.Account), if it's one of them.
|
||||
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
|
||||
for _, c := range choices {
|
||||
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
|
||||
return c.sourceRow, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// navResolved goes to what a resolved link points at, under row.
|
||||
func (m *Model) navResolved(msg resolvedMsg, row string) {
|
||||
if msg.find {
|
||||
m.startNav(msg.ref, msg.act, row)
|
||||
return
|
||||
}
|
||||
m.startLinkNav(msg.ref, msg.act, row)
|
||||
}
|
||||
|
||||
// findRef is openRef ending in the list, like Go to: for a link a tool
|
||||
// found (lazysecrets open --action).
|
||||
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
|
||||
cmd := m.openRef(r, act)
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
msg := cmd()
|
||||
if rm, ok := msg.(resolvedMsg); ok {
|
||||
rm.find = true
|
||||
return rm
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.send(tea.PasteMsg{Content: "api"})
|
||||
d.send(tea.PasteMsg{Content: "api-k"})
|
||||
if d.m.secrets.Len() != 1 || d.m.nav != nil {
|
||||
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
|
||||
}
|
||||
@@ -203,8 +203,21 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
|
||||
t.Fatal("open at startup didn't ask what to do")
|
||||
}
|
||||
d2.keys("o")
|
||||
if _, _, s := d2.selected(); s != "api-key" {
|
||||
t.Errorf("open at startup: selected %q", s)
|
||||
if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
|
||||
t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
|
||||
}
|
||||
|
||||
// --action: no question, it does that.
|
||||
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
|
||||
if d3.m.prompt != nil {
|
||||
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
|
||||
}
|
||||
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
|
||||
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
|
||||
}
|
||||
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
|
||||
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
|
||||
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +318,20 @@ func TestOpenAsksWhichAccount(t *testing.T) {
|
||||
t.Errorf("j enter picked %q", c.Source.Provider)
|
||||
}
|
||||
|
||||
// lazysecrets open --account: no question, that account.
|
||||
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
|
||||
r.Account = "Azure:bob@example.com/t1"
|
||||
d.send(OpenLink(r))
|
||||
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
|
||||
d.keys("o")
|
||||
}
|
||||
if strings.Contains(d.screen(), "Several accounts can see") {
|
||||
t.Fatalf("asked, with an account given:\n%s", d.screen())
|
||||
}
|
||||
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
|
||||
t.Errorf("--account opened via %q", c.Source.Provider)
|
||||
}
|
||||
|
||||
// Opening into a folded account unfolds it.
|
||||
d.keys("1", "g") // alice's header
|
||||
d.keys("enter") // fold it
|
||||
|
||||
+23
-78
@@ -1,14 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
@@ -23,58 +21,28 @@ import (
|
||||
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
||||
// to go back there on the next start.
|
||||
|
||||
// cacheFileVersion is the file's format; a file of another version is
|
||||
// ignored, and replaced on the next save.
|
||||
const cacheFileVersion = 1
|
||||
|
||||
// cacheMaxAge drops what hasn't been loaded again for this long.
|
||||
const cacheMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// persistEvery is how often a changed cache is saved while running, so
|
||||
// little is lost if lazysecrets is killed rather than quit.
|
||||
const persistEvery = 30 * time.Second
|
||||
|
||||
type cacheFile struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
type persistTickMsg struct{}
|
||||
|
||||
type persistedMsg struct{ err error }
|
||||
|
||||
// keepCached reports whether a cache key's account still exists and
|
||||
// isn't a repo: every key starts with the provider ID.
|
||||
func (m *Model) keepCached(key string) bool {
|
||||
id, _ := splitSourceRow(key)
|
||||
p, ok := m.byID[id]
|
||||
return ok && p.Kind() != provider.KindRepo
|
||||
}
|
||||
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
|
||||
|
||||
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
|
||||
|
||||
// loadCacheFile fills the cache from the file, leaving out what's too
|
||||
// old or belongs to accounts that aren't signed in any more.
|
||||
func (m *Model) loadCacheFile() {
|
||||
b, err := os.ReadFile(m.cachePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var f cacheFile
|
||||
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
|
||||
return
|
||||
}
|
||||
f := cachefile.Read(m.cachePath)
|
||||
now := m.now()
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
|
||||
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
|
||||
for id, e := range f.Sources {
|
||||
if m.keepCached(id+"|") && recent(e.At) {
|
||||
if m.keepProvider(id) && recent(e.At) {
|
||||
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
||||
m.sourcesAt[id] = e.At
|
||||
}
|
||||
@@ -102,33 +70,27 @@ func (m *Model) loadCacheFile() {
|
||||
}
|
||||
|
||||
// cacheSnapshot is the cache as the file holds it.
|
||||
func (m *Model) cacheSnapshot() cacheFile {
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Sources: map[string]stamped[[]provider.Source]{},
|
||||
Containers: map[string]stamped[[]provider.Container]{},
|
||||
Objects: map[string]stamped[[]provider.Object]{},
|
||||
Details: map[string]stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
func (m *Model) cacheSnapshot() cachefile.File {
|
||||
f := cachefile.New()
|
||||
f.Dropped, m.cacheDropped = m.cacheDropped, nil
|
||||
for id, r := range m.sourceResults {
|
||||
if r.err == nil && m.keepCached(id+"|") {
|
||||
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
|
||||
if r.err == nil && m.keepProvider(id) {
|
||||
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.containers {
|
||||
if m.keepCached(k) {
|
||||
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
|
||||
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.objects {
|
||||
if m.keepCached(k) {
|
||||
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
|
||||
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, e := range m.cache.details {
|
||||
if m.keepCached(k) {
|
||||
f.Details[k] = stamped[provider.Object]{e.at, e.value}
|
||||
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
|
||||
}
|
||||
}
|
||||
for k, v := range m.cache.cursor {
|
||||
@@ -139,28 +101,11 @@ func (m *Model) cacheSnapshot() cacheFile {
|
||||
return f
|
||||
}
|
||||
|
||||
// writeCacheFile writes f atomically, readable by the user only.
|
||||
func writeCacheFile(path string, f cacheFile) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
// saveCache saves f, merged with the file (lazysecrets list may have
|
||||
// loaded more since), from any goroutine.
|
||||
func (m *Model) saveCache(f cachefile.File) func() error {
|
||||
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
|
||||
return func() error { return cachefile.Save(path, f, keep, now) }
|
||||
}
|
||||
|
||||
// persistTick saves the cache, and where lazysecrets is, in the
|
||||
@@ -169,8 +114,8 @@ func (m *Model) persistTick() tea.Cmd {
|
||||
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
|
||||
if m.cacheDirty && m.cachePath != "" {
|
||||
m.cacheDirty = false
|
||||
f, path := m.cacheSnapshot(), m.cachePath
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
|
||||
save := m.saveCache(m.cacheSnapshot())
|
||||
cmds = append(cmds, func() tea.Msg { return persistedMsg{save()} })
|
||||
}
|
||||
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
|
||||
m.savedPlace = p.Link
|
||||
@@ -185,7 +130,7 @@ func (m *Model) persistTick() tea.Cmd {
|
||||
func (m Model) Persist() error {
|
||||
var errs []error
|
||||
if m.cachePath != "" {
|
||||
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
|
||||
errs = append(errs, m.saveCache(m.cacheSnapshot())())
|
||||
}
|
||||
if m.placeDir != "" {
|
||||
if p, ok := m.place(); ok {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
@@ -80,15 +82,16 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "cache.json")
|
||||
az := fake.Azure(0)
|
||||
now := time.Now()
|
||||
f := cacheFile{
|
||||
Version: cacheFileVersion,
|
||||
Containers: map[string]stamped[[]provider.Container]{
|
||||
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
|
||||
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
|
||||
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
|
||||
f := cachefile.File{
|
||||
Version: cachefile.Version,
|
||||
Containers: map[string]cachefile.Stamped[[]provider.Container]{
|
||||
az.ID() + "|recent": {At: now.Add(-time.Hour), Value: []provider.Container{{Name: "kv-recent"}}},
|
||||
az.ID() + "|old": {At: now.Add(-cachefile.MaxAge - time.Hour), Value: []provider.Container{{Name: "kv-old"}}},
|
||||
"Azure:gone@x/t|recent": {At: now, Value: []provider.Container{{Name: "kv-gone"}}},
|
||||
},
|
||||
}
|
||||
if err := writeCacheFile(file, f); err != nil {
|
||||
b, _ := json.Marshal(f) // as an older lazysecrets left it
|
||||
if err := os.WriteFile(file, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newDriverOpts(t, Options{CacheFile: file}, az)
|
||||
@@ -97,7 +100,7 @@ func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
|
||||
t.Error("dropped a recent entry")
|
||||
}
|
||||
if _, ok := c[az.ID()+"|old"]; ok {
|
||||
t.Error("kept an entry older than cacheMaxAge")
|
||||
t.Error("kept an entry older than cachefile.MaxAge")
|
||||
}
|
||||
if _, ok := c["Azure:gone@x/t|recent"]; ok {
|
||||
t.Error("kept an entry of an account that isn't signed in")
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// containerKey identifies a container in the UI and caches. The same vault
|
||||
// seen through two accounts must not share cached lists, since each
|
||||
// account may see different things.
|
||||
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||
func containerKey(c provider.Container) string { return cachefile.ContainerKey(c) }
|
||||
|
||||
// providerFor finds the provider instance a source came from. Sources
|
||||
// remembered from an earlier run (favourites) may name an account that
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
@@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
return nil
|
||||
}, reload)
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
|
||||
}
|
||||
for _, v := range e.value {
|
||||
c.vaults++
|
||||
key := containerKey(v) + "|" + string(provider.TypeSecret)
|
||||
key := cachefile.ObjectsKey(v, provider.TypeSecret)
|
||||
if objs, ok := m.cache.objects[key]; ok {
|
||||
c.vaultsLoaded++
|
||||
c.secrets += len(objs.value)
|
||||
@@ -263,7 +264,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
|
||||
// queueContainer queues loading c's secrets, unless loaded lately and not
|
||||
// force.
|
||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
|
||||
return
|
||||
}
|
||||
@@ -327,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
|
||||
m.crawl.active--
|
||||
key := msg.job.row
|
||||
if msg.job.container != nil {
|
||||
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
|
||||
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
|
||||
}
|
||||
delete(m.crawl.pending, key)
|
||||
switch {
|
||||
@@ -365,7 +366,7 @@ func (m *Model) sourceMark(row string) string {
|
||||
}
|
||||
loaded, loading := 0, false
|
||||
for _, c := range e.value {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
loading = loading || m.crawl.pending[key]
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
@@ -386,7 +387,7 @@ func (m *Model) sourceMark(row string) string {
|
||||
// (✗) or are loading (◌): whether a subscription is loaded says whether
|
||||
// its vaults are searchable.
|
||||
func (m *Model) vaultMark(c provider.Container) string {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
switch {
|
||||
case m.crawl.pending[key]:
|
||||
return m.st.Muted.Render("◌")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
@@ -617,7 +618,7 @@ func (m Model) loadHint() string {
|
||||
}
|
||||
loaded := 0
|
||||
for _, c := range e.value {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
key := cachefile.ObjectsKey(c, provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
|
||||
loaded++
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
@@ -39,7 +41,7 @@ type sourceRow struct {
|
||||
|
||||
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
|
||||
// either; provider IDs contain "/" (Azure:user/tenant).
|
||||
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||
func sourceRowKey(providerID, sourceID string) string { return cachefile.Row(providerID, sourceID) }
|
||||
|
||||
func splitSourceRow(row string) (providerID, sourceID string) {
|
||||
providerID, sourceID, _ = strings.Cut(row, "|")
|
||||
@@ -56,7 +58,7 @@ func sourceRowID(r sourceRow) string {
|
||||
return sourceRowKey(r.provider, r.source.ID)
|
||||
}
|
||||
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||
ID: sourceRowID,
|
||||
// Headers have no filter text, so filtering shows just sources.
|
||||
@@ -78,8 +80,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
|
||||
node := mem.Node(sourceKey(r.source))
|
||||
kind := st.Kind(r.group)
|
||||
starred := ""
|
||||
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
|
||||
starred = st.Favourite.Render(star)
|
||||
if r.kind == rowSource {
|
||||
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
||||
}
|
||||
switch {
|
||||
case r.kind == rowLinks:
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ 2 accounts ││ name platform-prod │
|
||||
│ ⚠ 1 expiring · 2 accounts ││ name platform-prod │
|
||||
╰───────────────────────────────╯│ ID sub-prod │
|
||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Azure (2) ││ vaults 3 │
|
||||
@@ -20,7 +20,8 @@
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ │
|
||||
│ db-password ││ │
|
||||
│ api-key ││ │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ legacy-token ✗ expired ││ │
|
||||
│ smtp-password (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
@@ -28,6 +29,5 @@
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
╰────────────────────── 1 of 4 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
|
||||
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
|
||||
@@ -11,6 +11,7 @@ type Set struct {
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
Note string // has a note (lazysecrets list)
|
||||
}
|
||||
|
||||
// None renders no icons.
|
||||
@@ -24,6 +25,7 @@ var NerdFont = Set{
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
Note: "\U000F09ED",
|
||||
}
|
||||
|
||||
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
||||
|
||||
Reference in new issue
Block a user