Compare commits
3
Commits
40d98189da
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6db064ab
|
||
|
|
a81fcaa5a6 | ||
|
|
e28328ef82 |
No files matched your search
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|||||||
+14
-2
@@ -5,6 +5,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -194,6 +195,11 @@ 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
|
||||||
@@ -208,6 +214,9 @@ 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"] {
|
||||||
@@ -265,7 +274,9 @@ Flags:
|
|||||||
statePath = filepath.Join(stateDir, "state.json")
|
statePath = filepath.Join(stateDir, "state.json")
|
||||||
}
|
}
|
||||||
st, err := store.Load(statePath)
|
st, err := store.Load(statePath)
|
||||||
if err != nil {
|
if errors.Is(err, store.ErrNewer) {
|
||||||
|
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
|
||||||
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,13 +417,14 @@ 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.UpdateSources),
|
Updater: updater(cfg.Updates.Sources),
|
||||||
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,
|
||||||
|
|||||||
@@ -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.UpdateSources}
|
c := &update.Client{Servers: cfg.Updates.Sources}
|
||||||
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)
|
||||||
|
|||||||
+7
-2
@@ -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
|
||||||
updateSources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
sources: # 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,6 +91,9 @@ 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`.
|
||||||
|
|
||||||
@@ -116,6 +119,8 @@ 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,
|
||||||
@@ -149,7 +154,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.updateSources` lists others, such as a mirror of your own. Offline, or with
|
`updates.sources` 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.
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
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.
|
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||||
|
escaped, `app\.name`, so it doesn't read as nesting.
|
||||||
|
|
||||||
## Which repos
|
## Which repos
|
||||||
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
// UpdateSources are the repositories updates come from, tried in
|
// Sources 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).
|
||||||
UpdateSources []string `yaml:"updateSources"`
|
Sources []string `yaml:"sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
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.UpdateSources {
|
for i, r := range c.Updates.Sources {
|
||||||
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.updateSources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
return fmt.Errorf("%s: updates.sources[%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,6 +284,16 @@ 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)
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ 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 updateSources: [git.example.com/team/lazysecrets]\n",
|
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
|
||||||
"http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n",
|
"http": "updates:\n sources: [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",
|
||||||
@@ -131,8 +132,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 updateSources: [https://git.example.com/team/lazysecrets]\n"))
|
c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n"))
|
||||||
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 1 {
|
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
||||||
t.Errorf("updates = %+v, %v", c.Updates, err)
|
t.Errorf("updates = %+v, %v", c.Updates, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -403,10 +403,11 @@ 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, extract = o.Container.ID, extractFor(o.Name)
|
file = o.Container.ID
|
||||||
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)
|
||||||
@@ -1012,7 +1013,7 @@ func formatOf(file string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type key struct {
|
type key struct {
|
||||||
name string // db.password, list[0]
|
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||||
extract string // sops --extract: ["db"]["password"]
|
extract string // sops --extract: ["db"]["password"]
|
||||||
encrypted bool
|
encrypted bool
|
||||||
line int // 1-based
|
line int // 1-based
|
||||||
@@ -1057,7 +1058,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: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -1074,9 +1075,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
|||||||
if top && k == "sops" {
|
if top && k == "sops" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
child := k
|
child := escape(k)
|
||||||
if name != "" {
|
if name != "" {
|
||||||
child = name + "." + k
|
child = name + "." + child
|
||||||
}
|
}
|
||||||
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
||||||
}
|
}
|
||||||
@@ -1098,23 +1099,56 @@ 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]). Names with dots in keys can't be
|
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
|
||||||
// told apart from nesting: those come from the listing instead.
|
// its names are the keys as they are.
|
||||||
func extractFor(name string) string {
|
func extractFor(file, name string) string {
|
||||||
var b strings.Builder
|
if formatOf(file) == "dotenv" {
|
||||||
for _, part := range strings.Split(name, ".") {
|
return quote(name)
|
||||||
base, idx, _ := strings.Cut(part, "[")
|
}
|
||||||
if base != "" {
|
var b, part strings.Builder
|
||||||
b.WriteString(quote(base))
|
flush := func() {
|
||||||
}
|
if part.Len() > 0 {
|
||||||
if idx != "" {
|
b.WriteString(quote(part.String()))
|
||||||
b.WriteString("[" + idx)
|
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
|
||||||
|
|||||||
@@ -545,3 +545,24 @@ 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
@@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
|
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -145,16 +145,23 @@ 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.
|
// that never saves, for tests and --demo. A file from a newer lazysecrets
|
||||||
|
// 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 err != nil {
|
if errors.Is(err, ErrNewer) {
|
||||||
|
s.path = ""
|
||||||
|
} else if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.data = d
|
s.data = d
|
||||||
return s, nil
|
return s, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func read(path string) (file, error) {
|
func read(path string) (file, error) {
|
||||||
@@ -172,12 +179,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -65,11 +66,17 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewerVersionRefused(t *testing.T) {
|
func TestNewerVersionReadOnly(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "state.json")
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
|
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
|
||||||
if _, err := Load(path); err == nil {
|
_ = os.WriteFile(path, []byte(newer), 0o600)
|
||||||
t.Error("a newer file was accepted")
|
s, err := Load(path)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ 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
|
||||||
@@ -474,6 +477,9 @@ 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 {
|
||||||
|
|||||||
@@ -122,3 +122,11 @@ 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "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, "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, "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, "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, "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, "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, "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, "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, "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.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
"lists.open": {"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.search": {"ctrl+s"}, "lists.note": {"n"},
|
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"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.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
"lists.copyLink": {"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": {"="},
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ 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.
|
// the default text colour if the theme sets one. Roles it doesn't know are
|
||||||
|
// 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
|
||||||
@@ -56,7 +58,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 {
|
||||||
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
|
continue
|
||||||
}
|
}
|
||||||
v, err := parse(theme[role])
|
v, err := parse(theme[role])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,13 +109,20 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
return st, fg, nil
|
return st, fg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedRoles() []string {
|
// Unknown says which of the theme's roles aren't known, for a warning, or
|
||||||
var out []string
|
// is empty.
|
||||||
for r := range Roles {
|
func Unknown(theme map[string][]string) string {
|
||||||
out = append(out, r)
|
var unknown []string
|
||||||
|
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.
|
||||||
|
|||||||
@@ -44,19 +44,29 @@ 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 {
|
||||||
|
|||||||
Reference in new issue
Block a user