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,
- 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
```
<p align="center"></p><!-- spacer -->
@ -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
```
<p align="center"></p><!-- spacer -->
@ -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
```
<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
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
```
<p align="center"></p><!-- spacer -->
@ -296,7 +316,7 @@ pda export --value "**https**"
<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
# 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
```
<p align="center"></p><!-- spacer -->
@ -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.
<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
# 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
```
<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 -->
@ -780,7 +888,7 @@ pda export
<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
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

20
testdata/list-all.ct vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
$ pda set a@nh 1
$ 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
$ 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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

4
testdata/meta.ct vendored
View file

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

View file

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

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

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