1 Commits
Author SHA1 Message Date
keb 8cf3d116ee chore: tidy comments that repeat the code or no longer match it
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 2s
- Comments that only restate a name (sgr, fg, mdLink, the repo provider's starred and current-repo notes) are gone.
- Stale ones match the code again: the repo package covers plain env files too, Launch runs argv, closeVersions no longer claims to move the cursor, and Check's fallback says why rather than naming a state no release had.
- The doc for versionString was attached to pseudo; the backtick in SourcesView's doc was a string-concatenation leftover; openFailed's doc was garbled.
- recentReleases sits next to latest, its user.
- .gitignore drops the Go template's boilerplate and patterns for files this project doesn't make.
2026-10-15 08:50:36 +02:00
72 changed files with 959 additions and 729 deletions

No files matched your search

-6
View File
@@ -2,12 +2,6 @@
Every release of lazysecrets, newest first. Every release of lazysecrets, newest first.
## v0.2.1 – 2026-10-15
### Fixed
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
## v0.2.0 – 2026-10-14 ## v0.2.0 – 2026-10-14
### Added ### Added
+22 -10
View File
@@ -7,23 +7,24 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **Read-only**: lazysecrets never creates, changes or deletes a secret.
That's by design and may stay that way.
- **Zero setup**: uses the logins you already have (`az`, the Databricks - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. - **Share links**: Share `lazysecrets://` links with colleagues.
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
- **Careful with values**: values stay hidden until you reveal them, and - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor - **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim) pipes.
for Neovim.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. - **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
@@ -44,8 +45,9 @@ Both approaches will install the latest release from [GitHub](https://github.com
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
`U` (or `lazysecrets update`) updates in place when there's a newer lazysecrets looks for a newer release once a day: the footer then shows
release. `↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
Or build it yourself: Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. `go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -68,20 +70,30 @@ use most:
| `l` / `h` | the details of what's selected / back | | `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in | | `/` | filter the list you're in |
| `space` | reveal / hide the value | | `space` | reveal / hide the value |
| `y` | copy the value | | `y` | copy the value (cleared from the clipboard after 30s) |
| `ctrl+s` | Go to: search everything and jump there | | `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites | | `f` / `F` | star / your favourites |
| `s` | copy a link to share | | `s` | copy a link to share |
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor ## Integration
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker). Lazysecret let you integrate it into your workflows by `lazysecrets` `list`/`get`/`open`
Here is an example of implementing a picker in `fzf`:
```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
```
![fzf-example](docs/assets/fzf-example.png) ![fzf-example](docs/assets/fzf-example.png)
More defails in the [Integration](docs/integration.md) docs
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
+10 -4
View File
@@ -18,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, _, err := readKey(ctx, providers, st, r) v, err := value(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -30,9 +30,15 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// readKey reads what r points at (ref.Read), and the version read. The // value reads what r points at (ref.Read), with the account that last
// account asked for (--account) goes first, else the one that last read // read it first.
// it. func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
v, _, err := readKey(ctx, providers, st, r)
return v, err
}
// readKey is value, also returning the version read. The account asked
// for (--account) goes first, else the one that last read it.
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) { func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
key := r key := r
key.Version = "" key.Version = ""
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, _, err := readKey(context.Background(), providers, st, r) v, err := value(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) t.Errorf("%s: %q, %v", link, v.Reveal(), err)
} }
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
"{{secrets/app/storage-key}}": "names no workspace", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr) t.Errorf("%s: %v, want %q", link, err, wantErr)
} }
} }
+8 -10
View File
@@ -69,7 +69,11 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil { if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err return "", "", err
} }
launch := append(append([]string{exe}, configArgs(configPath)...), "launch") launch := []string{exe}
if configPath != defaultConfigPath() {
launch = append(launch, "--config", configPath)
}
launch = append(launch, "launch")
if term != "" { if term != "" {
launch = append(launch, "--terminal", term) launch = append(launch, "--terminal", term)
} }
@@ -184,14 +188,8 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
// innerCommand runs lazysecrets on link, with the environment it needs. // innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string { func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe) inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
inner = append(inner, configArgs(configPath)...) if configPath != defaultConfigPath() {
inner = append(inner, "--config", configPath)
}
return append(inner, "open", link) return append(inner, "open", link)
} }
// configArgs passes the config files on, unless they're the default.
func configArgs(configPath string) []string {
if configPath == defaultConfigPath() {
return nil
}
return []string{"--config", configPath}
}
+73 -50
View File
@@ -68,20 +68,27 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue continue
} }
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command)) name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command)) return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
} }
} }
} }
return nil return nil
} }
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// refArg parses get's and open's arguments: a reference, and optionally // refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column) and, // the account to use when several could (lazysecrets list's column) and,
// for open, what to do with it rather than asking (--action). // for open, what to do with it rather than asking (--action).
@@ -143,7 +150,6 @@ func run() error {
for scripts: VAR=$(lazysecrets get <ref>) for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json] lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false] [--color always|never] [--icons=false] [--errors=false]
[--stars=false] [--notes=false] [--recent] [--expiry=false]
print every source, vault and secret it can print every source, vault and secret it can
find, one per line, for fzf and pickers: find, one per line, for fzf and pickers:
what's cached at once, then what loads what's cached at once, then what loads
@@ -171,22 +177,26 @@ Flags:
return nil return nil
} }
sub := "" // the subcommand; "" browses var open, getRef *ref.Ref
var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var openAction string var openAction string
var shareArgs, listArgs []string
printKeys := false
switch args := flag.Args(); { switch args := flag.Args(); {
case len(args) > 0 && (args[0] == "share" || args[0] == "list"): case len(args) > 0 && args[0] == "share":
sub, subArgs = args[0], args[1:] shareArgs = args[1:]
if len(shareArgs) == 0 {
shareArgs = []string{} // still sharing: says how
}
case len(args) > 0 && args[0] == "list":
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
sub = "keybindings" printKeys = true
case len(args) > 0 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, _, err := refArg("get", args[1:]) r, _, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
sub, getRef = "get", r getRef = &r
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -232,7 +242,7 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil { if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err return err
} }
if sub == "keybindings" { if printKeys {
fmt.Print(keymap.YAML()) fmt.Print(keymap.YAML())
return nil return nil
} }
@@ -260,13 +270,25 @@ Flags:
} }
// timeoutFor is how long a request to a provider of kind k may take. // timeoutFor is how long a request to a provider of kind k may take.
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) } timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
if !set["clipboard-clear"] { if !set["clipboard-clear"] && cfg.Values.ClipboardClear != nil {
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear) *clipClear = *cfg.Values.ClipboardClear
}
expiryDays := 30
if cfg.UI.ExpiryWarningDays != nil {
expiryDays = *cfg.UI.ExpiryWarningDays
}
revealTimeout := 60 * time.Second
if cfg.Values.RevealTimeout != nil {
revealTimeout = *cfg.Values.RevealTimeout
}
loadDelay := 150 * time.Millisecond
if cfg.UI.LoadDelay != nil {
loadDelay = *cfg.UI.LoadDelay
}
var updateEvery time.Duration // 0: the app's default, a day
if cfg.Updates.AutoCheckInterval != nil {
updateEvery = *cfg.Updates.AutoCheckInterval
} }
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
if !set["icons"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
@@ -276,17 +298,7 @@ Flags:
} }
} }
recentIcon := valueOr(cfg.UI.RecentIcon, false) recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon
startDir := *dir
if startDir == "" {
startDir, _ = os.Getwd()
}
// What was loaded last time.
cacheFile := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
// Stars, notes and the layout. Demo mode keeps them in memory, so // Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file. // sample vaults never end up in the real file.
@@ -344,9 +356,17 @@ Flags:
providers = append(providers, dbx) providers = append(providers, dbx)
} }
if config.On(pc.Repos.Enabled) { if config.On(pc.Repos.Enabled) {
start := *dir
if start == "" {
start, _ = os.Getwd()
}
roots := repo.DefaultRoots
if pc.Repos.Roots != nil {
roots = *pc.Repos.Roots
}
repos := repo.New(repo.Settings{ repos := repo.New(repo.Settings{
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots), Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
}) })
// The repo it started in (shown on top as Repo) and the // The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like // others, checked for secret files in the background like
@@ -362,14 +382,19 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
switch sub { if getRef != nil {
case "get":
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind)) ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, getRef) return get(ctx, providers, st, *getRef)
case "list": }
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout) if listArgs != nil {
case "share": cachePath := ""
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
cachePath = filepath.Join(d, "cache.json")
}
return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, time.Duration(expiryDays)*24*time.Hour, listArgs, os.Stdout)
}
if shareArgs != nil {
// The files from a repo, the key from whichever provider it's // The files from a repo, the key from whichever provider it's
// in: the longest timeout. // in: the longest timeout.
d := *timeout d := *timeout
@@ -378,7 +403,7 @@ Flags:
} }
ctx, cancel := context.WithTimeout(context.Background(), d) ctx, cancel := context.WithTimeout(context.Background(), d)
defer cancel() defer cancel()
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout) return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), shareArgs, os.Stdout)
} }
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -412,10 +437,16 @@ Flags:
onActive = srv.Touch onActive = srv.Touch
} }
// Where it was left in this folder. // What was loaded last time, and where it was left in this folder.
placeDir := "" var cacheFile, placeDir string
if !*demo { if !*demo {
placeDir = startDir if d, err := config.CacheDir(); err == nil && config.On(cfg.Storage.Cache) {
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil { if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs placeDir = abs
} }
@@ -432,7 +463,7 @@ Flags:
NoHistory: !config.On(cfg.Storage.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: expiryWarning, 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.Sources),
@@ -465,14 +496,6 @@ Flags:
return err return err
} }
// valueOr is *p, or def when p is nil (not set in the config).
func valueOr[T any](p *T, def T) T {
if p == nil {
return def
}
return *p
}
// configFiles are the config files given, for the status pane: those // configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one). // that exist, or the first if none does (where to create one).
func configFiles(list string) []string { func configFiles(list string) []string {
+4 -9
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -33,12 +32,8 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error { func updateCmd(configPath string, args []string) error {
fs := flag.NewFlagSet("update", flag.ContinueOnError) check := len(args) == 1 && args[0] == "--check"
check := fs.Bool("check", false, "just say if there's a newer release") if len(args) > 0 && !check {
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 0 {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ",")) cfg, err := config.LoadAll(strings.Split(configPath, ","))
@@ -54,14 +49,14 @@ func updateCmd(configPath string, args []string) error {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
} }
if !update.Newer(current, r.Tag) { if !update.Newer(current, r.Tag) {
if strings.HasPrefix(current, "dev") { if !update.Newer(current, "v999.0.0") {
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag) return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
} }
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current))) fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if *check { if check {
fmt.Println("lazysecrets update installs it.") fmt.Println("lazysecrets update installs it.")
return nil return nil
} }
+2 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"regexp" "regexp"
"runtime/debug" "runtime/debug"
"strings"
) )
// version is set by release builds: // version is set by release builds:
@@ -30,7 +31,7 @@ func versionString() string {
// go install …@<commit>. // go install …@<commit>.
if m := pseudo.FindStringSubmatch(v); m != nil { if m := pseudo.FindStringSubmatch(v); m != nil {
dirty := "" dirty := ""
if m[2] != "" { if strings.HasSuffix(v, "+dirty") {
dirty = "-dirty" dirty = "-dirty"
} }
return "dev-" + m[1][:7] + dirty return "dev-" + m[1][:7] + dirty
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 258 KiB

+4 -3
View File
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
On an account's group row, the fourth pane shows who it signs in as: name, On an account's group row, the fourth pane shows who it signs in as: name,
user, tenant, object ID, whether the session used MFA, directory roles user, tenant, object ID, whether the session used MFA, directory roles
(built-in ones by name) and its subscriptions, read from az's account list (built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has. `y` on and the token lazysecrets already has: no requests of their own. `y` on
the row copies the object ID, the one access policies and role the row copies the object ID, the one access policies and role
assignments ask for; `l` goes into the pane, where `j`/`k` pick any assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one. field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -67,8 +67,9 @@ fetched. `v` lists an item's versions. Items expiring within
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. Expiry dates come with the lists. It says how many on one goes to it. That makes no requests of its own: expiry dates come
vaults it covers; `ctrl+a` in search loads the rest. with the lists. It says how many vaults it covers; `ctrl+a` in search
loads the rest.
On a vault, the right pane shows its settings: resource group, location, On a vault, the right pane shows its settings: resource group, location,
SKU, whether access is by Azure roles or access policies, soft delete and SKU, whether access is by Azure roles or access policies, soft delete and
+38 -21
View File
@@ -1,11 +1,10 @@
# Config # Config
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
below) override it. Unknown keys are an error, so typos don't go it. Unknown keys are an error, so typos don't go unnoticed. `e` on the
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in status pane (`0`) opens it in your editor, as in lazygit (making it if
lazygit (making it if it isn't there yet); changes apply when lazysecrets it isn't there yet); changes apply when lazysecrets starts again.
starts again.
```yaml ```yaml
ui: ui:
@@ -18,7 +17,7 @@ ui:
values: values:
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on) hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
storage: storage:
cache: true # keep what was loaded (never values) for the next start cache: true # keep what was loaded (never values) for the next start
history: true # count uses of items for Recent (R) history: true # count uses of items for Recent (R)
@@ -29,15 +28,32 @@ updates:
- https://github.com/karleinarb/lazysecrets - https://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets
providers: providers:
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout timeout: 30s # per backend request; a provider's own timeout overrides it
azure: # likewise databricks and repos; azure covers every az account azure: # applies to every az account
enabled: true # default enabled: true # default
timeout: 30s timeout: 30s
hideSubscriptions: [platform-mgmt] # by name or ID
vaults: # listed under "Pinned vaults", for vaults
- https://kv-foo.vault.azure.net/ # you can read but not discover
databricks:
enabled: true
timeout: 30s
hideProfiles: [test]
repos:
enabled: true
timeout: 30s
roots: [~/git]
paths: [~/work/infra]
include: ["secrets/**"]
exclude: ["**/testdata/**"]
sharing: # sharing env files as encrypted links
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
keyRules: # what a key's value must be
minLength: 32 # characters, 16 at least
minClasses: 2 # kinds: lowercase, uppercase, digits, symbols
``` ```
More, each on its own page: [Azure](azure.md#vaults-you-cant-discover) More, each on its own page: [repos](repos.md) (`providers.repos`),
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
[repos](repos.md#which-files) (`providers.repos`),
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the [sharing env files](sharing.md#sharing-env-files) (`sharing`), the
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`), [link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
@@ -86,8 +102,9 @@ as boxes, turn them off with `ui.icons: false`.
Several config files can be layered, later ones winning (theme roles and Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
config plus an editor-generated theme: plugin can so add a generated theme file to your own config, the way
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh ```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -100,10 +117,11 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you `lazysecrets://` link, never with values. A star belongs to the account you
starred the item under (a vault two of your accounts can see can be starred 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. for one); a note describes the item, so it shows under every account. It's
Several lazysecrets windows can save to it without losing each other's JSON so an editor plugin can read it with a built-in parser; several
changes. One written by a newer lazysecrets is read but not saved to, lazysecrets windows can save to it without losing each other's changes.
with a warning, until you update. 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,
@@ -139,9 +157,8 @@ 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.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 status every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+5 -3
View File
@@ -12,7 +12,7 @@ repo, pick `kv-shared-prod / env-share` (the second account's) as the
key, then paste the link back. The demo repos aren't on disk, so opening key, then paste the link back. The demo repos aren't on disk, so opening
asks where to save, as for a repo you haven't cloned. asks where to save, as for a repo you haven't cloned.
A stripped binary is a third smaller: A stripped binary is about 21 MB instead of 30 MB:
```sh ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -35,8 +35,10 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed | | `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | | `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
`feat!:` marks a breaking change, `feat(scope):` names a part. `feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
`[skip changelog]` in the description leaves a pull request out. and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
become `feat: x` in the changelog. `[skip changelog]` in the description
leaves a pull request out.
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
platform on pull requests into `main`. `main` is protected: a pull request platform on pull requests into `main`. `main` is protected: a pull request
+6 -6
View File
@@ -34,7 +34,7 @@ tab-separated:
| name | `platform-prod / kv-app-prod / db-password` | | name | `platform-prod / kv-app-prod / db-password` |
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty | | status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty | | note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
| account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` | | account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link | | link | its `lazysecrets://` link |
What's cached prints at once, then what loads follows as it arrives: read What's cached prints at once, then what loads follows as it arrives: read
@@ -47,7 +47,7 @@ so `^prod` finds names starting with prod.
| Flag | | | Flag | |
|---|---| |---|---|
| `--format json` | an object per line instead, for editor plugins (below) | | `--format json` | an object per line instead, for editor plugins (below) |
| `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded | | `--kind secret,vault` | only these kinds: `source`, `vault`, `secret`, `key`, `certificate`; lists only other kinds need aren't loaded |
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks | | `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again | | `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read | | `--errors=false` | leave out the vaults and sources it couldn't read |
@@ -77,11 +77,12 @@ may be added:
| `expires` | when a secret, key or certificate expires, if it does (Azure) | | `expires` | when a secret, key or certificate expires, if it does (Azure) |
| `enabled` | `false` for a disabled one; only where the provider says (Azure) | | `enabled` | `false` for a disabled one; only where the provider says (Azure) |
[`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link) `lazysecrets open --action go --account <account> <link>` opens one in
opens one in lazysecrets without asking what to do or which account. lazysecrets without asking what to do or which account, ending in the
secrets list, like Go to.
### An fzf picker
### Example usage with fzf
```sh ```sh
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \ --header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
@@ -89,6 +90,5 @@ lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \ --bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)' --bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
![fzf-example](assets/fzf-example.png) ![fzf-example](assets/fzf-example.png)
+19 -16
View File
@@ -49,7 +49,7 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `space` | reveal / hide the selected value (public material for keys and certificates) |
| `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field | | `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
@@ -63,8 +63,10 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. It hides again after a while, and when the terminal it exactly as stored.
loses focus ([config](config.md)).
A revealed value hides again after `values.revealTimeout` (60s) and
when the terminal loses focus (`values.hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
@@ -73,7 +75,8 @@ loses focus ([config](config.md)).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list | | `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
| `n` | write a short note on it, shown dimmed on its row and in the detail pane | | `n` | write a short note on it, shown dimmed on its row and in the detail pane |
Both are kept in the [state file](config.md#state). A star belongs to the account you starred the item under; a note shows
under every account. Both are kept in the [state file](config.md#state).
## Go to ## Go to
@@ -121,16 +124,15 @@ it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing; `e` there opens your [config](config.md) in your your accounts are doing; `e` there opens your [config](config.md) in your
editor, as in lazygit. Focused, the right pane lists more under the editor, as in lazygit. Focused, the right pane lists more under the
logo; `y` copies any row: logo (when there's room): the version and updates (`U`), each account
(`L` on one that needs it signs in), what's expiring (`enter` goes to
- the version and updates (`U`) it), this session's errors, newest first (what a pane showed, or the
- each account (`L` on one that needs it signs in) footer flashed before you moved on; not kept after quitting), the config,
- what's expiring (`enter` goes to it) state and cache files (`e` opens one in your editor), and links to the
- this session's errors, newest first (not kept after quitting) docs, source, issues, releases and changelog (`o` opens one; on the
- the config, state and cache files (`e` opens one in your editor) version or update, its release), then the changelog of every release up
- links to the docs, source, issues, releases and changelog (`o` opens one) to yours, built in so it needs no network: `/` filters it (`/fixed`,
- the changelog of every release up to yours, built in: `/` filters it `/search`). `y` copies any row.
(`/fixed`, `/search`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -139,8 +141,9 @@ logo; `y` copies any row:
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | | `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) | | `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). `<>{}` are for The divider and pane heights are remembered (zoom isn't). macOS uses
macOS, where ctrl+arrows switch desktops. ctrl+arrows for Mission Control and switching desktops by default, hence
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
+10 -2
View File
@@ -100,5 +100,13 @@ sops 3.8 or later.
## Links ## Links
Repo links name the repo by its git remote, so they open in a colleague's `S` → `f` shares env files as an [encrypted link](sharing.md#sharing-env-files).
clone wherever it is ([sharing](sharing.md)).
Links name the repo by its git remote, so they open in a colleague's clone
wherever it is:
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [sharing](sharing.md).
+5 -9
View File
@@ -7,13 +7,10 @@ permissions: the link names the item, never its value. Env files can
also be [shared encrypted](#sharing-env-files), in the link itself. also be [shared encrypted](#sharing-env-files), in the link itself.
``` ```
lazysecrets://azure/subscriptions/<subscription-id> subscription
lazysecrets://azure/kv-app-prod.vault.azure.net vault lazysecrets://azure/kv-app-prod.vault.azure.net vault
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
``` ```
@@ -105,8 +102,8 @@ it now and then: new links use the newest version.
## Opening a link ## Opening a link
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard - Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
and fills in any reference it finds there. Key Vault URLs, Azure portal and fills in any reference it finds there. Key Vault URLs,
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too. `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal. - `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is - Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
set up. set up.
@@ -146,10 +143,9 @@ turns the question off too.
link then opens a new window of the terminal you ran it in, with link then opens a new window of the terminal you ran it in, with
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2, lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
tmux; `--terminal NAME` picks another. It records your `PATH`, config tmux; `--terminal NAME` picks another. It records your `PATH` and config
locations and XDG directories, since apps start without your shell's locations, since apps start without your shell's environment (and so
environment (and so without `az`): run it again after moving lazysecrets without `az`): run it again after moving lazysecrets or changing those.
or changing those.
- macOS: a small background app, `~/Applications/Lazysecrets Links.app` - macOS: a small background app, `~/Applications/Lazysecrets Links.app`
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as - Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
+3
View File
@@ -42,6 +42,9 @@ func New() Clipboard {
} }
} }
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+4 -2
View File
@@ -16,7 +16,6 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := cmp.Or(c.Description, c.Command) name := c.Description
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+10 -4
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -205,12 +204,17 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil return filepath.Join(base, "lazysecrets"), nil
} }
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones // LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the // before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor // settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing // plugin add a generated theme file to the user's own config. Missing
// files are skipped. Unknown keys are an error, so typos don't silently do // files are skipped.
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -225,7 +229,9 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
maps.Copy(keybinding[ctx], actions) for name, keys := range actions {
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
+7 -7
View File
@@ -18,7 +18,7 @@ func write(t *testing.T, content string) string {
} }
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := LoadAll([]string{write(t, ` c, err := Load(write(t, `
ui: ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
@@ -36,7 +36,7 @@ providers:
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)}) `))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,7 +61,7 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := LoadAll([]string{path}) c, err := Load(path)
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
@@ -85,7 +85,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
"short keys": "sharing:\n keyRules:\n minLength: 8\n", "short keys": "sharing:\n keyRules:\n minLength: 8\n",
"classes": "sharing:\n keyRules:\n minClasses: 5\n", "classes": "sharing:\n keyRules:\n minClasses: 5\n",
} { } {
if _, err := LoadAll([]string{write(t, content)}); err == nil { if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -132,18 +132,18 @@ theme:
} }
func TestUpdates(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := LoadAll([]string{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 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.Sources) != 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)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")}) c, err := Load(write(t, "ui:\n loadDelay: 300ms\n"))
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil { if _, err := Load(write(t, "ui:\n loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+7 -9
View File
@@ -9,6 +9,7 @@
package instance package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -35,18 +36,15 @@ func Dir(stateDir string) string {
type Status struct { type Status struct {
PID int `json:"pid"` PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus LastActive time.Time `json:"lastActive"` // last key press or focus
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView // Reach is how readily it can be shown: 2 in view (outside tmux, or
// in a tmux session a client shows), 1 in a tmux session that a tmux
// client can be switched to, 0 not at all.
Reach int `json:"reach"`
// App is the terminal app it would show in (macOS), preferring the // App is the terminal app it would show in (macOS), preferring the
// app the link was clicked in. // app the link was clicked in.
App string `json:"app,omitempty"` App string `json:"app,omitempty"`
} }
const (
unreachable = 0
switchable = 1 // in a tmux session that a tmux client can be switched to
inView = 2 // outside tmux, or in a tmux session a client shows
)
type request struct { type request struct {
Op string `json:"op"` // "status" or "open" Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"` Link string `json:"link,omitempty"`
@@ -116,7 +114,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(c).Decode(&req); err != nil { if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return return
} }
var resp response var resp response
@@ -190,7 +188,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f best = f
} }
} }
return best, best.Reach > unreachable return best, best.Reach > 0
} }
// Open hands link, clicked in front, to f. // Open hands link, clicked in front, to f.
+26 -25
View File
@@ -1,7 +1,6 @@
package instance package instance
import ( import (
"cmp"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
@@ -26,10 +25,9 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" } func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct { type client struct {
name, session string name, session, activity string // activity: Unix seconds
activity int // Unix seconds pid int
pid int app string
app string
} }
// clients lists the tmux server's clients, the one last typed in first. // clients lists the tmux server's clients, the one last typed in first.
@@ -44,31 +42,30 @@ func clients() []client {
if len(f) < 4 { if len(f) < 4 {
continue continue
} }
activity, err1 := strconv.Atoi(f[0]) pid, err := strconv.Atoi(f[1])
pid, err2 := strconv.Atoi(f[1]) if err != nil {
if err1 != nil || err2 != nil {
continue continue
} }
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)}) cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
} }
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) }) // Activity has the same width for centuries: strings compare like
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs return cs
} }
// place is where this instance would show: the tmux client (zero outside // place is where this instance would show: the tmux client (zero outside
// tmux), the app, and its reach. // tmux), the app, and reach: 2 already on screen there (outside tmux, or
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) { func place(front string) (client, string, int) {
if !inTmux() { if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), inView return client{}, linkhandler.HostApp(os.Getpid()), 2
} }
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}") session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil { if err != nil {
return client{}, "", unreachable return client{}, "", 0
} }
cs := clients() cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app, // Best first: in the front app on its session, in the front app,
// on its session, any. // on its session, any.
rank := func(c client) int { rank := func(c client) int {
@@ -81,16 +78,20 @@ func place(front string) (client, string, int) {
} }
return r return r
} }
best := cs[0] var best client
for _, c := range cs[1:] { found := false
if rank(c) > rank(best) { for _, c := range cs {
best = c if !found || rank(c) > rank(best) {
best, found = c, true
} }
} }
if best.session == session { if !found {
return best, best.app, inView return client{}, "", 0
} }
return best, best.app, switchable if best.session == session {
return best, best.app, 2
}
return best, best.app, 1
} }
// Raise brings this instance into view for a link clicked in front: in // Raise brings this instance into view for a link clicked in front: in
@@ -98,14 +99,14 @@ func place(front string) (client, string, int) {
// then its app to the front, showing a hidden dropdown terminal. // then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) { func Raise(front string) {
c, app, reach := place(front) c, app, reach := place(front)
if reach == unreachable { if reach == 0 {
return return
} }
if inTmux() { if inTmux() {
pane := os.Getenv("TMUX_PANE") pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane) _, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane) _, _ = tmux("select-pane", "-t", pane)
if reach == switchable { if reach == 1 {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane) _, _ = tmux("switch-client", "-c", c.name, "-t", pane)
} }
} }
+6 -2
View File
@@ -116,13 +116,17 @@ func Check() string {
if err != nil { if err != nil {
return "" // can't tell: assume it works return "" // can't tell: assume it works
} }
exe := strings.TrimSpace(string(out)) if exe := strings.TrimSpace(string(out)); !exists(exe) {
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
+3 -4
View File
@@ -144,6 +144,7 @@ func appleString(s string) string {
// it. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
@@ -165,11 +166,9 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to // passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment, // lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's XDG directories, so // without Homebrew's PATH (so no az) or the user's config locations.
// the window would use another cache and miss running instances' sockets.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
} }
+3
View File
@@ -107,6 +107,9 @@ func parseTerm(tok string) (term, bool) {
return t, true return t, true
} }
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes // Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context, // NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus. // like a path or a note). Without a name, nothing gets the name bonus.
+6
View File
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+121 -99
View File
@@ -16,7 +16,6 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
@@ -100,7 +99,13 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping // Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones. // disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) { var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
switch deref(s.State) { switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted: case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock() p.mu.Lock()
p.tenants[id] = tenant p.tenants[id] = tenant
p.mu.Unlock() p.mu.Unlock()
srcs = append(srcs, provider.Source{ out = append(out, provider.Source{
Kind: provider.KindAzure, Kind: provider.KindAzure,
ID: id, ID: id,
Name: deref(s.DisplayName), Name: deref(s.DisplayName),
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"), URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
}) })
} }
return srcs
})
if err != nil {
return nil, err
} }
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 { if len(p.opts.Vaults) > 0 {
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil { if err != nil {
return nil, err return nil, err
} }
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) { var out []provider.Container
for _, v := range page.Value { pager := client.NewListBySubscriptionPager(nil)
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
vaults = append(vaults, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
return vaults
})
}
// all lists every page, items picking out what to keep of each.
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
var out []T
for pager.More() { for pager.More() {
page, err := pager.NextPage(ctx) page, err := pager.NextPage(ctx)
if err != nil { if err != nil {
return nil, cleanError(err) return nil, cleanError(err)
} }
out = append(out, items(page)...) for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
})
}
} }
return out, nil return out, nil
} }
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object var out []provider.Object
switch t { switch t {
case provider.TypeSecret: case provider.TypeSecret:
tenant := p.tenant(c.Source.ID) pager := vc.secrets.NewListSecretPropertiesPager(nil)
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) { for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates. // Certificate-backing secrets appear under Certificates.
if s.ID != nil && !deref(s.Managed) { if s.ID == nil || deref(s.Managed) {
objs = append(objs, secretObject(c, s, tenant)) continue
} }
out = append(out, secretObject(c, s, p.tenant(c.Source.ID)))
} }
return objs }
})
case provider.TypeKey: case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) { pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID != nil && !deref(k.Managed) { if k.KID == nil || deref(k.Managed) {
objs = append(objs, keyObject(c, k)) continue
} }
out = append(out, keyObject(c, k))
} }
return objs }
})
case provider.TypeCertificate: case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) { pager := vc.certs.NewListCertificatePropertiesPager(nil)
for _, cp := range page.Value { for pager.More() {
if cp.ID != nil { page, err := pager.NextPage(ctx)
objs = append(objs, certObject(c, cp)) if err != nil {
} return nil, cleanError(err)
} }
return objs for _, cp := range page.Value {
}) if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
if err != nil {
return nil, err
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil return out, nil
} }
@@ -348,7 +353,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil { if resp.Key != nil {
if resp.Key.KID != nil { if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version() o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
} }
o.Attrs = keyAttrs(resp.Key) o.Attrs = keyAttrs(resp.Key)
} }
@@ -359,7 +364,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
} }
if resp.ID != nil { if resp.ID != nil {
o.Version = resp.ID.Version() o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
} }
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil { if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType) o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -376,46 +381,56 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil { if err != nil {
return nil, err return nil, err
} }
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object var out []provider.Object
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) { pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
if s.ID != nil { if s.ID != nil {
objs = append(objs, secretObject(o.Container, s, tenant)) out = append(out, secretObject(o.Container, s, p.tenant(o.Container.Source.ID)))
} }
} }
return objs }
})
case provider.TypeKey: case provider.TypeKey:
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) { pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID != nil { if k.KID != nil {
objs = append(objs, keyObject(o.Container, k)) out = append(out, keyObject(o.Container, k))
} }
} }
return objs }
})
case provider.TypeCertificate: case provider.TypeCertificate:
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) { pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value { for _, cp := range page.Value {
if cp.ID != nil { if cp.ID != nil {
objs = append(objs, certObject(o.Container, cp)) out = append(out, certObject(o.Container, cp))
} }
} }
return objs }
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
if err != nil { tenant := p.tenant(o.Container.Source.ID)
return nil, err
}
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) out[i].Reference = versionedReference(out[i])
if o.Type != provider.TypeSecret { // secretObject's is the version's already if o.Type != provider.TypeSecret { // secretObject's is the version's already
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page kind := map[provider.ObjectType]string{provider.TypeKey: "Key", provider.TypeCertificate: "Certificate"}[o.Type]
out[i].URL = portalAssetURL(tenant, kind, out[i].ID+"/"+out[i].Version) // the version's page
} }
} }
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) }) slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
@@ -437,49 +452,56 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's // Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty). // public certificate, as of o.Version (latest if empty).
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) {
v, _, err := p.VersionedValue(ctx, o) vc, err := p.clients(o.Container.Detail)
return v, err if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
v, _, err := p.VersionedValue(ctx, o)
return v, err
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
} }
// VersionedValue is Value, with the version read. // VersionedValue is Value, with the version read.
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) { func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
if o.Type != provider.TypeSecret {
v, err := p.Value(ctx, o)
return v, o.Version, err
}
vc, err := p.clients(o.Container.Detail) vc, err := p.clients(o.Container.Detail)
if err != nil { if err != nil {
return provider.SecretValue{}, "", err return provider.SecretValue{}, "", err
} }
var text string resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
switch o.Type { if err != nil {
case provider.TypeSecret: return provider.SecretValue{}, "", cleanError(err)
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if text, err = publicKeyPEM(resp.Key); err != nil {
return provider.SecretValue{}, "", err
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, "", cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
}
text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
} }
return provider.NewSecretValue([]byte(text)), o.Version, nil version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
} }
var ( var (
+15 -9
View File
@@ -55,8 +55,8 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
// portalAssetURL links to a Key Vault object's page: a version's with // portalAssetURL links to a Key Vault object's page: a version's with
// one in objectID; a secret's own (its versions) without. // one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string { func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
} }
func tenantPrefix(tenant string) string { func tenantPrefix(tenant string) string {
@@ -73,13 +73,19 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok { if !ok {
return "" return ""
} }
return base + "/" + t.Collection() switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
} }
// objectURL is the data-plane URL of an object, e.g. // objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name. // https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, t provider.ObjectType, name string) string { func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
} }
// secretObject is a secret, or a version of one, from a listing. Its URL // secretObject is a secret, or a version of one, from a listing. Its URL
@@ -87,7 +93,7 @@ func objectURL(c provider.Container, t provider.ObjectType, name string) string
// a version, that version's. // a version, that version's.
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object { func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, provider.TypeSecret, name) url := objectURL(c, "secrets", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeSecret, Type: provider.TypeSecret,
@@ -97,7 +103,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")), URL: portalAssetURL(tenant, "Secret", strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
} }
if a := p.Attributes; a != nil { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -107,7 +113,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object { func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name() name := p.KID.Name()
url := objectURL(c, provider.TypeKey, name) url := objectURL(c, "keys", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeKey, Type: provider.TypeKey,
@@ -126,7 +132,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object { func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, provider.TypeCertificate, name) url := objectURL(c, "certificates", name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeCertificate, Type: provider.TypeCertificate,
+13 -13
View File
@@ -2,7 +2,6 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API. // armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo { func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)} info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName info.group = id.ResourceGroupName
} }
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties pr := v.Properties
if pr == nil { if pr == nil {
return info return info
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
if on(v.purge, false) { add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
switch { switch {
case strings.EqualFold(v.publicAccess, "disabled"): case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only") add("network", "private endpoints only")
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
default: default:
add("network", "all networks") add("network", "all networks")
} }
switch { if v.endpoints > 0 {
case v.endpoints == 1: add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
} }
for i, k := range slices.Sorted(maps.Keys(v.tags)) { keys := make([]string, 0, len(v.tags))
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+11 -14
View File
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil return out, nil
} }
// fallbackVault is the backing vault of an AKV-backed scope, to read it // vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// from when Databricks fails, or "" when there's none to fall back to. It // back to the scope's Detail, so a scope remembered from an earlier session
// falls back to the scope's Detail, so a scope remembered from an earlier // (a favourite) works before its workspace's scopes are listed.
// session (a favourite) works before its workspace's scopes are listed. func (p *Provider) vaultURL(c provider.Container) string {
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
if p.keyVault == nil || ctx.Err() != nil {
return ""
}
p.mu.Lock() p.mu.Lock()
url := p.vaults[c.ID] url := p.vaults[c.ID]
p.mu.Unlock() p.mu.Unlock()
if url == "" { if url == "" {
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok { url, _ = strings.CutPrefix(c.Detail, akvDetail)
url = detail if url == c.Detail {
url = ""
} }
} }
return url return url
@@ -267,8 +264,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
// vaultObjects lists an AKV-backed scope's secrets from the vault itself. // vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) { func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.fallbackVault(ctx, c) url := p.vaultURL(c)
if url == "" { if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback return nil, errNoFallback
} }
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret) secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -305,8 +302,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
dbxErr := classify(pr, err) dbxErr := classify(pr, err)
url := p.fallbackVault(ctx, o.Container) url := p.vaultURL(o.Container)
if url == "" { if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr return provider.SecretValue{}, dbxErr
} }
v, kvErr := p.keyVault.Value(ctx, provider.Object{ v, kvErr := p.keyVault.Value(ctx, provider.Object{
+49 -30
View File
@@ -48,8 +48,19 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind } func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) } func (p *Provider) ID() string {
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) } if p.id != "" {
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -225,7 +236,9 @@ func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.O
o.Type = t o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok { if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + "/" + t.Collection() o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
} }
k := listKey{c.ID, t} k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o) p.objects[k] = append(p.objects[k], o)
@@ -239,7 +252,11 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
} }
} }
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC) var (
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -254,13 +271,12 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
func secretRef(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
// Azure returns a fake Azure provider with two subscriptions. // Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider { func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string { objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name) return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
} }
@@ -268,32 +284,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)}, {Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0), Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "deprecated")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0), Name: "smtp-password", Version: "7d2e90", Enabled: &no, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "smtp-password"), Reference: secretRef("kv-app-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -301,7 +317,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -310,7 +326,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -322,7 +338,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0), Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -335,10 +351,10 @@ func Azure(delay time.Duration) *Provider {
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
// A key to share env files with (S → f): random, as sharing.keyRules wants. // A key to share env files with (S → f): random, as sharing.keyRules wants.
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"), Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=") }, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -348,19 +364,19 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"), Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password") }, "dev-password")
// Left behind, as dev vaults are: expired, disabled, or both. // Left behind, as dev vaults are: expired, disabled, or both.
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0), Name: "stripe-webhook-secret", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"), Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
}, "whsec_demo_expired") }, "whsec_demo_expired")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0), Name: "test-user-password", Enabled: &no, Updated: base.AddDate(0, -5, 0),
Reference: secretRef("kv-app-dev", "test-user-password"), Reference: secretRef("kv-app-dev", "test-user-password"),
}, "disabled-demo-password") }, "disabled-demo-password")
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0), Name: "vendor-token", Enabled: &no, Updated: base.AddDate(-2, 0, 0),
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"), Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
}, "expired-and-disabled") }, "expired-and-disabled")
@@ -372,21 +388,24 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one. // its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider { func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0), Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0), Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -394,12 +413,12 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token") }, "pd-demo-token")
// Expiring soon whenever the demo runs: the other dates are fixed, // Expiring soon whenever the demo runs: the other dates are fixed,
// and would have passed. Tests use the first account only. // and would have passed. Tests use the first account only.
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0), Name: "grafana-api-key", Enabled: &yes, Updated: base.AddDate(0, -11, 0),
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"), Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
}, "glsa_demo_expiring") }, "glsa_demo_expiring")
-14
View File
@@ -84,20 +84,6 @@ const (
TypeCertificate ObjectType = "Certificate" TypeCertificate ObjectType = "Certificate"
) )
// Collection is the type's path segment in Key Vault URLs and links, e.g.
// "secrets".
func (t ObjectType) Collection() string {
switch t {
case TypeSecret:
return "secrets"
case TypeKey:
return "keys"
case TypeCertificate:
return "certificates"
}
return ""
}
// Container holds objects: a Key Vault or a Databricks secret scope. // Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct { type Container struct {
Source Source Source Source
+41 -49
View File
@@ -203,11 +203,13 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Go(func() { wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}) }()
} }
wg.Wait() wg.Wait()
} }
@@ -356,23 +358,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil { if err != nil {
return nil, err return nil, err
} }
meta, isSops := parseMeta(c.ID, b) meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b) keys, err := parseKeys(c.ID, b)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err) return nil, fmt.Errorf("%s: %w", c.Name, err)
} }
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}} if !isSops { // a plain env file
if isSops { out := make([]provider.Object, 0, len(keys))
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}} for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
} }
out := make([]provider.Object, 0, len(keys)) out := make([]provider.Object, 0, len(keys))
for _, k := range keys { for _, k := range keys {
o := provider.Object{ o := provider.Object{
Container: c, Type: provider.TypeSecret, Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs, ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
} }
if isSops && !k.encrypted { if !k.encrypted {
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"}) o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
} }
out = append(out, o) out = append(out, o)
} }
@@ -391,7 +400,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 = filePath(o.Container) file = o.Container.ID
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
extract = extractFor(file, o.Name) extract = extractFor(file, o.Name)
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
@@ -419,18 +431,12 @@ func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, er
return os.ReadFile(f.Path) return os.ReadFile(f.Path)
} }
// filePath is the file c is. A container made from a link has no ID: the
// file is its path in the repo.
func filePath(c provider.Container) string {
if c.ID != "" {
return c.ID
}
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
// File is the file c is, and the line o's key is on. // File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) { func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := filePath(c) path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path} f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops { if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops f.Sops = p.s.Sops
@@ -600,38 +606,33 @@ type lazysecretsFile struct {
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any. // ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) { func ShareKey(root string) (string, error) {
f, err := readLazysecretsFile(root)
return f.Sharing.Key, err
}
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
// empty.
func readLazysecretsFile(root string) (lazysecretsFile, error) {
var f lazysecretsFile
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")) b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return f, nil return "", nil
} }
if err != nil { if err != nil {
return f, err return "", err
} }
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil { if err := yaml.Unmarshal(b, &f); err != nil {
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
return f, nil return f.Sharing.Key, nil
} }
// selector says which files to look at: user (the include/exclude of the // selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere // repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files. // (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) { func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
f, err := readLazysecretsFile(root)
if err != nil {
return nil, nil, err
}
include, exclude := p.s.Include, p.s.Exclude include, exclude := p.s.Include, p.s.Exclude
if len(f.Include) > 0 || len(f.Exclude) > 0 { if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
include, exclude = f.Include, f.Exclude var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
} }
inc, err := globs(include) inc, err := globs(include)
if err != nil { if err != nil {
@@ -903,21 +904,12 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading return sopsMeta{}, false // not a format sops writes: skip reading
} }
b, err := os.ReadFile(file) b, err := os.ReadFile(file)
if err != nil { if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false
}
return parseMeta(file, b)
}
// parseMeta is readMeta, of the file's contents b.
func parseMeta(file string, b []byte) (sopsMeta, bool) {
format := formatOf(file)
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing return sopsMeta{}, false // cheap: most files aren't sops, no parsing
} }
var m sopsMeta var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"} known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch format { switch formatOf(file) {
case "yaml", "json": case "yaml", "json":
var doc struct { var doc struct {
Sops map[string]any `yaml:"sops"` Sops map[string]any `yaml:"sops"`
+2
View File
@@ -21,6 +21,8 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value. // Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) } func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked } func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+13 -7
View File
@@ -92,9 +92,15 @@ func (r Ref) ContainerName() string {
// Container.Detail. // Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" } func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) { func typeOf(collection string) (provider.ObjectType, bool) {
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for t, c := range collections {
if strings.EqualFold(t.Collection(), collection) { if strings.EqualFold(c, collection) {
return t, true return t, true
} }
} }
@@ -112,7 +118,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" { if r.Type == "" {
return u + "/" return u + "/"
} }
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} { for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
if seg != "" { if seg != "" {
u += "/" + url.PathEscape(seg) u += "/" + url.PathEscape(seg)
} }
@@ -134,7 +140,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name) segs = append(segs, r.Scope, r.Name)
default: default:
if r.Type != "" { if r.Type != "" {
segs = append(segs, r.Type.Collection()) segs = append(segs, collections[r.Type])
} }
segs = append(segs, r.Name, r.Version) segs = append(segs, r.Name, r.Version)
} }
@@ -267,7 +273,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0]) r.Host = strings.ToLower(segs[0])
rest := segs[1:] rest := segs[1:]
switch host := strings.ToLower(u.Host); host { switch strings.ToLower(u.Host) {
case "azure": case "azure":
r.Kind = provider.KindAzure r.Kind = provider.KindAzure
if r.Host == "subscriptions" { if r.Host == "subscriptions" {
@@ -294,11 +300,11 @@ func parseLink(s string) (Ref, error) {
} }
case "databricks", "repo": case "databricks", "repo":
r.Kind = provider.KindDatabricks r.Kind = provider.KindDatabricks
if host == "repo" { if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo r.Kind = provider.KindRepo
} }
if len(rest) > 2 { if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", host) return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
} }
if len(rest) > 0 { if len(rest) > 0 {
r.Scope = rest[0] r.Scope = rest[0]
+2 -7
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
) )
// Rules are what a key's value must be to share with it: random enough // Rules are what a key's value must be to share with it: random enough
@@ -41,18 +40,14 @@ func (r Rules) Check(key []byte) error {
kinds++ kinds++
} }
} }
n := utf8.RuneCountInString(s) n := len([]rune(s))
var has, needs []string var has, needs []string
if n < r.MinLength { if n < r.MinLength {
has = append(has, fmt.Sprintf("%d characters", n)) has = append(has, fmt.Sprintf("%d characters", n))
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength)) needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
} }
if kinds < r.MinClasses { if kinds < r.MinClasses {
kind := "kinds" has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1]))
if kinds == 1 {
kind = "kind"
}
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses)) needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
} }
if len(has) == 0 { if len(has) == 0 {
+1 -1
View File
@@ -174,7 +174,7 @@ func Read(r ref.Ref) (Sealed, error) {
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)") return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
} }
n, size := binary.Uvarint(b[1:]) n, size := binary.Uvarint(b[1:])
if size <= 0 { if size <= 0 || n > uint64(len(b)) {
return Sealed{}, bad return Sealed{}, bad
} }
rest := b[1+size:] rest := b[1+size:]
+27 -19
View File
@@ -3,11 +3,11 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"math"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -654,7 +654,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.cancelReq(slotDetails) m.done(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -685,7 +685,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.cancelReq(slotContainers) m.done(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
@@ -703,7 +703,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.cancelReq(slotObjects) m.done(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind)) m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -724,7 +724,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredObjectsFirst(msg.items)) m.secrets.SetItems(m.starredFirst(msg.items))
cmds = append(cmds, m.refreshShown(before)) cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -733,7 +733,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.cancelReq(slotValue) m.done(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -748,7 +748,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.cancelReq(slotVersions) m.done(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -760,7 +760,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.cancelReq(slotDescribe) m.done(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -775,7 +775,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.cancelReq(slotCopy) m.done(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -887,7 +887,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindingsFor(""), false return m.showKeybindings(), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -1167,11 +1167,14 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1256,7 +1259,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading() m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key) return m.loadObjects(c, m.objType, key)
} }
m.secrets.SetItems(m.starredObjectsFirst(e.value)) m.secrets.SetItems(m.starredFirst(e.value))
m.secrets.Select(m.cache.cursor[key]) m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) { if e.fresh(m.now()) {
return nil return nil
@@ -1513,6 +1516,9 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
} }
} }
// done releases the context of a completed request.
func (m *Model) done(p slot) { m.cancelReq(p) }
// loadSources starts a new generation of source loads, superseding any in // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1632,7 +1638,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false) return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
} }
return nil return nil
} }
@@ -1675,8 +1681,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes. // clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd { func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken token := m.clipToken
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} }) return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
} }
func (m *Model) cancelClipClear() { func (m *Model) cancelClipClear() {
@@ -1783,15 +1793,13 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
reveal := "show" revealed := m.detail.State() == panes.ValueShown
if m.detail.State() == panes.ValueShown { help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
reveal = "hide"
}
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now()))))) left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
} }
if m.flash.text != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) lines = append(lines, m.flashLine(m.flash))
+4 -4
View File
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
t.Errorf("detail hint doesn't follow the binding:\n%s", scr) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLines(scr, 1), "z show") { if !strings.Contains(lastLine(scr), "z show") {
t.Errorf("help bar: %q", lastLines(scr, 1)) t.Errorf("help bar: %q", lastLine(scr))
} }
d.keys("z") d.keys("z")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") { if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLines(scr, 1) bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
+1 -1
View File
@@ -66,7 +66,7 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
if i < 0 || sameListing(before[i], s) { if i < 0 || sameListing(before[i], s) {
return nil return nil
} }
m.detail.SetDescribed(s) m.detail.Refresh(s)
uncache(m, m.cache.details, detailKey(s)) uncache(m, m.cache.details, detailKey(s))
return m.describe(s) return m.describe(s)
} }
+20 -10
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := cmp.Or(c.Description, c.Command) label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmp.Or(c.Description, "Command"), input: &in, title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{ m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil return nil
@@ -175,7 +177,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh") shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmp.Or(msg.cmd.Description, "command") name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
o.Version = version // "": the current value, like copying it if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true return it, &o, true
} }
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
return in return in
} }
// pathInputWidth fits a link or a folder path in a prompt, in the window. func cmpOr(a, b string) string {
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) } if a != "" {
return a
}
return b
}
+1 -2
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = ansi.Strip(strings.Join(d.m.prompt.lines, "")) shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
} }
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") { if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+1 -1
View File
@@ -242,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+4 -5
View File
@@ -6,7 +6,6 @@ import (
"slices" "slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -31,7 +30,7 @@ func (m Model) expiringItems() []expiringItem {
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != expiry.None { if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -52,11 +51,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + expiry.Left(left) mark := "⚠ " + daysLeft(left)
if state == expiry.Expired { if state == expired {
mark = "✗ expired" mark = "✗ expired"
} }
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row} f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry" "git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -11,19 +12,30 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState = expiry.State
const (
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) { func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
return expiry.Of(o.Expires, m.now(), m.expiryWarning) return expiry.Of(o.Expires, m.now(), m.expiryWarning)
} }
// expiryWord is matched by the filter: "expired" or "expiring". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expiry.Expired: case expired:
return "expired" return "expired"
case expiry.Soon: case expiresSoon:
return "expiring" return "expiring"
} }
return "" return ""
} }
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+19 -32
View File
@@ -8,7 +8,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -116,7 +115,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account := accountOf(c.Source); account != "" { if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -135,45 +134,33 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expiry.Expired: case expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiry.Soon: case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left)) line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
} }
// starredFirst moves starred items to the front, keeping the order // starredContainersFirst moves starred vaults and scopes to the front,
// otherwise. It doesn't modify items, which may be cached. // keeping the order otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T { func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
rank := func(x T) int {
if starred(x) {
return 0
}
return 1
}
out := slices.Clone(items) out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) }) slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out return out
} }
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source { // starredFirst moves starred objects to the front, keeping the order
return starredFirst(sources, func(s provider.Source) bool { // otherwise. It doesn't modify items, which may be cached.
return m.store.Node(sourceKey(s)).StarredFor(s.Provider) func (m Model) starredFirst(items []provider.Object) []provider.Object {
}) out := slices.Clone(items)
} slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
return starredFirst(items, func(c provider.Container) bool {
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
})
}
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
return starredFirst(items, func(o provider.Object) bool {
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
}) })
return out
} }
// showLinkedContainers lists the vaults opened from links. // showLinkedContainers lists the vaults opened from links.
@@ -211,7 +198,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
} }
if m.versionsOf == nil { if m.versionsOf == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items())) m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
} }
} }
@@ -257,7 +244,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil { if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items())) m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
} }
} }
switch { switch {
+1 -2
View File
@@ -14,7 +14,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
// jump opens the favourites list, filters it and jumps to the first match. // jump opens the favourites list, filters it and jumps to the first match.
@@ -430,7 +429,7 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansi.Strip(o.label)) labels = append(labels, ansiStrip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) t.Errorf("favourites should list alice's star only: %q", labels)
+4 -5
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -62,7 +61,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -135,7 +134,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value))) add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -159,7 +158,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
@@ -222,5 +221,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmp.Or(f.Label, f.Value) return cmpOr(f.Label, f.Value)
} }
+18 -9
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -17,7 +16,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindingsFor). // of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -39,9 +38,10 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// reveal, when set, is what the reveal key will do, "show" or "hide": // revealed, when set, says whether the selected value is shown, so
// the same width so the bar doesn't shift. // the reveal key says just what it'll do: show or hide, the same
reveal string // width so the bar doesn't shift.
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -69,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
reveal := k.Reveal reveal := k.Reveal
if c.reveal != "" { if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal)) reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
} }
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter) b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
} }
@@ -84,6 +84,11 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"} var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys // selected one. Opened from the panes, it lists the focused pane's keys
@@ -149,7 +154,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom") label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }}) opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
} }
} }
@@ -258,7 +263,11 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding. // move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint { func move(up, down key.Binding) keyHint {
return keyHint{joinKeys(up, down), "move"} u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
} }
// withKey fills a hint's format with the binding's key ("· %s loads // withKey fills a hint's format with the binding's key ("· %s loads
+12 -1
View File
@@ -199,7 +199,18 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil return nil
} }
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" }) var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) { switch len(choices) {
case 0: case 0:
return m.openRef(r, actGo) return m.openRef(r, actGo)
+8 -3
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") { if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") { if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
@@ -225,6 +225,11 @@ func TestKeyPress(t *testing.T) {
} }
} }
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar) t.Errorf("help line %q", bar)
} }
} }
+19 -24
View File
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -209,7 +209,18 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account // openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup. // when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) { func (m *Model) openSource(r ref.Ref) {
choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) }) var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
if row, ok := preferredRow(r, choices); ok { if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}} choices = []choice{{sourceRow: row}}
} }
@@ -231,24 +242,6 @@ func (m *Model) openSource(r ref.Ref) {
} }
} }
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the // refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod / // provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The // kv-app-prod / api-key", with the type and version after it. The
@@ -440,7 +433,7 @@ func (m *Model) handleResolved(msg resolvedMsg) {
// (ref.Ref.Account), if it's one of them. // (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) { func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices { for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account { if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true return c.sourceRow, true
} }
} }
@@ -693,7 +686,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}} opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -721,7 +714,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
{key: "o", label: "Lazysecrets link", run: share("o")}, option{key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -837,8 +830,10 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() tea.Cmd { func (m *Model) startOpenPrompt() tea.Cmd {
in := newTextInput("open: ", m.pathInputWidth()) in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{ m.prompt = &modal{
title: "Open a link", input: &in, title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
+3 -1
View File
@@ -75,8 +75,10 @@ type modal struct {
// showFilterMenu opens a menu with a filter input above the options. // showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd { func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := newTextInput("/", 40) in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter" in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in} m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0) m.prompt.move(0)
m.flash = flash{} m.flash = flash{}
+4 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -57,9 +58,11 @@ func (m *Model) editNote() tea.Cmd {
c := o.Container c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
} }
in := newTextInput("note: ", 48) in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin" in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80 in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note) in.SetValue(m.store.Node(key).Note)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
+7 -5
View File
@@ -77,15 +77,17 @@ func TestNotesOnRows(t *testing.T) {
func TestWithNoteGivesWay(t *testing.T) { func TestWithNoteGivesWay(t *testing.T) {
t.Parallel() t.Parallel()
st := styles.Default() st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(ansi.Strip(got))) != 30 || if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(ansi.Strip(got), "prod SQL admin") { !strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", ansi.Strip(got)) t.Errorf("fits: %q", stripANSI(got))
} }
if got := ansi.Strip(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") || if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") { !strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got) t.Errorf("tight: %q", got)
} }
if got := ansi.Strip(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" { if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got) t.Errorf("no room: %q", got)
} }
} }
func stripANSI(s string) string { return ansi.Strip(s) }
+15 -18
View File
@@ -29,43 +29,41 @@ type persistTickMsg struct{}
type persistedMsg struct{ err error } type persistedMsg struct{ err error }
// keepers report whether a provider ID, or a cache key's, is an account // keepCached reports whether a cache key's account still exists and
// that still exists and isn't a repo. // isn't a repo: every key starts with the provider ID.
func (m *Model) keepers() (keep, keepKey func(string) bool) { func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
keep = cachefile.Keep(m.providers)
return keep, func(key string) bool { return keep(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 // loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more. // old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() { func (m *Model) loadCacheFile() {
f := cachefile.Read(m.cachePath) f := cachefile.Read(m.cachePath)
keep, keepKey := m.keepers()
now := m.now() now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge } recent := func(at time.Time) bool { return now.Sub(at) < cachefile.MaxAge }
for id, e := range f.Sources { for id, e := range f.Sources {
if keep(id) && recent(e.At) { if m.keepProvider(id) && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value} m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At m.sourcesAt[id] = e.At
} }
} }
for k, e := range f.Containers { for k, e := range f.Containers {
if keepKey(k) && recent(e.At) { if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At} m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
} }
} }
for k, e := range f.Objects { for k, e := range f.Objects {
if keepKey(k) && recent(e.At) { if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At} m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
} }
} }
for k, e := range f.Details { for k, e := range f.Details {
if keepKey(k) && recent(e.At) { if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At} m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
} }
} }
for k, v := range f.Cursor { for k, v := range f.Cursor {
if keepKey(k) { if m.keepCached(k) {
m.cache.cursor[k] = v m.cache.cursor[k] = v
} }
} }
@@ -74,30 +72,29 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it. // cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File { func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New() f := cachefile.New()
keep, keepKey := m.keepers()
f.Dropped, m.cacheDropped = m.cacheDropped, nil f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults { for id, r := range m.sourceResults {
if r.err == nil && keep(id) { if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources} f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
} }
} }
for k, e := range m.cache.containers { for k, e := range m.cache.containers {
if keepKey(k) { if m.keepCached(k) {
f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value} f.Containers[k] = cachefile.Stamped[[]provider.Container]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.objects { for k, e := range m.cache.objects {
if keepKey(k) { if m.keepCached(k) {
f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value} f.Objects[k] = cachefile.Stamped[[]provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, e := range m.cache.details { for k, e := range m.cache.details {
if keepKey(k) { if m.keepCached(k) {
f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value} f.Details[k] = cachefile.Stamped[provider.Object]{At: e.at, Value: e.value}
} }
} }
for k, v := range m.cache.cursor { for k, v := range m.cache.cursor {
if keepKey(k) { if m.keepCached(k) {
f.Cursor[k] = v f.Cursor[k] = v
} }
} }
+2 -3
View File
@@ -16,7 +16,6 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
const sopsFile = `db: const sopsFile = `db:
@@ -226,9 +225,9 @@ func TestSearchAllRepos(t *testing.T) {
var got []string var got []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo { if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansi.Strip(o.label)) t.Errorf("not a repo: %s", ansiStrip(o.label))
} }
got = append(got, ansi.Strip(o.label)) got = append(got, ansiStrip(o.label))
} }
if len(got) != 2 { if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got) t.Errorf("want db.password in both repos: %v", got)
+7 -16
View File
@@ -28,22 +28,12 @@ func (m *Model) startRevealTimer() tea.Cmd {
// revealTickCmd fires when the displayed second next changes. // revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd { func (m *Model) revealTickCmd() tea.Cmd {
token := m.revealToken wait := m.revealDeadline.Sub(m.now()) % time.Second
return tea.Tick(untilNextSecond(m.revealDeadline, m.now()), func(time.Time) tea.Msg { return revealTickMsg{token} }) if wait <= 0 {
} wait = time.Second
// untilNextSecond is how long until a countdown to deadline shows its
// next whole second.
func untilNextSecond(deadline, now time.Time) time.Duration {
if wait := deadline.Sub(now) % time.Second; wait > 0 {
return wait
} }
return time.Second token := m.revealToken
} return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
// secondsLeft is a countdown to deadline, in whole seconds rounded up.
func secondsLeft(deadline, now time.Time) int {
return max(0, int(math.Ceil(deadline.Sub(now).Seconds())))
} }
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd { func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
@@ -59,7 +49,8 @@ func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
} }
func (m *Model) showHidesIn() { func (m *Model) showHidesIn() {
m.detail.SetHidesIn(fmt.Sprintf("%ds", secondsLeft(m.revealDeadline, m.now()))) left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
} }
// hideValue hides the revealed value and stops its countdown. // hideValue hides the revealed value and stops its countdown.
+31 -21
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -45,6 +46,10 @@ type crawlMsg struct {
err error err error
} }
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of // searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with // each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group. // its source's row key as group.
@@ -62,7 +67,7 @@ func (m *Model) searchEntries() (sources, items []option) {
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort, es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key, option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)), starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
@@ -129,6 +134,8 @@ func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
} }
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is // providerFilter picks the providers a search or a load covers; nil is
// all of them. // all of them.
type providerFilter func(provider.Provider) bool type providerFilter func(provider.Provider) bool
@@ -141,7 +148,7 @@ func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id } return func(p provider.Provider) bool { return p.ID() == id }
} }
// coverageOf is the coverage of the providers in. // coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage { func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage var c coverage
for _, p := range m.providers { for _, p := range m.providers {
@@ -176,7 +183,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
// searchLines says what the search covers. // searchLines says what the search covers.
func (m *Model) searchLines() []string { func (m *Model) searchLines() []string {
c := m.coverageOf(nil) c := m.searchCoverage()
switch { switch {
case m.crawl.running: case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.", return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
@@ -203,8 +210,11 @@ func plural(n int, word string) string {
return fmt.Sprintf("%d %ss", n, word) return fmt.Sprintf("%d %ss", n, word)
} }
// startCrawlOf loads, for the providers in, every unlisted subscription's // startCrawl loads every unlisted subscription's vaults and every vault's
// vaults and every vault's secrets not loaded yet. // secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd { func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers { for _, p := range m.providers {
if in != nil && !in(p) { if in != nil && !in(p) {
@@ -291,7 +301,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
src := *job.src src := *job.src
timeout := m.timeout.of(src.Kind) timeout := m.timeout.of(src.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
for i := range items { for i := range items {
@@ -304,7 +314,7 @@ func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
p := m.providerFor(c.Source) p := m.providerFor(c.Source)
timeout := m.timeout.of(c.Source.Kind) timeout := m.timeout.of(c.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := contextWithTimeout(timeout)
defer cancel() defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret) items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items { for i := range items {
@@ -341,19 +351,6 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
return cmd return cmd
} }
// vaultsLoaded counts the vaults whose secrets are cached or can't be
// read, and says whether any are loading.
func (m *Model) vaultsLoaded(vaults []provider.Container) (loaded int, loading bool) {
for _, c := range vaults {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
return loaded, loading
}
// sourceMark shows how much of a subscription or workspace is loaded: // sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable. // ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string { func (m *Model) sourceMark(row string) string {
@@ -367,7 +364,14 @@ func (m *Model) sourceMark(row string) string {
if !ok { if !ok {
return m.st.Muted.Render("○") return m.st.Muted.Render("○")
} }
loaded, loading := m.vaultsLoaded(e.value) loaded, loading := 0, false
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch { switch {
case loading: case loading:
return m.st.Muted.Render("◌") return m.st.Muted.Render("◌")
@@ -392,3 +396,9 @@ func (m *Model) vaultMark(c provider.Container) string {
} }
return " " return " "
} }
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+20 -19
View File
@@ -6,7 +6,6 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
) )
@@ -116,19 +115,19 @@ func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Fatal("platform-dev not in sources") t.Fatal("platform-dev not in sources")
} }
} }
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "○") { if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
if len(d.m.search.shown) != 0 { if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown)) t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
} }
d.send(ctrl('r')) d.send(ctrl('r'))
if !strings.Contains(ansi.Strip(d.m.search.scopes[d.m.search.src].mark), "●") { if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark) t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
} }
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
if !strings.Contains(ansi.Strip(o.label), "platform-dev") { if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansi.Strip(o.label)) t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
} }
} }
if len(d.m.search.shown) == 0 { if len(d.m.search.shown) == 0 {
@@ -178,17 +177,19 @@ func TestStarFromSearch(t *testing.T) {
} }
} }
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) { func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch() d.openSearch()
v := d.m.search v := d.m.search
scope := func() string { return ansi.Strip(v.scopes[v.src].label) } scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" { if scope() != "All" {
t.Errorf("opened on %q, want All", scope()) t.Errorf("opened on %q, want All", scope())
} }
if v.res >= len(v.shown) || !strings.Contains(ansi.Strip(v.shown[v.res].label), "kv-shared-prod") { if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen()) t.Errorf("vault not under the cursor:\n%s", d.screen())
} }
d.send(ctrl('s')) // narrows to the source, and back d.send(ctrl('s')) // narrows to the source, and back
@@ -232,13 +233,13 @@ func TestSearchNarrowsToAccount(t *testing.T) {
t.Errorf("no results in the account:\n%s", d.screen()) t.Errorf("no results in the account:\n%s", d.screen())
} }
for _, o := range v.shown { for _, o := range v.shown {
if cachefile.ProviderOf(o.group) != header.provider { if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansi.Strip(o.label)) t.Errorf("result from another account: %q", ansiStrip(o.label))
} }
} }
// The account's sources are under it, indented. // The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || cachefile.ProviderOf(next.group) != header.provider || !strings.HasPrefix(ansi.Strip(next.label), " ") { if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansi.Strip(next.label)) t.Errorf("after the account: %q", ansiStrip(next.label))
} }
} }
@@ -326,7 +327,7 @@ func TestSearchHighlightsMatches(t *testing.T) {
t.Fatal("no results") t.Fatal("no results")
} }
o := d.m.search.shown[0] o := d.m.search.shown[0]
plain := []rune(ansi.Strip(o.label)) plain := []rune(ansiStrip(o.label))
var got string var got string
for _, h := range o.hits { for _, h := range o.hits {
got += string(plain[h]) got += string(plain[h])
@@ -347,14 +348,14 @@ func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("F") d.keys("F")
bar := lastLines(d.screen(), 1) bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} { for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) { if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want) t.Errorf("browsing: help bar %q lacks %q", bar, want)
} }
} }
d.keys("/") d.keys("/")
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") { if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar) t.Errorf("typing: help bar %q", bar)
} }
} }
@@ -374,8 +375,8 @@ func TestHelpFromPopups(t *testing.T) {
if o.header { if o.header {
sections = append(sections, o.label) sections = append(sections, o.label)
} }
if strings.Contains(ansi.Strip(o.label), "copy value") { if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansi.Strip(o.label)) t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
} }
} }
if strings.Join(sections, ",") != "Go to,While typing, after /" { if strings.Join(sections, ",") != "Go to,While typing, after /" {
@@ -427,7 +428,7 @@ func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc") d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLines(d.screen(), 1)) bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") { if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar) t.Errorf("help bar %q", bar)
} }
@@ -442,11 +443,11 @@ func TestLoadWithEmptyResults(t *testing.T) {
for d.m.search.src > 0 { for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) d.send(tea.KeyPressMsg{Code: tea.KeyUp})
} }
for i := 0; !strings.Contains(ansi.Strip(d.m.search.scope().label), "platform-dev"); i++ { for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) { if i > len(d.m.search.scopes) {
var labels []string var labels []string
for _, o := range d.m.search.scopes { for _, o := range d.m.search.scopes {
labels = append(labels, ansi.Strip(o.label)) labels = append(labels, ansiStrip(o.label))
} }
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results) t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
} }
+31 -7
View File
@@ -95,7 +95,9 @@ func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) } func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd { func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := newTextInput("/", searchWidth(m.w)-6) // as wide as the popup in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true} v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries() v.favs, v.recent = m.storeEntries()
m.search = v m.search = v
@@ -201,7 +203,7 @@ func (m *Model) fillSearch(keep bool) {
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())), id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
}) })
for _, o := range sources { for _, o := range sources {
if cachefile.ProviderOf(o.group) == p.ID() { if providerOf(o.group) == p.ID() {
o.label = " " + o.label o.label = " " + o.label
scopes = append(scopes, o) scopes = append(scopes, o)
} }
@@ -265,7 +267,7 @@ func (v *searchView) match() []option {
default: default:
account, ofAccount := v.scopeAccount() account, ofAccount := v.scopeAccount()
for _, o := range v.items { for _, o := range v.items {
if sc.id == allSources || ofAccount && cachefile.ProviderOf(o.group) == account || sc.group != "" && o.group == sc.group { if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o) cand = append(cand, o)
} }
} }
@@ -521,7 +523,7 @@ func (m *Model) searchLoads() tea.Cmd {
return m.startCrawlOf(v.scopeFilter()) return m.startCrawlOf(v.scopeFilter())
} }
if !v.onSource() { if !v.onSource() {
return m.startCrawlOf(nil) return m.startCrawl()
} }
providerID, sourceID := splitSourceRow(sc.group) providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources { for _, src := range m.sourceResults[providerID].sources {
@@ -614,7 +616,13 @@ func (m Model) loadHint() string {
} }
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name)) return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
} }
loaded, _ := m.vaultsLoaded(e.value) loaded := 0
for _, c := range e.value {
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) { if loaded == len(e.value) {
return "" return ""
} }
@@ -762,7 +770,7 @@ func (m Model) searchViewBox() string {
body = append(body, " "+m.st.Error.Render(l)) body = append(body, " "+m.st.Error.Render(l))
} }
} }
body = append(body, "", " "+panes.Tabs(m.st, ts, tabNames[:], m.search.tab)) body = append(body, "", " "+m.goToTabs(ts))
} }
body = append(body, " "+v.input.View(), "") body = append(body, " "+v.input.View(), "")
for _, l := range strings.Split(lists, "\n") { for _, l := range strings.Split(lists, "\n") {
@@ -795,7 +803,7 @@ func (m Model) searchViewBox() string {
// Tabs in the title, the current one underlined, like the panes; a // Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input. // picker has its own title, and the tabs over the input.
if v.picker == nil { if v.picker == nil {
head := ts.Render(goToTitle+" ") + panes.Tabs(m.st, ts, tabNames[:], m.search.tab) head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// A picker: in its own border, under what it's for. // A picker: in its own border, under what it's for.
@@ -848,3 +856,19 @@ func pickable(o option) (ref.Ref, bool) {
} }
return r, true return r, true
} }
// goToTabs draws Go to's tabs, the current one underlined.
func (m Model) goToTabs(ts lipgloss.Style) string {
var out string
for i, name := range tabNames {
if i > 0 {
out += ts.Render(" - ")
}
if i == m.search.tab {
out += ts.Underline(true).Render(name)
} else {
out += m.st.Muted.Render(name)
}
}
return out
}
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -337,7 +338,9 @@ func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
// askShareDir asks where to save files from a repo that isn't here. It // askShareDir asks where to save files from a repo that isn't here. It
// stays until they're saved, saying why when they can't be. // stays until they're saved, saying why when they can't be.
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd { func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
in := newTextInput("folder: ", m.pathInputWidth()) in := textinput.New()
in.Prompt = "folder: "
in.SetWidth(max(20, min(m.w-12, 64)))
dir := m.placeDir dir := m.placeDir
if dir == "" { if dir == "" {
dir, _ = os.Getwd() dir, _ = os.Getwd()
+22 -1
View File
@@ -134,7 +134,10 @@ func (m *Model) rebuildSources() {
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()}) account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id] r, done := m.sourceResults[id]
sources := r.sources sources := r.sources
if !flat { if !flat {
@@ -212,6 +215,17 @@ func (m Model) currentSource() (provider.Source, bool) {
return provider.Source{}, false return provider.Source{}, false
} }
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest. // starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int { func starRank(mem *store.Store, r sourceRow) int {
switch { switch {
@@ -225,6 +239,13 @@ func starRank(mem *store.Store, r sourceRow) int {
return 3 return 3
} }
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header. // selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) { func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected() r, ok := m.sources.Selected()
+2 -3
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -84,7 +83,7 @@ func (m Model) statusFields() []panes.Field {
} }
} }
// A release's rows open its release page. // A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmp.Or(m.versionLabel(), "dev")} version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") { if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
} }
@@ -160,7 +159,7 @@ func (m Model) accountStatus(p provider.Provider) string {
return r.err.Error() return r.err.Error()
} }
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()] noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmp.Or(noun, "source")) return plural(len(r.sources), cmpOr(noun, "source"))
} }
// ago is a duration as people say it: "just now", "5m ago", "2h ago". // ago is a duration as people say it: "just now", "5m ago", "2h ago".
+11 -1
View File
@@ -2,11 +2,13 @@ package app
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -128,7 +130,15 @@ func (m *Model) showUpdate() {
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd { opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
return m.openPage("the release page", r.URL, func() {}) switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}}) }})
} }
m.showMenu("lazysecrets "+r.Tag, lines, opts) m.showMenu("lazysecrets "+r.Tag, lines, opts)
+2 -3
View File
@@ -5,7 +5,6 @@ import (
"testing" "testing"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
"github.com/charmbracelet/x/ansi"
) )
func TestRecentScope(t *testing.T) { func TestRecentScope(t *testing.T) {
@@ -22,7 +21,7 @@ func TestRecentScope(t *testing.T) {
d.keys("R") d.keys("R")
var recent []string var recent []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
recent = append(recent, ansi.Strip(o.label)) recent = append(recent, ansiStrip(o.label))
} }
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") { if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent) t.Fatalf("want newest first: %q", recent)
@@ -32,7 +31,7 @@ func TestRecentScope(t *testing.T) {
t.Errorf("jumped to %q", s) t.Errorf("jumped to %q", s)
} }
d.keys("F") d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansi.Strip(d.m.search.shown[0].label), "kv-shared-prod") { if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen()) t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
} }
} }
+14 -5
View File
@@ -15,8 +15,8 @@ package keys
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"sort"
"strings" "strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
@@ -266,11 +266,11 @@ func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName[a.Context+"."+a.Name] = a byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true contexts[a.Context] = true
} }
for _, ctx := range slices.Sorted(maps.Keys(cfg)) { for _, ctx := range sortedKeys(cfg) {
if !contexts[ctx] { if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(slices.Sorted(maps.Keys(contexts)), ", ")) return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
} }
for _, name := range slices.Sorted(maps.Keys(cfg[ctx])) { for _, name := range sortedKeys(cfg[ctx]) {
a, ok := byName[ctx+"."+name] a, ok := byName[ctx+"."+name]
if !ok { if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", ")) return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
@@ -289,7 +289,16 @@ func (m *Map) names(ctx string) []string {
out = append(out, a.Name) out = append(out, a.Name)
} }
} }
slices.Sort(out) sort.Strings(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out return out
} }
-17
View File
@@ -69,20 +69,3 @@ func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
} }
return st.TitleBlurred return st.TitleBlurred
} }
// Tabs draws tab names for a title in style ts, the current one
// underlined and the rest muted.
func Tabs(st styles.Styles, ts lipgloss.Style, names []string, current int) string {
var out string
for i, name := range names {
if i > 0 {
out += ts.Render(" - ")
}
if i == current {
out += ts.Underline(true).Render(name)
} else {
out += st.Muted.Render(name)
}
}
return out
}
+31 -27
View File
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
d.container = &s.Container d.container = &s.Container
} }
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// ShowContainer shows a container summary when no secret is selected. // ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) { func (d *Detail) ShowContainer(c provider.Container) {
d.Clear() d.Clear()
@@ -188,31 +198,20 @@ func carryStyle(lines []string) []string {
return lines return lines
} }
var valueIndent = strings.Repeat(" ", 12)
// fieldLabel starts a detail line: the label, padded so values line up
// at valueIndent.
func fieldLabel(st styles.Styles, label string) string {
return " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
}
// lines renders the body, wrapped to the pane width. // lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string { func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3) inner := max(1, d.w-3)
var out []string var out []string
field := func(label, value string) { field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")) wrapped := carryStyle(strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n"))
out = append(out, fieldLabel(st, label)+wrapped[0]) out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] { for _, w := range wrapped[1:] {
out = append(out, valueIndent+w) out = append(out, indent+w)
} }
} }
muted := st.Muted.Render muted := st.Muted.Render
note := func() {
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
}
if d.container == nil { if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")} return []string{muted(" Select a vault or scope to browse its secrets.")}
@@ -222,27 +221,30 @@ func (d Detail) lines(st styles.Styles) []string {
if containerLabel == "" { if containerLabel == "" {
containerLabel = "vault" containerLabel = "vault"
} }
source := d.Icons.Prefix(c.Source.Kind) + cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind)) + " / " + c.Source.Name
if d.secret == nil { if d.secret == nil {
field(containerLabel, c.Name) field(containerLabel, c.Name)
note() if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" { if c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", source) field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details.")) out = append(out, "", muted(" Select a secret to see its details."))
return out return out
} }
s := *d.secret s := *d.secret
field("name", s.Name) field("name", s.Name)
note() if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name) field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" { if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail)) field("", muted(c.Detail))
} }
field("source", source) field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" { if s.Version != "" {
field("version", s.Version) field("version", s.Version)
} }
@@ -306,12 +308,14 @@ func (d Detail) lines(st styles.Styles) []string {
case provider.TypeCertificate: case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate" label, hidden, reveal = "cert", "", "show the certificate"
} }
indent := strings.Repeat(" ", 12)
out = append(out, "") out = append(out, "")
switch d.state { switch d.state {
case ValueHidden: case ValueHidden:
if hidden != "" { if hidden != "" {
field(label, hidden) field(label, hidden)
out = append(out, valueIndent+hints(reveal)) out = append(out, indent+hints(reveal))
} else { } else {
field(label, hints(reveal)) field(label, hints(reveal))
} }
@@ -328,9 +332,9 @@ func (d Detail) lines(st styles.Styles) []string {
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") { for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w w = lead + w
if i == 0 && j == 0 { if i == 0 && j == 0 {
out = append(out, fieldLabel(st, label)+st.Error.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else { } else {
out = append(out, valueIndent+st.Error.Render(w)) out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
} }
} }
} }
@@ -341,10 +345,10 @@ func (d Detail) lines(st styles.Styles) []string {
for _, line := range lines { for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first { if first {
out = append(out, fieldLabel(st, label)+st.Value.Render(w)) out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false first = false
} else { } else {
out = append(out, valueIndent+st.Value.Render(w)) out = append(out, indent+st.Value.Render(w))
} }
} }
} }
@@ -358,7 +362,7 @@ func (d Detail) lines(st styles.Styles) []string {
if d.hidesIn != "" { if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn) more = append(more, "hides in "+d.hidesIn)
} }
out = append(out, valueIndent+hints("hide", more...)) out = append(out, indent+hints("hide", more...))
} }
return append(out, d.versionLines(st)...) return append(out, d.versionLines(st)...)
} }
@@ -379,7 +383,7 @@ func (d Detail) versionLines(st styles.Styles) []string {
head += muted(" · " + d.VersionsKey + " to browse") head += muted(" · " + d.VersionsKey + " to browse")
} }
} }
out := []string{"", fieldLabel(st, "versions") + head} out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version current := d.versions[0].Version
for i, v := range d.versions { for i, v := range d.versions {
if i == maxVersionLines { if i == maxVersionLines {
+35 -3
View File
@@ -301,6 +301,15 @@ func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Rang
return out return out
} }
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text: // highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row. // the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim. // It returns nil if the row doesn't contain the Filter text verbatim.
@@ -329,7 +338,17 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
ts := TitleStyle(st, focused) ts := TitleStyle(st, focused)
title := ts.Render(l.Title) title := ts.Render(l.Title)
if len(l.Tabs) > 1 { if len(l.Tabs) > 1 {
title += ts.Render(" ") + Tabs(st, ts, l.Tabs, l.Tab) for i, tab := range l.Tabs {
sep := " - "
if i == 0 {
sep = " "
}
if i == l.Tab {
title += ts.Render(sep) + ts.Underline(true).Render(tab)
} else {
title += ts.Render(sep) + st.Muted.Render(tab)
}
}
} }
if q := l.input.Value(); q != "" && !l.filtering { if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q) title += ts.Render(" /" + q)
@@ -360,13 +379,26 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
default: default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ { for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
line := l.item.Render(st, l.items[l.visible[i]], inner-1) line := l.item.Render(st, l.items[l.visible[i]], inner-1)
line = " " + MarkMatches(line, l.highlight(i, ansi.Strip(line)), st.Match) plain := ansi.Strip(line)
hits := l.highlight(i, plain)
if i == l.cursor { if i == l.cursor {
base := st.SelectedBlurred base := st.SelectedBlurred
if focused { if focused {
base = st.Selected base = st.Selected
} }
line = Highlight(ansi.Truncate(line, inner, "…"), base, inner) text := " " + line
if len(hits) > 0 {
for j := range hits {
hits[j]++ // leading space
}
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
}
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
} else if len(hits) > 0 {
// Style just the matches, keeping the row's own colours.
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
} else {
line = " " + line
} }
body = append(body, line) body = append(body, line)
} }
+7 -2
View File
@@ -3,8 +3,8 @@ package styles
import ( import (
"fmt" "fmt"
"image/color" "image/color"
"maps"
"slices" "slices"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -51,7 +51,12 @@ var Roles = map[string]string{
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
for _, role := range slices.Sorted(maps.Keys(theme)) { roles := make([]string, 0, len(theme))
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok { if _, ok := Roles[role]; !ok {
continue continue
} }
+6 -6
View File
@@ -45,9 +45,9 @@ const (
// DefaultServers are where releases are looked for, in order. // DefaultServers are where releases are looked for, in order.
var DefaultServers = []string{GitHubRepo, DefaultRepo} var DefaultServers = []string{GitHubRepo, DefaultRepo}
// serverTimeout bounds asking one server for the latest release or its // latestTimeout bounds asking one server for the latest release, so a
// checksums, so a server that's down leaves time for the next. // server that's down leaves time for the next.
const serverTimeout = 5 * time.Second const latestTimeout = 5 * time.Second
// maxDownload bounds what's read from the server. // maxDownload bounds what's read from the server.
const maxDownload = 200 << 20 const maxDownload = 200 << 20
@@ -151,7 +151,7 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
ctx, cancel := context.WithTimeout(ctx, serverTimeout) ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel() defer cancel()
// The recent releases in one request (per_page on GitHub, limit on // The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs. // Gitea), for their changelogs.
@@ -220,7 +220,7 @@ func (c *Client) Install(ctx context.Context, r Release, exe string) error {
if m := Managed(exe); m != "" { if m := Managed(exe); m != "" {
return fmt.Errorf("%s is managed by %s: update it there", exe, m) return fmt.Errorf("%s is managed by %s: update it there", exe, m)
} }
if (runtime.GOOS != "darwin" && runtime.GOOS != "linux") || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") { if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH) return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
} }
version := strings.TrimPrefix(r.Tag, "v") version := strings.TrimPrefix(r.Tag, "v")
@@ -270,7 +270,7 @@ func (c *Client) download(ctx context.Context, tag, file string) ([]byte, error)
var errs []error var errs []error
for _, repo := range c.servers() { for _, repo := range c.servers() {
base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/" base := strings.TrimSuffix(repo, "/") + "/releases/download/" + tag + "/"
sctx, cancel := context.WithTimeout(ctx, serverTimeout) sctx, cancel := context.WithTimeout(ctx, latestTimeout)
sums, err := c.get(sctx, base+"checksums.txt", 1<<20) sums, err := c.get(sctx, base+"checksums.txt", 1<<20)
cancel() cancel()
if err != nil { if err != nil {