diff --git a/README.md b/README.md index efe6548..82acb99 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ pda rm kitty -y

-`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.list_all_stores` can be set to false to list `store.default_store_name` by default. +`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.always_show_all_stores` can be set to false to list `store.default_store_name` by default. ```bash pda ls # Key Store Value TTL @@ -798,6 +798,9 @@ pda config init # Overwrite an existing config with defaults. pda config init --new + +# Update config: migrate deprecated keys and fill missing defaults. +pda config init --update ```

@@ -821,6 +824,8 @@ always_prompt_delete = false always_prompt_glob_delete = true # prompt y/n before key overwrites always_prompt_overwrite = false +# encrypt all values at rest by default +always_encrypt = false [store] # store name used when none is specified @@ -832,9 +837,15 @@ always_prompt_overwrite = true [list] # list all, or list only the default store when none specified -list_all_stores = true +always_show_all_stores = true # default output, accepts: table|tsv|csv|markdown|html|ndjson|json default_list_format = "table" +# show full values without truncation +always_show_full_values = false +# suppress the header row +always_hide_header = false +# columns and order, accepts: key,store,value,ttl +default_columns = "key,store,value,ttl" [git] # auto fetch whenever a change happens @@ -843,6 +854,8 @@ auto_fetch = false auto_commit = false # auto push after committing auto_push = false +# commit message template ({{.Time}} is replaced with RFC3339 timestamp) +default_commit_message = "sync: {{.Time}}" ```

