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

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 08:48:23 +02:00
commit cbb155b498
130 files changed
+26734

No files matched your search

+342
View File
@@ -0,0 +1,342 @@
// Package store keeps what lazysecrets remembers between runs in one JSON
// file in the XDG state directory: a record per subscription, vault or
// secret the user has starred, annotated or used, keyed by its
// lazysecrets:// link, and the layout. It never holds secret values.
//
// JSON rather than YAML (the config's format) because the app writes it,
// and so an editor plugin can read it with a built-in parser
// (vim.json.decode).
package store
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"sync"
"time"
)
// Node is what's remembered about one subscription, vault or secret. Name,
// Path, Source and Account are context, so lists of nodes (favourites,
// recent) can show and jump to them before their subscription is loaded.
type Node struct {
Name string `json:"name,omitempty"`
Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault]
Source string `json:"source,omitempty"` // ID of the subscription or workspace profile
Account string `json:"account,omitempty"` // provider ID that last read it
// StarredBy lists the accounts (provider IDs) the item is starred
// for: a star is the item through that account. A note describes the
// item itself, whichever account shows it.
StarredBy []string `json:"starredBy,omitempty"`
Note string `json:"note,omitempty"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
}
// keep reports whether the record holds anything worth keeping; context
// alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
// ToggleStar stars or unstars the item for account, reporting whether
// it's starred now.
func (n *Node) ToggleStar(account string) bool {
if i := slices.Index(n.StarredBy, account); i >= 0 {
n.StarredBy = slices.Delete(n.StarredBy, i, i+1)
return false
}
n.StarredBy = append(n.StarredBy, account)
return true
}
// UpdateCheck is the last check for a newer release.
func (s *Store) UpdateCheck() UpdateCheck {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Update
}
// SetUpdateCheck records a check for a newer release.
func (s *Store) SetUpdateCheck(c UpdateCheck) error {
return s.save(func(d *file) { d.Update = c })
}
// Place is where lazysecrets was left when it last ran in dir.
func (s *Store) Place(dir string) (Place, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.data.Places[dir]
return p, ok
}
// SetPlace remembers where lazysecrets was left in dir.
func (s *Store) SetPlace(dir string, p Place) error {
return s.save(func(d *file) {
if d.Places == nil {
d.Places = map[string]Place{}
}
d.Places[dir] = p
for len(d.Places) > maxPlaces {
oldest := ""
for k, v := range d.Places {
if oldest == "" || v.At.Before(d.Places[oldest].At) {
oldest = k
}
}
delete(d.Places, oldest)
}
})
}
// Layout is the user's pane sizing, as offsets from the default layout so
// it adapts to the terminal size.
type Layout struct {
Split int `json:"split,omitempty"` // columns the divider moved
Heights [3]int `json:"heights,omitzero"` // rows added to each left pane
}
type file struct {
Version int `json:"version"`
Nodes map[string]Node `json:"nodes"`
Layout Layout `json:"layout,omitzero"`
// Flags are one-off choices, like "don't ask again".
Flags map[string]bool `json:"flags,omitempty"`
// Update is the last check for a newer release.
Update UpdateCheck `json:"update,omitzero"`
// Places is where lazysecrets was left, by the folder it ran in.
Places map[string]Place `json:"places,omitempty"`
}
// Place is what was selected when lazysecrets quit: an item's link, and
// the account and source it was read through.
type Place struct {
Link string `json:"link"`
Account string `json:"account"`
Source string `json:"source"`
At time.Time `json:"at"`
}
// maxPlaces bounds Places: the folders used most recently are kept.
const maxPlaces = 100
// UpdateCheck is the last look for a newer release: when, and what the
// latest was (its tag, changelog and page), so the next start within a
// day shows it without asking again.
type UpdateCheck struct {
At time.Time `json:"at,omitzero"`
Tag string `json:"tag,omitempty"`
Notes string `json:"notes,omitempty"`
URL string `json:"url,omitempty"`
}
const version = 1
type Store struct {
path string
mu sync.Mutex
data file
}
// Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo.
func Load(path string) (*Store, error) {
s := &Store{path: path}
d, err := read(path)
if err != nil {
return nil, err
}
s.data = d
return s, nil
}
func read(path string) (file, error) {
d := file{Version: version, Nodes: map[string]Node{}}
if path == "" {
return d, nil
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return d, nil
}
if err != nil {
return d, err
}
if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err)
}
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil {
d.Nodes = map[string]Node{}
}
return d, nil
}
// Node returns the record for key, empty if there is none.
func (s *Store) Node(key string) Node {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Nodes[key]
}
// Has reports whether there's a record for key.
func (s *Store) Has(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.data.Nodes[key]
return ok
}
// Nodes returns a copy of all records.
func (s *Store) Nodes() map[string]Node {
s.mu.Lock()
defer s.mu.Unlock()
return maps.Clone(s.data.Nodes)
}
// Update changes the record for key and saves. Another lazysecrets may
// have saved in the meantime, so the file is read again first and only
// this record is changed in it. A record left with nothing worth keeping
// is removed.
func (s *Store) Update(key string, change func(*Node)) error {
return s.save(func(d *file) {
n := d.Nodes[key]
change(&n)
if n.keep() {
d.Nodes[key] = n
} else {
delete(d.Nodes, key)
}
})
}
// Usage records — ones kept only for their use — are pruned when they
// go unused this long, and beyond this many, oldest first.
const (
usageMaxAge = 90 * 24 * time.Hour
usageMax = 100
)
// Use counts a use of key at the given time, with change updating its
// context, and prunes old usage records.
func (s *Store) Use(key string, at time.Time, change func(*Node)) error {
return s.save(func(d *file) {
n := d.Nodes[key]
change(&n)
n.Uses++
n.LastUsed = at
d.Nodes[key] = n
prune(d.Nodes, at)
})
}
// prune drops usage records unused since usageMaxAge before now, and the
// oldest beyond usageMax. Starred and noted records stay.
func prune(nodes map[string]Node, now time.Time) {
var usage []string
for k, n := range nodes {
if len(n.StarredBy) > 0 || n.Note != "" {
continue
}
if now.Sub(n.LastUsed) > usageMaxAge {
delete(nodes, k)
continue
}
usage = append(usage, k)
}
if len(usage) <= usageMax {
return
}
slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
for _, k := range usage[usageMax:] {
delete(nodes, k)
}
}
// Flag reports a one-off choice.
func (s *Store) Flag(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Flags[name]
}
// SetFlag records a one-off choice.
func (s *Store) SetFlag(name string, on bool) error {
return s.save(func(d *file) {
if d.Flags == nil {
d.Flags = map[string]bool{}
}
if on {
d.Flags[name] = true
} else {
delete(d.Flags, name)
}
})
}
func (s *Store) Layout() Layout {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Layout
}
func (s *Store) SetLayout(l Layout) error {
return s.save(func(d *file) { d.Layout = l })
}
// save applies change to the file as it is now on disk (or to memory, for
// an unsaved store) and writes it atomically, readable by the user only.
func (s *Store) save(change func(*file)) error {
s.mu.Lock()
defer s.mu.Unlock()
d := s.data
if s.path != "" {
var err error
if d, err = read(s.path); err != nil {
return err
}
} else {
d.Nodes = maps.Clone(d.Nodes)
}
change(&d)
d.Version = version
if s.path != "" {
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
if err := writeAtomic(s.path, append(b, '\n')); err != nil {
return err
}
}
s.data = d
return nil
}
func writeAtomic(path string, b []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // no-op after a successful rename
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)
}