feat: lazysecrets list prints the account, and open and get take it (--account)

This commit is contained in:
keb committed 2026-10-14 21:24:01 +02:00
1 parent 1f330bc619
commit b05d19209c
9 files changed
+118 -43

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 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) { 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 = ""
node := st.Node(key.String()) 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 // listCmd prints every source, vault and secret lazysecrets can find, one
// per line, for fzf, editor pickers and scripts: // 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 // Each line is the kind, the name, the account and the link,
// a word, the link without spaces, so even fields split on spaces keep // tab-separated (all but the name one word each, so even fields split on
// them first and last): the name coloured from the theme, // spaces keep them first and last): the name coloured from the theme,
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS, // with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
// ui.icons); the link never is. // 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) fs := flag.NewFlagSet("list", flag.ContinueOnError)
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks") 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") 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") 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)") 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)") 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": case "text":
write = func(e listing.Entry) error { write = func(e listing.Entry) error {
// A tab or line break in a name would break the line up. // 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 return err
} }
case "json": case "json":
@@ -150,8 +150,8 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool)
} }
name += e.Path[n-1] name += e.Path[n-1]
} }
if e.Account != "" { if e.AccountLabel != "" {
name += render(theme.Muted, " · "+e.Account) name += render(theme.Muted, " · "+e.AccountLabel)
} }
if e.Error != "" { if e.Error != "" {
name += " " + render(theme.Error, "✗ "+e.Error) name += " " + render(theme.Error, "✗ "+e.Error)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
) )
func TestListNames(t *testing.T) { 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" { if got := nameOf(e, styles.Default(), icons.None, false); got != "platform-prod / kv-app-prod / db-password · alice" {
t.Errorf("plain: %q", got) t.Errorf("plain: %q", got)
} }
+23 -6
View File
@@ -89,6 +89,22 @@ func cmpOr(a, b string) string {
return b 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 // defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
// config directory. // config directory.
func defaultConfigPath() string { func defaultConfigPath() string {
@@ -115,11 +131,12 @@ func run() error {
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage: fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref> lazysecrets [flags] [open [--account <account>]] <ref>
open a lazysecrets:// link (also a share link), open a lazysecrets:// link (also a share link),
Key Vault URL, @Microsoft.KeyVault(...) or Key Vault URL, @Microsoft.KeyVault(...) or
{{secrets/scope/key}} {{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>) 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]
@@ -163,8 +180,8 @@ Flags:
listArgs = append([]string{}, args[1:]...) // not nil: listing listArgs = append([]string{}, args[1:]...) // not nil: listing
case len(args) == 1 && args[0] == "keybindings": case len(args) == 1 && args[0] == "keybindings":
printKeys = true printKeys = true
case len(args) == 2 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, err := ref.Parse(args[1]) r, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
@@ -176,8 +193,8 @@ Flags:
case len(args) > 0 && args[0] == "update": case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:]) return updateCmd(*configPath, args[1:])
case len(args) == 0: case len(args) == 0:
case args[0] == "open" && len(args) == 2: case args[0] == "open" && len(args) >= 2:
r, err := ref.Parse(args[1]) r, err := refArg("open", args[1:])
if err != nil { if err != nil {
return err return err
} }
+33 -21
View File
@@ -138,26 +138,39 @@ a newline only on a terminal.
### Listing, for fzf and pickers ### Listing, for fzf and pickers
`lazysecrets list` prints every source, vault and secret, one per line: `lazysecrets list` prints every source, vault and secret, one per line,
its kind (`source`, `vault`, `secret`, `key` or `certificate`), its name tab-separated:
(`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, | Field | |
can search it instead, showing the names and acting on the links: |---|---|
| 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 ```sh
lazysecrets list | fzf --ansi --with-nth='2..-2' --accept-nth=-1 \ lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 \
--bind 'ctrl-o:become(lazysecrets open {-1})' \ --bind 'ctrl-o:become(lazysecrets open --account {-2} {-1})' \
--bind 'ctrl-y:execute-silent(lazysecrets get {-1} | pbcopy)' --bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)'
``` ```
`--with-nth='2..-2'` shows and searches the names, leaving out the kind, `--with-nth='2..-3'` shows and searches only the names; `{-1}` and
the first field, and the link, the last; `{-1}` and `--accept-nth=-1` are `--accept-nth=-1` are the link, `{-2}` the account. All but the name are
the link. The kind is one word and the link has no spaces, so this works one word, so this works without `--delimiter`. To filter by kind
without `--delimiter`. To filter by kind yourself: `awk -F'\t' '$1 == "secret"'`. `enter` prints yourself: `awk -F'\t' '$1 == "secret"'`.
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 `enter` prints the link (`lazysecrets get $(…)`), `ctrl-o` opens it in
value copied this way isn't cleared from the clipboard after lazysecrets and `ctrl-y` copies the value (`pbcopy` on macOS; `wl-copy` or
`values.clipboardClear`, as one copied in lazysecrets is. `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 [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 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. scripts, and `--icons=false` the icons. The link is never coloured.
`--format json` prints an object per line instead, for editor plugins, `--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 without colours or icons, and with `error` on a list it couldn't read:
it couldn't read:
```json ```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 `accountLabel`, the account's short name, is there when several az
listed. accounts are signed in. Values are never listed.
## From the browser and chat ## From the browser and chat
+10 -5
View File
@@ -33,8 +33,12 @@ type Entry struct {
Provider string `json:"provider"` Provider string `json:"provider"`
// Path names it: its source's, vault's and own names. // Path names it: its source's, vault's and own names.
Path []string `json:"path"` Path []string `json:"path"`
// Account is the az account it's seen through, when there are several. // Account is the provider it's listed through: an az account
Account string `json:"account,omitempty"` // (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 // 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 // listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link. // 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 continue
} }
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind), 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 { 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 { if r, err := ref.ForSource(src); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -199,7 +203,7 @@ func sourceEntry(src provider.Source) Entry {
} }
func containerEntry(c provider.Container) 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 { if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String() 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 == "" { if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
return return
} }
e.Account = p.ID()
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
if l.stopped != nil || l.seen[id] { if l.stopped != nil || l.seen[id] {
return 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 // Share is a share link's sealed files, as in the link; the other
// fields are empty. Package share reads it. // fields are empty. Package share reads it.
Share string 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. // 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) { switch len(choices) {
case 0: case 0:
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true} 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...) { for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) 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) m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
case msg.linked != nil: case msg.linked != nil:
m.addLinked(*msg.linked) 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 // startLinkNav opens a link: like startNav, ending on its object's
// details. // details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) { 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) 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. // Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header d.keys("1", "g") // alice's header
d.keys("enter") // fold it d.keys("enter") // fold it