From 5bcd3581dd640702a237cb7d625eae3cd2ee0a15 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 18:52:34 +0000 Subject: [PATCH] feat: adds --readonly and --pin flags, and displays Size column in list by default --- README.md | 186 ++++++++++++++++++----- cmd/config.go | 4 +- cmd/config_fields_test.go | 4 +- cmd/del.go | 6 + cmd/edit.go | 42 +++++- cmd/list.go | 223 +++++++++++++++++++++++----- cmd/meta.go | 70 ++++++++- cmd/mv.go | 16 +- cmd/ndjson.go | 22 ++- cmd/set.go | 20 ++- cmd/shared.go | 4 +- testdata/config-get.ct | 2 +- testdata/config-list.ct | 4 +- testdata/config-set.ct | 10 +- testdata/help-list.ct | 20 ++- testdata/help-remove.ct | 2 + testdata/help-set.ct | 6 + testdata/list-all-suppressed-err.ct | 4 +- testdata/list-all.ct | 20 +-- testdata/list-config-columns.ct | 4 +- testdata/list-config-hide-header.ct | 2 +- testdata/list-format-csv.ct | 6 +- testdata/list-format-markdown.ct | 8 +- testdata/list-key-filter.ct | 10 +- testdata/list-key-value-filter.ct | 8 +- testdata/list-meta-column.ct | 9 ++ testdata/list-no-header.ct | 2 +- testdata/list-no-keys.ct | 4 +- testdata/list-no-ttl.ct | 4 +- testdata/list-no-values.ct | 4 +- testdata/list-pinned-sort.ct | 9 ++ testdata/list-stores.ct | 8 +- testdata/list-value-filter.ct | 12 +- testdata/list-value-multi-filter.ct | 6 +- testdata/meta-decrypt.ct | 3 + testdata/meta-encrypt.ct | 3 + testdata/meta-pin.ct | 24 +++ testdata/meta-readonly.ct | 24 +++ testdata/meta-ttl.ct | 4 + testdata/meta.ct | 4 + testdata/multistore.ct | 4 +- testdata/mv-readonly.ct | 23 +++ testdata/remove-dedupe.ct | 6 +- testdata/remove-readonly.ct | 7 + testdata/set-pin.ct | 8 + testdata/set-readonly.ct | 17 +++ 46 files changed, 711 insertions(+), 177 deletions(-) create mode 100644 testdata/list-meta-column.ct create mode 100644 testdata/list-pinned-sort.ct create mode 100644 testdata/meta-pin.ct create mode 100644 testdata/meta-readonly.ct create mode 100644 testdata/mv-readonly.ct create mode 100644 testdata/remove-readonly.ct create mode 100644 testdata/set-pin.ct create mode 100644 testdata/set-readonly.ct diff --git a/README.md b/README.md index 1951587..32ab499 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - plaintext exports in 7 different formats, - support for all [binary data](https://github.com/Llywelwyn/pda#binary), - expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl), +- [read-only](https://github.com/Llywelwyn/pda#read-only--pinned) keys and [pinned](https://github.com/Llywelwyn/pda#read-only--pinned) entries, - built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor) and [configuration](https://github.com/Llywelwyn/pda#config), and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). @@ -55,6 +56,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [Templates](https://github.com/Llywelwyn/pda#templates) - [Filtering](https://github.com/Llywelwyn/pda#filtering) - [TTL](https://github.com/Llywelwyn/pda#ttl) +- [Read-only & Pinned](https://github.com/Llywelwyn/pda#read-only--pinned) - [Binary](https://github.com/Llywelwyn/pda#binary) - [Encryption](https://github.com/Llywelwyn/pda#encryption) - [Doctor](https://github.com/Llywelwyn/pda#doctor) @@ -152,6 +154,9 @@ pda set name "Alice" --safe pda set name "Bob" --safe pda get name # Alice + +# --readonly to protect a key from modification. +pda set api-url "https://prod.example.com" --readonly ```

@@ -202,12 +207,17 @@ pda mv name name2 --safe pda mv name name2 -y ``` -`pda cp` to make a copy. +`pda cp` to make a copy. All metadata is preserved. ```bash pda cp name name2 # 'mv --copy' and 'cp' are aliases. Either one works. pda mv name name2 --copy + +# Read-only keys can't be moved or overwritten without --force. +pda mv readonly-key newname +# FAIL cannot move 'readonly-key': key is read-only +pda mv readonly-key newname --force ```

@@ -216,7 +226,7 @@ pda mv name name2 --copy ```bash pda rm kitty -# Remove multiple keys, within the same or different stores. +# Remove multiple keys. pda rm kitty dog@animals # Mix exact keys with glob patterns. @@ -232,32 +242,42 @@ pda rm kitty -i # --yes/-y to auto-accept all confirmation prompts. pda rm kitty -y + +# Read-only keys can't be deleted without --force. +pda rm protected-key +# FAIL cannot remove 'protected-key': key is read-only +pda rm protected-key --force ```

-`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. +`pda ls` to see what you've got stored. The default columns are `meta,size,ttl,store,key,value`. Meta is a 4-char flag string showing `(e)ncrypted (w)ritable (t)tl (p)inned`, or a dash for an unset flag. Pinned entries sort to the top. + +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 only the default store when none is specified. ```bash pda ls -# Key Store Value TTL -# dogs default four legged mammals no expiry -# name default Alice no expiry +# Meta Size TTL Store Key Value +# -w-p 5 - store todo don't forget this +# ---- 23 - store url https://prod.example.com +# -w-- 5 - store name Alice # Narrow to a single store. -pda ls @default +pda ls @store # Or filter stores by glob pattern. pda ls --store "prod*" +# Suppress or add columns with --no-X flags. +# --no-X suppresses. --no-X=false adds even if not in default config. + # Or as CSV. pda ls --format csv -# Key,Store,Value,TTL -# dogs,default,four legged mammals,no expiry -# name,default,Alice,no expiry +# Meta,Size,TTL,Store,Key,Value +# -w--,5,-,store,name,Alice # Or as a JSON array. pda ls --format json -# [{"key":"dogs","value":"four legged mammals","encoding":"text","store":"default"},{"key":"name","value":"Alice","encoding":"text","store":"default"}] +# [{"key":"name","value":"Alice","encoding":"text","store":"store"}] # Or TSV, Markdown, HTML, NDJSON. @@ -273,12 +293,12 @@ pda ls --count --key "d*" Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. ```bash pda ls -# Key Value TTL -# note this is a very long (..30 more chars) no expiry +# Key Value +# note this is a very long (..30 more chars) pda ls --full -# Key Value TTL -# note this is a very long value that keeps on going and going no expiry +# Key Value +# note this is a very long value that keeps on going and going ```

@@ -296,7 +316,7 @@ pda export --value "**https**"

-`pda import` to import it all back. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to the default store. Pass a store name as a positional argument to force all entries into one store. Existing keys are updated and new keys are added. +`pda import` to import it all back. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to `store.default_store_name`. Pass a store name as a positional argument to force all entries into one store. Existing keys are updated and new keys are added. ```bash # Entries are routed to their original stores. pda import -f my_backup @@ -330,17 +350,18 @@ pda set alice@birthdays 11/11/1998 pda list-stores # Keys Size Store # 2 1.8k @birthdays -# 12 4.2k @default +# 12 4.2k @store # Just the names. pda list-stores --short # @birthdays -# @default +# @store # Check out a specific store. -pda ls @birthdays --no-header --no-ttl -# alice 11/11/1998 -# bob 05/12/1980 +pda ls @birthdays +# Store Key Value +# birthdays alice 11/11/1998 +# birthdays bob 05/12/1980 # Export it. pda export birthdays > friends_birthdays @@ -551,7 +572,7 @@ pda get hello --no-template `*` wildcards a word or series of characters, stopping at separator boundaries (the default separators are `/-_.@:` and space). ```bash -pda ls --no-values --no-header +pda ls # cat # dog # cog @@ -639,16 +660,19 @@ pda ls --key "19[90-99]" `--value` filters by value content using the same glob syntax. ```bash pda ls --value "**localhost**" -# db-url postgres://localhost:5432 no expiry +# Key Value +# db-url postgres://localhost:5432 # Combine key and value filters. pda ls --key "db*" --value "**localhost**" -# db-url postgres://localhost:5432 no expiry +# Key Value +# db-url postgres://localhost:5432 # Multiple --value patterns are OR'd. pda ls --value "**world**" --value "42" -# greeting hello world no expiry -# number 42 no expiry +# Key Value +# greeting hello world +# number 42 ```

@@ -682,34 +706,118 @@ pda set session2 "xyz" --ttl 54m10s `list` shows expiration in the TTL column by default. ```bash pda ls -# Key Value TTL -# session 123 in 59m30s -# session2 xyz in 51m40s +# TTL Key Value +# 59m30s session 123 +# 51m40s session2 xyz ``` `export` and `import` persist the expiry date. Expirations will continue ticking down regardless of if they're actively in a store or not - the expiry is just a timestamp, not a timer.

-`pda meta` views or modifies metadata (TTL, encryption) without changing a key's value. +`pda meta` views or modifies metadata (TTL, encryption, read-only, pinned) without changing a key's value. Changes print an ok message describing what was done. ```bash # View metadata for a key. pda meta session -# key: session@default +# key: session@store # secret: false -# expires: in 59m30s +# writable: true +# pinned: false +# expires: 59m30s # Set or change TTL. pda meta session --ttl 2h +# ok set ttl to 2h session # Clear TTL. pda meta session --ttl never +# ok cleared ttl session -# Encrypt an existing plaintext key. +# Encrypt a key. pda meta api-key --encrypt +# ok encrypted api-key # Decrypt an encrypted key. pda meta api-key --decrypt +# ok decrypted api-key + +# Mark a key as read-only. +pda meta api-url --readonly +# ok made readonly api-url + +# Make it writable. +pda meta api-url --writable +# ok made writable api-url + +# Pin a key to the top of the list. +pda meta todo --pin +# ok pinned todo + +# Unpin. +pda meta todo --unpin +# ok unpinned todo + +# Or combine multiple changes. +pda meta session --readonly --pin +# ok made readonly, pinned session + +# Modifying a read-only key requires making it writable, or just forcing it. +pda meta api-url --ttl 1h +# FAIL cannot meta 'api-url': key is read-only +pda meta api-url --ttl 1h --force +# ok set ttl to 1h api-url +``` + +

+ +### Read-only & Pinned + +Keys can be marked **read-only** to prevent accidental modification, and **pinned** to sort to the top of list output. Both flags are shown in the `Meta` column as part of the 4-char flag string: `ewtp` (encrypted, writable, ttl, pinned). + +```bash +# Set flags at creation time. +pda set api-url "https://prod.example.com" --readonly +pda set important "remember this" --pin + +# Or toggle them with meta. +pda meta api-url --readonly +# ok made readonly api-url +pda meta api-url --writable +# ok made writable api-url +pda meta important --pin +# ok pinned important + +# Or alongside an edit. +pda edit notes --readonly --pin +``` + +

+ +Read-only keys are protected from `set`, `rm`, `mv`, and `edit`. Use `--force` to bypass. +```bash +pda set api-url "new value" +# FAIL cannot set 'api-url': key is read-only + +pda set api-url "new value" --force +# overwrites despite read-only + +pda rm api-url --force +pda mv api-url new-name --force +``` + +

+ +`cp` can copy a read-only key freely (since the source isn't modified), and the copy preserves the read-only flag. Overwriting a read-only destination is blocked without `--force`. + +

+ +Pinned entries sort to the top of `list` output, preserving alphabetical order within the pinned and unpinned groups. +```bash +pda ls +# Meta Key Value +# -w-p important remember this +# -w-- name Alice +# -w-- other foo ```

@@ -780,7 +888,7 @@ pda export

-`mv`, `cp`, and `import` all preserve encryption. Overwriting an encrypted key without `--encrypt` will warn you. +`mv`, `cp`, and `import` all preserve encryption, read-only, and pinned flags. Overwriting an encrypted key without `--encrypt` will warn you. ```bash pda cp api-key api-key-backup # still encrypted @@ -795,8 +903,8 @@ pda set api-key "oops" If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. ```bash pda ls -# Key Value TTL -# api-key locked (identity file missing) no expiry +# Meta Key Value +# ew-- api-key locked (identity file missing) pda get api-key # FAIL cannot get 'api-key': secret is locked (identity file missing) @@ -930,7 +1038,7 @@ always_encrypt = false [store] # store name used when none is specified -default_store_name = "default" +default_store_name = "store" # prompt y/n before deleting whole store always_prompt_delete = true # prompt y/n before store overwrites @@ -945,8 +1053,8 @@ default_list_format = "table" 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" +# columns and order, accepts: meta,size,ttl,store,key,value +default_columns = "meta,size,ttl,store,key,value" [git] # auto fetch whenever a change happens diff --git a/cmd/config.go b/cmd/config.go index da42dcf..6f47bd9 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -104,14 +104,14 @@ func defaultConfig() Config { AlwaysPromptOverwrite: false, }, Store: StoreConfig{ - DefaultStoreName: "default", + DefaultStoreName: "store", AlwaysPromptDelete: true, AlwaysPromptOverwrite: true, }, List: ListConfig{ AlwaysShowAllStores: true, DefaultListFormat: "table", - DefaultColumns: "key,store,value,ttl", + DefaultColumns: "meta,size,ttl,store,key,value", }, Git: GitConfig{ AutoFetch: false, diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go index 89b4288..cbbb10f 100644 --- a/cmd/config_fields_test.go +++ b/cmd/config_fields_test.go @@ -134,8 +134,8 @@ func TestConfigFieldsStringField(t *testing.T) { if f.Kind != reflect.String { t.Errorf("store.default_store_name Kind = %v, want String", f.Kind) } - if f.Value != "default" { - t.Errorf("store.default_store_name Value = %v, want 'default'", f.Value) + if f.Value != "store" { + t.Errorf("store.default_store_name Value = %v, want 'store'", f.Value) } return } diff --git a/cmd/del.go b/cmd/del.go index 01f35a6..3dfda52 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -107,6 +107,8 @@ func del(cmd *cobra.Command, args []string) error { return nil } + force, _ := cmd.Flags().GetBool("force") + var removedNames []string for _, dbName := range storeOrder { st := byStore[dbName] @@ -123,6 +125,9 @@ func del(cmd *cobra.Command, args []string) error { if idx < 0 { return fmt.Errorf("cannot remove '%s': no such key", t.full) } + if entries[idx].ReadOnly && !force { + return fmt.Errorf("cannot remove '%s': key is read-only", t.full) + } entries = append(entries[:idx], entries[idx+1:]...) removedNames = append(removedNames, t.display) } @@ -137,6 +142,7 @@ func del(cmd *cobra.Command, args []string) error { 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().Bool("force", false, "bypass read-only protection") 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)") diff --git a/cmd/edit.go b/cmd/edit.go index b385457..96c31ad 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -48,10 +48,21 @@ func edit(cmd *cobra.Command, args []string) error { encryptFlag, _ := cmd.Flags().GetBool("encrypt") decryptFlag, _ := cmd.Flags().GetBool("decrypt") preserveNewline, _ := cmd.Flags().GetBool("preserve-newline") + force, _ := cmd.Flags().GetBool("force") + readonlyFlag, _ := cmd.Flags().GetBool("readonly") + writableFlag, _ := cmd.Flags().GetBool("writable") + pinFlag, _ := cmd.Flags().GetBool("pin") + unpinFlag, _ := cmd.Flags().GetBool("unpin") if encryptFlag && decryptFlag { return fmt.Errorf("cannot edit '%s': --encrypt and --decrypt are mutually exclusive", args[0]) } + if readonlyFlag && writableFlag { + return fmt.Errorf("cannot edit '%s': --readonly and --writable are mutually exclusive", args[0]) + } + if pinFlag && unpinFlag { + return fmt.Errorf("cannot edit '%s': --pin and --unpin are mutually exclusive", args[0]) + } // Load identity var identity *age.X25519Identity @@ -87,6 +98,9 @@ func edit(cmd *cobra.Command, args []string) error { original = nil } else { entry = &entries[idx] + if entry.ReadOnly && !force { + return fmt.Errorf("cannot edit '%s': key is read-only", args[0]) + } if entry.Locked { return fmt.Errorf("cannot edit '%s': secret is locked (identity file missing)", args[0]) } @@ -149,7 +163,7 @@ func edit(cmd *cobra.Command, args []string) error { } // Check for no-op - noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag + noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag && !readonlyFlag && !writableFlag && !pinFlag && !unpinFlag if bytes.Equal(original, newValue) && noMetaFlags { infof("no changes to '%s'", spec.Display()) return nil @@ -164,9 +178,11 @@ func edit(cmd *cobra.Command, args []string) error { // Build or update entry if creating { newEntry := Entry{ - Key: spec.Key, - Value: newValue, - Secret: encryptFlag, + Key: spec.Key, + Value: newValue, + Secret: encryptFlag, + ReadOnly: readonlyFlag, + Pinned: pinFlag, } if ttlStr != "" { expiresAt, err := parseTTLString(ttlStr) @@ -199,6 +215,19 @@ func edit(cmd *cobra.Command, args []string) error { } entry.Secret = false } + + if readonlyFlag { + entry.ReadOnly = true + } + if writableFlag { + entry.ReadOnly = false + } + if pinFlag { + entry.Pinned = true + } + if unpinFlag { + entry.Pinned = false + } } if err := writeStoreFile(p, entries, recipients); err != nil { @@ -219,5 +248,10 @@ func init() { editCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest") editCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)") editCmd.Flags().Bool("preserve-newline", false, "keep trailing newlines added by the editor") + editCmd.Flags().Bool("force", false, "bypass read-only protection") + editCmd.Flags().Bool("readonly", false, "mark the key as read-only") + editCmd.Flags().Bool("writable", false, "clear the read-only flag") + editCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)") + editCmd.Flags().Bool("unpin", false, "unpin the key") rootCmd.AddCommand(editCmd) } diff --git a/cmd/list.go b/cmd/list.go index 336f18d..86259f1 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -67,6 +67,8 @@ var columnNames = map[string]columnKind{ "key": columnKey, "store": columnStore, "value": columnValue, + "meta": columnMeta, + "size": columnSize, "ttl": columnTTL, } @@ -75,7 +77,7 @@ func validListColumns(v string) error { 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) + return fmt.Errorf("must be a comma-separated list of 'key', 'store', 'value', 'meta', 'size', 'ttl' (got '%s')", tok) } if seen[tok] { return fmt.Errorf("duplicate column '%s'", tok) @@ -103,7 +105,10 @@ var ( listBase64 bool listCount bool listNoKeys bool + listNoStore bool listNoValues bool + listNoMeta bool + listNoSize bool listNoTTL bool listFull bool listAll bool @@ -120,6 +125,8 @@ const ( columnValue columnTTL columnStore + columnMeta + columnSize ) var listCmd = &cobra.Command{ @@ -131,10 +138,9 @@ 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.`, +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, @@ -178,19 +184,35 @@ func list(cmd *cobra.Command, args []string) error { 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") + columns := parseColumns(config.List.DefaultColumns) + + // Each --no-X flag: if explicitly true, remove the column; + // if explicitly false (--no-X=false), add the column if missing. + type colToggle struct { + flag string + kind columnKind + } + for _, ct := range []colToggle{ + {"no-keys", columnKey}, + {"no-store", columnStore}, + {"no-values", columnValue}, + {"no-meta", columnMeta}, + {"no-size", columnSize}, + {"no-ttl", columnTTL}, + } { + if !cmd.Flags().Changed(ct.flag) { + continue + } + val, _ := cmd.Flags().GetBool(ct.flag) + if val { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == ct.kind }) + } else if !slices.Contains(columns, ct.kind) { + columns = append(columns, ct.kind) + } } - columns := parseColumns(config.List.DefaultColumns) - if listNoKeys { - columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnKey }) - } - if listNoValues { - columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnValue }) - } - if listNoTTL { - columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnTTL }) + if len(columns) == 0 { + return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable some --no-* flags") } keyPatterns, err := cmd.Flags().GetStringSlice("key") @@ -271,6 +293,17 @@ func list(cmd *cobra.Command, args []string) error { } } + // Stable sort: pinned entries first, preserving alphabetical order within each group + slices.SortStableFunc(filtered, func(a, b Entry) int { + if a.Pinned && !b.Pinned { + return -1 + } + if !a.Pinned && b.Pinned { + return 1 + } + return 0 + }) + if listCount { fmt.Fprintln(cmd.OutOrStdout(), len(filtered)) return nil @@ -330,7 +363,7 @@ func list(cmd *cobra.Command, args []string) error { } // Table-based formats - showValues := !listNoValues + showValues := slices.Contains(columns, columnValue) tw := table.NewWriter() tw.SetOutputMirror(output) tw.SetStyle(table.StyleDefault) @@ -384,16 +417,32 @@ func list(cmd *cobra.Command, args []string) error { } case columnStore: if tty { - row = append(row, dimStyle.Sprint(e.StoreName)) + row = append(row, text.Colors{text.Bold, text.FgYellow}.Sprint(e.StoreName)) } else { row = append(row, e.StoreName) } + case columnMeta: + if tty { + row = append(row, colorizeMeta(e)) + } else { + row = append(row, entryMetaString(e)) + } + case columnSize: + sizeStr := formatSize(len(e.Value)) + if tty { + if len(e.Value) >= 1000 { + sizeStr = text.Colors{text.Bold, text.FgGreen}.Sprint(sizeStr) + } else { + sizeStr = text.FgGreen.Sprint(sizeStr) + } + } + row = append(row, sizeStr) case columnTTL: ttlStr := formatExpiry(e.ExpiresAt) - if tty && e.ExpiresAt == 0 { - ttlStr = dimStyle.Sprint(ttlStr) - } - row = append(row, ttlStr) + if tty && e.ExpiresAt == 0 { + ttlStr = dimStyle.Sprint(ttlStr) + } + row = append(row, ttlStr) } } tw.AppendRow(row) @@ -484,6 +533,10 @@ func headerRow(columns []columnKind, tty bool) table.Row { row = append(row, h("Store")) case columnValue: row = append(row, h("Value")) + case columnMeta: + row = append(row, h("Meta")) + case columnSize: + row = append(row, h("Size")) case columnTTL: row = append(row, h("TTL")) } @@ -494,12 +547,13 @@ func headerRow(columns []columnKind, tty bool) table.Row { const ( keyColumnWidthCap = 30 storeColumnWidthCap = 20 + sizeColumnWidthCap = 10 ttlColumnWidthCap = 20 ) // columnLayout holds the resolved max widths for each column kind. type columnLayout struct { - key, store, value, ttl int + key, store, value, meta, size, ttl int } // computeLayout derives column widths from the terminal size and actual @@ -509,7 +563,16 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL var lay columnLayout termWidth := detectTerminalWidth(out) - // Scan entries for actual max key/store/TTL content widths. + // Meta column is always exactly 4 chars wide (ewtp). + lay.meta = 4 + + // Ensure columns are at least as wide as their headers. + lay.key = len("Key") + lay.store = len("Store") + lay.size = len("Size") + lay.ttl = len("TTL") + + // Scan entries for actual max key/store/size/TTL content widths. for _, e := range entries { if w := utf8.RuneCountInString(e.Key); w > lay.key { lay.key = w @@ -517,6 +580,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL if w := utf8.RuneCountInString(e.StoreName); w > lay.store { lay.store = w } + if w := utf8.RuneCountInString(formatSize(len(e.Value))); w > lay.size { + lay.size = w + } if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl { lay.ttl = w } @@ -527,6 +593,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL if lay.store > storeColumnWidthCap { lay.store = storeColumnWidthCap } + if lay.size > sizeColumnWidthCap { + lay.size = sizeColumnWidthCap + } if lay.ttl > ttlColumnWidthCap { lay.ttl = ttlColumnWidthCap } @@ -541,7 +610,7 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL return lay } - // Give the value column whatever is left after key and TTL. + // Give the value column whatever is left after fixed-width columns. lay.value = available for _, col := range columns { switch col { @@ -549,6 +618,10 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL lay.value -= lay.key case columnStore: lay.value -= lay.store + case columnMeta: + lay.value -= lay.meta + case columnSize: + lay.value -= lay.size case columnTTL: lay.value -= lay.ttl } @@ -568,31 +641,40 @@ func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay var configs []table.ColumnConfig for i, col := range columns { - var maxW int - var enforcer func(string, int) string + cc := table.ColumnConfig{Number: i + 1} switch col { case columnKey: - maxW = lay.key - enforcer = text.Trim + cc.WidthMax = lay.key + cc.WidthMaxEnforcer = text.Trim case columnStore: - maxW = lay.store - enforcer = text.Trim + cc.WidthMax = lay.store + cc.WidthMaxEnforcer = text.Trim + cc.Align = text.AlignRight + cc.AlignHeader = text.AlignRight case columnValue: - maxW = lay.value + cc.WidthMax = lay.value if full { - enforcer = text.WrapText + cc.WidthMaxEnforcer = text.WrapText } // When !full, values are already pre-truncated by // summariseValue — no enforcer needed. + case columnMeta: + cc.WidthMax = lay.meta + cc.WidthMaxEnforcer = text.Trim + cc.Align = text.AlignRight + cc.AlignHeader = text.AlignRight + case columnSize: + cc.WidthMax = lay.size + cc.WidthMaxEnforcer = text.Trim + cc.Align = text.AlignRight + cc.AlignHeader = text.AlignRight case columnTTL: - maxW = lay.ttl - enforcer = text.Trim + cc.WidthMax = lay.ttl + cc.WidthMaxEnforcer = text.Trim + cc.Align = text.AlignRight + cc.AlignHeader = text.AlignRight } - configs = append(configs, table.ColumnConfig{ - Number: i + 1, - WidthMax: maxW, - WidthMaxEnforcer: enforcer, - }) + configs = append(configs, cc) } tw.SetColumnConfigs(configs) } @@ -615,6 +697,64 @@ func detectTerminalWidth(out io.Writer) int { return 0 } +// entryMetaString returns a 4-char flag string: (e)ncrypted (w)ritable (t)tl (p)inned. +func entryMetaString(e Entry) string { + var b [4]byte + if e.Secret { + b[0] = 'e' + } else { + b[0] = '-' + } + if !e.ReadOnly { + b[1] = 'w' + } else { + b[1] = '-' + } + if e.ExpiresAt > 0 { + b[2] = 't' + } else { + b[2] = '-' + } + if e.Pinned { + b[3] = 'p' + } else { + b[3] = '-' + } + return string(b[:]) +} + +// colorizeMeta returns a colorized meta string for TTY display. +// e=bold+yellow, w=bold+red, t=bold+green, p=bold+yellow, unset=dim. +func colorizeMeta(e Entry) string { + dim := text.Colors{text.Faint} + yellow := text.Colors{text.Bold, text.FgYellow} + red := text.Colors{text.Bold, text.FgRed} + green := text.Colors{text.Bold, text.FgGreen} + + var b strings.Builder + if e.Secret { + b.WriteString(yellow.Sprint("e")) + } else { + b.WriteString(dim.Sprint("-")) + } + if !e.ReadOnly { + b.WriteString(red.Sprint("w")) + } else { + b.WriteString(dim.Sprint("-")) + } + if e.ExpiresAt > 0 { + b.WriteString(green.Sprint("t")) + } else { + b.WriteString(dim.Sprint("-")) + } + if e.Pinned { + b.WriteString(yellow.Sprint("p")) + } else { + b.WriteString(dim.Sprint("-")) + } + return b.String() +} + func renderTable(tw table.Writer) { switch listFormat.String() { case "tsv": @@ -635,7 +775,10 @@ func init() { 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(&listNoStore, "no-store", false, "suppress the store column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") + listCmd.Flags().BoolVar(&listNoMeta, "no-meta", false, "suppress the meta column") + listCmd.Flags().BoolVar(&listNoSize, "no-size", false, "suppress the size 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") diff --git a/cmd/meta.go b/cmd/meta.go index 0309649..62b862a 100644 --- a/cmd/meta.go +++ b/cmd/meta.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" ) @@ -9,13 +10,10 @@ import ( var metaCmd = &cobra.Command{ Use: "meta KEY[@STORE]", Short: "View or modify metadata for a key", - Long: `View or modify metadata (TTL, encryption) for a key without changing its value. + Long: `View or modify metadata (TTL, encryption, read-only, pinned) for a key +without changing its value. -With no flags, displays the key's current metadata. Use flags to modify: - --ttl DURATION Set expiry (e.g. 30m, 2h) - --ttl never Remove expiry - --encrypt Encrypt the value at rest - --decrypt Decrypt the value (store as plaintext)`, +With no flags, displays the key's current metadata. Pass flags to modify.`, Args: cobra.ExactArgs(1), RunE: meta, SilenceUsage: true, @@ -52,23 +50,46 @@ func meta(cmd *cobra.Command, args []string) error { ttlStr, _ := cmd.Flags().GetString("ttl") encryptFlag, _ := cmd.Flags().GetBool("encrypt") decryptFlag, _ := cmd.Flags().GetBool("decrypt") + readonlyFlag, _ := cmd.Flags().GetBool("readonly") + writableFlag, _ := cmd.Flags().GetBool("writable") + pinFlag, _ := cmd.Flags().GetBool("pin") + unpinFlag, _ := cmd.Flags().GetBool("unpin") + force, _ := cmd.Flags().GetBool("force") if encryptFlag && decryptFlag { return fmt.Errorf("cannot meta '%s': --encrypt and --decrypt are mutually exclusive", args[0]) } + if readonlyFlag && writableFlag { + return fmt.Errorf("cannot meta '%s': --readonly and --writable are mutually exclusive", args[0]) + } + if pinFlag && unpinFlag { + return fmt.Errorf("cannot meta '%s': --pin and --unpin are mutually exclusive", args[0]) + } // View mode: no flags set - if ttlStr == "" && !encryptFlag && !decryptFlag { + isModify := ttlStr != "" || encryptFlag || decryptFlag || readonlyFlag || writableFlag || pinFlag || unpinFlag + if !isModify { expiresStr := "never" if entry.ExpiresAt > 0 { expiresStr = formatExpiry(entry.ExpiresAt) } fmt.Fprintf(cmd.OutOrStdout(), " key: %s\n", spec.Full()) fmt.Fprintf(cmd.OutOrStdout(), " secret: %v\n", entry.Secret) + fmt.Fprintf(cmd.OutOrStdout(), " writable: %v\n", !entry.ReadOnly) + fmt.Fprintf(cmd.OutOrStdout(), " pinned: %v\n", entry.Pinned) fmt.Fprintf(cmd.OutOrStdout(), " expires: %s\n", expiresStr) return nil } + // Read-only enforcement: --readonly and --writable always work without --force, + // but other modifications on a read-only key require --force. + if entry.ReadOnly && !force && !readonlyFlag && !writableFlag { + onlyPinChange := !encryptFlag && !decryptFlag && ttlStr == "" && (pinFlag || unpinFlag) + if !onlyPinChange { + return fmt.Errorf("cannot meta '%s': key is read-only", args[0]) + } + } + // Modification mode — may need identity for encrypt if encryptFlag { identity, err = ensureIdentity() @@ -81,12 +102,19 @@ func meta(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot meta '%s': %v", args[0], err) } + var changes []string + if ttlStr != "" { expiresAt, err := parseTTLString(ttlStr) if err != nil { return fmt.Errorf("cannot meta '%s': %v", args[0], err) } entry.ExpiresAt = expiresAt + if expiresAt == 0 { + changes = append(changes, "cleared ttl") + } else { + changes = append(changes, "set ttl to "+ttlStr) + } } if encryptFlag { @@ -97,6 +125,7 @@ func meta(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0]) } entry.Secret = true + changes = append(changes, "encrypted") } if decryptFlag { @@ -107,18 +136,43 @@ func meta(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0]) } entry.Secret = false + changes = append(changes, "decrypted") + } + + if readonlyFlag { + entry.ReadOnly = true + changes = append(changes, "made readonly") + } + if writableFlag { + entry.ReadOnly = false + changes = append(changes, "made writable") + } + if pinFlag { + entry.Pinned = true + changes = append(changes, "pinned") + } + if unpinFlag { + entry.Pinned = false + changes = append(changes, "unpinned") } if err := writeStoreFile(p, entries, recipients); err != nil { return fmt.Errorf("cannot meta '%s': %v", args[0], err) } - return autoSync("meta " + spec.Display()) + summary := strings.Join(changes, ", ") + okf("%s %s", summary, spec.Display()) + return autoSync(summary + " " + spec.Display()) } func init() { metaCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear") metaCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest") metaCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)") + metaCmd.Flags().Bool("readonly", false, "mark the key as read-only") + metaCmd.Flags().Bool("writable", false, "clear the read-only flag") + metaCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)") + metaCmd.Flags().Bool("unpin", false, "unpin the key") + metaCmd.Flags().Bool("force", false, "bypass read-only protection for metadata changes") rootCmd.AddCommand(metaCmd) } diff --git a/cmd/mv.go b/cmd/mv.go index 2e1bf7e..fa7b9d4 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -71,6 +71,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { if err != nil { return err } + force, _ := cmd.Flags().GetBool("force") promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite) identity, _ := loadIdentity() @@ -103,6 +104,11 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } srcEntry := srcEntries[srcIdx] + // Block moving a read-only source (move removes the source) + if !keepSource && srcEntry.ReadOnly && !force { + return fmt.Errorf("cannot move '%s': key is read-only", fromSpec.Key) + } + sameStore := fromSpec.DB == toSpec.DB // Check destination for overwrite prompt @@ -121,6 +127,10 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { dstIdx := findEntry(dstEntries, toSpec.Key) + if dstIdx >= 0 && dstEntries[dstIdx].ReadOnly && !force { + return fmt.Errorf("cannot overwrite '%s': key is read-only", toSpec.Key) + } + if safe && dstIdx >= 0 { infof("skipped '%s': already exists", toSpec.Display()) return nil @@ -137,13 +147,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } } - // Write destination entry — preserve secret status + // Write destination entry — preserve metadata newEntry := Entry{ Key: toSpec.Key, Value: srcEntry.Value, ExpiresAt: srcEntry.ExpiresAt, Secret: srcEntry.Secret, Locked: srcEntry.Locked, + ReadOnly: srcEntry.ReadOnly, + Pinned: srcEntry.Pinned, } if sameStore { @@ -197,9 +209,11 @@ func init() { mvCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination") mvCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") mvCmd.Flags().Bool("safe", false, "do not overwrite if the destination already exists") + mvCmd.Flags().Bool("force", false, "bypass read-only protection") rootCmd.AddCommand(mvCmd) cpCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination") cpCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") cpCmd.Flags().Bool("safe", false, "do not overwrite if the destination already exists") + cpCmd.Flags().Bool("force", false, "bypass read-only protection") rootCmd.AddCommand(cpCmd) } diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 2e7f855..334e10e 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -43,6 +43,8 @@ type Entry struct { ExpiresAt uint64 // Unix timestamp; 0 = never expires Secret bool // encrypted on disk Locked bool // secret but no identity available to decrypt + ReadOnly bool // cannot be modified without --force + Pinned bool // sorts to top in list output StoreName string // populated by list --all } @@ -52,6 +54,8 @@ type jsonEntry struct { Value string `json:"value"` Encoding string `json:"encoding,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` + ReadOnly *bool `json:"readonly,omitempty"` + Pinned *bool `json:"pinned,omitempty"` Store string `json:"store,omitempty"` } @@ -149,6 +153,8 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) if je.ExpiresAt != nil { expiresAt = uint64(*je.ExpiresAt) } + readOnly := je.ReadOnly != nil && *je.ReadOnly + pinned := je.Pinned != nil && *je.Pinned if je.Encoding == "secret" { ciphertext, err := base64.StdEncoding.DecodeString(je.Value) @@ -156,14 +162,14 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) return Entry{}, fmt.Errorf("decode secret for '%s': %w", je.Key, err) } if identity == nil { - return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil + return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true, ReadOnly: readOnly, Pinned: pinned}, nil } plaintext, err := decrypt(ciphertext, identity) if err != nil { warnf("cannot decrypt '%s': %v", je.Key, err) - return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil + return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true, ReadOnly: readOnly, Pinned: pinned}, nil } - return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true}, nil + return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true, ReadOnly: readOnly, Pinned: pinned}, nil } var value []byte @@ -179,7 +185,7 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) default: return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key) } - return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil + return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt, ReadOnly: readOnly, Pinned: pinned}, nil } func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) { @@ -188,6 +194,14 @@ func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) { ts := int64(e.ExpiresAt) je.ExpiresAt = &ts } + if e.ReadOnly { + t := true + je.ReadOnly = &t + } + if e.Pinned { + t := true + je.Pinned = &t + } if e.Secret && e.Locked { // Passthrough: Value holds raw ciphertext, re-encode as-is diff --git a/cmd/set.go b/cmd/set.go index 7ba38e8..d81f41b 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -133,8 +133,14 @@ func set(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot set '%s': %v", args[0], err) } + force, _ := cmd.Flags().GetBool("force") + idx := findEntry(entries, spec.Key) + if idx >= 0 && entries[idx].ReadOnly && !force { + return fmt.Errorf("cannot set '%s': key is read-only", args[0]) + } + if safe && idx >= 0 { infof("skipped '%s': already exists", spec.Display()) return nil @@ -157,10 +163,15 @@ func set(cmd *cobra.Command, args []string) error { } } + pinFlag, _ := cmd.Flags().GetBool("pin") + readonlyFlag, _ := cmd.Flags().GetBool("readonly") + entry := Entry{ - Key: spec.Key, - Value: value, - Secret: secret, + Key: spec.Key, + Value: value, + Secret: secret, + ReadOnly: readonlyFlag, + Pinned: pinFlag, } if ttl != 0 { entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix()) @@ -185,5 +196,8 @@ func init() { setCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting an existing key") setCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest using age") setCmd.Flags().Bool("safe", false, "do not overwrite if the key already exists") + setCmd.Flags().Bool("force", false, "bypass read-only protection") + setCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)") + setCmd.Flags().Bool("readonly", false, "mark the key as read-only") setCmd.Flags().StringP("file", "f", "", "read value from a file") } diff --git a/cmd/shared.go b/cmd/shared.go index 87a8cfe..805261e 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -262,14 +262,14 @@ func validateDBName(name string) error { func formatExpiry(expiresAt uint64) string { if expiresAt == 0 { - return "none" + return "-" } expiry := time.Unix(int64(expiresAt), 0).UTC() remaining := time.Until(expiry) if remaining <= 0 { return "expired" } - return fmt.Sprintf("in %s", remaining.Round(time.Second)) + return remaining.Round(time.Second).String() } // parseTTLString parses a TTL string that is either a duration (e.g. "30m", "2h") diff --git a/testdata/config-get.ct b/testdata/config-get.ct index 1ca3e65..5edaf00 100644 --- a/testdata/config-get.ct +++ b/testdata/config-get.ct @@ -2,7 +2,7 @@ $ pda config get display_ascii_art true $ pda config get store.default_store_name -default +store $ pda config get git.auto_commit false diff --git a/testdata/config-list.ct b/testdata/config-list.ct index 1bce175..50a4c4d 100644 --- a/testdata/config-list.ct +++ b/testdata/config-list.ct @@ -4,14 +4,14 @@ 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.default_store_name = store store.always_prompt_delete = true store.always_prompt_overwrite = 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 +list.default_columns = meta,size,ttl,store,key,value git.auto_fetch = false git.auto_commit = false git.auto_push = false diff --git a/testdata/config-set.ct b/testdata/config-set.ct index 9c8e8cf..0e05785 100644 --- a/testdata/config-set.ct +++ b/testdata/config-set.ct @@ -32,7 +32,7 @@ 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') +FAIL cannot set 'list.default_columns': must be a comma-separated list of 'key', 'store', 'value', 'meta', 'size', 'ttl' (got 'foo') # Duplicate columns $ pda config set list.default_columns key,key --> FAIL @@ -50,9 +50,9 @@ 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 store.default_store_name store $ 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' +$ pda config set list.default_columns meta,size,ttl,store,key,value + ok store.default_store_name set to 'store' ok list.default_list_format set to 'table' - ok list.default_columns set to 'key,store,value,ttl' + ok list.default_columns set to 'meta,size,ttl,store,key,value' diff --git a/testdata/help-list.ct b/testdata/help-list.ct index 30815c9..d2fbec5 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -6,10 +6,9 @@ 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. +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. Usage: pda list [STORE] [flags] @@ -27,6 +26,9 @@ Flags: -k, --key strings filter keys with glob pattern (repeatable) --no-header suppress the header row --no-keys suppress the key column + --no-meta suppress the meta column + --no-size suppress the size column + --no-store suppress the store column --no-ttl suppress the TTL column --no-values suppress the value column -s, --store strings filter stores with glob pattern (repeatable) @@ -37,10 +39,9 @@ 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. +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. Usage: pda list [STORE] [flags] @@ -58,6 +59,9 @@ Flags: -k, --key strings filter keys with glob pattern (repeatable) --no-header suppress the header row --no-keys suppress the key column + --no-meta suppress the meta column + --no-size suppress the size column + --no-store suppress the store column --no-ttl suppress the TTL column --no-values suppress the value column -s, --store strings filter stores with glob pattern (repeatable) diff --git a/testdata/help-remove.ct b/testdata/help-remove.ct index 6e93d94..3170253 100644 --- a/testdata/help-remove.ct +++ b/testdata/help-remove.ct @@ -9,6 +9,7 @@ Aliases: remove, rm Flags: + --force bypass read-only protection -h, --help help for remove -i, --interactive prompt yes/no for each deletion -k, --key strings delete keys matching glob pattern (repeatable) @@ -24,6 +25,7 @@ Aliases: remove, rm Flags: + --force bypass read-only protection -h, --help help for remove -i, --interactive prompt yes/no for each deletion -k, --key strings delete keys matching glob pattern (repeatable) diff --git a/testdata/help-set.ct b/testdata/help-set.ct index 0d8ac57..e37b51a 100644 --- a/testdata/help-set.ct +++ b/testdata/help-set.ct @@ -23,8 +23,11 @@ Aliases: Flags: -e, --encrypt encrypt the value at rest using age -f, --file string read value from a file + --force bypass read-only protection -h, --help help for set -i, --interactive prompt before overwriting an existing key + --pin pin the key (sorts to top in list) + --readonly mark the key as read-only --safe do not overwrite if the key already exists -t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m) Set a key to a given value or stdin. Optionally specify a store. @@ -50,7 +53,10 @@ Aliases: Flags: -e, --encrypt encrypt the value at rest using age -f, --file string read value from a file + --force bypass read-only protection -h, --help help for set -i, --interactive prompt before overwriting an existing key + --pin pin the key (sorts to top in list) + --readonly mark the key as read-only --safe do not overwrite if the key already exists -t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m) diff --git a/testdata/list-all-suppressed-err.ct b/testdata/list-all-suppressed-err.ct index 432e144..300b688 100644 --- a/testdata/list-all-suppressed-err.ct +++ b/testdata/list-all-suppressed-err.ct @@ -1,5 +1,5 @@ # Error when all columns are suppressed $ pda set a@las 1 -$ pda ls las --no-keys --no-values --no-ttl --> FAIL +$ pda ls las --no-keys --no-store --no-values --no-meta --no-size --no-ttl --> FAIL FAIL cannot ls '@las': no columns selected -hint disable --no-keys, --no-values, or --no-ttl +hint disable some --no-* flags diff --git a/testdata/list-all.ct b/testdata/list-all.ct index d6a4023..7bbd52e 100644 --- a/testdata/list-all.ct +++ b/testdata/list-all.ct @@ -2,25 +2,25 @@ $ pda set lax@laa 1 $ pda set lax@lab 2 $ pda ls --key "lax" --format tsv -Key Store Value TTL -lax laa 1 none -lax lab 2 none +Meta Size TTL Store Key Value +-w-- 1 - laa lax 1 +-w-- 1 - lab lax 2 $ pda ls --key "lax" --count 2 $ pda ls --key "lax" --format json [{"key":"lax","value":"1","encoding":"text","store":"laa"},{"key":"lax","value":"2","encoding":"text","store":"lab"}] # Positional arg narrows to one store $ pda ls laa --key "lax" --format tsv -Key Store Value TTL -lax laa 1 none +Meta Size TTL Store Key Value +-w-- 1 - laa lax 1 # --store glob filter $ pda ls --store "la?" --key "lax" --format tsv -Key Store Value TTL -lax laa 1 none -lax lab 2 none +Meta Size TTL Store Key Value +-w-- 1 - laa lax 1 +-w-- 1 - lab lax 2 $ pda ls --store "laa" --key "lax" --format tsv -Key Store Value TTL -lax laa 1 none +Meta Size TTL Store Key Value +-w-- 1 - laa lax 1 # --store cannot be combined with positional arg $ pda ls --store "laa" laa --> FAIL FAIL cannot use --store with a store argument diff --git a/testdata/list-config-columns.ct b/testdata/list-config-columns.ct index 9b369d4..d213ecc 100644 --- a/testdata/list-config-columns.ct +++ b/testdata/list-config-columns.ct @@ -7,5 +7,5 @@ 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' +$ pda config set list.default_columns meta,size,ttl,store,key,value + ok list.default_columns set to 'meta,size,ttl,store,key,value' diff --git a/testdata/list-config-hide-header.ct b/testdata/list-config-hide-header.ct index 6a2c4f9..c918f58 100644 --- a/testdata/list-config-hide-header.ct +++ b/testdata/list-config-hide-header.ct @@ -3,7 +3,7 @@ $ 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 none +-w-- 1 - lchh a 1 # Reset $ pda config set list.always_hide_header false diff --git a/testdata/list-format-csv.ct b/testdata/list-format-csv.ct index e0cff1f..284580b 100644 --- a/testdata/list-format-csv.ct +++ b/testdata/list-format-csv.ct @@ -2,6 +2,6 @@ $ pda set a@csv 1 $ pda set b@csv 2 $ pda ls csv --format csv -Key,Store,Value,TTL -a,csv,1,none -b,csv,2,none +Meta,Size,TTL,Store,Key,Value +-w--,1,-,csv,a,1 +-w--,1,-,csv,b,2 diff --git a/testdata/list-format-markdown.ct b/testdata/list-format-markdown.ct index 67da1f2..698525e 100644 --- a/testdata/list-format-markdown.ct +++ b/testdata/list-format-markdown.ct @@ -2,7 +2,7 @@ $ pda set a@md 1 $ pda set b@md 2 $ pda ls md --format markdown -| Key | Store | Value | TTL | -| --- | --- | --- | --- | -| a | md | 1 | none | -| b | md | 2 | none | +| Meta | Size | TTL | Store | Key | Value | +| --- | --- | --- | --- | --- | --- | +| -w-- | 1 | - | md | a | 1 | +| -w-- | 1 | - | md | b | 2 | diff --git a/testdata/list-key-filter.ct b/testdata/list-key-filter.ct index 57e931e..81adf66 100644 --- a/testdata/list-key-filter.ct +++ b/testdata/list-key-filter.ct @@ -2,11 +2,11 @@ $ pda set a1@lg 1 $ pda set a2@lg 2 $ pda set b1@lg 3 $ pda ls lg --key "a*" --format tsv -Key Store Value TTL -a1 lg 1 none -a2 lg 2 none +Meta Size TTL Store Key Value +-w-- 1 - lg a1 1 +-w-- 1 - lg a2 2 $ pda ls lg --key "b*" --format tsv -Key Store Value TTL -b1 lg 3 none +Meta Size TTL Store Key Value +-w-- 1 - lg b1 3 $ pda ls lg --key "c*" --> FAIL FAIL cannot ls '@lg': no matches for key pattern 'c*' diff --git a/testdata/list-key-value-filter.ct b/testdata/list-key-value-filter.ct index 1a9d094..64ab066 100644 --- a/testdata/list-key-value-filter.ct +++ b/testdata/list-key-value-filter.ct @@ -2,10 +2,10 @@ $ pda set dburl@kv postgres://localhost:5432 $ pda set apiurl@kv https://api.example.com $ pda set dbpass@kv s3cret $ pda ls kv -k "db*" -v "**localhost**" --format tsv -Key Store Value TTL -dburl kv postgres://localhost:5432 none +Meta Size TTL Store Key Value +-w-- 25 - kv dburl postgres://localhost:5432 $ pda ls kv -k "*url*" -v "**example**" --format tsv -Key Store Value TTL -apiurl kv https://api.example.com none +Meta Size TTL Store Key Value +-w-- 23 - kv apiurl https://api.example.com $ pda ls kv -k "db*" -v "**nomatch**" --> FAIL FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**' diff --git a/testdata/list-meta-column.ct b/testdata/list-meta-column.ct new file mode 100644 index 0000000..e4aa116 --- /dev/null +++ b/testdata/list-meta-column.ct @@ -0,0 +1,9 @@ +# Meta column shows ewtp flags +$ pda set plain@lmc hello +$ pda set readonly@lmc world --readonly +$ pda set pinned@lmc foo --pin +$ pda ls lmc --format tsv --no-ttl --no-size +Meta Store Key Value +-w-p lmc pinned foo +-w-- lmc plain hello +---- lmc readonly world diff --git a/testdata/list-no-header.ct b/testdata/list-no-header.ct index 92ca62b..ed7d7e6 100644 --- a/testdata/list-no-header.ct +++ b/testdata/list-no-header.ct @@ -1,4 +1,4 @@ # --no-header suppresses the header row $ pda set a@nh 1 $ pda ls nh --format tsv --no-header -a nh 1 none +-w-- 1 - nh a 1 diff --git a/testdata/list-no-keys.ct b/testdata/list-no-keys.ct index f444f6c..fe2f435 100644 --- a/testdata/list-no-keys.ct +++ b/testdata/list-no-keys.ct @@ -1,5 +1,5 @@ # --no-keys suppresses the key column $ pda set a@nk 1 $ pda ls nk --format tsv --no-keys -Store Value TTL -nk 1 none +Meta Size TTL Store Value +-w-- 1 - nk 1 diff --git a/testdata/list-no-ttl.ct b/testdata/list-no-ttl.ct index 6f9107c..e74c6bd 100644 --- a/testdata/list-no-ttl.ct +++ b/testdata/list-no-ttl.ct @@ -1,5 +1,5 @@ # --no-ttl suppresses the TTL column $ pda set a@nt 1 $ pda ls nt --format tsv --no-ttl -Key Store Value -a nt 1 +Meta Size Store Key Value +-w-- 1 nt a 1 diff --git a/testdata/list-no-values.ct b/testdata/list-no-values.ct index 388f330..35a2ed3 100644 --- a/testdata/list-no-values.ct +++ b/testdata/list-no-values.ct @@ -1,5 +1,5 @@ # --no-values suppresses the value column $ pda set a@nv 1 $ pda ls nv --format tsv --no-values -Key Store TTL -a nv none +Meta Size TTL Store Key +-w-- 1 - nv a diff --git a/testdata/list-pinned-sort.ct b/testdata/list-pinned-sort.ct new file mode 100644 index 0000000..73ddaf1 --- /dev/null +++ b/testdata/list-pinned-sort.ct @@ -0,0 +1,9 @@ +# Pinned entries sort to the top +$ pda set alpha@lps one +$ pda set beta@lps two +$ pda set gamma@lps three --pin +$ pda ls lps --format tsv --no-meta --no-size +TTL Store Key Value +- lps gamma three +- lps alpha one +- lps beta two diff --git a/testdata/list-stores.ct b/testdata/list-stores.ct index 744109a..0e813b1 100644 --- a/testdata/list-stores.ct +++ b/testdata/list-stores.ct @@ -2,8 +2,8 @@ $ pda set a@lsalpha 1 $ pda set b@lsbeta 2 $ pda ls lsalpha --format tsv -Key Store Value TTL -a lsalpha 1 none +Meta Size TTL Store Key Value +-w-- 1 - lsalpha a 1 $ pda ls lsbeta --format tsv -Key Store Value TTL -b lsbeta 2 none +Meta Size TTL Store Key Value +-w-- 1 - lsbeta b 2 diff --git a/testdata/list-value-filter.ct b/testdata/list-value-filter.ct index ecb31b8..472a2ae 100644 --- a/testdata/list-value-filter.ct +++ b/testdata/list-value-filter.ct @@ -3,13 +3,13 @@ $ fecho tmpval hello world $ pda set greeting@vt < tmpval $ pda set number@vt 42 $ pda ls vt --value "**world**" --format tsv -Key Store Value TTL -greeting vt hello world (..1 more chars) none +Meta Size TTL Store Key Value +-w-- 12 - vt greeting hello world (..1 more chars) $ pda ls vt --value "**https**" --format tsv -Key Store Value TTL -url vt https://example.com none +Meta Size TTL Store Key Value +-w-- 19 - vt url https://example.com $ pda ls vt --value "*" --format tsv -Key Store Value TTL -number vt 42 none +Meta Size TTL Store Key Value +-w-- 2 - vt number 42 $ pda ls vt --value "**nomatch**" --> FAIL FAIL cannot ls '@vt': no matches for value pattern '**nomatch**' diff --git a/testdata/list-value-multi-filter.ct b/testdata/list-value-multi-filter.ct index 4725bc7..d193479 100644 --- a/testdata/list-value-multi-filter.ct +++ b/testdata/list-value-multi-filter.ct @@ -3,6 +3,6 @@ $ fecho tmpval hello world $ pda set greeting@vm < tmpval $ pda set number@vm 42 $ pda ls vm --value "**world**" --value "42" --format tsv -Key Store Value TTL -greeting vm hello world (..1 more chars) none -number vm 42 none +Meta Size TTL Store Key Value +-w-- 12 - vm greeting hello world (..1 more chars) +-w-- 2 - vm number 42 diff --git a/testdata/meta-decrypt.ct b/testdata/meta-decrypt.ct index 6b9741c..ac2d5e0 100644 --- a/testdata/meta-decrypt.ct +++ b/testdata/meta-decrypt.ct @@ -1,9 +1,12 @@ # Decrypt an existing encrypted key $ pda set --encrypt hello@md world $ pda meta hello@md --decrypt + ok decrypted hello@md $ pda meta hello@md key: hello@md secret: false + writable: true + pinned: false expires: never $ pda get hello@md world diff --git a/testdata/meta-encrypt.ct b/testdata/meta-encrypt.ct index f6898c3..20fba04 100644 --- a/testdata/meta-encrypt.ct +++ b/testdata/meta-encrypt.ct @@ -1,9 +1,12 @@ # Encrypt an existing plaintext key $ pda set hello@me world $ pda meta hello@me --encrypt + ok encrypted hello@me $ pda meta hello@me key: hello@me secret: true + writable: true + pinned: false expires: never # Value should still be retrievable $ pda get hello@me diff --git a/testdata/meta-pin.ct b/testdata/meta-pin.ct new file mode 100644 index 0000000..cb1fa9d --- /dev/null +++ b/testdata/meta-pin.ct @@ -0,0 +1,24 @@ +# --pin marks a key as pinned +$ pda set a@mp hello +$ pda meta a@mp --pin + ok pinned a@mp +$ pda meta a@mp + key: a@mp + secret: false + writable: true + pinned: true + expires: never + +# --unpin clears the pinned flag +$ pda meta a@mp --unpin + ok unpinned a@mp +$ pda meta a@mp + key: a@mp + secret: false + writable: true + pinned: false + expires: never + +# --pin and --unpin are mutually exclusive +$ pda meta a@mp --pin --unpin --> FAIL +FAIL cannot meta 'a@mp': --pin and --unpin are mutually exclusive diff --git a/testdata/meta-readonly.ct b/testdata/meta-readonly.ct new file mode 100644 index 0000000..08bd4d7 --- /dev/null +++ b/testdata/meta-readonly.ct @@ -0,0 +1,24 @@ +# --readonly marks a key as read-only +$ pda set a@mro hello +$ pda meta a@mro --readonly + ok made readonly a@mro +$ pda meta a@mro + key: a@mro + secret: false + writable: false + pinned: false + expires: never + +# --writable clears the read-only flag +$ pda meta a@mro --writable + ok made writable a@mro +$ pda meta a@mro + key: a@mro + secret: false + writable: true + pinned: false + expires: never + +# --readonly and --writable are mutually exclusive +$ pda meta a@mro --readonly --writable --> FAIL +FAIL cannot meta 'a@mro': --readonly and --writable are mutually exclusive diff --git a/testdata/meta-ttl.ct b/testdata/meta-ttl.ct index 51fd761..70128f2 100644 --- a/testdata/meta-ttl.ct +++ b/testdata/meta-ttl.ct @@ -1,11 +1,15 @@ # Set TTL on a key, then view it (just verify no error, can't match dynamic time) $ pda set hello@mt world $ pda meta hello@mt --ttl 1h + ok set ttl to 1h hello@mt # Clear TTL with --ttl never $ pda set --ttl 1h expiring@mt val $ pda meta expiring@mt --ttl never + ok cleared ttl expiring@mt $ pda meta expiring@mt key: expiring@mt secret: false + writable: true + pinned: false expires: never diff --git a/testdata/meta.ct b/testdata/meta.ct index e13fcf9..6d4b352 100644 --- a/testdata/meta.ct +++ b/testdata/meta.ct @@ -3,6 +3,8 @@ $ pda set hello@m world $ pda meta hello@m key: hello@m secret: false + writable: true + pinned: false expires: never # View metadata for an encrypted key @@ -10,4 +12,6 @@ $ pda set --encrypt secret@m hunter2 $ pda meta secret@m key: secret@m secret: true + writable: true + pinned: false expires: never diff --git a/testdata/multistore.ct b/testdata/multistore.ct index 79e7f63..9a84cde 100644 --- a/testdata/multistore.ct +++ b/testdata/multistore.ct @@ -6,5 +6,5 @@ bar $ pda get x@ms2 y $ pda ls ms2 --format tsv -Key Store Value TTL -x ms2 y none +Meta Size TTL Store Key Value +-w-- 1 - ms2 x y diff --git a/testdata/mv-readonly.ct b/testdata/mv-readonly.ct new file mode 100644 index 0000000..20c3141 --- /dev/null +++ b/testdata/mv-readonly.ct @@ -0,0 +1,23 @@ +# Cannot move a read-only key without --force +$ pda set a@mvro hello --readonly +$ pda mv a@mvro b@mvro --> FAIL +FAIL cannot move 'a': key is read-only + +# --force bypasses read-only protection +$ pda mv a@mvro b@mvro --force + ok renamed a@mvro to b@mvro + +# Copy preserves readonly metadata +$ pda cp b@mvro c@mvro + ok copied b@mvro to c@mvro +$ pda meta c@mvro + key: c@mvro + secret: false + writable: false + pinned: false + expires: never + +# Cannot overwrite a read-only destination +$ pda set d@mvro new +$ pda mv d@mvro c@mvro --> FAIL +FAIL cannot overwrite 'c': key is read-only diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct index c30a1e5..8ea5595 100644 --- a/testdata/remove-dedupe.ct +++ b/testdata/remove-dedupe.ct @@ -2,9 +2,9 @@ $ pda set foo@rdd 1 $ pda set bar@rdd 2 $ pda ls rdd --format tsv -Key Store Value TTL -bar rdd 2 none -foo rdd 1 none +Meta Size TTL Store Key Value +-w-- 1 - rdd bar 2 +-w-- 1 - rdd foo 1 $ pda rm foo@rdd --key "*@rdd" -y $ pda get bar@rdd --> FAIL FAIL cannot get 'bar@rdd': no such key diff --git a/testdata/remove-readonly.ct b/testdata/remove-readonly.ct new file mode 100644 index 0000000..a01de99 --- /dev/null +++ b/testdata/remove-readonly.ct @@ -0,0 +1,7 @@ +# Cannot remove a read-only key without --force +$ pda set a@rmro hello --readonly +$ pda rm a@rmro --> FAIL +FAIL cannot remove 'a@rmro': key is read-only + +# --force bypasses read-only protection +$ pda rm a@rmro --force diff --git a/testdata/set-pin.ct b/testdata/set-pin.ct new file mode 100644 index 0000000..67e7f95 --- /dev/null +++ b/testdata/set-pin.ct @@ -0,0 +1,8 @@ +# --pin marks a key as pinned +$ pda set b@sp beta --pin +$ pda meta b@sp + key: b@sp + secret: false + writable: true + pinned: true + expires: never diff --git a/testdata/set-readonly.ct b/testdata/set-readonly.ct new file mode 100644 index 0000000..ac6e1bc --- /dev/null +++ b/testdata/set-readonly.ct @@ -0,0 +1,17 @@ +# --readonly marks a key as read-only +$ pda set a@sro hello --readonly +$ pda meta a@sro + key: a@sro + secret: false + writable: false + pinned: false + expires: never + +# Cannot overwrite a read-only key without --force +$ pda set a@sro world --> FAIL +FAIL cannot set 'a@sro': key is read-only + +# --force bypasses read-only protection +$ pda set a@sro world --force +$ pda get a@sro +world