1 Commits
Author SHA1 Message Date
keb 40d98189da docs: SECURITY.md says how to report a vulnerability
CI / Checks (pull_request) Successful in 38s
PR title / Conventional title (pull_request) Successful in 2s
2026-10-13 23:26:51 +02:00
17 changed files with 57 additions and 201 deletions

No files matched your search

-13
View File
@@ -2,19 +2,6 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.1.2 – 2026-10-13
### Added
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
### Fixed
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
## v0.1.1 – 2026-10-13 ## v0.1.1 – 2026-10-13
### Added ### Added
+2 -14
View File
@@ -5,7 +5,6 @@ package main
import ( import (
"cmp" "cmp"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -195,11 +194,6 @@ Flags:
if err != nil { if err != nil {
return err return err
} }
// Warnings show when the window opens.
var warnings []string
if w := styles.Unknown(cfg.Theme); w != "" {
warnings = append(warnings, w)
}
keymap := keys.Default() keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil { if err := keymap.Apply(cfg.Keybinding); err != nil {
return err return err
@@ -214,9 +208,6 @@ Flags:
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if *timeout <= 0 {
return fmt.Errorf("--timeout: %s is not positive", *timeout)
}
// Each provider's own timeout, unless --timeout sets them all. // Each provider's own timeout, unless --timeout sets them all.
timeouts := map[provider.Kind]time.Duration{} timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] { if !set["timeout"] {
@@ -274,9 +265,7 @@ Flags:
statePath = filepath.Join(stateDir, "state.json") statePath = filepath.Join(stateDir, "state.json")
} }
st, err := store.Load(statePath) st, err := store.Load(statePath)
if errors.Is(err, store.ErrNewer) { if err != nil {
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
} else if err != nil {
return err return err
} }
@@ -417,14 +406,13 @@ Flags:
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
HideOnBlur: config.On(cfg.Values.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Updates.Sources), Updater: updater(cfg.Updates.UpdateSources),
UpdateCheck: config.On(cfg.Updates.AutoCheck), UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery, UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath), ConfigFiles: configFiles(*configPath),
StatePath: statePath, StatePath: statePath,
+1 -1
View File
@@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Updates.Sources} c := &update.Client{Servers: cfg.Updates.UpdateSources}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
+2 -7
View File
@@ -23,7 +23,7 @@ storage:
updates: updates:
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way) autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
autoCheckInterval: 24h # at most this often autoCheckInterval: 24h # at most this often
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) updateSources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
@@ -91,9 +91,6 @@ theme:
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `ui.icons: false`. as boxes, turn them off with `ui.icons: false`.
@@ -119,8 +116,6 @@ starred the item under (a vault two of your accounts can see can be starred
for one); a note describes the item, so it shows under every account. It's for one); a note describes the item, so it shows under every account. It's
JSON so an editor plugin can read it with a built-in parser; several JSON so an editor plugin can read it with a built-in parser; several
lazysecrets windows can save to it without losing each other's changes. lazysecrets windows can save to it without losing each other's changes.
One written by a newer lazysecrets is read but not saved to, with a
warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item Revealing, copying, opening in the browser, sharing and jumping to an item
count as using it: its record keeps when it was last used and how often, count as using it: its record keeps when it was last used and how often,
@@ -154,7 +149,7 @@ At startup, in the background, lazysecrets asks for the latest release
until then it reuses the answer from the state file. Releases are until then it reuses the answer from the state file. Releases are
published on GitHub and on git.kebcloud.no, and it asks them in that published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds; order, the next when one doesn't answer within 5 seconds;
`updates.sources` lists others, such as a mirror of your own. Offline, or with `updates.updateSources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows what changed since your version, across the last 10 releases. `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
+1 -2
View File
@@ -3,8 +3,7 @@
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them `.env`, INI) and plain env files as vaults, and the keys in them
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is (`db.password`, `list[0]`) as secrets.
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
+4 -14
View File
@@ -82,10 +82,10 @@ type Updates struct {
AutoCheck *bool `yaml:"autoCheck"` AutoCheck *bool `yaml:"autoCheck"`
// AutoCheckInterval defaults to 24h. // AutoCheckInterval defaults to 24h.
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"` AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
// Sources are the repositories updates come from, tried in // UpdateSources are the repositories updates come from, tried in
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, // order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
// then git.kebcloud.no (update.DefaultServers). // then git.kebcloud.no (update.DefaultServers).
Sources []string `yaml:"sources"` UpdateSources []string `yaml:"updateSources"`
} }
type LinkHandler struct { type LinkHandler struct {
@@ -268,9 +268,9 @@ func (c Config) validate(path string) error {
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 { if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d) return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
} }
for i, r := range c.Updates.Sources { for i, r := range c.Updates.UpdateSources {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) return fmt.Errorf("%s: updates.updateSources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
} }
} }
if k := c.Sharing.Key; k != "" { if k := c.Sharing.Key; k != "" {
@@ -284,16 +284,6 @@ func (c Config) validate(path string) error {
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 { if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n) return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
} }
for name, d := range map[string]time.Duration{
"providers.timeout": c.Providers.Timeout,
"providers.azure.timeout": c.Providers.Azure.Timeout,
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
"providers.repos.timeout": c.Providers.Repos.Timeout,
} {
if d < 0 {
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
}
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+4 -5
View File
@@ -75,10 +75,9 @@ func TestLoadRejectsMistakes(t *testing.T) {
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"flat keys": "icons: false\n", "flat keys": "icons: false\n",
"duration": "providers:\n timeout: soon\n", "duration": "providers:\n timeout: soon\n",
"negative": "providers:\n repos:\n timeout: -1s\n",
"view": "ui:\n sourcesView: tree\n", "view": "ui:\n sourcesView: tree\n",
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n", "sources": "updates:\n updateSources: [git.example.com/team/lazysecrets]\n",
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n", "http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n",
"interval": "updates:\n autoCheckInterval: 0s\n", "interval": "updates:\n autoCheckInterval: 0s\n",
"share key": "sharing:\n key: https://kv.vault.azure.net/\n", "share key": "sharing:\n key: https://kv.vault.azure.net/\n",
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n", "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
@@ -132,8 +131,8 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n updateSources: [https://git.example.com/team/lazysecrets]\n"))
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 1 {
t.Errorf("updates = %+v, %v", c.Updates, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
+15 -49
View File
@@ -403,11 +403,10 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file = o.Container.ID file, extract = o.Container.ID, extractFor(o.Name)
if file == "" { if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
} }
extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name) return plainValue(file, o.Name)
@@ -1013,7 +1012,7 @@ func formatOf(file string) string {
} }
type key struct { type key struct {
name string // db.password, list[0], with . [ ] \ in keys escaped name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
line int // 1-based line int // 1-based
@@ -1058,7 +1057,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -1075,9 +1074,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" { if top && k == "sops" {
continue continue
} }
child := escape(k) child := k
if name != "" { if name != "" {
child = name + "." + child child = name + "." + k
} }
walk(n.Content[i+1], child, extract+quote(k), false, out) walk(n.Content[i+1], child, extract+quote(k), false, out)
} }
@@ -1099,56 +1098,23 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
} }
// escape keeps a key's dots and brackets from reading as nesting in a
// name: app\.name is one key, app.name two.
func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made // extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat: // from a link (db.password, list[0]). Names with dots in keys can't be
// its names are the keys as they are. // told apart from nesting: those come from the listing instead.
func extractFor(file, name string) string { func extractFor(name string) string {
if formatOf(file) == "dotenv" { var b strings.Builder
return quote(name) for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
} }
var b, part strings.Builder if idx != "" {
flush := func() { b.WriteString("[" + idx)
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
} }
} }
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String() return b.String()
} }
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
}
func absPath(p string) string { func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil { if a, err := filepath.Abs(p); err == nil {
return a return a
-21
View File
@@ -545,24 +545,3 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d) t.Errorf("untracked env: %+v", d)
} }
} }
func TestKeyNamesEscaped(t *testing.T) {
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
if err != nil {
t.Fatal(err)
}
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
}
}
+1 -1
View File
@@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) {
} }
default: default:
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host) return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
} }
return r, nil return r, nil
} }
+6 -13
View File
@@ -145,23 +145,16 @@ type Store struct {
data file data file
} }
// ErrNewer is a state file written by a newer lazysecrets.
var ErrNewer = errors.New("written by a newer lazysecrets")
// Load reads path; a missing file is an empty store. path "" gives a store // Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. A file from a newer lazysecrets // that never saves, for tests and --demo.
// is read but never saved, so nothing it keeps is lost: Load returns the
// store with an ErrNewer error.
func Load(path string) (*Store, error) { func Load(path string) (*Store, error) {
s := &Store{path: path} s := &Store{path: path}
d, err := read(path) d, err := read(path)
if errors.Is(err, ErrNewer) { if err != nil {
s.path = ""
} else if err != nil {
return nil, err return nil, err
} }
s.data = d s.data = d
return s, err return s, nil
} }
func read(path string) (file, error) { func read(path string) (file, error) {
@@ -179,12 +172,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil { if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err) 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 { if d.Nodes == nil {
d.Nodes = map[string]Node{} d.Nodes = map[string]Node{}
} }
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil return d, nil
} }
+4 -11
View File
@@ -1,7 +1,6 @@
package store package store
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
} }
} }
func TestNewerVersionReadOnly(t *testing.T) { func TestNewerVersionRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}` _ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
_ = os.WriteFile(path, []byte(newer), 0o600) if _, err := Load(path); err == nil {
s, err := Load(path) t.Error("a newer file was accepted")
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
t.Fatalf("Load = %v, %v", s, err)
}
_ = s.SetFlag("x", true)
if b, _ := os.ReadFile(path); string(b) != newer {
t.Errorf("the newer file was overwritten: %s", b)
} }
} }
-6
View File
@@ -79,9 +79,6 @@ type Options struct {
// and keeps its state, for the status pane. // and keeps its state, for the status pane.
ConfigFiles []string ConfigFiles []string
StatePath string StatePath string
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning string
// Changelog is CHANGELOG.md, for the status pane. // Changelog is CHANGELOG.md, for the status pane.
Changelog string Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
@@ -477,9 +474,6 @@ func New(providers []provider.Provider, opts Options) Model {
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { if len(m.sourceResults) > 0 {
-8
View File
@@ -122,11 +122,3 @@ func TestUpdateAsked(t *testing.T) {
t.Errorf("U on a dev build:\n%s", dev.screen()) t.Errorf("U on a dev build:\n%s", dev.screen())
} }
} }
func TestStartupWarning(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"})
if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") {
t.Errorf("no warning:\n%s", d.screen())
}
}
+6 -6
View File
@@ -95,7 +95,7 @@ func (m *Map) Actions() []Action {
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."), a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
@@ -116,7 +116,7 @@ func (m *Map) Actions() []Action {
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."), a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
@@ -184,13 +184,13 @@ var defaults = map[string][]string{
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"}, "lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
"lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, "lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
+8 -17
View File
@@ -45,9 +45,7 @@ var Roles = map[string]string{
} }
// FromTheme returns the default styles with the theme's roles applied, and // FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one. Roles it doesn't know are // the default text colour if the theme sets one.
// skipped (see Unknown): a theme file made for a newer lazysecrets still
// works.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) { func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default() st := Default()
var fg color.Color var fg color.Color
@@ -58,7 +56,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
sort.Strings(roles) sort.Strings(roles)
for _, role := range roles { for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
} }
v, err := parse(theme[role]) v, err := parse(theme[role])
if err != nil { if err != nil {
@@ -109,20 +107,13 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
return st, fg, nil return st, fg, nil
} }
// Unknown says which of the theme's roles aren't known, for a warning, or func sortedRoles() []string {
// is empty. var out []string
func Unknown(theme map[string][]string) string { for r := range Roles {
var unknown []string out = append(out, r)
for r := range theme {
if _, ok := Roles[r]; !ok {
unknown = append(unknown, r)
} }
} slices.Sort(out)
if len(unknown) == 0 { return out
return ""
}
slices.Sort(unknown)
return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)"
} }
// value is a parsed role: a colour (nil for default) and attributes. // value is a parsed role: a colour (nil for default) and attributes.
+3 -13
View File
@@ -44,29 +44,19 @@ func TestFromTheme(t *testing.T) {
func TestFromThemeRejectsMistakes(t *testing.T) { func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{ for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}}, "colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}}, "hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}}, "palette": {"errorColor": {"300"}},
} { } {
if _, _, err := FromTheme(theme); err == nil { if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
} }
} }
} }
func TestUnknownRolesSkipped(t *testing.T) {
theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}}
if _, _, err := FromTheme(theme); err != nil {
t.Errorf("unknown role failed the theme: %v", err)
}
if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") {
t.Errorf("warning = %q", w)
}
if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" {
t.Errorf("warning for known roles: %q", w)
}
}
func TestEmptyThemeIsDefault(t *testing.T) { func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil) st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {