From b05d19209cbdce7b88a190d2fade99160f3a7ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Wed, 14 Oct 2026 13:47:31 +0200 Subject: [PATCH] feat: lazysecrets list prints the account, and open and get take it (--account) --- cmd/lazysecrets/get.go | 9 ++++-- cmd/lazysecrets/list.go | 16 +++++------ cmd/lazysecrets/list_test.go | 2 +- cmd/lazysecrets/main.go | 29 +++++++++++++++---- docs/links.md | 54 +++++++++++++++++++++-------------- internal/listing/listing.go | 15 ++++++---- internal/ref/ref.go | 4 +++ internal/ui/app/links.go | 18 ++++++++++++ internal/ui/app/links_test.go | 14 +++++++++ 9 files changed, 118 insertions(+), 43 deletions(-) diff --git a/cmd/lazysecrets/get.go b/cmd/lazysecrets/get.go index 7eff320..b02f192 100644 --- a/cmd/lazysecrets/get.go +++ b/cmd/lazysecrets/get.go @@ -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) } diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 5097392..2b26f46 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -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) diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index e6137fd..3791044 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -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) } diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index edad365..a0f214b 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -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 ] ", 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] + lazysecrets [flags] [open [--account ]] open a lazysecrets:// link (also a share link), Key Vault URL, @Microsoft.KeyVault(...) or {{secrets/scope/key}} - lazysecrets [flags] get print the value a lazysecrets:// link points at, + lazysecrets [flags] get [--account ] + print the value a lazysecrets:// link points at, for scripts: VAR=$(lazysecrets get ) 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 } diff --git a/docs/links.md b/docs/links.md index 47f71b0..01dd0fc 100644 --- a/docs/links.md +++ b/docs/links.md @@ -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:/`), `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/","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 diff --git a/internal/listing/listing.go b/internal/listing/listing.go index 9a06717..8d9fe7f 100644 --- a/internal/listing/listing.go +++ b/internal/listing/listing.go @@ -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:/), 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 diff --git a/internal/ref/ref.go b/internal/ref/ref.go index df5ff2b..d6fdc82 100644 --- a/internal/ref/ref.go +++ b/internal/ref/ref.go @@ -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. diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index 3063558..87220fc 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -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) { diff --git a/internal/ui/app/links_test.go b/internal/ui/app/links_test.go index 2ae1846..d57d0d7 100644 --- a/internal/ui/app/links_test.go +++ b/internal/ui/app/links_test.go @@ -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