Hello world
Release / Build and publish (push) Successful in 37s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-12 15:33:18 +02:00
commit d1ae98e696
134 files changed
+27239

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
package app
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"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.
// 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
}
// 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
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if m.keepCached(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 {
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{},
}
for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
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)
}
// 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
f, path := m.cacheSnapshot(), m.cachePath
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
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, writeCacheFile(m.cachePath, 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)
}
}