feat(config): some additional config options, and config migration from deprecated keys

This commit is contained in:
Lewis Wynne 2026-02-12 19:31:24 +00:00
parent 629358a81b
commit 4e78cefd56
16 changed files with 363 additions and 51 deletions

View file

@ -216,7 +216,7 @@ pda rm kitty -y
<p align="center"></p><!-- spacer -->
`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
```
<p align="center"></p><!-- spacer -->
@ -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}}"
```
<p align="center"></p><!-- spacer -->

View file

@ -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) {

View file

@ -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)

View file

@ -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)

92
cmd/config_migrate.go Normal file
View file

@ -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
}
}

View file

@ -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
})

View file

@ -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
}

View file

@ -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 {

View file

@ -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)

View file

@ -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")
}

View file

@ -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

View file

@ -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}}

View file

@ -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'

11
testdata/list-config-columns.ct vendored Normal file
View file

@ -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'

10
testdata/list-config-hide-header.ct vendored Normal file
View file

@ -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'

10
testdata/set-config-encrypt.ct vendored Normal file
View file

@ -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'