feat(config): some additional config options, and config migration from deprecated keys
This commit is contained in:
parent
629358a81b
commit
4e78cefd56
16 changed files with 363 additions and 51 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
92
cmd/config_migrate.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
59
cmd/list.go
59
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue