`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>
194 lines
5.7 KiB
Go
194 lines
5.7 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
// What was loaded is kept between runs in a cache file (Options.CacheFile):
|
|
// the Sources lists, vaults, secrets' metadata and where the cursor was in
|
|
// each. Starting again shows it at once, and lists older than cacheTTL
|
|
// reload in the background as usual. Values are never in it; neither are
|
|
// repos, whose files are read from disk anyway.
|
|
//
|
|
// And where lazysecrets was left, per folder it ran in, goes in the store,
|
|
// to go back there on the next start.
|
|
|
|
// 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 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 { 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() {
|
|
f := cachefile.Read(m.cachePath)
|
|
now := m.now()
|
|
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
|
|
for id, e := range f.Sources {
|
|
if m.keepProvider(id) && recent(e.At) {
|
|
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
|
|
m.sourcesAt[id] = e.At
|
|
}
|
|
}
|
|
for k, e := range f.Containers {
|
|
if m.keepCached(k) && recent(e.At) {
|
|
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
|
|
}
|
|
}
|
|
for k, e := range f.Objects {
|
|
if m.keepCached(k) && recent(e.At) {
|
|
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
|
|
}
|
|
}
|
|
for k, e := range f.Details {
|
|
if m.keepCached(k) && recent(e.At) {
|
|
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
|
|
}
|
|
}
|
|
for k, v := range f.Cursor {
|
|
if m.keepCached(k) {
|
|
m.cache.cursor[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// cacheSnapshot is the cache as the file holds it.
|
|
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.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] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
|
|
}
|
|
}
|
|
for k, e := range m.cache.objects {
|
|
if m.keepCached(k) {
|
|
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] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
|
|
}
|
|
}
|
|
for k, v := range m.cache.cursor {
|
|
if m.keepCached(k) {
|
|
f.Cursor[k] = v
|
|
}
|
|
}
|
|
return f
|
|
}
|
|
|
|
// 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
|
|
// background when they changed.
|
|
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
|
|
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
|
|
st, dir := m.store, m.placeDir
|
|
cmds = append(cmds, func() tea.Msg { return persistedMsg{st.SetPlace(dir, p)} })
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// Persist saves the cache and where lazysecrets was left; run it after
|
|
// the program ends.
|
|
func (m Model) Persist() error {
|
|
var errs []error
|
|
if m.cachePath != "" {
|
|
errs = append(errs, m.saveCache(m.cacheSnapshot())())
|
|
}
|
|
if m.placeDir != "" {
|
|
if p, ok := m.place(); ok {
|
|
errs = append(errs, m.store.SetPlace(m.placeDir, p))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// place is the selected item, as deep as the focus.
|
|
func (m *Model) place() (store.Place, bool) {
|
|
src, ok := m.selectedSource()
|
|
if !ok || m.nav != nil {
|
|
return store.Place{}, false
|
|
}
|
|
var r ref.Ref
|
|
var err error
|
|
switch c, cok := m.containers.Selected(); {
|
|
case m.focus == paneSources || !cok:
|
|
r, err = ref.ForSource(src)
|
|
case m.focus == paneContainers:
|
|
r, err = ref.ForContainer(c, m.objType)
|
|
default:
|
|
o, ook := m.detail.Secret()
|
|
if !ook {
|
|
r, err = ref.ForContainer(c, m.objType)
|
|
} else {
|
|
r, err = ref.ForObject(o, "")
|
|
}
|
|
}
|
|
if err != nil {
|
|
return store.Place{}, false
|
|
}
|
|
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
|
|
}
|
|
|
|
// restorePlace goes back to where lazysecrets was left in dir, if that
|
|
// account is still signed in.
|
|
func (m *Model) restorePlace() {
|
|
p, ok := m.store.Place(m.placeDir)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := m.byID[p.Account]; !ok {
|
|
return
|
|
}
|
|
r, err := ref.Parse(p.Link)
|
|
if err != nil {
|
|
return
|
|
}
|
|
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
|
|
m.nav.sourceOnly = r.IsSource()
|
|
m.nav.quiet = true
|
|
}
|
|
|
|
func (m *Model) handlePersisted(msg persistedMsg) {
|
|
if msg.err != nil {
|
|
m.logErrOf("saving the cache or place", msg.err)
|
|
}
|
|
}
|