feat: adds --readonly and --pin flags, and displays Size column in list by default

This commit is contained in:
Lewis Wynne 2026-02-13 18:52:34 +00:00
parent e5b6dcd187
commit 5bcd3581dd
46 changed files with 711 additions and 177 deletions

186
README.md
View file

@ -26,6 +26,7 @@
- plaintext exports in 7 different formats, - plaintext exports in 7 different formats,
- support for all [binary data](https://github.com/Llywelwyn/pda#binary), - support for all [binary data](https://github.com/Llywelwyn/pda#binary),
- expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl), - 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), - 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). 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) - [Templates](https://github.com/Llywelwyn/pda#templates)
- [Filtering](https://github.com/Llywelwyn/pda#filtering) - [Filtering](https://github.com/Llywelwyn/pda#filtering)
- [TTL](https://github.com/Llywelwyn/pda#ttl) - [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) - [Binary](https://github.com/Llywelwyn/pda#binary)
- [Encryption](https://github.com/Llywelwyn/pda#encryption) - [Encryption](https://github.com/Llywelwyn/pda#encryption)
- [Doctor](https://github.com/Llywelwyn/pda#doctor) - [Doctor](https://github.com/Llywelwyn/pda#doctor)
@ -152,6 +154,9 @@ pda set name "Alice" --safe
pda set name "Bob" --safe pda set name "Bob" --safe
pda get name pda get name
# Alice # Alice
# --readonly to protect a key from modification.
pda set api-url "https://prod.example.com" --readonly
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -202,12 +207,17 @@ pda mv name name2 --safe
pda mv name name2 -y pda mv name name2 -y
``` ```
`pda cp` to make a copy. `pda cp` to make a copy. All metadata is preserved.
```bash ```bash
pda cp name name2 pda cp name name2
# 'mv --copy' and 'cp' are aliases. Either one works. # 'mv --copy' and 'cp' are aliases. Either one works.
pda mv name name2 --copy 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
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -216,7 +226,7 @@ pda mv name name2 --copy
```bash ```bash
pda rm kitty pda rm kitty
# Remove multiple keys, within the same or different stores. # Remove multiple keys.
pda rm kitty dog@animals pda rm kitty dog@animals
# Mix exact keys with glob patterns. # Mix exact keys with glob patterns.
@ -232,32 +242,42 @@ pda rm kitty -i
# --yes/-y to auto-accept all confirmation prompts. # --yes/-y to auto-accept all confirmation prompts.
pda rm kitty -y 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
``` ```
<p align="center"></p><!-- spacer --> <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.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 ```bash
pda ls pda ls
# Key Store Value TTL # Meta Size TTL Store Key Value
# dogs default four legged mammals no expiry # -w-p 5 - store todo don't forget this
# name default Alice no expiry # ---- 23 - store url https://prod.example.com
# -w-- 5 - store name Alice
# Narrow to a single store. # Narrow to a single store.
pda ls @default pda ls @store
# Or filter stores by glob pattern. # Or filter stores by glob pattern.
pda ls --store "prod*" 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. # Or as CSV.
pda ls --format csv pda ls --format csv
# Key,Store,Value,TTL # Meta,Size,TTL,Store,Key,Value
# dogs,default,four legged mammals,no expiry # -w--,5,-,store,name,Alice
# name,default,Alice,no expiry
# Or as a JSON array. # Or as a JSON array.
pda ls --format json 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. # 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. Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value.
```bash ```bash
pda ls pda ls
# Key Value TTL # Key Value
# note this is a very long (..30 more chars) no expiry # note this is a very long (..30 more chars)
pda ls --full pda ls --full
# Key Value TTL # Key Value
# note this is a very long value that keeps on going and going no expiry # note this is a very long value that keeps on going and going
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -296,7 +316,7 @@ pda export --value "**https**"
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`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 ```bash
# Entries are routed to their original stores. # Entries are routed to their original stores.
pda import -f my_backup pda import -f my_backup
@ -330,17 +350,18 @@ pda set alice@birthdays 11/11/1998
pda list-stores pda list-stores
# Keys Size Store # Keys Size Store
# 2 1.8k @birthdays # 2 1.8k @birthdays
# 12 4.2k @default # 12 4.2k @store
# Just the names. # Just the names.
pda list-stores --short pda list-stores --short
# @birthdays # @birthdays
# @default # @store
# Check out a specific store. # Check out a specific store.
pda ls @birthdays --no-header --no-ttl pda ls @birthdays
# alice 11/11/1998 # Store Key Value
# bob 05/12/1980 # birthdays alice 11/11/1998
# birthdays bob 05/12/1980
# Export it. # Export it.
pda export birthdays > friends_birthdays 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). `*` wildcards a word or series of characters, stopping at separator boundaries (the default separators are `/-_.@:` and space).
```bash ```bash
pda ls --no-values --no-header pda ls
# cat # cat
# dog # dog
# cog # cog
@ -639,16 +660,19 @@ pda ls --key "19[90-99]"
`--value` filters by value content using the same glob syntax. `--value` filters by value content using the same glob syntax.
```bash ```bash
pda ls --value "**localhost**" pda ls --value "**localhost**"
# db-url postgres://localhost:5432 no expiry # Key Value
# db-url postgres://localhost:5432
# Combine key and value filters. # Combine key and value filters.
pda ls --key "db*" --value "**localhost**" 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. # Multiple --value patterns are OR'd.
pda ls --value "**world**" --value "42" pda ls --value "**world**" --value "42"
# greeting hello world no expiry # Key Value
# number 42 no expiry # greeting hello world
# number 42
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -682,34 +706,118 @@ pda set session2 "xyz" --ttl 54m10s
`list` shows expiration in the TTL column by default. `list` shows expiration in the TTL column by default.
```bash ```bash
pda ls pda ls
# Key Value TTL # TTL Key Value
# session 123 in 59m30s # 59m30s session 123
# session2 xyz in 51m40s # 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. `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.
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`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 ```bash
# View metadata for a key. # View metadata for a key.
pda meta session pda meta session
# key: session@default # key: session@store
# secret: false # secret: false
# expires: in 59m30s # writable: true
# pinned: false
# expires: 59m30s
# Set or change TTL. # Set or change TTL.
pda meta session --ttl 2h pda meta session --ttl 2h
# ok set ttl to 2h session
# Clear TTL. # Clear TTL.
pda meta session --ttl never pda meta session --ttl never
# ok cleared ttl session
# Encrypt an existing plaintext key. # Encrypt a key.
pda meta api-key --encrypt pda meta api-key --encrypt
# ok encrypted api-key
# Decrypt an encrypted key. # Decrypt an encrypted key.
pda meta api-key --decrypt 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
```
<p align="center"></p><!-- spacer -->
### 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
```
<p align="center"></p><!-- spacer -->
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
```
<p align="center"></p><!-- spacer -->
`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`.
<p align="center"></p><!-- spacer -->
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
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -780,7 +888,7 @@ pda export
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
`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 ```bash
pda cp api-key api-key-backup pda cp api-key api-key-backup
# still encrypted # 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. 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 ```bash
pda ls pda ls
# Key Value TTL # Meta Key Value
# api-key locked (identity file missing) no expiry # ew-- api-key locked (identity file missing)
pda get api-key pda get api-key
# FAIL cannot get 'api-key': secret is locked (identity file missing) # FAIL cannot get 'api-key': secret is locked (identity file missing)
@ -930,7 +1038,7 @@ always_encrypt = false
[store] [store]
# store name used when none is specified # store name used when none is specified
default_store_name = "default" default_store_name = "store"
# prompt y/n before deleting whole store # prompt y/n before deleting whole store
always_prompt_delete = true always_prompt_delete = true
# prompt y/n before store overwrites # prompt y/n before store overwrites
@ -945,8 +1053,8 @@ default_list_format = "table"
always_show_full_values = false always_show_full_values = false
# suppress the header row # suppress the header row
always_hide_header = false always_hide_header = false
# columns and order, accepts: key,store,value,ttl # columns and order, accepts: meta,size,ttl,store,key,value
default_columns = "key,store,value,ttl" default_columns = "meta,size,ttl,store,key,value"
[git] [git]
# auto fetch whenever a change happens # auto fetch whenever a change happens

View file

@ -104,14 +104,14 @@ func defaultConfig() Config {
AlwaysPromptOverwrite: false, AlwaysPromptOverwrite: false,
}, },
Store: StoreConfig{ Store: StoreConfig{
DefaultStoreName: "default", DefaultStoreName: "store",
AlwaysPromptDelete: true, AlwaysPromptDelete: true,
AlwaysPromptOverwrite: true, AlwaysPromptOverwrite: true,
}, },
List: ListConfig{ List: ListConfig{
AlwaysShowAllStores: true, AlwaysShowAllStores: true,
DefaultListFormat: "table", DefaultListFormat: "table",
DefaultColumns: "key,store,value,ttl", DefaultColumns: "meta,size,ttl,store,key,value",
}, },
Git: GitConfig{ Git: GitConfig{
AutoFetch: false, AutoFetch: false,

View file

@ -134,8 +134,8 @@ func TestConfigFieldsStringField(t *testing.T) {
if f.Kind != reflect.String { if f.Kind != reflect.String {
t.Errorf("store.default_store_name Kind = %v, want String", f.Kind) t.Errorf("store.default_store_name Kind = %v, want String", f.Kind)
} }
if f.Value != "default" { if f.Value != "store" {
t.Errorf("store.default_store_name Value = %v, want 'default'", f.Value) t.Errorf("store.default_store_name Value = %v, want 'store'", f.Value)
} }
return return
} }

View file

@ -107,6 +107,8 @@ func del(cmd *cobra.Command, args []string) error {
return nil return nil
} }
force, _ := cmd.Flags().GetBool("force")
var removedNames []string var removedNames []string
for _, dbName := range storeOrder { for _, dbName := range storeOrder {
st := byStore[dbName] st := byStore[dbName]
@ -123,6 +125,9 @@ func del(cmd *cobra.Command, args []string) error {
if idx < 0 { if idx < 0 {
return fmt.Errorf("cannot remove '%s': no such key", t.full) 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:]...) entries = append(entries[:idx], entries[idx+1:]...)
removedNames = append(removedNames, t.display) removedNames = append(removedNames, t.display)
} }
@ -137,6 +142,7 @@ func del(cmd *cobra.Command, args []string) error {
func init() { func init() {
delCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion") delCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion")
delCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") 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("key", "k", nil, "delete keys matching glob pattern (repeatable)")
delCmd.Flags().StringSliceP("store", "s", nil, "target stores 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)") delCmd.Flags().StringSliceP("value", "v", nil, "delete entries matching value glob pattern (repeatable)")

View file

@ -48,10 +48,21 @@ func edit(cmd *cobra.Command, args []string) error {
encryptFlag, _ := cmd.Flags().GetBool("encrypt") encryptFlag, _ := cmd.Flags().GetBool("encrypt")
decryptFlag, _ := cmd.Flags().GetBool("decrypt") decryptFlag, _ := cmd.Flags().GetBool("decrypt")
preserveNewline, _ := cmd.Flags().GetBool("preserve-newline") 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 { if encryptFlag && decryptFlag {
return fmt.Errorf("cannot edit '%s': --encrypt and --decrypt are mutually exclusive", args[0]) 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 // Load identity
var identity *age.X25519Identity var identity *age.X25519Identity
@ -87,6 +98,9 @@ func edit(cmd *cobra.Command, args []string) error {
original = nil original = nil
} else { } else {
entry = &entries[idx] entry = &entries[idx]
if entry.ReadOnly && !force {
return fmt.Errorf("cannot edit '%s': key is read-only", args[0])
}
if entry.Locked { if entry.Locked {
return fmt.Errorf("cannot edit '%s': secret is locked (identity file missing)", args[0]) 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 // Check for no-op
noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag && !readonlyFlag && !writableFlag && !pinFlag && !unpinFlag
if bytes.Equal(original, newValue) && noMetaFlags { if bytes.Equal(original, newValue) && noMetaFlags {
infof("no changes to '%s'", spec.Display()) infof("no changes to '%s'", spec.Display())
return nil return nil
@ -167,6 +181,8 @@ func edit(cmd *cobra.Command, args []string) error {
Key: spec.Key, Key: spec.Key,
Value: newValue, Value: newValue,
Secret: encryptFlag, Secret: encryptFlag,
ReadOnly: readonlyFlag,
Pinned: pinFlag,
} }
if ttlStr != "" { if ttlStr != "" {
expiresAt, err := parseTTLString(ttlStr) expiresAt, err := parseTTLString(ttlStr)
@ -199,6 +215,19 @@ func edit(cmd *cobra.Command, args []string) error {
} }
entry.Secret = false 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 { 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("encrypt", "e", false, "encrypt the value at rest")
editCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)") 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("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) rootCmd.AddCommand(editCmd)
} }

View file

@ -67,6 +67,8 @@ var columnNames = map[string]columnKind{
"key": columnKey, "key": columnKey,
"store": columnStore, "store": columnStore,
"value": columnValue, "value": columnValue,
"meta": columnMeta,
"size": columnSize,
"ttl": columnTTL, "ttl": columnTTL,
} }
@ -75,7 +77,7 @@ func validListColumns(v string) error {
for _, raw := range strings.Split(v, ",") { for _, raw := range strings.Split(v, ",") {
tok := strings.TrimSpace(raw) tok := strings.TrimSpace(raw)
if _, ok := columnNames[tok]; !ok { 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] { if seen[tok] {
return fmt.Errorf("duplicate column '%s'", tok) return fmt.Errorf("duplicate column '%s'", tok)
@ -103,7 +105,10 @@ var (
listBase64 bool listBase64 bool
listCount bool listCount bool
listNoKeys bool listNoKeys bool
listNoStore bool
listNoValues bool listNoValues bool
listNoMeta bool
listNoSize bool
listNoTTL bool listNoTTL bool
listFull bool listFull bool
listAll bool listAll bool
@ -120,6 +125,8 @@ const (
columnValue columnValue
columnTTL columnTTL
columnStore columnStore
columnMeta
columnSize
) )
var listCmd = &cobra.Command{ 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 positional argument to narrow to a single store, or use --store/-s with a
glob pattern to filter by store name. glob pattern to filter by store name.
The Store column is always shown so entries can be distinguished across Use --key/-k and --value/-v to filter by key or value glob, and --store/-s
stores. Use --key/-k and --value/-v to filter by key or value glob, and to filter by store name. All filters are repeatable and OR'd within the
--store/-s to filter by store name. All filters are repeatable and OR'd same flag.`,
within the same flag.`,
Aliases: []string{"ls"}, Aliases: []string{"ls"},
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: list, RunE: list,
@ -178,19 +184,35 @@ func list(cmd *cobra.Command, args []string) error {
targetDB = "@" + dbName targetDB = "@" + dbName
} }
if listNoKeys && listNoValues && listNoTTL { columns := parseColumns(config.List.DefaultColumns)
return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl")
// 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 len(columns) == 0 {
if listNoKeys { return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable some --no-* flags")
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 })
} }
keyPatterns, err := cmd.Flags().GetStringSlice("key") 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 { if listCount {
fmt.Fprintln(cmd.OutOrStdout(), len(filtered)) fmt.Fprintln(cmd.OutOrStdout(), len(filtered))
return nil return nil
@ -330,7 +363,7 @@ func list(cmd *cobra.Command, args []string) error {
} }
// Table-based formats // Table-based formats
showValues := !listNoValues showValues := slices.Contains(columns, columnValue)
tw := table.NewWriter() tw := table.NewWriter()
tw.SetOutputMirror(output) tw.SetOutputMirror(output)
tw.SetStyle(table.StyleDefault) tw.SetStyle(table.StyleDefault)
@ -384,10 +417,26 @@ func list(cmd *cobra.Command, args []string) error {
} }
case columnStore: case columnStore:
if tty { if tty {
row = append(row, dimStyle.Sprint(e.StoreName)) row = append(row, text.Colors{text.Bold, text.FgYellow}.Sprint(e.StoreName))
} else { } else {
row = append(row, e.StoreName) 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: case columnTTL:
ttlStr := formatExpiry(e.ExpiresAt) ttlStr := formatExpiry(e.ExpiresAt)
if tty && e.ExpiresAt == 0 { if tty && e.ExpiresAt == 0 {
@ -484,6 +533,10 @@ func headerRow(columns []columnKind, tty bool) table.Row {
row = append(row, h("Store")) row = append(row, h("Store"))
case columnValue: case columnValue:
row = append(row, h("Value")) row = append(row, h("Value"))
case columnMeta:
row = append(row, h("Meta"))
case columnSize:
row = append(row, h("Size"))
case columnTTL: case columnTTL:
row = append(row, h("TTL")) row = append(row, h("TTL"))
} }
@ -494,12 +547,13 @@ func headerRow(columns []columnKind, tty bool) table.Row {
const ( const (
keyColumnWidthCap = 30 keyColumnWidthCap = 30
storeColumnWidthCap = 20 storeColumnWidthCap = 20
sizeColumnWidthCap = 10
ttlColumnWidthCap = 20 ttlColumnWidthCap = 20
) )
// columnLayout holds the resolved max widths for each column kind. // columnLayout holds the resolved max widths for each column kind.
type columnLayout struct { 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 // 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 var lay columnLayout
termWidth := detectTerminalWidth(out) 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 { for _, e := range entries {
if w := utf8.RuneCountInString(e.Key); w > lay.key { if w := utf8.RuneCountInString(e.Key); w > lay.key {
lay.key = w 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 { if w := utf8.RuneCountInString(e.StoreName); w > lay.store {
lay.store = w 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 { if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl {
lay.ttl = w lay.ttl = w
} }
@ -527,6 +593,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
if lay.store > storeColumnWidthCap { if lay.store > storeColumnWidthCap {
lay.store = storeColumnWidthCap lay.store = storeColumnWidthCap
} }
if lay.size > sizeColumnWidthCap {
lay.size = sizeColumnWidthCap
}
if lay.ttl > ttlColumnWidthCap { if lay.ttl > ttlColumnWidthCap {
lay.ttl = ttlColumnWidthCap lay.ttl = ttlColumnWidthCap
} }
@ -541,7 +610,7 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
return lay 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 lay.value = available
for _, col := range columns { for _, col := range columns {
switch col { switch col {
@ -549,6 +618,10 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL
lay.value -= lay.key lay.value -= lay.key
case columnStore: case columnStore:
lay.value -= lay.store lay.value -= lay.store
case columnMeta:
lay.value -= lay.meta
case columnSize:
lay.value -= lay.size
case columnTTL: case columnTTL:
lay.value -= lay.ttl lay.value -= lay.ttl
} }
@ -568,31 +641,40 @@ func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay
var configs []table.ColumnConfig var configs []table.ColumnConfig
for i, col := range columns { for i, col := range columns {
var maxW int cc := table.ColumnConfig{Number: i + 1}
var enforcer func(string, int) string
switch col { switch col {
case columnKey: case columnKey:
maxW = lay.key cc.WidthMax = lay.key
enforcer = text.Trim cc.WidthMaxEnforcer = text.Trim
case columnStore: case columnStore:
maxW = lay.store cc.WidthMax = lay.store
enforcer = text.Trim cc.WidthMaxEnforcer = text.Trim
cc.Align = text.AlignRight
cc.AlignHeader = text.AlignRight
case columnValue: case columnValue:
maxW = lay.value cc.WidthMax = lay.value
if full { if full {
enforcer = text.WrapText cc.WidthMaxEnforcer = text.WrapText
} }
// When !full, values are already pre-truncated by // When !full, values are already pre-truncated by
// summariseValue — no enforcer needed. // 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: case columnTTL:
maxW = lay.ttl cc.WidthMax = lay.ttl
enforcer = text.Trim cc.WidthMaxEnforcer = text.Trim
cc.Align = text.AlignRight
cc.AlignHeader = text.AlignRight
} }
configs = append(configs, table.ColumnConfig{ configs = append(configs, cc)
Number: i + 1,
WidthMax: maxW,
WidthMaxEnforcer: enforcer,
})
} }
tw.SetColumnConfigs(configs) tw.SetColumnConfigs(configs)
} }
@ -615,6 +697,64 @@ func detectTerminalWidth(out io.Writer) int {
return 0 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) { func renderTable(tw table.Writer) {
switch listFormat.String() { switch listFormat.String() {
case "tsv": case "tsv":
@ -635,7 +775,10 @@ func init() {
listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") 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().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(&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(&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().BoolVar(&listNoTTL, "no-ttl", false, "suppress the TTL column")
listCmd.Flags().BoolVarP(&listFull, "full", "f", false, "show full values without truncation") listCmd.Flags().BoolVarP(&listFull, "full", "f", false, "show full values without truncation")
listCmd.Flags().BoolVar(&listNoHeader, "no-header", false, "suppress the header row") listCmd.Flags().BoolVar(&listNoHeader, "no-header", false, "suppress the header row")

View file

@ -2,6 +2,7 @@ package cmd
import ( import (
"fmt" "fmt"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -9,13 +10,10 @@ import (
var metaCmd = &cobra.Command{ var metaCmd = &cobra.Command{
Use: "meta KEY[@STORE]", Use: "meta KEY[@STORE]",
Short: "View or modify metadata for a key", 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: With no flags, displays the key's current metadata. Pass 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)`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: meta, RunE: meta,
SilenceUsage: true, SilenceUsage: true,
@ -52,23 +50,46 @@ func meta(cmd *cobra.Command, args []string) error {
ttlStr, _ := cmd.Flags().GetString("ttl") ttlStr, _ := cmd.Flags().GetString("ttl")
encryptFlag, _ := cmd.Flags().GetBool("encrypt") encryptFlag, _ := cmd.Flags().GetBool("encrypt")
decryptFlag, _ := cmd.Flags().GetBool("decrypt") 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 { if encryptFlag && decryptFlag {
return fmt.Errorf("cannot meta '%s': --encrypt and --decrypt are mutually exclusive", args[0]) 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 // View mode: no flags set
if ttlStr == "" && !encryptFlag && !decryptFlag { isModify := ttlStr != "" || encryptFlag || decryptFlag || readonlyFlag || writableFlag || pinFlag || unpinFlag
if !isModify {
expiresStr := "never" expiresStr := "never"
if entry.ExpiresAt > 0 { if entry.ExpiresAt > 0 {
expiresStr = formatExpiry(entry.ExpiresAt) expiresStr = formatExpiry(entry.ExpiresAt)
} }
fmt.Fprintf(cmd.OutOrStdout(), " key: %s\n", spec.Full()) fmt.Fprintf(cmd.OutOrStdout(), " key: %s\n", spec.Full())
fmt.Fprintf(cmd.OutOrStdout(), " secret: %v\n", entry.Secret) 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) fmt.Fprintf(cmd.OutOrStdout(), " expires: %s\n", expiresStr)
return nil 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 // Modification mode — may need identity for encrypt
if encryptFlag { if encryptFlag {
identity, err = ensureIdentity() 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) return fmt.Errorf("cannot meta '%s': %v", args[0], err)
} }
var changes []string
if ttlStr != "" { if ttlStr != "" {
expiresAt, err := parseTTLString(ttlStr) expiresAt, err := parseTTLString(ttlStr)
if err != nil { if err != nil {
return fmt.Errorf("cannot meta '%s': %v", args[0], err) return fmt.Errorf("cannot meta '%s': %v", args[0], err)
} }
entry.ExpiresAt = expiresAt entry.ExpiresAt = expiresAt
if expiresAt == 0 {
changes = append(changes, "cleared ttl")
} else {
changes = append(changes, "set ttl to "+ttlStr)
}
} }
if encryptFlag { 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]) return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0])
} }
entry.Secret = true entry.Secret = true
changes = append(changes, "encrypted")
} }
if decryptFlag { 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]) return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0])
} }
entry.Secret = false 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 { if err := writeStoreFile(p, entries, recipients); err != nil {
return fmt.Errorf("cannot meta '%s': %v", args[0], err) 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() { func init() {
metaCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear") 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("encrypt", "e", false, "encrypt the value at rest")
metaCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)") 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) rootCmd.AddCommand(metaCmd)
} }

View file

@ -71,6 +71,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
if err != nil { if err != nil {
return err return err
} }
force, _ := cmd.Flags().GetBool("force")
promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite) promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite)
identity, _ := loadIdentity() identity, _ := loadIdentity()
@ -103,6 +104,11 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
} }
srcEntry := srcEntries[srcIdx] 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 sameStore := fromSpec.DB == toSpec.DB
// Check destination for overwrite prompt // Check destination for overwrite prompt
@ -121,6 +127,10 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
dstIdx := findEntry(dstEntries, toSpec.Key) 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 { if safe && dstIdx >= 0 {
infof("skipped '%s': already exists", toSpec.Display()) infof("skipped '%s': already exists", toSpec.Display())
return nil 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{ newEntry := Entry{
Key: toSpec.Key, Key: toSpec.Key,
Value: srcEntry.Value, Value: srcEntry.Value,
ExpiresAt: srcEntry.ExpiresAt, ExpiresAt: srcEntry.ExpiresAt,
Secret: srcEntry.Secret, Secret: srcEntry.Secret,
Locked: srcEntry.Locked, Locked: srcEntry.Locked,
ReadOnly: srcEntry.ReadOnly,
Pinned: srcEntry.Pinned,
} }
if sameStore { if sameStore {
@ -197,9 +209,11 @@ func init() {
mvCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination") mvCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination")
mvCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") 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("safe", false, "do not overwrite if the destination already exists")
mvCmd.Flags().Bool("force", false, "bypass read-only protection")
rootCmd.AddCommand(mvCmd) rootCmd.AddCommand(mvCmd)
cpCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination") cpCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination")
cpCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") 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("safe", false, "do not overwrite if the destination already exists")
cpCmd.Flags().Bool("force", false, "bypass read-only protection")
rootCmd.AddCommand(cpCmd) rootCmd.AddCommand(cpCmd)
} }

View file

@ -43,6 +43,8 @@ type Entry struct {
ExpiresAt uint64 // Unix timestamp; 0 = never expires ExpiresAt uint64 // Unix timestamp; 0 = never expires
Secret bool // encrypted on disk Secret bool // encrypted on disk
Locked bool // secret but no identity available to decrypt 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 StoreName string // populated by list --all
} }
@ -52,6 +54,8 @@ type jsonEntry struct {
Value string `json:"value"` Value string `json:"value"`
Encoding string `json:"encoding,omitempty"` Encoding string `json:"encoding,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"`
ReadOnly *bool `json:"readonly,omitempty"`
Pinned *bool `json:"pinned,omitempty"`
Store string `json:"store,omitempty"` Store string `json:"store,omitempty"`
} }
@ -149,6 +153,8 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error)
if je.ExpiresAt != nil { if je.ExpiresAt != nil {
expiresAt = uint64(*je.ExpiresAt) expiresAt = uint64(*je.ExpiresAt)
} }
readOnly := je.ReadOnly != nil && *je.ReadOnly
pinned := je.Pinned != nil && *je.Pinned
if je.Encoding == "secret" { if je.Encoding == "secret" {
ciphertext, err := base64.StdEncoding.DecodeString(je.Value) 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) return Entry{}, fmt.Errorf("decode secret for '%s': %w", je.Key, err)
} }
if identity == nil { 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) plaintext, err := decrypt(ciphertext, identity)
if err != nil { if err != nil {
warnf("cannot decrypt '%s': %v", je.Key, err) 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 var value []byte
@ -179,7 +185,7 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error)
default: default:
return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key) 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) { 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) ts := int64(e.ExpiresAt)
je.ExpiresAt = &ts je.ExpiresAt = &ts
} }
if e.ReadOnly {
t := true
je.ReadOnly = &t
}
if e.Pinned {
t := true
je.Pinned = &t
}
if e.Secret && e.Locked { if e.Secret && e.Locked {
// Passthrough: Value holds raw ciphertext, re-encode as-is // Passthrough: Value holds raw ciphertext, re-encode as-is

View file

@ -133,8 +133,14 @@ func set(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot set '%s': %v", args[0], err) return fmt.Errorf("cannot set '%s': %v", args[0], err)
} }
force, _ := cmd.Flags().GetBool("force")
idx := findEntry(entries, spec.Key) 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 { if safe && idx >= 0 {
infof("skipped '%s': already exists", spec.Display()) infof("skipped '%s': already exists", spec.Display())
return nil 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{ entry := Entry{
Key: spec.Key, Key: spec.Key,
Value: value, Value: value,
Secret: secret, Secret: secret,
ReadOnly: readonlyFlag,
Pinned: pinFlag,
} }
if ttl != 0 { if ttl != 0 {
entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix()) 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("interactive", "i", false, "prompt before overwriting an existing key")
setCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest using age") 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("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") setCmd.Flags().StringP("file", "f", "", "read value from a file")
} }

View file

@ -262,14 +262,14 @@ func validateDBName(name string) error {
func formatExpiry(expiresAt uint64) string { func formatExpiry(expiresAt uint64) string {
if expiresAt == 0 { if expiresAt == 0 {
return "none" return "-"
} }
expiry := time.Unix(int64(expiresAt), 0).UTC() expiry := time.Unix(int64(expiresAt), 0).UTC()
remaining := time.Until(expiry) remaining := time.Until(expiry)
if remaining <= 0 { if remaining <= 0 {
return "expired" 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") // parseTTLString parses a TTL string that is either a duration (e.g. "30m", "2h")

View file

@ -2,7 +2,7 @@ $ pda config get display_ascii_art
true true
$ pda config get store.default_store_name $ pda config get store.default_store_name
default store
$ pda config get git.auto_commit $ pda config get git.auto_commit
false false

View file

@ -4,14 +4,14 @@ key.always_prompt_delete = false
key.always_prompt_glob_delete = true key.always_prompt_glob_delete = true
key.always_prompt_overwrite = false key.always_prompt_overwrite = false
key.always_encrypt = false key.always_encrypt = false
store.default_store_name = default store.default_store_name = store
store.always_prompt_delete = true store.always_prompt_delete = true
store.always_prompt_overwrite = true store.always_prompt_overwrite = true
list.always_show_all_stores = true list.always_show_all_stores = true
list.default_list_format = table list.default_list_format = table
list.always_show_full_values = false list.always_show_full_values = false
list.always_hide_header = 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_fetch = false
git.auto_commit = false git.auto_commit = false
git.auto_push = false git.auto_push = false

View file

@ -32,7 +32,7 @@ json
# Invalid list columns # Invalid list columns
$ pda config set list.default_columns foo --> FAIL $ 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 # Duplicate columns
$ pda config set list.default_columns key,key --> FAIL $ 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'? hint did you mean 'git.auto_commit'?
# Reset changed values so subsequent tests see defaults # 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_list_format table
$ pda config set list.default_columns key,store,value,ttl $ pda config set list.default_columns meta,size,ttl,store,key,value
ok store.default_store_name set to 'default' ok store.default_store_name set to 'store'
ok list.default_list_format set to 'table' 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'

20
testdata/help-list.ct vendored
View file

@ -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 positional argument to narrow to a single store, or use --store/-s with a
glob pattern to filter by store name. glob pattern to filter by store name.
The Store column is always shown so entries can be distinguished across Use --key/-k and --value/-v to filter by key or value glob, and --store/-s
stores. Use --key/-k and --value/-v to filter by key or value glob, and to filter by store name. All filters are repeatable and OR'd within the
--store/-s to filter by store name. All filters are repeatable and OR'd same flag.
within the same flag.
Usage: Usage:
pda list [STORE] [flags] pda list [STORE] [flags]
@ -27,6 +26,9 @@ Flags:
-k, --key strings filter keys with glob pattern (repeatable) -k, --key strings filter keys with glob pattern (repeatable)
--no-header suppress the header row --no-header suppress the header row
--no-keys suppress the key column --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-ttl suppress the TTL column
--no-values suppress the value column --no-values suppress the value column
-s, --store strings filter stores with glob pattern (repeatable) -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 positional argument to narrow to a single store, or use --store/-s with a
glob pattern to filter by store name. glob pattern to filter by store name.
The Store column is always shown so entries can be distinguished across Use --key/-k and --value/-v to filter by key or value glob, and --store/-s
stores. Use --key/-k and --value/-v to filter by key or value glob, and to filter by store name. All filters are repeatable and OR'd within the
--store/-s to filter by store name. All filters are repeatable and OR'd same flag.
within the same flag.
Usage: Usage:
pda list [STORE] [flags] pda list [STORE] [flags]
@ -58,6 +59,9 @@ Flags:
-k, --key strings filter keys with glob pattern (repeatable) -k, --key strings filter keys with glob pattern (repeatable)
--no-header suppress the header row --no-header suppress the header row
--no-keys suppress the key column --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-ttl suppress the TTL column
--no-values suppress the value column --no-values suppress the value column
-s, --store strings filter stores with glob pattern (repeatable) -s, --store strings filter stores with glob pattern (repeatable)

View file

@ -9,6 +9,7 @@ Aliases:
remove, rm remove, rm
Flags: Flags:
--force bypass read-only protection
-h, --help help for remove -h, --help help for remove
-i, --interactive prompt yes/no for each deletion -i, --interactive prompt yes/no for each deletion
-k, --key strings delete keys matching glob pattern (repeatable) -k, --key strings delete keys matching glob pattern (repeatable)
@ -24,6 +25,7 @@ Aliases:
remove, rm remove, rm
Flags: Flags:
--force bypass read-only protection
-h, --help help for remove -h, --help help for remove
-i, --interactive prompt yes/no for each deletion -i, --interactive prompt yes/no for each deletion
-k, --key strings delete keys matching glob pattern (repeatable) -k, --key strings delete keys matching glob pattern (repeatable)

View file

@ -23,8 +23,11 @@ Aliases:
Flags: Flags:
-e, --encrypt encrypt the value at rest using age -e, --encrypt encrypt the value at rest using age
-f, --file string read value from a file -f, --file string read value from a file
--force bypass read-only protection
-h, --help help for set -h, --help help for set
-i, --interactive prompt before overwriting an existing key -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 --safe do not overwrite if the key already exists
-t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m) -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. Set a key to a given value or stdin. Optionally specify a store.
@ -50,7 +53,10 @@ Aliases:
Flags: Flags:
-e, --encrypt encrypt the value at rest using age -e, --encrypt encrypt the value at rest using age
-f, --file string read value from a file -f, --file string read value from a file
--force bypass read-only protection
-h, --help help for set -h, --help help for set
-i, --interactive prompt before overwriting an existing key -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 --safe do not overwrite if the key already exists
-t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m) -t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m)

View file

@ -1,5 +1,5 @@
# Error when all columns are suppressed # Error when all columns are suppressed
$ pda set a@las 1 $ 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 FAIL cannot ls '@las': no columns selected
hint disable --no-keys, --no-values, or --no-ttl hint disable some --no-* flags

20
testdata/list-all.ct vendored
View file

@ -2,25 +2,25 @@
$ pda set lax@laa 1 $ pda set lax@laa 1
$ pda set lax@lab 2 $ pda set lax@lab 2
$ pda ls --key "lax" --format tsv $ pda ls --key "lax" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
lax laa 1 none -w-- 1 - laa lax 1
lax lab 2 none -w-- 1 - lab lax 2
$ pda ls --key "lax" --count $ pda ls --key "lax" --count
2 2
$ pda ls --key "lax" --format json $ pda ls --key "lax" --format json
[{"key":"lax","value":"1","encoding":"text","store":"laa"},{"key":"lax","value":"2","encoding":"text","store":"lab"}] [{"key":"lax","value":"1","encoding":"text","store":"laa"},{"key":"lax","value":"2","encoding":"text","store":"lab"}]
# Positional arg narrows to one store # Positional arg narrows to one store
$ pda ls laa --key "lax" --format tsv $ pda ls laa --key "lax" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
lax laa 1 none -w-- 1 - laa lax 1
# --store glob filter # --store glob filter
$ pda ls --store "la?" --key "lax" --format tsv $ pda ls --store "la?" --key "lax" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
lax laa 1 none -w-- 1 - laa lax 1
lax lab 2 none -w-- 1 - lab lax 2
$ pda ls --store "laa" --key "lax" --format tsv $ pda ls --store "laa" --key "lax" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
lax laa 1 none -w-- 1 - laa lax 1
# --store cannot be combined with positional arg # --store cannot be combined with positional arg
$ pda ls --store "laa" laa --> FAIL $ pda ls --store "laa" laa --> FAIL
FAIL cannot use --store with a store argument FAIL cannot use --store with a store argument

View file

@ -7,5 +7,5 @@ Key Value
a 1 a 1
# Reset # Reset
$ pda config set list.default_columns key,store,value,ttl $ pda config set list.default_columns meta,size,ttl,store,key,value
ok list.default_columns set to 'key,store,value,ttl' ok list.default_columns set to 'meta,size,ttl,store,key,value'

View file

@ -3,7 +3,7 @@ $ pda config set list.always_hide_header true
$ pda set a@lchh 1 $ pda set a@lchh 1
$ pda ls lchh --format tsv $ pda ls lchh --format tsv
ok list.always_hide_header set to 'true' ok list.always_hide_header set to 'true'
a lchh 1 none -w-- 1 - lchh a 1
# Reset # Reset
$ pda config set list.always_hide_header false $ pda config set list.always_hide_header false

View file

@ -2,6 +2,6 @@
$ pda set a@csv 1 $ pda set a@csv 1
$ pda set b@csv 2 $ pda set b@csv 2
$ pda ls csv --format csv $ pda ls csv --format csv
Key,Store,Value,TTL Meta,Size,TTL,Store,Key,Value
a,csv,1,none -w--,1,-,csv,a,1
b,csv,2,none -w--,1,-,csv,b,2

View file

@ -2,7 +2,7 @@
$ pda set a@md 1 $ pda set a@md 1
$ pda set b@md 2 $ pda set b@md 2
$ pda ls md --format markdown $ pda ls md --format markdown
| Key | Store | Value | TTL | | Meta | Size | TTL | Store | Key | Value |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| a | md | 1 | none | | -w-- | 1 | - | md | a | 1 |
| b | md | 2 | none | | -w-- | 1 | - | md | b | 2 |

View file

@ -2,11 +2,11 @@ $ pda set a1@lg 1
$ pda set a2@lg 2 $ pda set a2@lg 2
$ pda set b1@lg 3 $ pda set b1@lg 3
$ pda ls lg --key "a*" --format tsv $ pda ls lg --key "a*" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
a1 lg 1 none -w-- 1 - lg a1 1
a2 lg 2 none -w-- 1 - lg a2 2
$ pda ls lg --key "b*" --format tsv $ pda ls lg --key "b*" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
b1 lg 3 none -w-- 1 - lg b1 3
$ pda ls lg --key "c*" --> FAIL $ pda ls lg --key "c*" --> FAIL
FAIL cannot ls '@lg': no matches for key pattern 'c*' FAIL cannot ls '@lg': no matches for key pattern 'c*'

View file

@ -2,10 +2,10 @@ $ pda set dburl@kv postgres://localhost:5432
$ pda set apiurl@kv https://api.example.com $ pda set apiurl@kv https://api.example.com
$ pda set dbpass@kv s3cret $ pda set dbpass@kv s3cret
$ pda ls kv -k "db*" -v "**localhost**" --format tsv $ pda ls kv -k "db*" -v "**localhost**" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
dburl kv postgres://localhost:5432 none -w-- 25 - kv dburl postgres://localhost:5432
$ pda ls kv -k "*url*" -v "**example**" --format tsv $ pda ls kv -k "*url*" -v "**example**" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
apiurl kv https://api.example.com none -w-- 23 - kv apiurl https://api.example.com
$ pda ls kv -k "db*" -v "**nomatch**" --> FAIL $ pda ls kv -k "db*" -v "**nomatch**" --> FAIL
FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**' FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**'

9
testdata/list-meta-column.ct vendored Normal file
View file

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

View file

@ -1,4 +1,4 @@
# --no-header suppresses the header row # --no-header suppresses the header row
$ pda set a@nh 1 $ pda set a@nh 1
$ pda ls nh --format tsv --no-header $ pda ls nh --format tsv --no-header
a nh 1 none -w-- 1 - nh a 1

View file

@ -1,5 +1,5 @@
# --no-keys suppresses the key column # --no-keys suppresses the key column
$ pda set a@nk 1 $ pda set a@nk 1
$ pda ls nk --format tsv --no-keys $ pda ls nk --format tsv --no-keys
Store Value TTL Meta Size TTL Store Value
nk 1 none -w-- 1 - nk 1

View file

@ -1,5 +1,5 @@
# --no-ttl suppresses the TTL column # --no-ttl suppresses the TTL column
$ pda set a@nt 1 $ pda set a@nt 1
$ pda ls nt --format tsv --no-ttl $ pda ls nt --format tsv --no-ttl
Key Store Value Meta Size Store Key Value
a nt 1 -w-- 1 nt a 1

View file

@ -1,5 +1,5 @@
# --no-values suppresses the value column # --no-values suppresses the value column
$ pda set a@nv 1 $ pda set a@nv 1
$ pda ls nv --format tsv --no-values $ pda ls nv --format tsv --no-values
Key Store TTL Meta Size TTL Store Key
a nv none -w-- 1 - nv a

9
testdata/list-pinned-sort.ct vendored Normal file
View file

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

View file

@ -2,8 +2,8 @@
$ pda set a@lsalpha 1 $ pda set a@lsalpha 1
$ pda set b@lsbeta 2 $ pda set b@lsbeta 2
$ pda ls lsalpha --format tsv $ pda ls lsalpha --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
a lsalpha 1 none -w-- 1 - lsalpha a 1
$ pda ls lsbeta --format tsv $ pda ls lsbeta --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
b lsbeta 2 none -w-- 1 - lsbeta b 2

View file

@ -3,13 +3,13 @@ $ fecho tmpval hello world
$ pda set greeting@vt < tmpval $ pda set greeting@vt < tmpval
$ pda set number@vt 42 $ pda set number@vt 42
$ pda ls vt --value "**world**" --format tsv $ pda ls vt --value "**world**" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
greeting vt hello world (..1 more chars) none -w-- 12 - vt greeting hello world (..1 more chars)
$ pda ls vt --value "**https**" --format tsv $ pda ls vt --value "**https**" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
url vt https://example.com none -w-- 19 - vt url https://example.com
$ pda ls vt --value "*" --format tsv $ pda ls vt --value "*" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
number vt 42 none -w-- 2 - vt number 42
$ pda ls vt --value "**nomatch**" --> FAIL $ pda ls vt --value "**nomatch**" --> FAIL
FAIL cannot ls '@vt': no matches for value pattern '**nomatch**' FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'

View file

@ -3,6 +3,6 @@ $ fecho tmpval hello world
$ pda set greeting@vm < tmpval $ pda set greeting@vm < tmpval
$ pda set number@vm 42 $ pda set number@vm 42
$ pda ls vm --value "**world**" --value "42" --format tsv $ pda ls vm --value "**world**" --value "42" --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
greeting vm hello world (..1 more chars) none -w-- 12 - vm greeting hello world (..1 more chars)
number vm 42 none -w-- 2 - vm number 42

View file

@ -1,9 +1,12 @@
# Decrypt an existing encrypted key # Decrypt an existing encrypted key
$ pda set --encrypt hello@md world $ pda set --encrypt hello@md world
$ pda meta hello@md --decrypt $ pda meta hello@md --decrypt
ok decrypted hello@md
$ pda meta hello@md $ pda meta hello@md
key: hello@md key: hello@md
secret: false secret: false
writable: true
pinned: false
expires: never expires: never
$ pda get hello@md $ pda get hello@md
world world

View file

@ -1,9 +1,12 @@
# Encrypt an existing plaintext key # Encrypt an existing plaintext key
$ pda set hello@me world $ pda set hello@me world
$ pda meta hello@me --encrypt $ pda meta hello@me --encrypt
ok encrypted hello@me
$ pda meta hello@me $ pda meta hello@me
key: hello@me key: hello@me
secret: true secret: true
writable: true
pinned: false
expires: never expires: never
# Value should still be retrievable # Value should still be retrievable
$ pda get hello@me $ pda get hello@me

24
testdata/meta-pin.ct vendored Normal file
View file

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

24
testdata/meta-readonly.ct vendored Normal file
View file

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

View file

@ -1,11 +1,15 @@
# Set TTL on a key, then view it (just verify no error, can't match dynamic time) # Set TTL on a key, then view it (just verify no error, can't match dynamic time)
$ pda set hello@mt world $ pda set hello@mt world
$ pda meta hello@mt --ttl 1h $ pda meta hello@mt --ttl 1h
ok set ttl to 1h hello@mt
# Clear TTL with --ttl never # Clear TTL with --ttl never
$ pda set --ttl 1h expiring@mt val $ pda set --ttl 1h expiring@mt val
$ pda meta expiring@mt --ttl never $ pda meta expiring@mt --ttl never
ok cleared ttl expiring@mt
$ pda meta expiring@mt $ pda meta expiring@mt
key: expiring@mt key: expiring@mt
secret: false secret: false
writable: true
pinned: false
expires: never expires: never

4
testdata/meta.ct vendored
View file

@ -3,6 +3,8 @@ $ pda set hello@m world
$ pda meta hello@m $ pda meta hello@m
key: hello@m key: hello@m
secret: false secret: false
writable: true
pinned: false
expires: never expires: never
# View metadata for an encrypted key # View metadata for an encrypted key
@ -10,4 +12,6 @@ $ pda set --encrypt secret@m hunter2
$ pda meta secret@m $ pda meta secret@m
key: secret@m key: secret@m
secret: true secret: true
writable: true
pinned: false
expires: never expires: never

View file

@ -6,5 +6,5 @@ bar
$ pda get x@ms2 $ pda get x@ms2
y y
$ pda ls ms2 --format tsv $ pda ls ms2 --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
x ms2 y none -w-- 1 - ms2 x y

23
testdata/mv-readonly.ct vendored Normal file
View file

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

View file

@ -2,9 +2,9 @@
$ pda set foo@rdd 1 $ pda set foo@rdd 1
$ pda set bar@rdd 2 $ pda set bar@rdd 2
$ pda ls rdd --format tsv $ pda ls rdd --format tsv
Key Store Value TTL Meta Size TTL Store Key Value
bar rdd 2 none -w-- 1 - rdd bar 2
foo rdd 1 none -w-- 1 - rdd foo 1
$ pda rm foo@rdd --key "*@rdd" -y $ pda rm foo@rdd --key "*@rdd" -y
$ pda get bar@rdd --> FAIL $ pda get bar@rdd --> FAIL
FAIL cannot get 'bar@rdd': no such key FAIL cannot get 'bar@rdd': no such key

7
testdata/remove-readonly.ct vendored Normal file
View file

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

8
testdata/set-pin.ct vendored Normal file
View file

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

17
testdata/set-readonly.ct vendored Normal file
View file

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