feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers #70

Merged
keb merged 45 commits from feat/list into main 2026-10-14 21:30:44 +02:00
9 changed files with 118 additions and 43 deletions
Showing only changes of commit b05d19209c - Show all commits

No files matched your search

+7 -2
View File
@@ -37,10 +37,15 @@ func value(ctx context.Context, providers []provider.Provider, st *store.Store,
return v, err
}
// readKey is value, also returning the version read.
// 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) {
key := r
key.Version = ""
node := st.Node(key.String())
return ref.Read(ctx, providers, r, node.Account, node.Source)
account, source := node.Account, node.Source
if r.Account != "" && r.Account != account {
account, source = r.Account, ""
}
return ref.Read(ctx, providers, r, account, source)
}
+8 -8
View File
@@ -26,11 +26,11 @@ import (
// listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts:
//
// lazysecrets list | fzf --ansi --with-nth='2..-2' --accept-nth=-1
// lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1
//
// Each line is the kind, the name and the link, tab-separated (the kind
// a word, the link without spaces, so even fields split on spaces keep
// them first and last): the name coloured from the theme,
// Each line is the kind, the name, the account and the link,
// tab-separated (all but the name one word each, so even fields split on
// spaces keep them first and last): the name coloured from the theme,
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
// ui.icons); the link never is.
//
@@ -42,7 +42,7 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
format := fs.String("format", "text", "text: kind, name and link, tab-separated; json: an object per line")
format := fs.String("format", "text", "text: kind, name, account and link, tab-separated; json: an object per line")
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
@@ -75,7 +75,7 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi
case "text":
write = func(e listing.Entry) error {
// A tab or line break in a name would break the line up.
_, err := fmt.Fprintf(out, "%s\t%s\t%s\n", e.Kind, oneLine.Replace(nameOf(e, theme, iconSet, *color == "always")), e.Link)
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\n", e.Kind, oneLine.Replace(nameOf(e, theme, iconSet, *color == "always")), e.Account, e.Link)
return err
}
case "json":
@@ -150,8 +150,8 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
}
name += e.Path[n-1]
}
if e.Account != "" {
name += render(theme.Muted, " · "+e.Account)
if e.AccountLabel != "" {
name += render(theme.Muted, " · "+e.AccountLabel)
}
if e.Error != "" {
name += " " + render(theme.Error, "✗ "+e.Error)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
func TestListNames(t *testing.T) {
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, Account: "alice"}
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
if got := nameOf(e, styles.Default(), icons.None, false); got != "platform-prod / kv-app-prod / db-password · alice" {
t.Errorf("plain: %q", got)
}
+23 -6
View File
@@ -89,6 +89,22 @@ func cmpOr(a, b string) string {
return b
}
// refArg parses get's and open's arguments: a reference, and optionally
// the account to use when several could (lazysecrets list's column).
func refArg(cmd string, args []string) (ref.Ref, error) {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
if err := fs.Parse(args); err != nil {
return ref.Ref{}, err
}
if fs.NArg() != 1 {
return ref.Ref{}, fmt.Errorf("usage: lazysecrets %s [--account <account>] <ref>", cmd)
}
r, err := ref.Parse(fs.Arg(0))
r.Account = *account
return r, err
}
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory.
func defaultConfigPath() string {
@@ -115,11 +131,12 @@ func run() error {
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref>
lazysecrets [flags] [open [--account <account>]] <ref>
open a lazysecrets:// link (also a share link),
Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}}
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
lazysecrets [flags] get [--account <account>] <ref>
print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>)
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
[--color always|never] [--icons=false] [--errors=false]
@@ -163,8 +180,8 @@ Flags:
listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings":
printKeys = true
case len(args) == 2 && args[0] == "get":
r, err := ref.Parse(args[1])
case len(args) > 0 && args[0] == "get":
r, err := refArg("get", args[1:])
if err != nil {
return err
}
@@ -176,8 +193,8 @@ Flags:
case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:])
case len(args) == 0:
case args[0] == "open" && len(args) == 2:
r, err := ref.Parse(args[1])
case args[0] == "open" && len(args) >= 2:
r, err := refArg("open", args[1:])
if err != nil {
return err
}
+33 -21
View File
@@ -138,26 +138,39 @@ a newline only on a terminal.
### Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line:
its kind (`source`, `vault`, `secret`, `key` or `certificate`), its name
(`platform-prod / kv-app-prod / db-password`) and its link,
tab-separated. It's what Go to searches, so fzf, or an editor's picker,
can search it instead, showing the names and acting on the links:
`lazysecrets list` prints every source, vault and secret, one per line,
tab-separated:
| Field | |
|---|---|
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
| name | `platform-prod / kv-app-prod / db-password` |
| account | what it's listed through: an az account (`Azure:<user>/<tenant>`), `Databricks` or `Repos` |
| link | its `lazysecrets://` link |
It's what Go to searches, so fzf, or an editor's picker, can search it
instead, showing the names and acting on the links:
```sh
lazysecrets list | fzf --ansi --with-nth='2..-2' --accept-nth=-1 \
--bind 'ctrl-o:become(lazysecrets open {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get {-1} | pbcopy)'
lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \
--bind 'ctrl-o:become(lazysecrets open --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)'
```
`--with-nth='2..-2'` shows and searches the names, leaving out the kind,
the first field, and the link, the last; `{-1}` and `--accept-nth=-1` are
the link. The kind is one word and the link has no spaces, so this works
without `--delimiter`. To filter by kind yourself: `awk -F'\t' '$1 == "secret"'`. `enter` prints
it (`lazysecrets get $(…)`), `ctrl-o` opens it in lazysecrets and `ctrl-y`
copies the value (`pbcopy` on macOS; `wl-copy` or `xclip` on Linux). A
value copied this way isn't cleared from the clipboard after
`values.clipboardClear`, as one copied in lazysecrets is.
`--with-nth='2..-3'` shows and searches only the names; `{-1}` and
`--accept-nth=-1` are the link, `{-2}` the account. All but the name are
one word, so this works without `--delimiter`. To filter by kind
yourself: `awk -F'\t' '$1 == "secret"'`.
`enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
lazysecrets and `ctrl-y` copies the value (`pbcopy` on macOS; `wl-copy` or
`xclip` on Linux). A value copied this way isn't cleared from the
clipboard after `values.clipboardClear`, as one copied in lazysecrets is.
`--account` makes `open` and `get` use the account it was listed through,
when several of yours can see it: without, `open` asks which, and `get`
tries the one that last read it first. The link stays as it is, to copy
and share; the account isn't part of it.
What's [cached](config.md#cache) prints at once. Then, like Go to, it loads
what's missing or older than `--max-age` (2m), and what's new in it follows
@@ -183,15 +196,14 @@ shows the colours with `--ansi`. `--color never` leaves them out, for
scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins,
without colours or icons, with each entry's kind, and `error` on a list
it couldn't read:
without colours or icons, and with `error` on a list it couldn't read:
```json
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"alice"}
{"link":"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password","kind":"secret","provider":"azure","path":["platform-prod","kv-app-prod","db-password"],"account":"Azure:alice@example.com/<tenant>","accountLabel":"alice"}
```
`account` is there when several az accounts are signed in. Values are never
listed.
`accountLabel`, the account's short name, is there when several az
accounts are signed in. Values are never listed.
## From the browser and chat
+10 -5
View File
@@ -33,8 +33,12 @@ type Entry struct {
Provider string `json:"provider"`
// Path names it: its source's, vault's and own names.
Path []string `json:"path"`
// Account is the az account it's seen through, when there are several.
Account string `json:"account,omitempty"`
// Account is the provider it's listed through: an az account
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
// get take it (--account) to use that account when several could.
Account string `json:"account"`
// AccountLabel names the az account, when there are several.
AccountLabel string `json:"accountLabel,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
@@ -186,12 +190,12 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
continue
}
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, Account: accountOf(c.Source)})
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source)})
}
}
func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, Account: accountOf(src)}
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src)}
if r, err := ref.ForSource(src); err == nil {
e.Link = r.String()
}
@@ -199,7 +203,7 @@ func sourceEntry(src provider.Source) Entry {
}
func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, Account: accountOf(c.Source)}
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source)}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String()
}
@@ -212,6 +216,7 @@ func (l *lister) put(p provider.Provider, e Entry) {
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return
}
e.Account = p.ID()
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] {
return
+4
View File
@@ -56,6 +56,10 @@ type Ref struct {
// Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it.
Share string
// Account, when set, is the provider (its ID) to open or read it
// with when several could, from lazysecrets list. It isn't part of
// the link.
Account string
}
// IsShare reports whether r is a share link.
+18
View File
@@ -218,6 +218,9 @@ func (m *Model) openSource(r ref.Ref) {
}
}
}
if row, ok := preferredRow(r, choices); ok {
choices = []choice{{sourceRow: row}}
}
switch len(choices) {
case 0:
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
@@ -410,6 +413,10 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
if row, ok := preferredRow(msg.ref, choices); ok {
m.startLinkNav(msg.ref, msg.act, row)
return
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
case msg.linked != nil:
m.addLinked(*msg.linked)
@@ -419,6 +426,17 @@ func (m *Model) handleResolved(msg resolvedMsg) {
}
}
// preferredRow is the choice under the account the reference asks for
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && providerOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
return "", false
}
// startLinkNav opens a link: like startNav, ending on its object's
// details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
+14
View File
@@ -305,6 +305,20 @@ func TestOpenAsksWhichAccount(t *testing.T) {
t.Errorf("j enter picked %q", c.Source.Provider)
}
// lazysecrets open --account: no question, that account.
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
r.Account = "Azure:bob@example.com/t1"
d.send(OpenLink(r))
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys("o")
}
if strings.Contains(d.screen(), "Several accounts can see") {
t.Fatalf("asked, with an account given:\n%s", d.screen())
}
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
t.Errorf("--account opened via %q", c.Source.Provider)
}
// Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header
d.keys("enter") // fold it