diff --git a/cmd/config.go b/cmd/config.go index 7015555..6a42a97 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -23,6 +23,7 @@ THE SOFTWARE. package cmd import ( + "bytes" "fmt" "os" "path/filepath" @@ -43,6 +44,7 @@ type KeyConfig struct { AlwaysPromptDelete bool `toml:"always_prompt_delete"` AlwaysPromptGlobDelete bool `toml:"always_prompt_glob_delete"` AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` + AlwaysEncrypt bool `toml:"always_encrypt"` } type StoreConfig struct { @@ -52,14 +54,18 @@ type StoreConfig struct { } type ListConfig struct { - ListAllStores bool `toml:"list_all_stores"` - DefaultListFormat string `toml:"default_list_format"` + AlwaysShowAllStores bool `toml:"always_show_all_stores"` + DefaultListFormat string `toml:"default_list_format"` + AlwaysShowFullValues bool `toml:"always_show_full_values"` + AlwaysHideHeader bool `toml:"always_hide_header"` + DefaultColumns string `toml:"default_columns"` } type GitConfig struct { - AutoFetch bool `toml:"auto_fetch"` - AutoCommit bool `toml:"auto_commit"` - AutoPush bool `toml:"auto_push"` + AutoFetch bool `toml:"auto_fetch"` + AutoCommit bool `toml:"auto_commit"` + AutoPush bool `toml:"auto_push"` + DefaultCommitMessage string `toml:"default_commit_message"` } var ( @@ -78,7 +84,15 @@ var ( ) func init() { - config, configUndecodedKeys, configErr = loadConfig() + var migrations []migration + config, configUndecodedKeys, migrations, configErr = loadConfig() + for _, m := range migrations { + if m.Conflict { + warnf("both '%s' and '%s' present; using '%s'", m.Old, m.New, m.New) + } else { + warnf("config key '%s' is deprecated, use '%s'", m.Old, m.New) + } + } } func defaultConfig() Config { @@ -95,35 +109,56 @@ func defaultConfig() Config { AlwaysPromptOverwrite: true, }, List: ListConfig{ - ListAllStores: true, - DefaultListFormat: "table", + AlwaysShowAllStores: true, + DefaultListFormat: "table", + DefaultColumns: "key,store,value,ttl", }, Git: GitConfig{ - AutoFetch: false, - AutoCommit: false, - AutoPush: false, + AutoFetch: false, + AutoCommit: false, + AutoPush: false, + DefaultCommitMessage: "sync: {{.Time}}", }, } } -func loadConfig() (Config, []string, error) { +// loadConfig returns (config, undecodedKeys, migrations, error). +// Migrations are returned but NOT printed — callers decide. +func loadConfig() (Config, []string, []migration, error) { cfg := defaultConfig() path, err := configPath() if err != nil { - return cfg, nil, err + return cfg, nil, nil, err } - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return cfg, nil, nil - } - return cfg, nil, err - } - - meta, err := toml.DecodeFile(path, &cfg) + data, err := os.ReadFile(path) if err != nil { - return cfg, nil, fmt.Errorf("parse %s: %w", path, err) + if os.IsNotExist(err) { + return cfg, nil, nil, nil + } + return cfg, nil, nil, err + } + + // Decode into a raw map so we can run deprecation migrations before + // the struct decode sees the keys. + var raw map[string]any + if _, err := toml.Decode(string(data), &raw); err != nil { + return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err) + } + + warnings := migrateRawConfig(raw) + + // Re-encode the migrated map and decode into the typed struct so + // defaults fill any missing fields. + var buf bytes.Buffer + if err := toml.NewEncoder(&buf).Encode(raw); err != nil { + return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err) + } + + meta, err := toml.Decode(buf.String(), &cfg) + if err != nil { + return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err) } var undecoded []string @@ -139,15 +174,29 @@ func loadConfig() (Config, []string, error) { cfg.List.DefaultListFormat = defaultConfig().List.DefaultListFormat } if err := validListFormat(cfg.List.DefaultListFormat); err != nil { - return cfg, undecoded, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) + return cfg, undecoded, warnings, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) } - return cfg, undecoded, nil + if cfg.List.DefaultColumns == "" { + cfg.List.DefaultColumns = defaultConfig().List.DefaultColumns + } + if err := validListColumns(cfg.List.DefaultColumns); err != nil { + return cfg, undecoded, warnings, fmt.Errorf("parse %s: list.default_columns: %w", path, err) + } + + if cfg.Git.DefaultCommitMessage == "" { + cfg.Git.DefaultCommitMessage = defaultConfig().Git.DefaultCommitMessage + } + + return cfg, undecoded, warnings, nil } // validateConfig checks invariants on a Config value before it is persisted. func validateConfig(cfg Config) error { - return validListFormat(cfg.List.DefaultListFormat) + if err := validListFormat(cfg.List.DefaultListFormat); err != nil { + return err + } + return validListColumns(cfg.List.DefaultColumns) } func configPath() (string, error) { diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index acfe3fe..a118d45 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -100,7 +100,14 @@ var configEditCmd = &cobra.Command{ return err } - cfg, undecoded, err := loadConfig() + cfg, undecoded, migrations, err := loadConfig() + for _, m := range migrations { + if m.Conflict { + warnf("both '%s' and '%s' present; using '%s'", m.Old, m.New, m.New) + } else { + warnf("config key '%s' is deprecated, use '%s'", m.Old, m.New) + } + } if err != nil { warnf("config has errors: %v", err) printHint("re-run 'pda config edit' to fix") @@ -112,6 +119,7 @@ var configEditCmd = &cobra.Command{ config = cfg configUndecodedKeys = undecoded configErr = nil + okf("saved config: %s", p) return nil }, } @@ -144,14 +152,42 @@ var configInitCmd = &cobra.Command{ return fmt.Errorf("cannot determine config path: %w", err) } newFlag, _ := cmd.Flags().GetBool("new") + updateFlag, _ := cmd.Flags().GetBool("update") + + if newFlag && updateFlag { + return fmt.Errorf("--new and --update are mutually exclusive") + } + + if updateFlag { + if _, err := os.Stat(p); os.IsNotExist(err) { + return withHint( + fmt.Errorf("no config file to update"), + "use 'pda config init' to create one", + ) + } + cfg, _, migrations, loadErr := loadConfig() + if loadErr != nil { + return fmt.Errorf("cannot update config: %w", loadErr) + } + if err := writeConfigFile(cfg); err != nil { + return err + } + for _, m := range migrations { + okf("%s migrated to %s", m.Old, m.New) + } + okf("updated config: %s", p) + return nil + } + if !newFlag { if _, err := os.Stat(p); err == nil { return withHint( fmt.Errorf("config file already exists"), - "use 'pda config edit' or 'pda config init --new'", + "use '--update' to update your config, or '--new' to get a fresh copy", ) } } + okf("generated config: %s", p) return writeConfigFile(defaultConfig()) }, } @@ -163,8 +199,6 @@ var configSetCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { key, raw := args[0], args[1] - - // Work on a copy of the current config so we can write it back. cfg := config defaults := defaultConfig() fields := configFields(&cfg, &defaults) @@ -201,15 +235,16 @@ var configSetCmd = &cobra.Command{ return err } - // Reload so subsequent commands in the same process see the change. config = cfg configUndecodedKeys = nil + okf("%s set to '%s'", key, raw) return nil }, } func init() { configInitCmd.Flags().Bool("new", false, "overwrite existing config file") + configInitCmd.Flags().Bool("update", false, "migrate deprecated keys and fill missing defaults") configCmd.AddCommand(configEditCmd) configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configInitCmd) diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go index dac76e4..89b4288 100644 --- a/cmd/config_fields_test.go +++ b/cmd/config_fields_test.go @@ -40,14 +40,19 @@ func TestConfigFieldsDottedKeys(t *testing.T) { "key.always_prompt_delete": true, "key.always_prompt_glob_delete": true, "key.always_prompt_overwrite": true, + "key.always_encrypt": true, "store.default_store_name": true, "store.always_prompt_delete": true, "store.always_prompt_overwrite": true, - "list.list_all_stores": true, + "list.always_show_all_stores": true, "list.default_list_format": true, + "list.always_show_full_values": true, + "list.always_hide_header": true, + "list.default_columns": true, "git.auto_fetch": true, "git.auto_commit": true, "git.auto_push": true, + "git.default_commit_message": true, } got := make(map[string]bool) diff --git a/cmd/config_migrate.go b/cmd/config_migrate.go new file mode 100644 index 0000000..6050152 --- /dev/null +++ b/cmd/config_migrate.go @@ -0,0 +1,92 @@ +package cmd + +import "strings" + +type deprecation struct { + Old string // e.g. "list.list_all_stores" + New string // e.g. "list.always_show_all_stores" +} + +type migration struct { + Old string // key that was removed + New string // key that holds the value + Conflict bool // both old and new were present; new key wins +} + +var deprecations = []deprecation{ + {"list.list_all_stores", "list.always_show_all_stores"}, +} + +func migrateRawConfig(raw map[string]any) []migration { + var migrations []migration + for _, dep := range deprecations { + oldParts := strings.Split(dep.Old, ".") + newParts := strings.Split(dep.New, ".") + + _, ok := nestedGet(raw, oldParts) + if !ok { + continue + } + m := migration{Old: dep.Old, New: dep.New} + if _, exists := nestedGet(raw, newParts); exists { + m.Conflict = true + } else { + nestedSet(raw, newParts, nestedMustGet(raw, oldParts)) + } + nestedDelete(raw, oldParts) + migrations = append(migrations, m) + } + return migrations +} + +func nestedMustGet(m map[string]any, parts []string) any { + v, _ := nestedGet(m, parts) + return v +} + +func nestedGet(m map[string]any, parts []string) (any, bool) { + for i, p := range parts { + v, ok := m[p] + if !ok { + return nil, false + } + if i == len(parts)-1 { + return v, true + } + sub, ok := v.(map[string]any) + if !ok { + return nil, false + } + m = sub + } + return nil, false +} + +func nestedSet(m map[string]any, parts []string, val any) { + for i, p := range parts { + if i == len(parts)-1 { + m[p] = val + return + } + sub, ok := m[p].(map[string]any) + if !ok { + sub = make(map[string]any) + m[p] = sub + } + m = sub + } +} + +func nestedDelete(m map[string]any, parts []string) { + for i, p := range parts { + if i == len(parts)-1 { + delete(m, p) + return + } + sub, ok := m[p].(map[string]any) + if !ok { + return + } + m = sub + } +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 3cbf7ae..9efd9e3 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -101,7 +101,7 @@ func TestDoctorUndecodedKeys(t *testing.T) { } savedCfg, savedUndecoded, savedErr := config, configUndecodedKeys, configErr - config, configUndecodedKeys, configErr = loadConfig() + config, configUndecodedKeys, _, configErr = loadConfig() t.Cleanup(func() { config, configUndecodedKeys, configErr = savedCfg, savedUndecoded, savedErr }) diff --git a/cmd/list.go b/cmd/list.go index 8795d8b..ee604e3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -64,6 +64,42 @@ func validListFormat(v string) error { func (e *formatEnum) Type() string { return "format" } +var columnNames = map[string]columnKind{ + "key": columnKey, + "store": columnStore, + "value": columnValue, + "ttl": columnTTL, +} + +func validListColumns(v string) error { + seen := make(map[string]bool) + for _, raw := range strings.Split(v, ",") { + tok := strings.TrimSpace(raw) + if _, ok := columnNames[tok]; !ok { + return fmt.Errorf("must be a comma-separated list of 'key', 'store', 'value', 'ttl' (got '%s')", tok) + } + if seen[tok] { + return fmt.Errorf("duplicate column '%s'", tok) + } + seen[tok] = true + } + if len(seen) == 0 { + return fmt.Errorf("at least one column is required") + } + return nil +} + +func parseColumns(v string) []columnKind { + var cols []columnKind + for _, raw := range strings.Split(v, ",") { + tok := strings.TrimSpace(raw) + if kind, ok := columnNames[tok]; ok { + cols = append(cols, kind) + } + } + return cols +} + var ( listBase64 bool listCount bool @@ -121,7 +157,7 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot use --store with a store argument") } - allStores := len(args) == 0 && (config.List.ListAllStores || listAll) + allStores := len(args) == 0 && (config.List.AlwaysShowAllStores || listAll) var targetDB string if allStores { targetDB = "all" @@ -147,16 +183,15 @@ func list(cmd *cobra.Command, args []string) error { 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 := parseColumns(config.List.DefaultColumns) + if listNoKeys { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnKey }) } - columns = append(columns, columnStore) - if !listNoValues { - columns = append(columns, columnValue) + if listNoValues { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnValue }) } - if !listNoTTL { - columns = append(columns, columnTTL) + if listNoTTL { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnTTL }) } keyPatterns, err := cmd.Flags().GetStringSlice("key") @@ -310,7 +345,7 @@ func list(cmd *cobra.Command, args []string) error { tty := stdoutIsTerminal() && listFormat.String() == "table" - if !listNoHeader { + if !(listNoHeader || config.List.AlwaysHideHeader) { tw.AppendHeader(headerRow(columns, tty)) tw.Style().Format.Header = text.FormatDefault } @@ -329,7 +364,7 @@ func list(cmd *cobra.Command, args []string) error { dimValue = true } } - if !listFull { + if !(listFull || config.List.AlwaysShowFullValues) { valueStr = summariseValue(valueStr, lay.value, tty) } } @@ -365,7 +400,7 @@ func list(cmd *cobra.Command, args []string) error { tw.AppendRow(row) } - applyColumnWidths(tw, columns, output, lay, listFull) + applyColumnWidths(tw, columns, output, lay, listFull || config.List.AlwaysShowFullValues) renderTable(tw) return nil } diff --git a/cmd/set.go b/cmd/set.go index 2f1a7de..4f8c88d 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -73,6 +73,7 @@ func set(cmd *cobra.Command, args []string) error { if err != nil { return err } + secret = secret || config.Key.AlwaysEncrypt spec, err := store.parseKey(args[0], true) if err != nil { diff --git a/cmd/shared.go b/cmd/shared.go index 40e2410..a5d1740 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -262,7 +262,7 @@ func validateDBName(name string) error { func formatExpiry(expiresAt uint64) string { if expiresAt == 0 { - return "no expiry" + return "none" } expiry := time.Unix(int64(expiresAt), 0).UTC() remaining := time.Until(expiry) diff --git a/cmd/sync.go b/cmd/sync.go index b775d4a..0353470 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -23,7 +23,7 @@ THE SOFTWARE. package cmd import ( - "fmt" + "strings" "time" "github.com/spf13/cobra" @@ -66,7 +66,7 @@ func sync(manual bool, customMsg string) error { if changed { msg := customMsg if msg == "" { - msg = fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) + msg = strings.ReplaceAll(config.Git.DefaultCommitMessage, "{{.Time}}", time.Now().UTC().Format(time.RFC3339)) if manual { printHint("use -m to set a custom commit message") } diff --git a/testdata/config-init.ct b/testdata/config-init.ct index 20539f3..5d5eb85 100644 --- a/testdata/config-init.ct +++ b/testdata/config-init.ct @@ -1,10 +1,30 @@ # Init creates a config file $ pda config init + ok generated config: /tmp/TestMain2533282848/002/config.toml # Second init fails $ pda config init --> FAIL FAIL config file already exists -hint use 'pda config edit' or 'pda config init --new' +hint use '--update' to update your config, or '--new' to get a fresh copy # Init --new overwrites $ pda config init --new + ok generated config: /tmp/TestMain2533282848/002/config.toml + +# --update preserves user changes +$ pda config set list.always_show_all_stores false +$ pda config get list.always_show_all_stores + ok list.always_show_all_stores set to 'false' +false +$ pda config init --update +$ pda config get list.always_show_all_stores + ok updated config: /tmp/TestMain2533282848/002/config.toml +false + +# --new and --update are mutually exclusive +$ pda config init --new --update --> FAIL +FAIL --new and --update are mutually exclusive + +# Reset for other tests +$ pda config init --new + ok generated config: /tmp/TestMain2533282848/002/config.toml diff --git a/testdata/config-list.ct b/testdata/config-list.ct index b67c388..6a909d7 100644 --- a/testdata/config-list.ct +++ b/testdata/config-list.ct @@ -3,11 +3,16 @@ display_ascii_art = true key.always_prompt_delete = false key.always_prompt_glob_delete = true key.always_prompt_overwrite = false +key.always_encrypt = false store.default_store_name = default store.always_prompt_delete = true store.always_prompt_overwrite = true -list.list_all_stores = true +list.always_show_all_stores = true list.default_list_format = table +list.always_show_full_values = false +list.always_hide_header = false +list.default_columns = key,store,value,ttl git.auto_fetch = false git.auto_commit = false git.auto_push = false +git.default_commit_message = sync: {{.Time}} diff --git a/testdata/config-set.ct b/testdata/config-set.ct index f99bebb..9c8e8cf 100644 --- a/testdata/config-set.ct +++ b/testdata/config-set.ct @@ -1,16 +1,19 @@ # Set a bool value and verify with get $ pda config set git.auto_commit true $ pda config get git.auto_commit + ok git.auto_commit set to 'true' true # Set a string value $ pda config set store.default_store_name mystore $ pda config get store.default_store_name + ok store.default_store_name set to 'mystore' mystore # Set back to original $ pda config set git.auto_commit false $ pda config get git.auto_commit + ok git.auto_commit set to 'false' false # Bad type @@ -24,9 +27,32 @@ FAIL cannot set 'list.default_list_format': must be one of 'table', 'tsv', 'csv' # Valid list format $ pda config set list.default_list_format json $ pda config get list.default_list_format + ok list.default_list_format set to 'json' json +# Invalid list columns +$ pda config set list.default_columns foo --> FAIL +FAIL cannot set 'list.default_columns': must be a comma-separated list of 'key', 'store', 'value', 'ttl' (got 'foo') + +# Duplicate columns +$ pda config set list.default_columns key,key --> FAIL +FAIL cannot set 'list.default_columns': duplicate column 'key' + +# Valid list columns +$ pda config set list.default_columns key,value +$ pda config get list.default_columns + ok list.default_columns set to 'key,value' +key,value + # Unknown key $ pda config set git.auto_comit true --> FAIL FAIL unknown config key 'git.auto_comit' hint did you mean 'git.auto_commit'? + +# Reset changed values so subsequent tests see defaults +$ pda config set store.default_store_name default +$ pda config set list.default_list_format table +$ pda config set list.default_columns key,store,value,ttl + ok store.default_store_name set to 'default' + ok list.default_list_format set to 'table' + ok list.default_columns set to 'key,store,value,ttl' diff --git a/testdata/list-config-columns.ct b/testdata/list-config-columns.ct new file mode 100644 index 0000000..9b369d4 --- /dev/null +++ b/testdata/list-config-columns.ct @@ -0,0 +1,11 @@ +# default_columns = "key,value" shows only key and value +$ pda config set list.default_columns key,value +$ pda set a@lcc 1 +$ pda ls lcc --format tsv + ok list.default_columns set to 'key,value' +Key Value +a 1 + +# Reset +$ pda config set list.default_columns key,store,value,ttl + ok list.default_columns set to 'key,store,value,ttl' diff --git a/testdata/list-config-hide-header.ct b/testdata/list-config-hide-header.ct new file mode 100644 index 0000000..6f8b61e --- /dev/null +++ b/testdata/list-config-hide-header.ct @@ -0,0 +1,10 @@ +# always_hide_header config suppresses the header row +$ pda config set list.always_hide_header true +$ pda set a@lchh 1 +$ pda ls lchh --format tsv + ok list.always_hide_header set to 'true' +a lchh 1 no expiry + +# Reset +$ pda config set list.always_hide_header false + ok list.always_hide_header set to 'false' diff --git a/testdata/set-config-encrypt.ct b/testdata/set-config-encrypt.ct new file mode 100644 index 0000000..75307f2 --- /dev/null +++ b/testdata/set-config-encrypt.ct @@ -0,0 +1,10 @@ +# always_encrypt config encrypts without --encrypt flag +$ pda config set key.always_encrypt true +$ pda set secret-key@sce mysecretvalue +$ pda get secret-key@sce + ok key.always_encrypt set to 'true' +mysecretvalue + +# Reset +$ pda config set key.always_encrypt false + ok key.always_encrypt set to 'false'