package main import ( "context" "encoding/json" "errors" "flag" "fmt" "io" "os" "os/signal" "slices" "strings" "syscall" "time" "charm.land/lipgloss/v2" "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/listing" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" ) // listCmd prints every source, vault and secret lazysecrets can find, one // per line, for fzf, editor pickers and scripts: // // lazysecrets list | fzf --ansi --delimiter='\t' --with-nth=1 | cut -f2 // // Each line is the name, then the link: the name coloured from the theme, // with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS, // ui.icons); the link never is. // // What's cached prints at once; what's missing or older than --max-age // follows as it loads. A list that can't be read is a line too, as it // fails: the vault or source, then ✗ and why in the theme's error colour. // They're in the output rather than on stderr, which would draw over fzf. func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out io.Writer) error { 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: name 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)") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")") if err := fs.Parse(args); err != nil { return err } if fs.NArg() > 0 { return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]") } var only []string for k := range strings.SplitSeq(*kinds, ",") { if k = strings.TrimSpace(k); k == "" { continue } if !slices.Contains(listing.Kinds, k) { return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", ")) } only = append(only, k) } if *color != "always" && *color != "never" { return fmt.Errorf("--color: %q is not always or never", *color) } iconSet := icons.None if *iconsOn { iconSet = icons.NerdFont } var write func(listing.Entry) error switch *format { 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\n", oneLine.Replace(nameOf(e, theme, iconSet, *color == "always")), e.Link) return err } case "json": enc := json.NewEncoder(out) enc.SetEscapeHTML(false) write = func(e listing.Entry) error { return enc.Encode(e) } default: return fmt.Errorf("--format: %q is not text or json", *format) } // When the reader goes away (a pick in fzf), writing fails instead of // killing the process, so what loaded is still saved. signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE) var gone error emit := func(e listing.Entry) error { if e.Error != "" && !*showErrors { return nil } if err := write(e); err != nil { gone = err return err } return nil } f := cachefile.New() if cachePath != "" { f = cachefile.Read(cachePath) } f = listing.Run(context.Background(), listing.Options{ Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, }, emit) if cachePath != "" && !*cached { byID := map[string]provider.Provider{} for _, p := range providers { byID[p.ID()] = p } keep := func(id string) bool { p, ok := byID[id]; return ok && p.Kind() != provider.KindRepo } if err := cachefile.Save(cachePath, f, keep, time.Now()); err != nil { return err } } if gone != nil && !errors.Is(gone, syscall.EPIPE) { return gone } return nil // or the reader has what it wanted } var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") // nameOf is an entry's name for the text format: the provider's icon, // the path with the item's own name standing out, and the account. A // list that couldn't be read has ✗ and why after it. func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool) string { render := func(st lipgloss.Style, s string) string { if !color || s == "" { return s } return st.Render(s) } kind := provider.Kind("") for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { if strings.EqualFold(string(k), e.Provider) { kind = k } } icon := iconSet.Prefix(kind) var name string if n := len(e.Path); n > 0 { if n > 1 { name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ") } name += e.Path[n-1] } if e.Account != "" { name += render(theme.Muted, " · "+e.Account) } if e.Error != "" { name += " " + render(theme.Error, "✗ "+e.Error) } return render(theme.Kind(kind), strings.TrimSuffix(icon, " ")) + strings.Repeat(" ", min(len(icon), 1)) + name }