feat: makes ls list all stores by default, with config option to disable. adds --store glob support

This commit is contained in:
Lewis Wynne 2026-02-11 23:04:14 +00:00
parent b6248e409f
commit 55b2e7f6cb
35 changed files with 487 additions and 177 deletions

View file

@ -39,12 +39,14 @@ type Config struct {
}
type KeyConfig struct {
AlwaysPromptDelete bool `toml:"always_prompt_delete"`
AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
AlwaysPromptDelete bool `toml:"always_prompt_delete"`
AlwaysPromptGlobDelete bool `toml:"always_prompt_glob_delete"`
AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
}
type StoreConfig struct {
DefaultStoreName string `toml:"default_store_name"`
ListAllStores bool `toml:"list_all_stores"`
AlwaysPromptDelete bool `toml:"always_prompt_delete"`
AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
}
@ -77,11 +79,13 @@ func defaultConfig() Config {
return Config{
DisplayAsciiArt: true,
Key: KeyConfig{
AlwaysPromptDelete: false,
AlwaysPromptOverwrite: false,
AlwaysPromptDelete: false,
AlwaysPromptGlobDelete: true,
AlwaysPromptOverwrite: false,
},
Store: StoreConfig{
DefaultStoreName: "default",
ListAllStores: true,
AlwaysPromptDelete: true,
AlwaysPromptOverwrite: true,
},

View file

@ -55,12 +55,21 @@ func del(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
valuePatterns, err := cmd.Flags().GetStringSlice("value")
if err != nil {
return err
}
storePatterns, err := cmd.Flags().GetStringSlice("store")
if err != nil {
return err
}
if len(args) == 0 && len(keyPatterns) == 0 {
hasFilters := len(keyPatterns) > 0 || len(valuePatterns) > 0 || len(storePatterns) > 0
if len(args) == 0 && !hasFilters {
return fmt.Errorf("cannot remove: no keys provided")
}
targets, err := resolveDeleteTargets(store, args, keyPatterns)
targets, err := resolveDeleteTargets(store, args, keyPatterns, valuePatterns, storePatterns)
if err != nil {
return err
}
@ -75,8 +84,9 @@ func del(cmd *cobra.Command, args []string) error {
}
byStore := make(map[string]*storeTargets)
var storeOrder []string
promptGlob := hasFilters && config.Key.AlwaysPromptGlobDelete
for _, target := range targets {
if !yes && (interactive || config.Key.AlwaysPromptDelete) {
if !yes && (interactive || config.Key.AlwaysPromptDelete || promptGlob) {
var confirm string
promptf("remove '%s'? (y/n)", target.display)
if err := scanln(&confirm); err != nil {
@ -126,6 +136,8 @@ func init() {
delCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion")
delCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
delCmd.Flags().StringSliceP("key", "k", nil, "delete keys matching glob pattern (repeatable)")
delCmd.Flags().StringSliceP("store", "s", nil, "target stores matching glob pattern (repeatable)")
delCmd.Flags().StringSliceP("value", "v", nil, "delete entries matching value glob pattern (repeatable)")
rootCmd.AddCommand(delCmd)
}
@ -152,7 +164,7 @@ func keyExists(store *Store, arg string) (bool, error) {
return findEntry(entries, spec.Key) >= 0, nil
}
func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string) ([]resolvedTarget, error) {
func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, valuePatterns []string, storePatterns []string) ([]resolvedTarget, error) {
targetSet := make(map[string]struct{})
var targets []resolvedTarget
@ -185,16 +197,32 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin
addTarget(spec)
}
if len(globPatterns) == 0 {
if len(globPatterns) == 0 && len(valuePatterns) == 0 && len(storePatterns) == 0 {
return targets, nil
}
// Resolve --store patterns into a list of target stores.
storeMatchers, err := compileGlobMatchers(storePatterns)
if err != nil {
return nil, fmt.Errorf("cannot remove: %v", err)
}
valueMatchers, err := compileValueMatchers(valuePatterns)
if err != nil {
return nil, fmt.Errorf("cannot remove: %v", err)
}
type compiledPattern struct {
rawArg string
db string
matcher glob.Glob
}
// When --store or --value is given without --key, match all keys.
if len(globPatterns) == 0 {
globPatterns = []string{"**"}
}
var compiled []compiledPattern
for _, raw := range globPatterns {
spec, err := store.parseKey(raw, true)
@ -206,37 +234,50 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin
if err != nil {
return nil, fmt.Errorf("cannot remove '%s': %v", raw, err)
}
compiled = append(compiled, compiledPattern{
rawArg: raw,
db: spec.DB,
matcher: m,
})
if len(storeMatchers) > 0 && !strings.Contains(raw, "@") {
// --store given and pattern has no explicit @STORE: expand across matching stores.
allStores, err := store.AllStores()
if err != nil {
return nil, fmt.Errorf("cannot remove: %v", err)
}
for _, s := range allStores {
if globMatch(storeMatchers, s) {
compiled = append(compiled, compiledPattern{rawArg: raw, db: s, matcher: m})
}
}
} else {
compiled = append(compiled, compiledPattern{rawArg: raw, db: spec.DB, matcher: m})
}
}
keysByDB := make(map[string][]string)
getKeys := func(db string) ([]string, error) {
if keys, ok := keysByDB[db]; ok {
return keys, nil
entriesByDB := make(map[string][]Entry)
getEntries := func(db string) ([]Entry, error) {
if entries, ok := entriesByDB[db]; ok {
return entries, nil
}
keys, err := store.Keys(db)
p, err := store.storePath(db)
if err != nil {
return nil, err
}
keysByDB[db] = keys
return keys, nil
entries, err := readStoreFile(p, nil)
if err != nil {
return nil, err
}
entriesByDB[db] = entries
return entries, nil
}
for _, p := range compiled {
keys, err := getKeys(p.db)
entries, err := getEntries(p.db)
if err != nil {
return nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err)
}
for _, k := range keys {
if p.matcher.Match(k) {
for _, e := range entries {
if p.matcher.Match(e.Key) && valueMatch(valueMatchers, e) {
addTarget(KeySpec{
Raw: k,
RawKey: k,
Key: k,
Raw: e.Key,
RawKey: e.Key,
Key: e.Key,
DB: p.db,
})
}

View file

@ -302,12 +302,18 @@ func configDiffs() []string {
if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete {
diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete))
}
if config.Key.AlwaysPromptGlobDelete != def.Key.AlwaysPromptGlobDelete {
diffs = append(diffs, fmt.Sprintf("key.always_prompt_glob_delete: %v", config.Key.AlwaysPromptGlobDelete))
}
if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite {
diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite))
}
if config.Store.DefaultStoreName != def.Store.DefaultStoreName {
diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName))
}
if config.Store.ListAllStores != def.Store.ListAllStores {
diffs = append(diffs, fmt.Sprintf("store.list_all_stores: %v", config.Store.ListAllStores))
}
if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete {
diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete))
}

View file

@ -40,6 +40,7 @@ var exportCmd = &cobra.Command{
func init() {
exportCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)")
exportCmd.Flags().StringSliceP("store", "s", nil, "filter stores with glob pattern (repeatable)")
exportCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)")
rootCmd.AddCommand(exportCmd)
}

View file

@ -28,6 +28,7 @@ import (
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"unicode/utf8"
@ -63,6 +64,7 @@ var (
listNoValues bool
listNoTTL bool
listFull bool
listAll bool
listNoHeader bool
listFormat formatEnum = "table"
@ -75,11 +77,22 @@ const (
columnKey columnKind = iota
columnValue
columnTTL
columnStore
)
var listCmd = &cobra.Command{
Use: "list [STORE]",
Short: "List the contents of a store",
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,
@ -88,8 +101,22 @@ var listCmd = &cobra.Command{
func list(cmd *cobra.Command, args []string) error {
store := &Store{}
targetDB := "@" + config.Store.DefaultStoreName
if len(args) == 1 {
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 {
@ -113,6 +140,7 @@ func list(cmd *cobra.Command, args []string) error {
if !listNoKeys {
columns = append(columns, columnKey)
}
columns = append(columns, columnStore)
if !listNoValues {
columns = append(columns, columnValue)
}
@ -138,26 +166,62 @@ func list(cmd *cobra.Command, args []string) error {
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()
}
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)
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 and value regex
// Filter by key glob, value regex, and store glob
var filtered []Entry
for _, e := range entries {
if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) {
if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) && globMatch(storeMatchers, e.StoreName) {
filtered = append(filtered, e)
}
}
@ -167,15 +231,19 @@ func list(cmd *cobra.Command, args []string) error {
return nil
}
if (len(matchers) > 0 || len(valueMatchers) > 0) && len(filtered) == 0 {
switch {
case len(matchers) > 0 && len(valueMatchers) > 0:
return fmt.Errorf("cannot ls '%s': no matches for key pattern %s and value pattern %s", targetDB, formatGlobPatterns(keyPatterns), formatValuePatterns(valuePatterns))
case len(valueMatchers) > 0:
return fmt.Errorf("cannot ls '%s': no matches for value pattern %s", targetDB, formatValuePatterns(valuePatterns))
default:
return fmt.Errorf("cannot ls '%s': no matches for key pattern %s", targetDB, formatGlobPatterns(keyPatterns))
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()
@ -187,6 +255,7 @@ func list(cmd *cobra.Command, args []string) error {
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)
@ -198,15 +267,16 @@ func list(cmd *cobra.Command, args []string) error {
// JSON format: emit a single JSON array
if listFormat.String() == "json" {
var entries []jsonEntry
var jsonEntries []jsonEntry
for _, e := range filtered {
je, err := encodeJsonEntry(e, recipient)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
entries = append(entries, je)
je.Store = e.StoreName
jsonEntries = append(jsonEntries, je)
}
data, err := json.Marshal(entries)
data, err := json.Marshal(jsonEntries)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
@ -267,6 +337,12 @@ func list(cmd *cobra.Command, args []string) error {
} 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 {
@ -359,6 +435,8 @@ func headerRow(columns []columnKind, tty bool) table.Row {
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:
@ -369,13 +447,14 @@ func headerRow(columns []columnKind, tty bool) table.Row {
}
const (
keyColumnWidthCap = 30
ttlColumnWidthCap = 20
keyColumnWidthCap = 30
storeColumnWidthCap = 20
ttlColumnWidthCap = 20
)
// columnLayout holds the resolved max widths for each column kind.
type columnLayout struct {
key, value, ttl int
key, store, value, ttl int
}
// computeLayout derives column widths from the terminal size and actual
@ -385,11 +464,14 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
var lay columnLayout
termWidth := detectTerminalWidth(out)
// Scan entries for actual max key/TTL content widths.
// 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
}
@ -397,6 +479,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
if lay.key > keyColumnWidthCap {
lay.key = keyColumnWidthCap
}
if lay.store > storeColumnWidthCap {
lay.store = storeColumnWidthCap
}
if lay.ttl > ttlColumnWidthCap {
lay.ttl = ttlColumnWidthCap
}
@ -417,6 +502,8 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
switch col {
case columnKey:
lay.value -= lay.key
case columnStore:
lay.value -= lay.store
case columnTTL:
lay.value -= lay.ttl
}
@ -442,6 +529,9 @@ func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay
case columnKey:
maxW = lay.key
enforcer = text.Trim
case columnStore:
maxW = lay.store
enforcer = text.Trim
case columnValue:
maxW = lay.value
if full {
@ -496,6 +586,7 @@ func renderTable(tw table.Writer) {
}
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")
@ -505,6 +596,7 @@ func init() {
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)
}

View file

@ -43,6 +43,7 @@ type Entry struct {
ExpiresAt uint64 // Unix timestamp; 0 = never expires
Secret bool // encrypted on disk
Locked bool // secret but no identity available to decrypt
StoreName string // populated by list --all
}
// jsonEntry is the NDJSON on-disk format.
@ -51,6 +52,7 @@ type jsonEntry struct {
Value string `json:"value"`
Encoding string `json:"encoding,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
Store string `json:"store,omitempty"`
}
// readStoreFile reads all non-expired entries from an NDJSON file.

View file

@ -46,15 +46,16 @@ var restoreCmd = &cobra.Command{
func restore(cmd *cobra.Command, args []string) error {
store := &Store{}
dbName := config.Store.DefaultStoreName
if len(args) == 1 {
explicitStore := len(args) == 1
targetDB := config.Store.DefaultStoreName
if explicitStore {
parsed, err := store.parseDB(args[0], false)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", args[0], err)
}
dbName = parsed
targetDB = parsed
}
displayTarget := "@" + dbName
displayTarget := "@" + targetDB
keyPatterns, err := cmd.Flags().GetStringSlice("key")
if err != nil {
@ -65,6 +66,15 @@ func restore(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
storePatterns, err := cmd.Flags().GetStringSlice("store")
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
storeMatchers, err := compileGlobMatchers(storePatterns)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
reader, closer, err := restoreInput(cmd)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
@ -73,11 +83,6 @@ func restore(cmd *cobra.Command, args []string) error {
defer closer.Close()
}
p, err := store.storePath(dbName)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
decoder := json.NewDecoder(bufio.NewReaderSize(reader, 8*1024*1024))
interactive, err := cmd.Flags().GetBool("interactive")
@ -101,7 +106,6 @@ func restore(cmd *cobra.Command, args []string) error {
if promptOverwrite {
filePath, _ := cmd.Flags().GetString("file")
if strings.TrimSpace(filePath) == "" {
// Data comes from stdin — open /dev/tty for interactive prompts.
tty, err := os.Open("/dev/tty")
if err != nil {
return fmt.Errorf("cannot restore '%s': --interactive requires --file (-f) when reading from stdin on this platform", displayTarget)
@ -111,26 +115,60 @@ func restore(cmd *cobra.Command, args []string) error {
}
}
restored, err := restoreEntries(decoder, p, restoreOpts{
opts := restoreOpts{
matchers: matchers,
storeMatchers: storeMatchers,
promptOverwrite: promptOverwrite,
drop: drop,
identity: identity,
recipient: recipient,
promptReader: promptReader,
})
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
if len(matchers) > 0 && restored == 0 {
return fmt.Errorf("cannot restore '%s': no matches for key pattern %s", displayTarget, formatGlobPatterns(keyPatterns))
// When a specific store is given, all entries go there (original behaviour).
// Otherwise, route entries to their original store via the "store" field.
if explicitStore {
p, err := store.storePath(targetDB)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
restored, err := restoreEntries(decoder, map[string]string{targetDB: p}, targetDB, opts)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
if err := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil {
return err
}
okf("restored %d entries into @%s", restored, targetDB)
} else {
restored, err := restoreEntries(decoder, nil, targetDB, opts)
if err != nil {
return fmt.Errorf("cannot restore: %v", err)
}
if err := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil {
return err
}
okf("restored %d entries", restored)
}
okf("restored %d entries into @%s", restored, dbName)
return autoSync()
}
func reportRestoreFilters(displayTarget string, restored int, matchers []glob.Glob, keyPatterns []string, storeMatchers []glob.Glob, storePatterns []string) error {
hasFilters := len(matchers) > 0 || len(storeMatchers) > 0
if hasFilters && restored == 0 {
var parts []string
if len(matchers) > 0 {
parts = append(parts, fmt.Sprintf("key pattern %s", formatGlobPatterns(keyPatterns)))
}
if len(storeMatchers) > 0 {
parts = append(parts, fmt.Sprintf("store pattern %s", formatGlobPatterns(storePatterns)))
}
return fmt.Errorf("cannot restore '%s': no matches for %s", displayTarget, strings.Join(parts, " and "))
}
return nil
}
func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) {
filePath, err := cmd.Flags().GetString("file")
if err != nil {
@ -148,6 +186,7 @@ func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) {
type restoreOpts struct {
matchers []glob.Glob
storeMatchers []glob.Glob
promptOverwrite bool
drop bool
identity *age.X25519Identity
@ -155,14 +194,49 @@ type restoreOpts struct {
promptReader io.Reader
}
func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) {
var existing []Entry
if !opts.drop {
var err error
existing, err = readStoreFile(storePath, opts.identity)
if err != nil {
return 0, err
// restoreEntries decodes NDJSON entries and writes them to store files.
// storePaths maps store names to file paths. If nil, entries are routed to
// their original store (from the "store" field), falling back to defaultDB.
func restoreEntries(decoder *json.Decoder, storePaths map[string]string, defaultDB string, opts restoreOpts) (int, error) {
s := &Store{}
// Per-store accumulator.
type storeAcc struct {
path string
entries []Entry
loaded bool
}
stores := make(map[string]*storeAcc)
getStore := func(dbName string) (*storeAcc, error) {
if acc, ok := stores[dbName]; ok {
return acc, nil
}
var p string
if storePaths != nil {
var ok bool
p, ok = storePaths[dbName]
if !ok {
return nil, fmt.Errorf("unexpected store '%s'", dbName)
}
} else {
var err error
p, err = s.storePath(dbName)
if err != nil {
return nil, err
}
}
acc := &storeAcc{path: p}
if !opts.drop {
existing, err := readStoreFile(p, opts.identity)
if err != nil {
return nil, err
}
acc.entries = existing
}
acc.loaded = true
stores[dbName] = acc
return acc, nil
}
entryNo := 0
@ -183,13 +257,27 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (
if !globMatch(opts.matchers, je.Key) {
continue
}
if !globMatch(opts.storeMatchers, je.Store) {
continue
}
// Determine target store.
targetDB := defaultDB
if storePaths == nil && je.Store != "" {
targetDB = je.Store
}
entry, err := decodeJsonEntry(je, opts.identity)
if err != nil {
return 0, fmt.Errorf("entry %d: %w", entryNo, err)
}
idx := findEntry(existing, entry.Key)
acc, err := getStore(targetDB)
if err != nil {
return 0, fmt.Errorf("entry %d: %v", entryNo, err)
}
idx := findEntry(acc.entries, entry.Key)
if opts.promptOverwrite && idx >= 0 {
promptf("overwrite '%s'? (y/n)", entry.Key)
@ -210,16 +298,18 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (
}
if idx >= 0 {
existing[idx] = entry
acc.entries[idx] = entry
} else {
existing = append(existing, entry)
acc.entries = append(acc.entries, entry)
}
restored++
}
if restored > 0 || opts.drop {
if err := writeStoreFile(storePath, existing, opts.recipient); err != nil {
return 0, err
for _, acc := range stores {
if restored > 0 || opts.drop {
if err := writeStoreFile(acc.path, acc.entries, opts.recipient); err != nil {
return 0, err
}
}
}
return restored, nil
@ -228,6 +318,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (
func init() {
restoreCmd.Flags().StringP("file", "f", "", "path to an NDJSON dump (defaults to stdin)")
restoreCmd.Flags().StringSliceP("key", "k", nil, "restore keys matching glob pattern (repeatable)")
restoreCmd.Flags().StringSliceP("store", "s", nil, "restore entries from stores matching glob pattern (repeatable)")
restoreCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting existing keys")
restoreCmd.Flags().Bool("drop", false, "drop existing entries before restoring (full replace)")
rootCmd.AddCommand(restoreCmd)