/* Copyright © 2025 Lewis Wynne Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package cmd import ( "encoding/json" "errors" "fmt" "io" "os" "slices" "strconv" "strings" "unicode/utf8" "filippo.io/age" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" "golang.org/x/term" ) // formatEnum implements pflag.Value for format selection. type formatEnum string func (e *formatEnum) String() string { return string(*e) } func (e *formatEnum) Set(v string) error { switch v { case "table", "tsv", "csv", "html", "markdown", "ndjson", "json": *e = formatEnum(v) return nil default: return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', 'ndjson', or 'json'") } } func (e *formatEnum) Type() string { return "format" } var ( listBase64 bool listCount bool listNoKeys bool listNoValues bool listNoTTL bool listFull bool listAll bool listNoHeader bool listFormat formatEnum = "table" dimStyle = text.Colors{text.Faint, text.Italic} ) type columnKind int const ( columnKey columnKind = iota columnValue columnTTL columnStore ) var listCmd = &cobra.Command{ Use: "list [STORE]", Short: "List the contents of all stores", Long: `List the contents of all stores. By default, list shows entries from every store. Pass a store name as a positional argument to narrow to a single store, or use --store/-s with a glob pattern to filter by store name. The Store column is always shown so entries can be distinguished across stores. Use --key/-k and --value/-v to filter by key or value glob, and --store/-s to filter by store name. All filters are repeatable and OR'd within the same flag.`, Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(1), RunE: list, SilenceUsage: true, } func list(cmd *cobra.Command, args []string) error { store := &Store{} storePatterns, err := cmd.Flags().GetStringSlice("store") if err != nil { return fmt.Errorf("cannot ls: %v", err) } if len(storePatterns) > 0 && len(args) > 0 { return fmt.Errorf("cannot use --store with a store argument") } allStores := len(args) == 0 && (config.Store.ListAllStores || listAll) var targetDB string if allStores { targetDB = "all" } else if len(args) == 0 { targetDB = "@" + config.Store.DefaultStoreName } else { rawArg := args[0] dbName, err := store.parseDB(rawArg, false) if err != nil { return fmt.Errorf("cannot ls '%s': %v", args[0], err) } if _, err := store.FindStore(dbName); err != nil { var notFound errNotFound if errors.As(err, ¬Found) { return fmt.Errorf("cannot ls '%s': %w", args[0], err) } return fmt.Errorf("cannot ls '%s': %v", args[0], err) } targetDB = "@" + dbName } if listNoKeys && listNoValues && listNoTTL { return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl") } var columns []columnKind if !listNoKeys { columns = append(columns, columnKey) } columns = append(columns, columnStore) if !listNoValues { columns = append(columns, columnValue) } if !listNoTTL { columns = append(columns, columnTTL) } keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } matchers, err := compileGlobMatchers(keyPatterns) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } valuePatterns, err := cmd.Flags().GetStringSlice("value") if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } valueMatchers, err := compileValueMatchers(valuePatterns) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } storeMatchers, err := compileGlobMatchers(storePatterns) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } identity, _ := loadIdentity() var recipient *age.X25519Recipient if identity != nil { recipient = identity.Recipient() } var entries []Entry if allStores { storeNames, err := store.AllStores() if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } for _, name := range storeNames { p, err := store.storePath(name) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } storeEntries, err := readStoreFile(p, identity) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } for i := range storeEntries { storeEntries[i].StoreName = name } entries = append(entries, storeEntries...) } slices.SortFunc(entries, func(a, b Entry) int { if c := strings.Compare(a.Key, b.Key); c != 0 { return c } return strings.Compare(a.StoreName, b.StoreName) }) } else { dbName := targetDB[1:] // strip leading '@' p, err := store.storePath(dbName) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } entries, err = readStoreFile(p, identity) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } for i := range entries { entries[i].StoreName = dbName } } // Filter by key glob, value regex, and store glob var filtered []Entry for _, e := range entries { if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) && globMatch(storeMatchers, e.StoreName) { filtered = append(filtered, e) } } if listCount { fmt.Fprintln(cmd.OutOrStdout(), len(filtered)) return nil } hasFilters := len(matchers) > 0 || len(valueMatchers) > 0 || len(storeMatchers) > 0 if hasFilters && len(filtered) == 0 { var parts []string if len(matchers) > 0 { parts = append(parts, fmt.Sprintf("key pattern %s", formatGlobPatterns(keyPatterns))) } if len(valueMatchers) > 0 { parts = append(parts, fmt.Sprintf("value pattern %s", formatValuePatterns(valuePatterns))) } if len(storeMatchers) > 0 { parts = append(parts, fmt.Sprintf("store pattern %s", formatGlobPatterns(storePatterns))) } return fmt.Errorf("cannot ls '%s': no matches for %s", targetDB, strings.Join(parts, " and ")) } output := cmd.OutOrStdout() // NDJSON format: emit JSON lines directly (encrypted form for secrets) if listFormat.String() == "ndjson" { for _, e := range filtered { je, err := encodeJsonEntry(e, recipient) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } je.Store = e.StoreName data, err := json.Marshal(je) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } fmt.Fprintln(output, string(data)) } return nil } // JSON format: emit a single JSON array if listFormat.String() == "json" { var jsonEntries []jsonEntry for _, e := range filtered { je, err := encodeJsonEntry(e, recipient) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } je.Store = e.StoreName jsonEntries = append(jsonEntries, je) } data, err := json.Marshal(jsonEntries) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } fmt.Fprintln(output, string(data)) return nil } // Table-based formats showValues := !listNoValues tw := table.NewWriter() tw.SetOutputMirror(output) tw.SetStyle(table.StyleDefault) tw.Style().Options.SeparateHeader = false tw.Style().Options.SeparateFooter = false tw.Style().Options.DrawBorder = false tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateColumns = false tw.Style().Box.PaddingLeft = "" tw.Style().Box.PaddingRight = " " tty := stdoutIsTerminal() && listFormat.String() == "table" if !listNoHeader { tw.AppendHeader(headerRow(columns, tty)) tw.Style().Format.Header = text.FormatDefault } lay := computeLayout(columns, output, filtered) for _, e := range filtered { var valueStr string dimValue := false if showValues { if e.Locked { valueStr = "locked (identity file missing)" dimValue = true } else { valueStr = store.FormatBytes(listBase64, e.Value) if !utf8.Valid(e.Value) && !listBase64 { dimValue = true } } if !listFull { valueStr = summariseValue(valueStr, lay.value, tty) } } row := make(table.Row, 0, len(columns)) for _, col := range columns { switch col { case columnKey: if tty { row = append(row, text.Bold.Sprint(e.Key)) } else { row = append(row, e.Key) } case columnValue: if tty && dimValue { row = append(row, dimStyle.Sprint(valueStr)) } else { row = append(row, valueStr) } case columnStore: if tty { row = append(row, dimStyle.Sprint(e.StoreName)) } else { row = append(row, e.StoreName) } case columnTTL: ttlStr := formatExpiry(e.ExpiresAt) if tty && e.ExpiresAt == 0 { ttlStr = dimStyle.Sprint(ttlStr) } row = append(row, ttlStr) } } tw.AppendRow(row) } applyColumnWidths(tw, columns, output, lay, listFull) renderTable(tw) return nil } // summariseValue flattens a value to its first line and, when maxWidth > 0, // truncates to fit. In both cases it appends "(..N more chars)" showing the // total number of omitted characters. func summariseValue(s string, maxWidth int, tty bool) string { first := s if i := strings.IndexByte(s, '\n'); i >= 0 { first = s[:i] } totalRunes := utf8.RuneCountInString(s) firstRunes := utf8.RuneCountInString(first) // Nothing omitted and fits (or no width constraint). if firstRunes == totalRunes && (maxWidth <= 0 || firstRunes <= maxWidth) { return first } // How many runes of first can we show? showRunes := firstRunes if maxWidth > 0 && showRunes > maxWidth { showRunes = maxWidth } style := func(s string) string { if tty { return dimStyle.Sprint(s) } return s } // Iteratively make room for the suffix (at most two passes since // the digit count can change by one at a boundary like 9→10). for range 2 { omitted := totalRunes - showRunes if omitted <= 0 { return first } suffix := fmt.Sprintf(" (..%d more chars)", omitted) suffixRunes := utf8.RuneCountInString(suffix) if maxWidth <= 0 { return first + style(suffix) } if showRunes+suffixRunes <= maxWidth { runes := []rune(first) if showRunes < len(runes) { first = string(runes[:showRunes]) } return first + style(suffix) } avail := maxWidth - suffixRunes if avail <= 0 { // Suffix alone exceeds maxWidth; fall through to hard trim. break } showRunes = avail } // Column too narrow for the suffix — just truncate with an ellipsis. if maxWidth >= 2 { return text.Trim(first, maxWidth-1) + style("…") } return text.Trim(first, maxWidth) } func headerRow(columns []columnKind, tty bool) table.Row { h := func(s string) interface{} { if tty { return text.Underline.Sprint(s) } return s } row := make(table.Row, 0, len(columns)) for _, col := range columns { switch col { case columnKey: row = append(row, h("Key")) case columnStore: row = append(row, h("Store")) case columnValue: row = append(row, h("Value")) case columnTTL: row = append(row, h("TTL")) } } return row } const ( keyColumnWidthCap = 30 storeColumnWidthCap = 20 ttlColumnWidthCap = 20 ) // columnLayout holds the resolved max widths for each column kind. type columnLayout struct { key, store, value, ttl int } // computeLayout derives column widths from the terminal size and actual // content widths of the key/TTL columns (capped at fixed maximums). This // avoids reserving 30+40 chars for key+TTL when the real content is narrower. func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnLayout { var lay columnLayout termWidth := detectTerminalWidth(out) // Scan entries for actual max key/store/TTL content widths. for _, e := range entries { if w := utf8.RuneCountInString(e.Key); w > lay.key { lay.key = w } if w := utf8.RuneCountInString(e.StoreName); w > lay.store { lay.store = w } if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl { lay.ttl = w } } if lay.key > keyColumnWidthCap { lay.key = keyColumnWidthCap } if lay.store > storeColumnWidthCap { lay.store = storeColumnWidthCap } if lay.ttl > ttlColumnWidthCap { lay.ttl = ttlColumnWidthCap } if termWidth <= 0 { return lay } padding := len(columns) * 2 available := termWidth - padding if available < len(columns) { return lay } // Give the value column whatever is left after key and TTL. lay.value = available for _, col := range columns { switch col { case columnKey: lay.value -= lay.key case columnStore: lay.value -= lay.store case columnTTL: lay.value -= lay.ttl } } if lay.value < 10 { lay.value = 10 } return lay } func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay columnLayout, full bool) { termWidth := detectTerminalWidth(out) if termWidth <= 0 { return } tw.SetAllowedRowLength(termWidth) var configs []table.ColumnConfig for i, col := range columns { var maxW int var enforcer func(string, int) string switch col { case columnKey: maxW = lay.key enforcer = text.Trim case columnStore: maxW = lay.store enforcer = text.Trim case columnValue: maxW = lay.value if full { enforcer = text.WrapText } // When !full, values are already pre-truncated by // summariseValue — no enforcer needed. case columnTTL: maxW = lay.ttl enforcer = text.Trim } configs = append(configs, table.ColumnConfig{ Number: i + 1, WidthMax: maxW, WidthMaxEnforcer: enforcer, }) } tw.SetColumnConfigs(configs) } func detectTerminalWidth(out io.Writer) int { type fd interface{ Fd() uintptr } if f, ok := out.(fd); ok { if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 { return w } } if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 { return w } if cols := os.Getenv("COLUMNS"); cols != "" { if parsed, err := strconv.Atoi(cols); err == nil && parsed > 0 { return parsed } } return 0 } func renderTable(tw table.Writer) { switch listFormat.String() { case "tsv": tw.RenderTSV() case "csv": tw.RenderCSV() case "html": tw.RenderHTML() case "markdown": tw.RenderMarkdown() default: tw.Render() } } func init() { listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "list across all stores") listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") listCmd.Flags().BoolVarP(&listCount, "count", "c", false, "print only the count of matching entries") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVar(&listNoTTL, "no-ttl", false, "suppress the TTL column") listCmd.Flags().BoolVarP(&listFull, "full", "f", false, "show full values without truncation") listCmd.Flags().BoolVar(&listNoHeader, "no-header", false, "suppress the header row") listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson|json)") listCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)") listCmd.Flags().StringSliceP("store", "s", nil, "filter stores with glob pattern (repeatable)") listCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(listCmd) }