diff --git a/.gitignore b/.gitignore
index 7e6986f..4cae49a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
.cache
.gocache
.build
-.worktrees
diff --git a/README.md b/README.md
index 097a7b6..3d39640 100644
--- a/README.md
+++ b/README.md
@@ -19,2217 +19,646 @@
`pda!` is a command-line key-value store tool with:
-- [templates](#templates) supporting arbitrary shell execution, conditionals, loops, more,
-- [encryption](#encryption) at rest using [age](https://github.com/FiloSottile/age),
-- Git-backed [version control](#git) with automatic syncing,
-- [search and filtering](#filtering) by key, value, or store,
-- plaintext exports in 7 different formats,
-- support for all [binary data](#binary-data),
-- expiring keys with a [time-to-live](#ttl),
-- [read-only](#read-only) keys and [pinned](#pinned) entries,
-- built-in [diagnostics](#doctor) and [configuration](#config),
+- [templates](https://github.com/Llywelwyn/pda#templates),
+- search and filtering with [globs](https://github.com/Llywelwyn/pda#globs),
+- Git-backed [version control](https://github.com/Llywelwyn/pda#git),
+- plaintext exports in multiple formats,
+- [secrets](https://github.com/Llywelwyn/pda#secrets),
+- support for [binary data](https://github.com/Llywelwyn/pda#binary),
+- [time-to-live](https://github.com/Llywelwyn/pda#ttl) support,
and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb).
-`pda` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. [`pda list`](#listing) outputs tabular data by default, but also supports [CSV](https://en.wikipedia.org/wiki/Comma-separated_values), [TSV](https://en.wikipedia.org/wiki/Tab-separated_values), [Markdown](https://en.wikipedia.org/wiki/Markdown) and [HTML](https://en.wikipedia.org/wiki/HTML) tables, [JSON](https://en.wikipedia.org/wiki/JSON), and raw NDJSON. Everything is in plaintext to make version control easy, and to avoid tying anybody to using this tool forever.
-
-Git versioning can be initiated with [`pda init`](#git), and varying levels of automation can be toggled via the [config](#config): `git.autocommit`, `git.autofetch`, and `git.autopush`. Running Git operations on every change can be slow, but a commit is fast. A happy middle-ground is enabling `git.autocommit` and doing the rest manually via [`pda sync`](#git) when changing devices.
-
-[Templates](#templates) are a primary feature, enabling values to make use of substitutions, environment variables, arbitrary shell execution, cross-references to other keys, and more.
+`pda` canonically stores key-value pairs in [badger](https://github.com/dgraph-io/badger) databases for the sake of speed, but supports exporting everything out to a handful of different plaintext formats too, including but not limited to [CSV](https://en.wikipedia.org/wiki/Comma-separated_values), [TSV](https://en.wikipedia.org/wiki/Tab-separated_values), [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON), and [Markdown](https://en.wikipedia.org/wiki/Markdown) and [HTML](https://en.wikipedia.org/wiki/HTML_element#Tables) tables. `pda` uses newline-delimited JSON for version control; a full snapshot of every existing key-value pair across all stores can be manually requested with the snapshot command, or auto-commit can be enabled in the config to automatically generate a descriptive commit for every change made.
+### Contents
+
+- [Overview](https://github.com/Llywelwyn/pda#overview)
+- [Installation](https://github.com/Llywelwyn/pda#installation)
+- [Get Started](https://github.com/Llywelwyn/pda#get-started)
+- [Git-backed version control](https://github.com/Llywelwyn/pda#git)
+- [Templates](https://github.com/Llywelwyn/pda#templates)
+- [Globs](https://github.com/Llywelwyn/pda#globs)
+- [Secrets](https://github.com/Llywelwyn/pda#secrets)
+- [TTL](https://github.com/Llywelwyn/pda#ttl)
+- [Binary](https://github.com/Llywelwyn/pda#binary)
+- [Environment](https://github.com/Llywelwyn/pda#environment)
+
+
+
+### Overview
+
+ ```bash
+Available Commands:
+ get # Get a value.
+ set # Set a value.
+ cp # Copy a value.
+ mv # Move a value.
+ del # Delete a value.
+ del-db # Delete a whole database.
+ list-dbs # List all databases.
+ dump # Export a database as NDJSON.
+ restore # Imports NDJSON into a database.
+ completion # Generate autocompletions for a specified shell.
+ help # Additional help for any command.
+ version # Current version.
+ vcs # List version control subcommands.
+```
+
+
+
### Installation
-
-
- ↑
- Prerequisites ·
- Build ·
- Shell Completion
-
-
-
-#### Prerequisites
-
-`pda` has no mandatory requirements outside of a shell to run it in. However, it is enhanced by other tools being installed.
-
-- [go](https://go.dev) is needed for compiling the `pda` binary.
-- [git](https://git-scm.com) enhances `pda` with [version control](#git).
-
-#### Build
-
-The easiest (and most universal) way to install `pda` is to use `go install` to build from source. The same command can be used to update.
-
```bash
+# Get the latest release from the AUR
+yay -S pda
+
+# Or use pda-git for the latest commit
+yay -S pda-git
+
+# Go install
go install github.com/llywelwyn/pda@latest
-# Or from a spceific commit.
+# Or
git clone https://github.com/llywelwyn/pda
cd pda
go install
```
-[Arch Linux](https://archlinux.org) users can install and update `pda` from the [aur](https://aur.archlinux.org) with a package manager of choice. There are two packages available: `pda`, the latest stable release, and `pda-git`, which will install the latest commit to the main branch on this repository.
-
-```bash
-# Latest stable release
-yay -S pda
-
-# Latest commit
-yay -S pda-git
-
-# Updating
-yay -Syu pda
-```
-
-#### Setting up Shell Completion
-
-`pda` is built with [cobra](https://cobra.dev) and so comes with shell completions for bash, zsh, fish, and powershell.
-
-```bash
-# Bash
-pda completion bash > /etc/bash_completion.d/pda
-
-# Zsh
-pda completion zsh > "${fpath[1]}/_pda"
-
-# Fish
-pda completion fish > ~/.config/fish/completions/pda.fish
-
-# Powershell
-pda completion powershell | Out-String | Invoke-Expression
-```
-
-Powershell users will need to manually add the above command to their profile; the given command will only instantiate `pda` for the current shell instance.
-
-## Overview
-
-
-
-
-
-Most commands have aliases and flags. Run `pda help [command]` to see them.
-
-### Key commands
-
-
-
- ↑ ·
- Setting,
- Getting,
- Running,
- Listing,
- Editing,
- Moving and Copying,
- Removing
-
-
-
-Use of `pda` revolves around creating keys with [`pda set`](#setting) and later retrieving them with [`pda get`](#getting). Keys can belong to a single store which can be set manually or left to default to the default store. Keys can be modified with [`pda edit`](#editing) and [`pda meta`](#metadata) for content or metadata editing respectively, and can be listed with [`pda list`](#listing). Keys are written as `KEY[@STORE]`. The default store can be configured with `store.default_store_name`.
-
-Keys are capable of storing any arbitrary bytes and are not limited to just text.
-
-Advanced usage of `pda` revolves around [templates](#templates) and [`pda run`](#running).
-
-#### Setting
-
-
-
- ↑ ·
- pda set
-
-
-
-[`pda set`](#setting) (alias: [`s`](#setting)) creates a key-value pair, and requires a key and a value as inputs. The first argument given will always be used to determine the key (and store). Values can come from arguments, stdin, or a file.
+### Get Started
+`pda set` to save a key.
```bash
-# create a key-value pair
+# From arguments
pda set name "Alice"
-# create a key-value pair in the "Favourites" store
-pda set movie@favourites "The Road"
-
-# create a key-value pair with piped input
-echo "Bob" | pda set name
-
-# create a key-value pair with redirection
-pda set example < silmarillion.txt
-
-# create a pinned key-value pair from a file
-pda set --pin example --file example.md
-
-# create an encrypted key-value pair, expiring in one day
-pda set secret "Secret data." --encrypt --ttl 24h
+# From stdin
+echo "Alice" | pda set name
+cat dogs.txt | pda set dogs
+pda set kitty < cat.png
```
-The `interactive` and `safe` flags exist to prevent accidentally overwriting an existing key when creating a new one. These flags exist on all writable commands.
+
+`pda get` to retrieve it.
```bash
-# prevent ever overwriting an existing key
-pda set name "Bob" --safe
+pda get name
+# Alice
-# guarantee a prompt when overwriting an existing key
-pda set name "Joe" --interactive
+# Or run it directly.
+pda get name --run
```
-Making a key `readonly` will also prevent unintended changes. It prevents making any changes unless `force` is passed or the key is made writable once again with [`pda edit`](#editing) or [`pda meta`](#metadata).
+
+`pda mv` to move it.
```bash
-# create a readonly key-value pair
-pda set repo "https://github.com/llywelwyn/pda" --readonly
-
-# force-overwrite a readonly key-value pair
-pda set dog "A four-legged mammal that isn't a cat." --force
+pda mv name name2
+# renamed name to name2
```
-#### Getting
-
-
-
- ↑ ·
- Templates ·
- pda get
-
-
-
-[`pda get`](#getting) (alias: [`g`](#getting)) can be used to retrieve a key's value, and takes one argument: the desired key. The value is output to stdout. [Templates](#templates) are evaluated at retrieval time unless opted-out via the `no-template` flag.
-
+`pda cp` to make a copy.
```bash
-# get the value of a key
-❯ pda get name
-Alice
+pda cp name name2
+
+# 'mv --copy' and 'cp' are aliases. Either one works.
+pda mv name name2 --copy
```
-As mentioned in [setting](#setting), values support any arbitrary bytes. Values which are not valid UTF8 are retrieved slightly differently. Printing raw bytes directly in the terminal can (and will) cause [undefined behaviour](https://en.wikipedia.org/wiki/Undefined_behavior), so if a TTY is detected then a raw `pda get` will return instead some metadata about the contents of the bytes. In a non-TTY setting (when the data is piped or redirected), the raw bytes will be returned as expected.
-
-If a representation of the bytes in a TTY is desired, the `base64` flag provides a safe way to view them.
+
+`pda del` to delete one or more keys.
```bash
-# get the information of a non-UTF8 key
-❯ pda get cat_gif
-(size: 101.2k, image/gif)
+pda del kitty
+# remove "kitty": are you sure? [y/n]
+# y
-# get the raw bytes of a non-UTF8 key via pipe
-pda get cat_gif | xdg-open
+# Or skip the prompt.
+pda del kitty --force
-# get the raw bytes of a non-UTF8 key via redirect
-pda get cat_gif > cat.gif
+# Remove multiple keys, within the same or different stores.
+pda del kitty dog@animals
+# remove "kitty", "dog@animals": are you sure? [y/n]
+# y
-# get the base64 representation of a non-UTF8 key
-❯ pda get cat_gif --base64
-R0lGODlhXANYAvf/MQAAAAEBAQICAgMDAwQEBAUFBQYGBggI...
+# Mix exact keys with globs.
+pda set cog "cogs"
+pda set dog "doggy"
+pda set kitty "cat"
+pda del kitty --glob ?og
+# remove "kitty", "cog", "dog": are you sure? [y/n]
+# y
+# Default glob separators: "/-_.@: " (space included). Override with --glob-sep.
```
-The existence of a key can be checked with `exists`. It returns a `0 exit code` on an existent key, or a `1 exit code` on a non-existent one. This is primarily useful for scripting.
+
+`pda ls` to see what you've got stored.
```bash
-# check if an existent key exists
-❯ pda get name --exists
-exit code 0
+pda ls
+# name Alice
+# dogs four legged mammals
-# check if a non-existent key exists
-❯ pda get nlammfd --exists
-exit code 1
+# Or as CSV.
+pda ls --format csv
+# name,Alice
+# dogs,four legged mammals
+
+# Or TSV, or Markdown, or HTML.
```
-Running [`pda get`](#getting) will resolve templates in the stored key at run-time. This can be prevented with the `no-template` flag.
+
+`pda dump` to export everything as NDJSON.
```bash
-# set key "user" to a template of the USER environment variable
-❯ pda set user "{{ env "USER" }}"
+pda dump > my_backup
-# get a templated key
-❯ pda get user
-lew
-
-# get a templated key without resolving the template
-❯ pda get user --no-template
-{{ env "USER" }}
+# Dump only matching keys.
+pda dump --glob a*
```
-#### Running
-
-
-
- ↑ ·
- pda get --run ·
- pda run
-
-
-
-[`pda run`](#running) retrieves a key and executes it as a shell command. It uses the shell set in `$SHELL. If, somehow, this environment variable is unset, it falls back and attempts to use `/bin/sh`.
-
-Running takes one argument: the key. [`pda run`](#running) and [`pda get --run`](#getting) are functionally equivalent.
+
+`pda restore` to import it all back.
```bash
-# create a key containg a script
-❯ pda set my_script "echo Hello, world."
+# Restore with an argument.
+pda restore -f my_backup
+# Restored 2 entries into @default.
-# get and run a key using $SHELL
-❯ pda get my_script --run
-Hello, world.
+# Or from stdin.
+pda restore < my_backup
+# Restored 2 entries into @default.
-❯ pda run my_script
-Hello, world.
+# Restore only matching keys.
+pda restore --glob a* -f my_backup
```
-[Templates](#templates) are fully resolved before any shell execution happens.
+
+You can have as many stores as you want.
```bash
-# create a key containing a script, and a template
-❯ pda set greet 'echo "Hello, {{ default "Jane Doe" .NAME }}"'
+# Save to a spceific store.
+pda set alice@birthdays 11/11/1998
-# run the key directly in $SHELL
-❯ pda run greet
-Hello, Jane Doe
+# See which stores have contents.
+pda list-dbs
+# @default
+# @birthdays
-# run the key, setting NAME to "Alice"
-❯ pda run greet NAME="Alice"
-Hello, Alice
+# Check out a specific store.
+pda ls @birthdays
+# alice 11/11/1998
+# bob 05/12/1980
+
+# Dump it.
+pda dump birthdays > friends_birthdays
+
+# Restore it.
+pda restore birthdays < friends_birthdays
+
+# Delete it.
+pda del-db birthdays --force
```
-#### Listing
-
-
-
- ↑ ·
- pda list ·
- Filtering
-
-
-
-[`pda list`](#listing) (alias: [`ls`](#listing)) displays your key-value pairs. The default columns are `meta,size,ttl,store,key,value`. Meta is a 4-char flag string: `(e)ncrypted (w)ritable (t)tl (p)inned`, or a dash for an unset flag.
-
- -b, --base64 view binary data as base64
- -c, --count print only the count of matching entries
-
-[`pda list`](#listing) (alias: [`ls`](#listing)) displays stored key-value pairs with [pinned](#pinning) keys first, followed by alphabetical order. Default behaviour is to list [metadata](#metadata), size, [time-to-live](#ttl), store, the key, and value as a table. The order and visibility of every column can be toggled either via `list.default_columns` in the [config](#config) or via one-off flags.
-
-It accepts one or zero arguments. If no argument is passed, with a default configuration [`pda list`](#listing) will display all keys from all stores, but this behaviour can be toggled to instead display only keys from the default store. If a store name is passed as an argument, only the keys contained within that store will be listed.
-
-If `list.always_show_all_stores` is toggled off, the behaviour can be enabled on an individual basis by passing `all`.
-
-```bash
-# list all store contents
-❯ pda list
-Meta Size TTL Store Key Value
--w-p 5 - todos todo don't forget this
----- 23 - store url https://example.com
--w-- 5 - me name Alice
-
-# list only the contents of the "todos" store
-❯ pda list todos
-Meta Size TTL Store Key Value
--w-p 5 - todos todo don't forget this
-
-# list all store contents, but without Meta, Size, or TTL
-❯ pda list --no-ttl --no-header --no-size
-Store Key Value
-todos todo don't forget this
-store url https://example.com
- me name Alice
-
-# count the number of entries for a given query
-❯ pda list --count
-3
-```
-
-When [listing](#listing), [glob patterns](#filtering) can be used to further filter by `key`, `store`, or `value`.
-
-```bash
-# list all store contents beginning with "https"
-pda ls --value "https**"
-
-# list all store contents beginning with "https" with "db" in the key
-pda ls --value "https**" --key "**db**"
-```
-
-The standard tabular output of [`pda list`](#listing) can be swapped out for tab-separated values, comma-separated values, a markdown table, a HTML table, newline-delimited JSON, or JSON. Newline-delimited JSON is the native storage format of a `pda` store.
-
-```bash
-# list all store contents as comma-separated values
-❯ pda ls --format csv
-Meta,Size,TTL,Store,Key,Value
--w--,5,-,store,name,Alice
-
-# list all store contents as JSON
-❯ pda ls --format json
-[{"key":"name","value":"Alice","encoding":"text","store":"store"}]
-```
-
-By default, long values are truncated to fit the terminal, but this behaviour can be toggled via the [config](#config) or by passing `full`. The setting is `list.always_show_full_values`.
-
-```bash
-❯ pda ls
-Key Value
-note this is a very long (..30 more chars)
-
-❯ pda ls --full
-Key Value
-note this is a very long value that keeps on going and going
-```
-
-As with [`getting`](#getting), non-UTF8 data in lists will be substituted for a summary rather than displaying raw bytes. This can be changed out for a base64 representation by passing `base64`.
-
-#### Editing
-
-
-
- ↑ ·
- pda edit
-
-
-
-[`pda edit`](#editing) (alias: [`e`](#editing)) opens a key's value in your `$EDITOR`. If the key doesn't exist, an empty file is opened, and saving non-empty content creates it.
-
-When [editing](#editing) a key, [metadata](#metadata)-altering flags can be passed in the same operation. These alterations will only take place if the edit is finalised by saving.
-
-```bash
-# edit an existing key or create a new one
-pda edit name
-
-# edit a key and pin it
-pda edit name --pin
-
-# edit a key and give it an expiration, and encrypt it
-pda edit secret_stuff --ttl 1h --encrypt
-
-# edit a key and make it readonly
-pda edit do_not_change --readonly
-```
-
-Most `$EDITOR` will add a trailing newline on saving a file. These are stripped by default, but this behaviour can be toggled via `edit.always_preserve_newline` in the [config](#config) or as a one-off by passing `preserve-newline`.
-
-```bash
-# edit a key and preserve the automatic $EDITOR newline
-pda edit example --preserve-newline
-```
-
-Binary values will be presented as base64 for editing and will be decoded back to raw bytes on save. [Read-only](#read-only) keys require being made writable before they can be edited, or `force` can be explicitly passed.
-
-#### Moving & Copying
-
-
-
- ↑ ·
- pda move,
- pda copy
-
-
-
-To move (or rename) a key, [`pda move`](#moving--copying) (alias: [`mv`](#moving--copying)) can be used. To copy a key, [`pda move --copy`](#moving--copying) (alias: [`cp`](#moving--copying)) can be used. With both of these operations, all [metadata](#metadata) is preserved.
-
-```bash
-# rename a key
-❯ pda move name name2
- ok renamed name to name2
-
-# move a key across stores
-❯ pda mv name name@some_other_store
- ok renamed name to name@some_other_store
-
-# copy a key
-❯ pda cp name name2
- ok copied name to name2
-```
-
-Accidental overwrites have a few ways of being prevented. `safe` exists for moving and copying as it does for all changeful commands, skipping an operation if a destination already exists that would be overwritten. A [read-only](#read-only) key will also prevent being moved or overwritten unless `force` is explicitly passed.
-
-Additionally, `interactive` being passed or `key.always_prompt_overwrite` being enabled in the [config](#config) will cause a yes-no prompt to be presented if a key is going to be overwritten. Inversely, prompts can always be skipped by passing `yes`.
-
-```bash
-# move a key safely
-❯ pda mv name name2 --safe
-# info skipped 'name2': already exists
-
-# move a key interactively
-❯ pda mv name name2 --safe
- ??? overwrite 'name2'? (y/n)
- >>> y
-
-# move a key and skip all warning prompts
-❯ pda mv name name2 --yes
- ok renamed name to name2
-```
-
-#### Removing
-
-
-
- ↑ ·
- pda remove
-
-
-
-[`pda remove`](#removing) (alias: [`rm`](#removing)) deletes one or more keys. Any number of keys can be deleted in a single call, with keys from differing stores able to be mixed freely.
-
-```bash
-# delete a single key
-pda remove kitty
-
-# delete multiple keys at once
-pda remove kitty doggy
-
-# delete across stores
-pda remove kitty secret@private
-```
-
-Exact positional keys can be combined with [glob patterns](#filtering) via `key`, `store`, and `value` to widen the scope of a deletion. Glob-matched deletions prompt for confirmation by default due to their more error-prone nature. This is configurable with `key.always_prompt_glob_delete` in the [config](#config).
-
-```bash
-# delete "kitty" and everything matching the key "?og"
-❯ pda rm kitty --key "?og"
- ??? remove 'cog'? (y/n)
- ==> y
- ??? remove 'dog'? (y/n)
-
-# delete keys matching a store and key pattern
-❯ pda rm --store "temp*" --key "session*"
-```
-
-Passing `interactive` prompts before each deletion, including exact keys. This behaviour can be made permanent with `key.always_prompt_delete` in the [config](#config). Inversely, `yes` auto-accepts all confirmation prompts.
-
-```bash
-# prompt before each deletion
-❯ pda rm kitty -i
- ??? remove 'kitty'? (y/n)
- ==> y
-
-# auto-accept all prompts
-❯ pda rm kitty -y
-```
-
-[Read-only](#read-only) keys cannot be deleted without explicitly passing `force`.
-
-```bash
-# remove a read-only key
-❯ pda rm protected-key
-FAIL cannot remove 'protected-key': key is read-only
-
-# force-remove a read-only key
-❯ pda rm protected-key --force
-```
-
-### Metadata
-
-
-
- ↑ ·
- pda meta,
- TTL,
- Encryption,
- Read-Only,
- Pinned
-
-
-
-[`pda meta`](#metadata) can be used to view or modify metadata for a given key without touching its value. It always takes one argument: the desired key.
-
-If no flags are passed, [`pda meta`](#metadata) will display the key's current metadata. Any flags passed can be used to modify metadata in-place: [`ttl`](#ttl), [`encrypt`](#encryption) or [`decrypt`](#encryption), [`readonly`](#read-only) or [`writable`](#read-only), and [`pin`](#pinned) or [`unpin`](#pinned). Multiple changes can be combined in a single command.
-
-In [`pda list`](#listing) output, metadata is demonstrated via a `Meta` column. The presence of each type of metadata is marked by a character, or a dash if unset: [(e)ncrypted](#encryption), [(w)ritable](#read-only), [(t)ime-to-live](#ttl), and [(p)inned](#pinned).
-
-```bash
-# view a key's underlying metadata
-❯ pda meta session
- key: session@store
- secret: false
- writable: true
- pinned: false
- expires: 59m30s
-
-# make a key read-only
-❯ pda meta session --readonly
-
-# remove a key's expiration time
-❯ pda meta session --ttl never
-```
-
-Modifying a [read-only](#read-only) key's metadata requires `force` or by first making it `writable`. A [read-only](#read-only) key can still be [pinned or unpinned](#pinned) as pin state only determines where a key is on `list output`[#listing], and does not change the actual key state.
-
-#### TTL
-
-
-
- ↑ ·
- pda set,
- pda meta
-
-
-
-Keys can be given an expiration time. Expired keys are marked for garbage collection and deleted on the next access to the [store](#stores). [TTL](#ttl) can be set at creation time via [`pda set --ttl`](#setting), or toggled later with [`pda meta --ttl`](#metadata) and [`pda edit --ttl`](#editing).
-
-```bash
-# expire after 1 hour
-pda set session "123" --ttl 1h
-
-# expire after 54 minutes and 10 seconds
-pda set session2 "xyz" --ttl 54m10s
-
-# remove an expiration time
-pda meta session --ttl never
-```
-
-TTL can be displayed with [`pda list`](#listing) in the [TTL](#ttl) column, or with [`pda meta`](#metadata).
-
-```bash
-# view ttl in a store's list output
-❯ pda ls
- TTL Key Value
-59m30s session 123
-51m40s session2 xyz
-
-# view the metadata of a specific key
-❯ pda meta session
-```
-
-Expiration time is preserved on [`import`](#import--export) and [`export`](#import--export) and [moving or copying](#moving--copying). TTL is stored as a timestamp rather than a timer; keys with a TTL are checked on access to the store they reside in, and any with an expiry that has already passed are deleted. If a key expires while having been exported, it will be deleted on import the next time `pda` touches the file.
-
-#### Encryption
-
-
-
- ↑ ·
- pda set,
- pda meta,
- pda identity
-
-
-
-[`pda set --encrypt`](#setting) encrypts values at rest using [age](https://github.com/FiloSottile/age). Values are stored on disk as age ciphertext and decrypted automatically at run-time by commands like [`pda get`](#getting) and [`pda list`](#listing) when the correct identity file is present. An X25519 [identity](#identity) is generated on first use.
-
-By default, the only recipient for encrypted keys is your own [identity](#identity) file. Additional recipients can be added or removed via [`pda identity`](#identity).
-
-```bash
-# create a key called "api-key" and encrypt it
-❯ pda set --encrypt api-key "sk-live-abc123"
- ok created identity at ~/.local/share/pda/identity.txt
-
-# encrypt a key after editing in $EDITOR
-❯ pda edit --encrypt api-key
-
-# decrypt a key via meta
-❯ pda meta --decrypt api-key
-```
-
-Because the on-disk value of an encrypted key is ciphertext, encrypted entries are safe to commit and push with [Git](#git).
-
-```bash
-❯ pda export
-{"key":"api-key","value":"YWdlLWVuY3J5cHRpb24u...","encoding":"secret"}
-```
-
-[`mv`](#moving--copying), [`cp`](#moving--copying), and [`import`](#import--export) all preserve encryption, read-only, and pinned flags. Overwriting an encrypted key by setting a new value without `--encrypt` will warn you.
-
-```bash
-# setting a new key and forgetting the "--encrypt" flag
-❯ pda set api-key "oops"
-WARN overwriting encrypted key 'api-key' as plaintext
-hint pass --encrypt to keep it encrypted
-```
-
-If your [identity](#identity) file does not match an intended recipient of an encrypted key, the value will be inaccessible. It will display `locked` on fetch until a matching [identity](#identity) is found.
-
-```bash
-❯ pda ls
-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)
-```
-
-Encrypted keys can be made the default by enabling `key.always_encrypt` in the [config](#config).
-
-#### Read-Only
-
-
-
- ↑ ·
- pda set,
- pda meta
-
-
-
-Keys marked [read-only](#read-only) are protected from accidental modification. A [read-only](#read-only) flag can be set at creation time, toggled later with [`pda meta`](#metadata), or applied alongside an [`edit`](#editing). Making a key [`writable`](#metadata) again or explicitly passing [`force`](#metadata) allows changes through. A key being made writable is a permanent change, whereas the `force` flag is a one-off.
-
-```bash
-# create a read-only key
-pda set api-url "https://prod.example.com" --readonly
-
-# set a key to read-only with meta
-❯ pda meta api-url --readonly
- ok made readonly api-url
-
-# set a key as writable with meta
-❯ pda meta api-url --writable
- ok made writable api-url
-
-# edit a key, and set as readonly on save
-❯ pda edit notes --readonly
-```
-
-Read-only keys are protected from [`setting`](#setting), [`removing`](#removing), [`moving`](#moving--copying), and [`editing`](#editing). They are *not protected* from the deletion of an entire [store](#stores).
-
-```bash
-# set a new value to a read-only key
-❯ pda set api-url "new value"
-FAIL cannot set 'api-url': key is read-only
-
-# force changes to a read-only key with the force flag
-❯ pda set api-url "new value" --force
-❯ pda remove api-url --force
-❯ pda move api-url new-name --force
-```
-
-[`pda copy`](#moving--copying) can copy a read-only key freely (since the source isn't modified), and the copy preserves the read-only flag. Overwriting a read-only destination is blocked without `force`.
-
-#### Pinned
-
-
-
- ↑ ·
- pda set,
- pda meta
-
-
-
-Pinned keys sort to the top of [`pda list`](#listing) output, preserving alphabetical order within the pinned and unpinned groups. A pin can be set at creation time, toggled with [`pda meta`](#metadata), or applied alongside an [`edit`](#editing).
-
-```bash
-# pin a key at creation time
-pda set important "remember this" --pin
-
-# pin a key with meta
-❯ pda meta todo --pin
- ok pinned todo
-
-# unpin a key with meta
-❯ pda meta todo --unpin
- ok unpinned todo
-
-# view pinned keys in list output, at the top
-❯ pda ls
-Meta Key Value
--w-p important remember this
--w-- name Alice
--w-- other foo
-```
-
-### Stores
-
-
-
- ↑ ·
- pda list-stores,
- pda move-store,
- pda remove-store
-
-
-
-Stores are saved on disk as NDJSON files. `pda` supports any number of stores, and creating them is automatic. If a key is created with a `@STORE` suffix, and the named store does not already exist, it will be created automatically to support the new key.
-
-[`pda list-stores`](#stores) (alias: [`lss`](#stores)) shows all stores with their respective key counts and file sizes. Passing `short` prints only the store names.
-
-```bash
-# list all stores
-❯ pda list-stores
-Keys Size Store
- 2 1.8k @birthdays
- 12 4.2k @store
-
-# list all store names
-❯ pda list-stores --short
-@birthdays
-@store
-```
-
-[`pda move-store`](#stores) (alias: [`mvs`](#stores)) renames a store. Passing `copy` keeps the source intact.
-
-```bash
-# rename a store
-pda move-store birthdays bdays
-
-# copy a store
-pda move-store birthdays bdays --copy
-```
-
-[`pda remove-store`](#stores) (alias: [`rms`](#stores)) deletes a store. A confirmation prompt is shown by default (configurable with `store.always_prompt_delete` in the [config](#config)). Deleting an entire store does not require unsetting [read-only](#read-only) on contained keys: if a [read-only](#read-only) key is within a store, it **will be deleted** if the store is removed.
-
-```bash
-# delete a store
-pda remove-store birthdays
-```
-
-As with changeful key operations, store commands support `interactive` and `safe` flags where they make sense. Moving or removing a store interactively will generate a confirmation prompt if anything would be lost by the action being taken. The `safe` flag will prevent moving a store from ever overwriting another store.
-
-Inversely, `yes` can be passed to bypass any confirmation prompts.
-
-#### Import & Export
-
-
-
- ↑ ·
- pda export,
- pda import
-
-
-
-[`pda export`](#import--export) dumps entries as NDJSON (it is functionally an alias for `list --format ndjson`). The [filtering](#filtering) flags `key`, `value`, and `store` all work with exports. It shares functionality with [`pda list`](#listing) in regard to which stores get exported: if `list.always_show_all_stores` is set and no store name is specified as an argument, all stores will be exported.
-
-```bash
-# export everything
-pda export > my_backup
-
-# export only matching keys
-pda export --key "a*"
-
-# export only entries whose values contain a URL
-pda export --value "**https**"
-```
-
-[`pda import`](#import--export) restores entries from an NDJSON dump. The default behaviour for an import is to merge with any existing stores of the same name. To completely replace existing stores instead of merging, `drop` can be passed.
-
-[Importing](#import--export) takes one or zero arguments. On export each key saves the name of the store it came from in its metadata; on import, by default, each key will be returned to that same store. If a store name is passed as an argument to [`pda import`](#import--export), this behaviour will be overriden and all keys will be imported into the specified store.
-
-As with [exporting](#import--export), `key`, `value`, and `store` flags can be passed to filter which keys will be imported from the input file.
-
-```bash
-# entries are routed to their original stores
-pda import -f my_backup
-# ok restored 5 entries
-
-# force all entries into a single store
-pda import mystore -f my_backup
-# ok restored 5 entries into @mystore
-
-# read from stdin
-pda import < my_backup
-```
-
-`interactive` can be passed to [`pda import`](#import--export) to prompt on potential overwrite, and is generally recommended if an import is ever being routed to a specific store, as it is likely to cause collisions.
-
-[`pda export`](#import--export) encodes [binary data](#binary-data) as base64. All [metadata](#metadata) is preserved through export and import.
-
-### Templates
-
-
-
- ↑ ·
- pda get,
- pda run
-
-
-
-Values support Go's [`text/template`](https://pkg.go.dev/text/template) syntax. Templates are evaluated on [`pda get`](#getting) and [`pda run`](#running).
-
-`text/template` is a Turing-complete templating library that supports pipelines, nested templates, conditionals, loops, and more. Actions are given with `{{ action }}` syntax. To better accomodate `text/template`, `pda` adds a small set of built-in functions on top of the standard library.
-
-These same functions are also available in `git.default_commit_message` templates, in addition to `summary`, which returns the action that triggered the commit (e.g. "set foo", "removed bar").
-
-`no-template` can be passed to output a raw value without resolving the template.
-
-#### Basic Substitution
-
-
-
- ↑ ·
- Templates
-
-
-
-Template variables are substituted from `KEY=VALUE` arguments passed to [`pda get`](#getting):
-
-```bash
-pda set greeting "Hello, {{ .NAME }}"
-
-❯ pda get greeting NAME="Alice"
-Hello, Alice
-```
-
-#### `default`
-
-
-
- ↑ ·
- Templates
-
-
-
-`default` sets a fallback value when a variable is missing or empty:
-
-```bash
-pda set greeting "Hello, {{ default "World" .NAME }}"
-
-❯ pda get greeting
-Hello, World
-
-❯ pda get greeting NAME="Bob"
-Hello, Bob
-```
-
-#### `require`
-
-
-
- ↑ ·
- Templates
-
-
-
-`require` errors if the variable is missing or empty:
-
-```bash
-pda set file "{{ require .FILE }}"
-
-❯ pda get file
-FAIL cannot get 'file': ...required value is missing or empty
-```
-
-#### `env`
-
-
-
- ↑ ·
- Templates
-
-
-
-`env` reads from environment variables:
-
-```bash
-pda set my_name "{{ env "USER" }}"
-
-❯ pda get my_name
-llywelwyn
-```
-
-#### `time`
-
-
-
- ↑ ·
- Templates
-
-
-
-`time` returns the current UTC time in RFC3339 format:
-
-```bash
-pda set note "Created at {{ time }}"
-
-❯ pda get note
-Created at 2025-01-15T12:00:00Z
-```
-
-#### `enum`
-
-
-
- ↑ ·
- Templates
-
-
-
-`enum` restricts a variable to a set of acceptable values:
-
-```bash
-pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
-
-❯ pda get level LEVEL=info
-Log level: info
-
-❯ pda get level LEVEL=debug
-FAIL cannot get 'level': ...invalid value 'debug', allowed: [info warn error]
-```
-
-#### `int`
-
-
-
- ↑ ·
- Templates
-
-
-
-`int` parses a variable as an integer. Useful mostly for loops or arithmetic.
-
-```bash
-❯ pda set number "{{ int .N }}"
-❯ pda get number N=3
-3
-
-# using "int" in a loop
-❯ pda set meows "{{ range int .COUNT }}meow! {{ end }}"
-❯ pda get meows COUNT=4
-meow! meow! meow! meow!
-```
-
-#### `list`
-
-
-
- ↑ ·
- Templates
-
-
-
-`list` parses a comma-separated string into a list for iteration:
-
-```bash
-pda set names "{{ range list .NAMES }}Hi {{.}}. {{ end }}"
-
-❯ pda get names NAMES=Bob,Alice
-Hi Bob. Hi Alice.
-```
-
-#### `shell`
-
-
-
- ↑ ·
- Templates
-
-
-
-`shell` executes a command and returns its stdout. Commands executed by the `shell` function are executed by `$SHELL`. If it is somehow unset, it defaults to using `/usr/sh`.
-
-```bash
-❯ pda set rev '{{ shell "git rev-parse --short HEAD" }}'
-❯ pda get rev
-a1b2c3d
-
-❯ pda set today '{{ shell "date +%Y-%m-%d" }}'
-❯ pda get today
-2025-06-15
-```
-
-#### `pda`
-
-
-
- ↑ ·
- Templates
-
-
-
-`pda` returns the output of [`pda get`](#getting) on a key.
-
-```bash
-# use the value of "base_url" in another key
-❯ pda set base_url "https://api.example.com"
-❯ pda set endpoint '{{ pda "base_url" }}/users/{{ require .ID }}'
-❯ pda get endpoint ID=42
-https://api.example.com/users/42
-
-# use the value of a key from another store
-❯ pda set host@urls "https://example.com"
-❯ pda set api '{{ pda "host@urls" }}/api'
-❯ pda get api
-https://example.com/api
-```
-
-### Filtering
-
-
-
- ↑ ·
- pda list,
- pda remove,
- pda export,
- pda import
-
-
-
-[`key`](#filtering), [`value`](#filtering), and [`store`](#filtering) flags can be used to filter entries via globs. All three flags are repeatable, with results matching one-or-more of the patterns per flag. When multiple flags are combined, results must satisfy all of them (AND across flags, OR within the same flag).
-
-These filters work with [`pda list`](#listing), [`pda export`](#import--export), [`pda import`](#import--export), and [`pda remove`](#removing).
-
-[`gobwas/glob`](https://github.com/gobwas/glob) is used for matching. The default separators are `/-_.@:` and space. For a detailed guide to globbing, I highly recommend taking a look at the documentation there directly.
-
-#### Glob Patterns
-
-
-
- ↑ ·
- Filtering
-
-
-
-`*` wildcards a word or series of characters, stopping at separator boundaries.
-
-```bash
-# list all store contents
-❯ pda ls
-cat
-mouse house
-foo.bar.baz
-
-# match any single-word key
-❯ pda ls --key "*"
-cat
-
-# match any two-word key
-❯ pda ls --key "* *"
-mouse house
-
-# match any key starting with "foo." and ending with ".baz", with one word between
-❯ pda ls --key "foo.*.baz"
-foo.bar.baz
-```
-
-`**` super-wildcards ignore word boundaries.
-
-```bash
-# match anything beginning with "foo"
-❯ pda ls --key "foo**"
-foo.bar.baz
-```
-
-`?` matches a single character:
-
-```bash
-# match anything beginning with any letter, and ending with "og"
-❯ pda ls --key "?og"
-dog
-cog
-```
-
-`[abc]` matches one of the characters in the brackets.
-
-```bash
-# match anything beginning with "d" or "c", and ending with "og"
-❯ pda ls --key "[dc]og"
-dog
-cog
-
-# negate with '!'
-❯ pda ls --key "[!dc]og"
-bog
-```
-
-`[a-c]` matches a range:
-
-```bash
-# match anything beginning with "a" to "g", and ending with "ag"
-❯ pda ls --key "[a-g]ag"
-bag
-gag
-
-# negate with '!'
-❯ pda ls --key "[!a-g]ag"
-wag
-```
-
-#### Filtering by Key
-
-
-
- ↑ ·
- Filtering,
- pda list
-
-
-
-[`key`](#filtering) filters entries by key name. Multiple `key` patterns are OR'd. An entry matches if it matches any of them.
-
-```bash
-pda ls --key "db*"
-pda ls --key "session*" --key "token*"
-```
-
-
-#### Filtering by Value
-
-
-
- ↑ ·
- Filtering,
- pda list
-
-
-
-[`value`](#filtering) filters by value. Multiple `value` patterns are OR'd.
-
-```bash
-❯ pda ls --value "**localhost**"
-❯ pda ls --value "**world**" --value "42"
-```
-
-Locked (encrypted without an available identity) and non-UTF-8 (binary) entries are silently excluded from `value` matching.
-
-#### Filtering by Store
-
-
-
- ↑ ·
- Filtering,
- pda list
-
-
-
-[`store`](#filtering) filters by store name. Multiple `store` patterns are OR'd.
-
-```bash
-pda ls --store "prod*"
-pda export --store "dev*"
-```
-
-#### Combining Filters
-
-
-
- ↑ ·
- Filtering
-
-
-
-`key`, `value`, and `store` filters can be combined. Results must match at least one of each category of filter used. For example, checking for `key` and two different `value` globs on the same filter: the results must match `key` and at least one of the two `value` globs; the results do not need to match both values.
-
-```bash
-pda ls --key "db*" --value "**localhost**"
-```
-
-Globs can be combined to create some deeply complex queries. For example, [`key`](#filtering) can be combined with exact positional args on [`rm`](#removing) to remove exactly the "cat" key, and any keys beginning with "cat", "dog", or "mouse" followed by zero-or-more additional words.
-
-```bash
-pda rm cat --key "{mouse,[cd]og}**"
-# ??? remove 'cat'? (y/n)
-# ==> y
-# ??? remove 'mouse trap'? (y/n)
-# ...
-```
-
-### Binary Data
-
-
-
- ↑ ·
- pda set,
- pda get,
- pda list
-
-
-
-`pda!` supports all binary data. Values can be read from a file with `--file` or piped in via stdin. Retrieval works the same way: pipe or redirect the output to get the raw bytes.
-
-```bash
-# store binary data from a file
-pda set logo < logo.png
-pda set logo -f logo.png
-
-# retrieve binary data
-pda get logo > output.png
-```
-
-On a TTY, [`get`](#getting) and [`list`](#listing) show a summary instead of printing raw bytes (which can cause undefined terminal behaviour). In a non-TTY setting (piped or redirected), the raw bytes are returned as expected. Passing `base64` provides a safe way to view binary data in a terminal.
-
-```bash
-# TTY shows a summary
-❯ pda get logo
-(binary: 4.2 KB, image/png)
-
-# base64 view
-❯ pda get logo --base64
-iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12...
-```
-
-[`pda export`](#import--export) encodes binary data as base64 in the NDJSON, and [`pda edit`](#editing) presents binary values as base64 for editing and decodes them back on save.
-
-```bash
-❯ pda export
-{"key":"logo","value":"89504E470D0A1A0A0000000D4948445200000001000000010802000000","encoding":"base64"}
-```
+
### Git
-
-
- ↑ ·
- pda init,
- pda sync,
- pda git,
- Config
-
-
-
-[`pda init`](#git) initialises version control in the data directory. Without arguments, this creates a local-only repository. Passing a remote URL clones from an existing repository instead, useful for syncing `pda` across machines. For anything that [`pda sync`](#sync) doesn't cover, [`pda git`](#pda-git) passes arguments directly to `git` from within the data directory (this generally should be avoided unless things are very broken somehow).
+pda! supports automatic version control backed by Git, either in a local-only repository or by initialising from a remote repository.
+`pda vcs init` will initialise the version control system.
```bash
-# initialise an empty local repository
-pda init
+# Initialise an empty pda! repository.
+pda vcs init
-# or clone from an existing remote
-pda init https://github.com/llywelwyn/my-repository
+# Or clone an existing one.
+pda vcs init https://github.com/llywelwyn/my-repository
+
+# --clean to replace your (existing) local repo with a new one.
+pda vcs init --clean
```
-Passing `clean` removes any existing `.git` directory first. This is primarily useful for reinitialising a broken repository or switching to a different remote.
+
+`pda vcs snapshot` to save a copy of your pda.
```bash
-pda init --clean
-pda init https://github.com/llywelwyn/my-repository --clean
+pda vcs snapshot
+# functionally, dumps all databases into pda/vcs
+# to convert them into text, and commits them to
+# your local pda! repository.
```
-#### Sync
-
-
-
- ↑ ·
- pda sync
-
-
-
-[`pda sync`](#sync) conducts a best-effort sync of local data with the Git repository. Any time you swap machine or know you've made changes outside of `pda!`, syncing is recommended.
-
-If the local repository is ahead, syncing will commit and push. If it is behind, syncing will detect this and prompt: either stash local changes and pull, or abort and resolve manually. Running [`pda sync`](#sync) will always fetch, commit, and push regardless of the automation settings in [config](#config).
+
+`pda vcs push` and `pda vcs pull`
```bash
-# sync with Git
-pda sync
+# Push to a remote repository.
+pda vcs push
-# with a custom commit message
-pda sync -m "new bookmarks"
+# Pull all keys from version control and restore them.
+pda vcs pull
+
+# Or --clean to delete your existing stores before restoring.
+pda vcs pull --clean
```
-#### Auto-Commit, Push, and Fetch
-
-
-
- ↑ ·
- pda config
-
-
-
-The amount of Git automation can be configured via `git.auto_commit`, `git.auto_fetch`, and `git.auto_push` in the [config](#config). All three are disabled by default.
-
-`git.auto_commit` commits changes to the local repository any time data is changed. `git.auto_fetch` fetches from the remote before committing, though this incurs a noticeable slowdown due to network round-trips. `git.auto_push` pushes committed changes to the remote after each commit.
-
-`auto_fetch` and `auto_push` are additional steps that happen during the commit process, so they have no effect if `auto_commit` is disabled. Running all Git operations on every change can be slow, but a commit is fast. A happy middle-ground is enabling `git.auto_commit` and doing the rest manually via [`pda sync`](#sync) when changing devices.
-
-
-### Identity
-
-
-
- ↑ ·
- pda identity,
- Encryption
-
-
-
-[`pda identity`](#identity) (alias: [`id`](#identity)) manages the [age](https://github.com/FiloSottile/age) identity used for [encryption](#encryption). An identity is generated automatically the first time [`--encrypt`](#encryption) is used, but one can also be created manually with `--new`. [`--new`](#identity) will error if an identity already exists; delete the file manually to replace it.
+
+`pda vcs gitignore` to generate a .gitignore for pda!
```bash
-# create a new identity manually
-pda identity --new
+# Generate if not present.
+pda vcs gitignore
+
+# Rewrite an existing .gitignore
+pda vcs gitignore --rewrite
```
-With no flags, [`pda identity`](#identity) displays your public key, identity file path, and any additional recipients. Passing `path` prints only the identity file path, useful for scripting.
+
+`pda vcs log` to view pda!'s Git log.
```bash
-❯ pda identity
- ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
- ok identity ~/.config/pda/identity.txt
-
-❯ pda identity --path
-~/.config/pda/identity.txt
+pda vcs log
```
-#### Recipients
+
-
-
- ↑ ·
- pda identity
-
-
+### Templates
-By default, secrets are encrypted only for your own identity. To encrypt for additional recipients (e.g. another device or a friend), use [`--add-recipient`](#identity) with their age public key. All existing secrets are automatically re-encrypted for every recipient.
+Values support effectively all of Go's `text/template` syntax. Templates are evaluated on `pda get`.
+`text/template` is a Turing-complete templating library that supports most of what you'd expect in a scripting language. Actions are given with ``{{ action }}`` syntax and support pipelines and nested templates, along with a lot more. I recommend reading the documentation if you want to do anything more complicated than described here.
+
+To fit `text/template` nicely into this tool, pda has a sparse set of additional functions built-in. For example, `default` values, `enum`s, `require`d values, `lists`, among others.
+
+Below is more detail on the extra functions added by this tool.
+
+
+
+`{{ .BASIC }}` substitution
```bash
-❯ pda identity --add-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
- ok re-encrypted api-key
- ok added recipient age1ql3z...
- ok re-encrypted 1 secret(s)
+pda set greeting "Hello, {{ .NAME }}"
+pda get greeting NAME="Alice"
+# Hello, Alice
```
-Removing a recipient with `--remove-recipient` re-encrypts all secrets without their key. Additional recipients are shown in the default [`pda identity`](#identity) output.
+
+`default` sets a default value.
```bash
-pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
-
-❯ pda identity
- ok pubkey age1abc...
- ok identity ~/.local/share/pda/identity.txt
- ok recipient age1ql3z...
+pda set greeting "Hello, {{ default "World" .NAME }}"
+pda get greeting
+# Hello, World
+pda get greeting NAME="Bob"
+# Hello, Bob
```
-### Config
-
-
-
- ↑ ·
- pda config,
- pda doctor
-
-
-
-Config is stored at `~/.config/pda/config.toml` (Linux/macOS) or `%LOCALAPPDATA%/pda/config.toml` (Windows). All values have sensible defaults, so a config file is entirely optional. [`pda config`](#config) manages configuration without needing to edit files by hand, and [`pda doctor`](#doctor) will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit.
+
+`require` errors if missing.
```bash
-# list all config values and their current settings
-pda config list
-
-# get a single value
-❯ pda config get git.auto_commit
-false
-
-# set a value (validated before saving)
-pda config set git.auto_commit true
-
-# open in $EDITOR (validated on save)
-pda config edit
-
-# print the config file path
-pda config path
-
-# generate a fresh default config file
-pda config init
-
-# overwrite an existing config with defaults
-pda config init --new
-
-# update config: migrate deprecated keys and fill missing defaults
-pda config init --update
+pda set file "{{ require .FILE }}"
+pda get file
+# Error: required value missing or empty
```
-#### Default Config
+
-
-
- ↑ ·
- Config,
- pda config
-
-
-
-All values below are the defaults. A missing config file or missing keys will use these values.
-
-```toml
-# display ascii header in long root and version commands
-display_ascii_art = true
-
-[key]
-# prompt y/n before deleting keys
-always_prompt_delete = false
-# prompt y/n before deleting with a glob match
-always_prompt_glob_delete = true
-# prompt y/n before key overwrites
-always_prompt_overwrite = false
-# encrypt all values at rest by default
-always_encrypt = false
-
-[store]
-# store name used when none is specified
-default_store_name = "store"
-# prompt y/n before deleting whole store
-always_prompt_delete = true
-# prompt y/n before store overwrites
-always_prompt_overwrite = true
-
-[list]
-# list all, or list only the default store when none specified
-always_show_all_stores = true
-# default output, accepts: table|tsv|csv|markdown|html|ndjson|json
-default_list_format = "table"
-# show full values without truncation
-always_show_full_values = false
-# suppress the header row
-always_hide_header = false
-# columns and order, accepts: meta,size,ttl,store,key,value
-default_columns = "meta,size,ttl,store,key,value"
-
-[git]
-# auto fetch whenever a change happens
-auto_fetch = false
-# auto commit any changes
-auto_commit = false
-# auto push after committing
-auto_push = false
-# commit message if none manually specified
-# supports templates, see: #templates section
-default_commit_message = "{{ summary }} {{ time }}"
+`env` reads from environment variables.
+```bash
+pda set my_name "{{ env "USER" }}"
+pda get my_name
+# llywelwyn
```
+
+
+`enum` restricts acceptable values.
+```bash
+pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
+pda get level LEVEL=info
+# Log level: info
+pda get level LEVEL=debug
+# Error: invalid value "debug" (allowed: [info warn error])
+```
+
+
+
+`int` to parse as an integer.
+```bash
+pda set number "{{ int .N }}"
+pda get number N=3
+# 3
+
+# Use it in a loop.
+pda set meows "{{ range int .COUNT }}meow! {{ end }}"
+pda get meows COUNT=4
+# meow! meow! meow! meow!
+```
+
+
+
+`list` to parse CSV as a list.
+```bash
+pda set names "{{ range list .NAMES }}Hi {{.}}. {{ end }}"
+pda get names NAMES=Bob,Alice
+# Hi Bob. Hi Alice.
+```
+
+
+
+pass `no-template` to output literally without templating.
+```bash
+pda set hello "{{ if .MORNING }}Good morning.{{ end }}"
+pda get hello MORNING=1
+# Good morning.
+pda get hello --no-template
+# {{ if .MORNING }}Good morning.{{ end }}
+```
+
+
+
+### Globs
+
+Globs can be used in a few commands where their use makes sense. `gobwas/glob` is used for matching.
+
+Searching for globs is inherently slower than looking for direct matches, so globs are opt-in via a repeatable `--glob/-g` flag by default rather than having every string treated as a glob by default. Realistically the performance impact will be negligible unless you have many thousands of entries in the same database.
+
+
+
+`*` wildcards a word or series of characters.
+```bash
+pda ls --no-values
+# cat
+# dog
+# cog
+# mouse hotdog
+# mouse house
+# foo.bar.baz
+
+pda ls --glob "*"
+# cat
+# dog
+# cog
+
+pda ls --glob "* *"
+# mouse hotdog
+# mouse house
+
+pda ls --glob "foo.*.baz"
+# foo.bar.baz
+```
+
+
+
+`**` super-wildcards ignore word boundaries.
+```bash
+pda ls --glob "foo**"
+# foo.bar.baz
+
+pda ls --glob "**g"
+# dog
+# cog
+# mouse hotdog
+```
+
+
+
+`?` wildcards a single letter.
+```bash
+pda ls --glob ?og
+# dog
+# cog
+# frog --> fail
+# dogs --> fail
+```
+
+
+
+`[abc]` must match one of the characters in the brackets.
+```bash
+pda ls --glob [dc]og
+# dog
+# cog
+# bog --> fail
+
+# Can be negated with '!'
+pda ls --glob [!dc]og
+# dog --> fail
+# cog --> fail
+# bog
+```
+
+
+
+`[a-c]` must fall within the range given in the brackets
+```bash
+pda ls --glob [a-g]ag
+# bag
+# gag
+# wag --> fail
+
+# Can be negated with '!'
+pda ls --glob [!a-g]ag
+# bag --> fail
+# gag --> fail
+# wag
+
+pda ls --glob 19[90-99]
+# 1991
+# 1992
+# 2001 --> fail
+# 1988 --> fail
+```
+
+
+
+Globs can be arbitrarily complex, and can be combined with strict matches.
+```bash
+pda ls --no-keys
+# cat
+# mouse trap
+# dog house
+# cat flap
+# cogwheel
+
+pda rm cat --glob "{mouse,[cd]og}**"
+# remove: 'cat', 'mouse trap', 'dog house', 'cogwheel': are you sure? [y/n]
+```
+
+
+
+`--glob-sep` can be used to change the default list of separators used to determine word boundaries. Separators default to a somewhat reasonable list of common alphanumeric characters so should be usable in most usual situations.
+```bash
+pda ls --no-keys
+# foo%baz
+
+pda ls --glob "*"
+# foo%baz
+
+pda ls --glob "*" --glob-sep "%"
+# foo%baz --> fail
+# % is considered a word boundary, so "*" no longer matches.
+
+pda ls --glob "*%*" --glob-sep "%"
+# foo%baz
+```
+
+
+
+### Secrets
+
+Mark sensitive values with `secret` to stop accidents.
+```bash
+# Store a secret
+pda set password "hunter2" --secret
+```
+
+
+
+`secret` is used for revealing secrets too.
+```bash
+pda get password
+# Error: "password" is marked secret; re-run with --secret to display it
+pda get password --secret
+# hunter2
+```
+
+
+
+`list` censors secrets.
+```bash
+pda ls
+# password ************
+
+pda ls --secret
+# password hunter2
+```
+
+
+
+`dump` excludes secrets unless allowed.
+```bash
+pda dump
+# nil
+
+pda dump --secret
+# {"key":"password","value":"hunter2","encoding":"text"}
+```
+
+
+
+### TTL
+
+`ttl` sets an expiration time. Expired keys get marked for garbage collection and will be deleted on the next-run of the store. They wont be accessible.
+```bash
+# Expire after 1 hour
+pda set session "123" --ttl 1h
+
+# After 52 minutes and 10 seconds
+pda set session2 "xyz" --ttl 54m10s
+```
+
+
+
+`list --ttl` shows expiration date in list output.
+```bash
+pda ls --ttl
+# session 123 2025-11-21T15:30:00Z (in 59m30s)
+# session2 xyz 2025-11-21T15:21:40Z (in 51m40s)
+```
+
+`dump` and `restore` persists 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.
+
+
+
+### Binary
+
+Save binary data.
+```bash
+pda set logo < logo.png```
+```
+
+
+
+And `get` it like normal.
+```bash
+pda get logo > output.png
+```
+
+
+
+`list` and `get` will omit binary data whenever it's a human reading it. If it's being piped somewhere or ran outside of a TTY, it'll output the whole data.
+
+`include-binary` to show the full binary data regardless.
+```bash
+pda get logo
+# (omitted binary data)
+
+pda get logo --include-binary
+# 89504E470D0A1A0A0000000D4948445200000001000000010802000000
+```
+
+
+
+`dump` encodes binary data as base64.
+```bash
+pda dump
+# {"key":"logo","value":"89504E470D0A1A0A0000000D4948445200000001000000010802000000","encoding":"base64"}
+```
+
+
+
### Environment
-
-
- ↑ ·
- Config,
- pda doctor
-
-
+Config is stored in your user config directory in `pda/config.toml`.
-`pda!` respects a small set of environment variables for overriding paths and tools. These are primarily useful for isolating stores across environments or for scripting.
-
-`PDA_CONFIG` overrides the config directory, causing `pda!` to look for `config.toml` here instead of the default XDG location. `PDA_DATA` overrides the data storage directory where stores and the Git repository live. Default data locations follow XDG conventions: `~/.local/share/pda/` on Linux, `~/Library/Application Support/pda/` on macOS, and `%LOCALAPPDATA%/pda/` on Windows.
+Usually: `~/.config/pda/config.toml`
+`PDA_CONFIG` overrides the default config location. pda! will look for a config.toml file in that directory.
```bash
-# use an alternative config directory
PDA_CONFIG=/tmp/config/ pda set key value
+```
-# use an alternative data directory
+
+
+Data is stored in your user data directory under `pda/stores/`.
+
+Usually:
+- linux: `~/.local/share/pda/stores/`
+- macOS: `~/Library/Application Support/pda/stores/`
+- windows: `%LOCALAPPDATA%/pda/stores/`
+
+`PDA_DATA` overrides the default storage location.
+```bash
PDA_DATA=/tmp/stores pda set key value
```
-`EDITOR` is used by [`pda edit`](#editing) and [`pda config edit`](#config) to open values in a text editor, and must be set for these commands to work. `SHELL` is used by [`pda run`](#running) (or [`pda get --run`](#getting)) for command execution, falling back to `/bin/sh` if unset.
+
+`pda get --run` uses `SHELL` for command execution.
```bash
-EDITOR=nvim pda edit mykey
+# SHELL is usually your current shell.
+pda get script --run
+
+# An empty SHELL falls back to using 'sh'.
+export SHELL=""
+pda get script --run
```
-### Doctor
-
-
-
- ↑ ·
- Config,
- Environment
-
-
-
-[`pda doctor`](#doctor) runs a set of health checks against your [environment](#environment), covering installed tools, [config](#config) validity, [store](#stores) integrity, and [Git](#git) status. As mentioned in [config](#config), it also doubles as a config audit by warning about unrecognised keys and surfacing any non-default values.
-
-```bash
-❯ pda doctor
- ok pda! 2026.14 (linux/amd64)
- ok OS: Linux 6.18.7-arch1-1
- ok Go: go1.23.0
- ok Git: 2.45.0
- ok Shell: /bin/zsh
- ok Config: /home/user/.config/pda
- ok Non-default config:
- ├── display_ascii_art: false
- └── git.auto_commit: true
- ok Data: /home/user/.local/share/pda
- ok Identity: /home/user/.config/pda/identity.txt
- ok Git initialised on main
- ok Git remote configured
- ok Git in sync with remote
- ok 3 store(s), 15 key(s), 2 secret(s), 4.2k total size
- ok No issues found
-```
-
-Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red). Only `FAIL` produces a non-zero exit code. `WARN` is generally not a problem, but may indicate that some functionality isn't being made use of (like [version control](#git) not having been initialised yet).
-
-### Version
-
-
-
- ↑ ·
- pda version
-
-
-
-[`pda version`](#version) displays the current version. `pda!` uses calendar versioning in the format `YYYY.WW`. Passing `short` prints just the release string without ASCII art, useful for scripting. The ASCII art can also be permanently disabled with `display_ascii_art = false` in the [config](#config).
-
-```bash
-# display the full version output
-pda version
-
-# or just the release
-❯ pda version --short
-pda! 2026.14
-```
-
-### Help
-
-
-
- ↑
-
-
-
-
-
-#### `pda set`
-
-
-
- ↑ ·
- See also:
- Setting
-
-
-
-```text
-Set a key to a given value or stdin. Optionally specify a store.
-
-Pass --encrypt to encrypt the value at rest using age. An identity file
-is generated automatically on first use.
-
-PDA supports parsing Go templates. Actions are delimited with {{ }}.
-
-For example:
- 'Hello, {{ .NAME }}' can be substituted with NAME="John Doe".
- 'Hello, {{ env "USER" }}' will fetch the USER env variable.
- 'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
- 'Hello, {{ require .NAME }}' will error if NAME is blank.
- '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.
-
-Usage:
- pda set KEY[@STORE] [VALUE] [flags]
-
-Aliases:
- set, s
-
-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)
-```
-
-#### `pda get`
-
-
-
- ↑ ·
- See also:
- Getting,
- Templates
-
-
-
-```text
-Get the value of a key. Optionally specify a store.
-
-{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
-additional argument after the initial KEY being fetched.
-
-For example:
- pda set greeting 'Hello, {{ .NAME }}!'
- pda get greeting NAME=World
-
-Usage:
- pda get KEY[@STORE] [flags]
-
-Aliases:
- get, g
-
-Flags:
- -b, --base64 view binary data as base64
- --exists exit 0 if the key exists, exit 1 if not (no output)
- -h, --help help for get
- --no-template directly output template syntax
- -c, --run execute the result as a shell command
-```
-
-#### `pda run`
-
-
-
- ↑ ·
- See also:
- Running,
- Templates
-
-
-
-```text
-Get the value of a key and execute it as a shell command. Optionally specify a store.
-
-{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
-additional argument after the initial KEY being fetched.
-
-For example:
- pda set greeting 'Hello, {{ .NAME }}!'
- pda run greeting NAME=World
-
-Usage:
- pda run KEY[@STORE] [flags]
-
-Flags:
- -b, --base64 view binary data as base64
- -h, --help help for run
- --no-template directly output template syntax
-```
-
-#### `pda list`
-
-
-
- ↑ ·
- See also:
- Listing,
- Filtering
-
-
-
-```text
-List the contents of all stores.
-
-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.
-
-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]
-
-Aliases:
- list, ls
-
-Flags:
- -a, --all list across all stores
- -b, --base64 view binary data as base64
- -c, --count print only the count of matching entries
- -o, --format format output format (table|tsv|csv|markdown|html|ndjson|json)
- -f, --full show full values without truncation
- -h, --help help for list
- -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)
- -v, --value strings filter values with glob pattern (repeatable)
-```
-
-#### `pda edit`
-
-
-
- ↑ ·
- See also:
- Editing
-
-
-
-```text
-Open a key's value in $EDITOR. If the key doesn't exist, opens an
-empty file — saving non-empty content creates the key.
-
-Binary values are presented as base64 for editing and decoded back on save.
-
-Metadata flags (--ttl, --encrypt, --decrypt) can be passed alongside the edit
-to modify metadata in the same operation.
-
-Usage:
- pda edit KEY[@STORE] [flags]
-
-Aliases:
- edit, e
-
-Flags:
- -d, --decrypt decrypt the value (store as plaintext)
- -e, --encrypt encrypt the value at rest
- --force bypass read-only protection
- -h, --help help for edit
- --pin pin the key (sorts to top in list)
- --preserve-newline keep trailing newlines added by the editor
- --readonly mark the key as read-only
- --ttl string set expiry (e.g. 30m, 2h) or 'never' to clear
- --unpin unpin the key
- --writable clear the read-only flag
-```
-
-#### `pda move`
-
-
-
- ↑ ·
- See also:
- Moving & Copying
-
-
-
-```text
-Move a key
-
-Usage:
- pda move FROM[@STORE] TO[@STORE] [flags]
-
-Aliases:
- move, mv
-
-Flags:
- --copy copy instead of move (keeps source)
- --force bypass read-only protection
- -h, --help help for move
- -i, --interactive prompt before overwriting destination
- --safe do not overwrite if the destination already exists
- -y, --yes skip all confirmation prompts
-```
-
-#### `pda copy`
-
-
-
- ↑ ·
- See also:
- Moving & Copying
-
-
-
-```text
-Make a copy of a key
-
-Usage:
- pda copy FROM[@STORE] TO[@STORE] [flags]
-
-Aliases:
- copy, cp
-
-Flags:
- --force bypass read-only protection
- -h, --help help for copy
- -i, --interactive prompt before overwriting destination
- --safe do not overwrite if the destination already exists
- -y, --yes skip all confirmation prompts
-```
-
-#### `pda remove`
-
-
-
- ↑ ·
- See also:
- Removing,
- Filtering
-
-
-
-```text
-Delete one or more keys
-
-Usage:
- pda remove KEY[@STORE] [KEY[@STORE] ...] [flags]
-
-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)
- -s, --store strings target stores matching glob pattern (repeatable)
- -v, --value strings delete entries matching value glob pattern (repeatable)
- -y, --yes skip all confirmation prompts
-```
-
-#### `pda meta`
-
-
-
- ↑ ·
- See also:
- Metadata,
- TTL,
- Encryption,
- Read-Only,
- Pinned
-
-
-
-```text
-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. Pass flags to modify.
-
-Usage:
- pda meta KEY[@STORE] [flags]
-
-Flags:
- -d, --decrypt decrypt the value (store as plaintext)
- -e, --encrypt encrypt the value at rest
- --force bypass read-only protection for metadata changes
- -h, --help help for meta
- --pin pin the key (sorts to top in list)
- --readonly mark the key as read-only
- --ttl string set expiry (e.g. 30m, 2h) or 'never' to clear
- --unpin unpin the key
- --writable clear the read-only flag
-```
-
-#### `pda identity`
-
-
-
- ↑ ·
- See also:
- Identity,
- Encryption
-
-
-
-```text
-Show or create the age encryption identity
-
-Usage:
- pda identity [flags]
-
-Aliases:
- identity, id
-
-Flags:
- --add-recipient string add an age public key as an additional encryption recipient
- -h, --help help for identity
- --new generate a new identity (errors if one already exists)
- --path print only the identity file path
- --remove-recipient string remove an age public key from the recipient list
-```
-
-#### `pda export`
-
-
-
- ↑ ·
- See also:
- Import & Export
-
-
-
-```text
-Export store as NDJSON (alias for list --format ndjson)
-
-Usage:
- pda export [STORE] [flags]
-
-Flags:
- -h, --help help for export
- -k, --key strings filter keys with glob pattern (repeatable)
- -s, --store strings filter stores with glob pattern (repeatable)
- -v, --value strings filter values with glob pattern (repeatable)
-```
-
-#### `pda import`
-
-
-
- ↑ ·
- See also:
- Import & Export
-
-
-
-```text
-Restore key/value pairs from an NDJSON dump
-
-Usage:
- pda import [STORE] [flags]
-
-Flags:
- --drop drop existing entries before restoring (full replace)
- -f, --file string path to an NDJSON dump (defaults to stdin)
- -h, --help help for import
- -i, --interactive prompt before overwriting existing keys
- -k, --key strings restore keys matching glob pattern (repeatable)
- -s, --store strings restore entries from stores matching glob pattern (repeatable)
-```
-
-#### `pda list-stores`
-
-
-
- ↑ ·
- See also:
- Stores
-
-
-
-```text
-List all stores
-
-Usage:
- pda list-stores [flags]
-
-Aliases:
- list-stores, lss
-
-Flags:
- -h, --help help for list-stores
- --no-header suppress the header row
- --short only print store names
-```
-
-#### `pda move-store`
-
-
-
- ↑ ·
- See also:
- Stores
-
-
-
-```text
-Rename a store
-
-Usage:
- pda move-store FROM TO [flags]
-
-Aliases:
- move-store, mvs
-
-Flags:
- --copy copy instead of move (keeps source)
- -h, --help help for move-store
- -i, --interactive prompt before overwriting destination
- --safe do not overwrite if the destination store already exists
- -y, --yes skip all confirmation prompts
-```
-
-#### `pda remove-store`
-
-
-
- ↑ ·
- See also:
- Stores
-
-
-
-```text
-Delete a store
-
-Usage:
- pda remove-store STORE [flags]
-
-Aliases:
- remove-store, rms
-
-Flags:
- -h, --help help for remove-store
- -i, --interactive prompt yes/no for each deletion
- -y, --yes skip all confirmation prompts
-```
-
-#### `pda init`
-
-
-
- ↑ ·
- See also:
- Init,
- Git
-
-
-
-```text
-Initialise pda! version control
-
-Usage:
- pda init [remote-url] [flags]
-
-Flags:
- --clean remove .git from stores directory before initialising
- -h, --help help for init
-```
-
-#### `pda sync`
-
-
-
- ↑ ·
- See also:
- Sync,
- Git
-
-
-
-```text
-Manually sync your stores with Git
-
-Usage:
- pda sync [flags]
-
-Flags:
- -h, --help help for sync
- -m, --message string custom commit message (defaults to timestamp)
-```
-
-#### `pda git`
-
-
-
- ↑ ·
- See also:
- Git
-
-
-
-```text
-Run any arbitrary command. Use with caution.
-
-The Git repository lives directly in the data directory
-("PDA_DATA"). Store files (*.ndjson) are tracked by Git as-is.
-
-If you manually modify files without using the built-in
-commands, you may desync your repository.
-
-Generally prefer "pda sync".
-
-Usage:
- pda git [args...] [flags]
-
-Flags:
- -h, --help help for git
-```
-
-#### `pda config`
-
-
-
- ↑ ·
- See also:
- Config
-
-
-
-```text
-View and modify configuration
-
-Usage:
- pda config [command]
-
-Available Commands:
- edit Open config file in $EDITOR
- get Print a configuration value
- init Generate default config file
- list List all configuration values
- path Print config file path
- set Set a configuration value
-
-Flags:
- -h, --help help for config
-
-Use "pda config [command] --help" for more information about a command.
-```
-
-#### `pda doctor`
-
-
-
- ↑ ·
- See also:
- Doctor
-
-
-
-```text
-Check environment health
-
-Usage:
- pda doctor [flags]
-
-Flags:
- -h, --help help for doctor
-```
-
-#### `pda version`
-
-
-
- ↑ ·
- See also:
- Version
-
-
-
-```text
-Display pda! version
-
-Usage:
- pda version [flags]
-
-Flags:
- -h, --help help for version
- --short print only the version string
-```
-
-### License
-
-
-
- ↑
-
-
-
-MIT — see [LICENSE](LICENSE).
+
diff --git a/cmd/commit_message.go b/cmd/commit_message.go
deleted file mode 100644
index 4b3c0f5..0000000
--- a/cmd/commit_message.go
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "bytes"
- "text/template"
-)
-
-// renderCommitMessage renders a commit message template. It extends the
-// shared template FuncMap with {{ summary }}, which returns the action
-// description for the current commit. On any error the raw template string
-// is returned so that commits are never blocked by a bad template.
-func renderCommitMessage(tmpl string, summary string) string {
- funcMap := templateFuncMap()
- funcMap["summary"] = func() string { return summary }
-
- t, err := template.New("commit").Option("missingkey=zero").Funcs(funcMap).Parse(tmpl)
- if err != nil {
- return tmpl
- }
-
- var buf bytes.Buffer
- if err := t.Execute(&buf, nil); err != nil {
- return tmpl
- }
- return buf.String()
-}
diff --git a/cmd/commit_message_test.go b/cmd/commit_message_test.go
deleted file mode 100644
index 2c75d14..0000000
--- a/cmd/commit_message_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package cmd
-
-import (
- "strings"
- "testing"
-)
-
-func TestRenderCommitMessage(t *testing.T) {
- t.Run("summary and time", func(t *testing.T) {
- msg := renderCommitMessage("{{ summary }} {{ time }}", "set foo")
- if !strings.HasPrefix(msg, "set foo ") {
- t.Errorf("expected prefix 'set foo ', got %q", msg)
- }
- parts := strings.SplitN(msg, " ", 3)
- if len(parts) < 3 || !strings.Contains(parts[2], "T") {
- t.Errorf("expected RFC3339 time, got %q", msg)
- }
- })
-
- t.Run("empty summary", func(t *testing.T) {
- msg := renderCommitMessage("{{ summary }} {{ time }}", "")
- if !strings.HasPrefix(msg, " ") {
- t.Errorf("expected leading space (empty summary), got %q", msg)
- }
- })
-
- t.Run("default function", func(t *testing.T) {
- msg := renderCommitMessage(`{{ default "sync" (summary) }}`, "")
- if msg != "sync" {
- t.Errorf("expected 'sync', got %q", msg)
- }
- msg = renderCommitMessage(`{{ default "sync" (summary) }}`, "set foo")
- if msg != "set foo" {
- t.Errorf("expected 'set foo', got %q", msg)
- }
- })
-
- t.Run("env function", func(t *testing.T) {
- t.Setenv("PDA_TEST_USER", "alice")
- msg := renderCommitMessage(`{{ env "PDA_TEST_USER" }}: {{ summary }}`, "set foo")
- if msg != "alice: set foo" {
- t.Errorf("expected 'alice: set foo', got %q", msg)
- }
- })
-
- t.Run("bad template returns raw", func(t *testing.T) {
- raw := "{{ bad template"
- msg := renderCommitMessage(raw, "test")
- if msg != raw {
- t.Errorf("expected raw %q, got %q", raw, msg)
- }
- })
-}
diff --git a/cmd/completions.go b/cmd/completions.go
deleted file mode 100644
index 0f8b856..0000000
--- a/cmd/completions.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package cmd
-
-import (
- "strings"
-
- "github.com/spf13/cobra"
-)
-
-// completeKeys returns key[@store] completions for the current toComplete prefix.
-// It handles three cases:
-// - No "@" typed yet: return all keys from all stores (as "key@store")
-// - "@" typed with partial store: return store-scoped completions
-// - "key@store" with known store: return keys from that store
-func completeKeys(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- store := &Store{}
- stores, err := store.AllStores()
- if err != nil || len(stores) == 0 {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
-
- var completions []string
- parts := strings.SplitN(toComplete, "@", 2)
-
- if len(parts) == 2 {
- // User typed "something@" — complete keys within matching stores.
- prefix := parts[0]
- dbFilter := strings.ToLower(parts[1])
- for _, db := range stores {
- if !strings.HasPrefix(db, dbFilter) {
- continue
- }
- keys, err := store.Keys(db)
- if err != nil {
- continue
- }
- for _, k := range keys {
- if prefix == "" || strings.HasPrefix(k, strings.ToLower(prefix)) {
- completions = append(completions, k+"@"+db)
- }
- }
- }
- } else {
- // No "@" yet — offer key@store for every key in every store.
- lowerPrefix := strings.ToLower(toComplete)
- for _, db := range stores {
- keys, err := store.Keys(db)
- if err != nil {
- continue
- }
- for _, k := range keys {
- full := k + "@" + db
- if strings.HasPrefix(full, lowerPrefix) || strings.HasPrefix(k, lowerPrefix) {
- completions = append(completions, full)
- }
- }
- }
- }
-
- return completions, cobra.ShellCompDirectiveNoFileComp
-}
-
-// completeStores returns store name completions.
-func completeStores(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- store := &Store{}
- stores, err := store.AllStores()
- if err != nil {
- return nil, cobra.ShellCompDirectiveNoFileComp
- }
-
- var completions []string
- lowerPrefix := strings.ToLower(toComplete)
- for _, db := range stores {
- if strings.HasPrefix(db, lowerPrefix) {
- completions = append(completions, db)
- }
- }
- return completions, cobra.ShellCompDirectiveNoFileComp
-}
-
-// completeStoreFlag is a completion function for --store / -s string slice flags.
-func completeStoreFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
- return completeStores(cmd, args, toComplete)
-}
diff --git a/cmd/config.go b/cmd/config.go
index 6f47bd9..fb7a876 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -23,7 +23,6 @@ THE SOFTWARE.
package cmd
import (
- "bytes"
"fmt"
"os"
"path/filepath"
@@ -36,41 +35,27 @@ type Config struct {
DisplayAsciiArt bool `toml:"display_ascii_art"`
Key KeyConfig `toml:"key"`
Store StoreConfig `toml:"store"`
- List ListConfig `toml:"list"`
Git GitConfig `toml:"git"`
}
type KeyConfig struct {
- AlwaysPromptDelete bool `toml:"always_prompt_delete"`
- AlwaysPromptGlobDelete bool `toml:"always_prompt_glob_delete"`
- AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
- AlwaysEncrypt bool `toml:"always_encrypt"`
+ AlwaysPromptDelete bool `toml:"always_prompt_delete"`
+ AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
}
type StoreConfig struct {
- DefaultStoreName string `toml:"default_store_name"`
- AlwaysPromptDelete bool `toml:"always_prompt_delete"`
- AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"`
-}
-
-type ListConfig struct {
- AlwaysShowAllStores bool `toml:"always_show_all_stores"`
- DefaultListFormat string `toml:"default_list_format"`
- AlwaysShowFullValues bool `toml:"always_show_full_values"`
- AlwaysHideHeader bool `toml:"always_hide_header"`
- DefaultColumns string `toml:"default_columns"`
+ DefaultStoreName string `toml:"default_store_name"`
+ AlwaysPromptDelete bool `toml:"always_prompt_delete"`
}
type GitConfig struct {
- AutoFetch bool `toml:"auto_fetch"`
- AutoCommit bool `toml:"auto_commit"`
- AutoPush bool `toml:"auto_push"`
- DefaultCommitMessage string `toml:"default_commit_message"`
+ AutoFetch bool `toml:"auto_fetch"`
+ AutoCommit bool `toml:"auto_commit"`
+ AutoPush bool `toml:"auto_push"`
}
var (
- config Config
- configUndecodedKeys []string
+ config Config
asciiArt string = ` ▄▄
██
██▄███▄ ▄███▄██ ▄█████▄
@@ -84,119 +69,82 @@ var (
)
func init() {
- var migrations []migration
- config, configUndecodedKeys, migrations, configErr = loadConfig()
- for _, m := range migrations {
- if m.Conflict {
- warnf("both '%s' and '%s' present; using '%s'", m.Old, m.New, m.New)
- } else {
- warnf("config key '%s' is deprecated, use '%s'", m.Old, m.New)
- }
- }
+ config, configErr = loadConfig()
}
func defaultConfig() Config {
return Config{
DisplayAsciiArt: true,
Key: KeyConfig{
- AlwaysPromptDelete: false,
- AlwaysPromptGlobDelete: true,
- AlwaysPromptOverwrite: false,
+ AlwaysPromptDelete: false,
+ AlwaysPromptOverwrite: false,
},
Store: StoreConfig{
- DefaultStoreName: "store",
- AlwaysPromptDelete: true,
- AlwaysPromptOverwrite: true,
- },
- List: ListConfig{
- AlwaysShowAllStores: true,
- DefaultListFormat: "table",
- DefaultColumns: "meta,size,ttl,store,key,value",
+ DefaultStoreName: "default",
+ AlwaysPromptDelete: true,
},
Git: GitConfig{
- AutoFetch: false,
- AutoCommit: false,
- AutoPush: false,
- DefaultCommitMessage: "{{ summary }} {{ time }}",
+ AutoFetch: false,
+ AutoCommit: false,
+ AutoPush: false,
},
}
}
-// loadConfig returns (config, undecodedKeys, migrations, error).
-// Migrations are returned but NOT printed — callers decide.
-func loadConfig() (Config, []string, []migration, error) {
+func loadConfig() (Config, error) {
cfg := defaultConfig()
path, err := configPath()
if err != nil {
- return cfg, nil, nil, err
+ return cfg, err
}
- data, err := os.ReadFile(path)
- if err != nil {
+ if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
- return cfg, nil, nil, nil
+ return cfg, nil
}
- return cfg, nil, nil, err
+ return cfg, err
}
- // Decode into a raw map so we can run deprecation migrations before
- // the struct decode sees the keys.
- var raw map[string]any
- if _, err := toml.Decode(string(data), &raw); err != nil {
- return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err)
- }
-
- warnings := migrateRawConfig(raw)
-
- // Re-encode the migrated map and decode into the typed struct so
- // defaults fill any missing fields.
- var buf bytes.Buffer
- if err := toml.NewEncoder(&buf).Encode(raw); err != nil {
- return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err)
- }
-
- meta, err := toml.Decode(buf.String(), &cfg)
+ md, err := toml.DecodeFile(path, &cfg)
if err != nil {
- return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err)
+ return cfg, fmt.Errorf("parse %s: %w", path, err)
}
- var undecoded []string
- for _, key := range meta.Undecoded() {
- undecoded = append(undecoded, key.String())
+ if !md.IsDefined("display_ascii_art") {
+ cfg.DisplayAsciiArt = defaultConfig().DisplayAsciiArt
+
}
- if cfg.Store.DefaultStoreName == "" {
+ if !md.IsDefined("key", "always_prompt_delete") {
+ cfg.Key.AlwaysPromptDelete = defaultConfig().Key.AlwaysPromptDelete
+ }
+
+ if !md.IsDefined("store", "default_store_name") || cfg.Store.DefaultStoreName == "" {
cfg.Store.DefaultStoreName = defaultConfig().Store.DefaultStoreName
+
+ }
+ if !md.IsDefined("store", "always_prompt_delete") {
+ cfg.Store.AlwaysPromptDelete = defaultConfig().Store.AlwaysPromptDelete
}
- if cfg.List.DefaultListFormat == "" {
- cfg.List.DefaultListFormat = defaultConfig().List.DefaultListFormat
- }
- if err := validListFormat(cfg.List.DefaultListFormat); err != nil {
- return cfg, undecoded, warnings, fmt.Errorf("parse %s: list.default_list_format: %w", path, err)
+ if !md.IsDefined("key", "always_prompt_overwrite") {
+ cfg.Key.AlwaysPromptOverwrite = defaultConfig().Key.AlwaysPromptOverwrite
}
- if cfg.List.DefaultColumns == "" {
- cfg.List.DefaultColumns = defaultConfig().List.DefaultColumns
- }
- if err := validListColumns(cfg.List.DefaultColumns); err != nil {
- return cfg, undecoded, warnings, fmt.Errorf("parse %s: list.default_columns: %w", path, err)
+ if !md.IsDefined("git", "auto_fetch") {
+ cfg.Git.AutoFetch = defaultConfig().Git.AutoFetch
}
- if cfg.Git.DefaultCommitMessage == "" {
- cfg.Git.DefaultCommitMessage = defaultConfig().Git.DefaultCommitMessage
+ if !md.IsDefined("git", "auto_commit") {
+ cfg.Git.AutoCommit = defaultConfig().Git.AutoCommit
}
- return cfg, undecoded, warnings, nil
-}
-
-// validateConfig checks invariants on a Config value before it is persisted.
-func validateConfig(cfg Config) error {
- if err := validListFormat(cfg.List.DefaultListFormat); err != nil {
- return err
+ if !md.IsDefined("git", "auto_push") {
+ cfg.Git.AutoPush = defaultConfig().Git.AutoPush
}
- return validListColumns(cfg.List.DefaultColumns)
+
+ return cfg, nil
}
func configPath() (string, error) {
diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go
deleted file mode 100644
index a118d45..0000000
--- a/cmd/config_cmd.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "reflect"
- "strings"
-
- "github.com/BurntSushi/toml"
- "github.com/spf13/cobra"
-)
-
-var configCmd = &cobra.Command{
- Use: "config",
- Short: "View and modify configuration",
-}
-
-var configListCmd = &cobra.Command{
- Use: "list",
- Aliases: []string{"ls"},
- Short: "List all configuration values",
- Args: cobra.NoArgs,
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- defaults := defaultConfig()
- fields := configFields(&config, &defaults)
- for _, f := range fields {
- fmt.Printf("%s = %v\n", f.Key, f.Value)
- }
- return nil
- },
-}
-
-var configGetCmd = &cobra.Command{
- Use: "get ",
- Short: "Print a configuration value",
- Args: cobra.ExactArgs(1),
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- defaults := defaultConfig()
- fields := configFields(&config, &defaults)
- f := findConfigField(fields, args[0])
- if f == nil {
- err := fmt.Errorf("unknown config key '%s'", args[0])
- if suggestions := suggestConfigKey(fields, args[0]); len(suggestions) > 0 {
- return withHint(err, fmt.Sprintf("did you mean '%s'?", strings.Join(suggestions, "', '")))
- }
- return err
- }
- fmt.Printf("%v\n", f.Value)
- return nil
- },
-}
-
-var configPathCmd = &cobra.Command{
- Use: "path",
- Short: "Print config file path",
- Args: cobra.NoArgs,
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- p, err := configPath()
- if err != nil {
- return fmt.Errorf("cannot determine config path: %w", err)
- }
- fmt.Println(p)
- return nil
- },
-}
-
-var configEditCmd = &cobra.Command{
- Use: "edit",
- Short: "Open config file in $EDITOR",
- Args: cobra.NoArgs,
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- editor := os.Getenv("EDITOR")
- if editor == "" {
- return withHint(
- fmt.Errorf("EDITOR not set"),
- "set $EDITOR to your preferred text editor",
- )
- }
- p, err := configPath()
- if err != nil {
- return fmt.Errorf("cannot determine config path: %w", err)
- }
- // Create default config if file doesn't exist
- if _, err := os.Stat(p); os.IsNotExist(err) {
- if err := writeConfigFile(defaultConfig()); err != nil {
- return err
- }
- }
- c := exec.Command(editor, p)
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- if err := c.Run(); err != nil {
- return err
- }
-
- cfg, undecoded, migrations, err := loadConfig()
- for _, m := range migrations {
- if m.Conflict {
- warnf("both '%s' and '%s' present; using '%s'", m.Old, m.New, m.New)
- } else {
- warnf("config key '%s' is deprecated, use '%s'", m.Old, m.New)
- }
- }
- if err != nil {
- warnf("config has errors: %v", err)
- printHint("re-run 'pda config edit' to fix")
- return nil
- }
- if len(undecoded) > 0 {
- warnf("unrecognised key(s) will be ignored: %s", strings.Join(undecoded, ", "))
- }
- config = cfg
- configUndecodedKeys = undecoded
- configErr = nil
- okf("saved config: %s", p)
- return nil
- },
-}
-
-func writeConfigFile(cfg Config) error {
- p, err := configPath()
- if err != nil {
- return fmt.Errorf("cannot determine config path: %w", err)
- }
- if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
- return fmt.Errorf("cannot create config directory: %w", err)
- }
- f, err := os.Create(p)
- if err != nil {
- return fmt.Errorf("cannot write config: %w", err)
- }
- defer f.Close()
- enc := toml.NewEncoder(f)
- return enc.Encode(cfg)
-}
-
-var configInitCmd = &cobra.Command{
- Use: "init",
- Short: "Generate default config file",
- Args: cobra.NoArgs,
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- p, err := configPath()
- if err != nil {
- return fmt.Errorf("cannot determine config path: %w", err)
- }
- newFlag, _ := cmd.Flags().GetBool("new")
- updateFlag, _ := cmd.Flags().GetBool("update")
-
- if newFlag && updateFlag {
- return fmt.Errorf("--new and --update are mutually exclusive")
- }
-
- if updateFlag {
- if _, err := os.Stat(p); os.IsNotExist(err) {
- return withHint(
- fmt.Errorf("no config file to update"),
- "use 'pda config init' to create one",
- )
- }
- cfg, _, migrations, loadErr := loadConfig()
- if loadErr != nil {
- return fmt.Errorf("cannot update config: %w", loadErr)
- }
- if err := writeConfigFile(cfg); err != nil {
- return err
- }
- for _, m := range migrations {
- okf("%s migrated to %s", m.Old, m.New)
- }
- okf("updated config: %s", p)
- return nil
- }
-
- if !newFlag {
- if _, err := os.Stat(p); err == nil {
- return withHint(
- fmt.Errorf("config file already exists"),
- "use '--update' to update your config, or '--new' to get a fresh copy",
- )
- }
- }
- okf("generated config: %s", p)
- return writeConfigFile(defaultConfig())
- },
-}
-
-var configSetCmd = &cobra.Command{
- Use: "set ",
- Short: "Set a configuration value",
- Args: cobra.ExactArgs(2),
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- key, raw := args[0], args[1]
- cfg := config
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
- f := findConfigField(fields, key)
- if f == nil {
- err := fmt.Errorf("unknown config key '%s'", key)
- if suggestions := suggestConfigKey(fields, key); len(suggestions) > 0 {
- return withHint(err, fmt.Sprintf("did you mean '%s'?", strings.Join(suggestions, "', '")))
- }
- return err
- }
-
- switch f.Kind {
- case reflect.Bool:
- switch strings.ToLower(raw) {
- case "true":
- f.Field.SetBool(true)
- case "false":
- f.Field.SetBool(false)
- default:
- return fmt.Errorf("cannot set '%s': expected bool (true/false), got '%s'", key, raw)
- }
- case reflect.String:
- f.Field.SetString(raw)
- default:
- return fmt.Errorf("cannot set '%s': unsupported type %s", key, f.Kind)
- }
-
- if err := validateConfig(cfg); err != nil {
- return fmt.Errorf("cannot set '%s': %w", key, err)
- }
-
- if err := writeConfigFile(cfg); err != nil {
- return err
- }
-
- config = cfg
- configUndecodedKeys = nil
- okf("%s set to '%s'", key, raw)
- return nil
- },
-}
-
-func init() {
- configInitCmd.Flags().Bool("new", false, "overwrite existing config file")
- configInitCmd.Flags().Bool("update", false, "migrate deprecated keys and fill missing defaults")
- configCmd.AddCommand(configEditCmd)
- configCmd.AddCommand(configGetCmd)
- configCmd.AddCommand(configInitCmd)
- configCmd.AddCommand(configListCmd)
- configCmd.AddCommand(configPathCmd)
- configCmd.AddCommand(configSetCmd)
- rootCmd.AddCommand(configCmd)
-}
diff --git a/cmd/config_fields.go b/cmd/config_fields.go
deleted file mode 100644
index f65f122..0000000
--- a/cmd/config_fields.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package cmd
-
-import (
- "reflect"
- "strings"
-
- "github.com/agnivade/levenshtein"
-)
-
-// ConfigField represents a single leaf field in the Config struct,
-// mapped to its dotted TOML key path.
-type ConfigField struct {
- Key string // dotted key, e.g. "git.auto_commit"
- Value any // current value
- Default any // value from defaultConfig()
- IsDefault bool // Value == Default
- Field reflect.Value // settable reflect.Value (from cfg pointer)
- Kind reflect.Kind // field type kind
-}
-
-// configFields walks cfg and defaults in parallel, returning a ConfigField
-// for every leaf field. Keys are built from TOML struct tags.
-func configFields(cfg, defaults *Config) []ConfigField {
- var fields []ConfigField
- walk(reflect.ValueOf(cfg).Elem(), reflect.ValueOf(defaults).Elem(), "", &fields)
- return fields
-}
-
-func walk(cv, dv reflect.Value, prefix string, out *[]ConfigField) {
- ct := cv.Type()
- for i := 0; i < ct.NumField(); i++ {
- sf := ct.Field(i)
- tag := sf.Tag.Get("toml")
- if tag == "" || tag == "-" {
- continue
- }
-
- key := tag
- if prefix != "" {
- key = prefix + "." + tag
- }
-
- cfv := cv.Field(i)
- dfv := dv.Field(i)
-
- if sf.Type.Kind() == reflect.Struct {
- walk(cfv, dfv, key, out)
- continue
- }
-
- *out = append(*out, ConfigField{
- Key: key,
- Value: cfv.Interface(),
- Default: dfv.Interface(),
- IsDefault: reflect.DeepEqual(cfv.Interface(), dfv.Interface()),
- Field: cfv,
- Kind: sf.Type.Kind(),
- })
- }
-}
-
-// findConfigField returns the ConfigField matching the given dotted key,
-// or nil if not found.
-func findConfigField(fields []ConfigField, key string) *ConfigField {
- for i := range fields {
- if fields[i].Key == key {
- return &fields[i]
- }
- }
- return nil
-}
-
-// suggestConfigKey returns suggestions for a mistyped config key. More generous
-// than key/store suggestions since the config key space is small (~11 keys).
-// Normalises spaces to underscores and matches against both the full dotted key
-// and the leaf segment (part after the last dot).
-func suggestConfigKey(fields []ConfigField, target string) []string {
- normalized := strings.ReplaceAll(target, " ", "_")
- var suggestions []string
- for _, f := range fields {
- if matchesConfigKey(normalized, f.Key) {
- suggestions = append(suggestions, f.Key)
- }
- }
- return suggestions
-}
-
-func matchesConfigKey(input, key string) bool {
- // Substring match (either direction)
- if strings.Contains(key, input) || strings.Contains(input, key) {
- return true
- }
- // Levenshtein against full dotted key
- if levenshtein.ComputeDistance(input, key) <= max(len(key)/3, 4) {
- return true
- }
- // Levenshtein against leaf segment
- if i := strings.LastIndex(key, "."); i >= 0 {
- leaf := key[i+1:]
- if levenshtein.ComputeDistance(input, leaf) <= max(len(leaf)/3, 1) {
- return true
- }
- }
- return false
-}
diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go
deleted file mode 100644
index cbbb10f..0000000
--- a/cmd/config_fields_test.go
+++ /dev/null
@@ -1,144 +0,0 @@
-package cmd
-
-import (
- "reflect"
- "testing"
-)
-
-func TestConfigFieldsReturnsAllFields(t *testing.T) {
- cfg := defaultConfig()
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- // Count expected leaf fields by walking the struct
- expected := countLeafFields(reflect.TypeOf(Config{}))
- if len(fields) != expected {
- t.Errorf("configFields returned %d fields, want %d", len(fields), expected)
- }
-}
-
-func countLeafFields(t reflect.Type) int {
- n := 0
- for i := 0; i < t.NumField(); i++ {
- f := t.Field(i)
- if f.Type.Kind() == reflect.Struct {
- n += countLeafFields(f.Type)
- } else {
- n++
- }
- }
- return n
-}
-
-func TestConfigFieldsDottedKeys(t *testing.T) {
- cfg := defaultConfig()
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- want := map[string]bool{
- "display_ascii_art": true,
- "key.always_prompt_delete": true,
- "key.always_prompt_glob_delete": true,
- "key.always_prompt_overwrite": true,
- "key.always_encrypt": true,
- "store.default_store_name": true,
- "store.always_prompt_delete": true,
- "store.always_prompt_overwrite": true,
- "list.always_show_all_stores": true,
- "list.default_list_format": true,
- "list.always_show_full_values": true,
- "list.always_hide_header": true,
- "list.default_columns": true,
- "git.auto_fetch": true,
- "git.auto_commit": true,
- "git.auto_push": true,
- "git.default_commit_message": true,
- }
-
- got := make(map[string]bool)
- for _, f := range fields {
- got[f.Key] = true
- }
-
- for k := range want {
- if !got[k] {
- t.Errorf("missing key %q", k)
- }
- }
- for k := range got {
- if !want[k] {
- t.Errorf("unexpected key %q", k)
- }
- }
-}
-
-func TestConfigFieldsAllDefaults(t *testing.T) {
- cfg := defaultConfig()
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- for _, f := range fields {
- if !f.IsDefault {
- t.Errorf("field %q should be default, got Value=%v Default=%v", f.Key, f.Value, f.Default)
- }
- }
-}
-
-func TestConfigFieldsDetectsNonDefault(t *testing.T) {
- cfg := defaultConfig()
- cfg.Git.AutoCommit = true
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- for _, f := range fields {
- if f.Key == "git.auto_commit" {
- if f.IsDefault {
- t.Errorf("git.auto_commit should not be default after change")
- }
- if f.Value != true {
- t.Errorf("git.auto_commit Value = %v, want true", f.Value)
- }
- return
- }
- }
- t.Error("git.auto_commit not found in fields")
-}
-
-func TestConfigFieldsSettable(t *testing.T) {
- cfg := defaultConfig()
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- for _, f := range fields {
- if f.Key == "git.auto_push" {
- if f.Kind != reflect.Bool {
- t.Errorf("git.auto_push Kind = %v, want Bool", f.Kind)
- }
- f.Field.SetBool(true)
- if !cfg.Git.AutoPush {
- t.Error("setting field via reflect did not update cfg")
- }
- return
- }
- }
- t.Error("git.auto_push not found in fields")
-}
-
-func TestConfigFieldsStringField(t *testing.T) {
- cfg := defaultConfig()
- defaults := defaultConfig()
- fields := configFields(&cfg, &defaults)
-
- for _, f := range fields {
- if f.Key == "store.default_store_name" {
- if f.Kind != reflect.String {
- t.Errorf("store.default_store_name Kind = %v, want String", f.Kind)
- }
- if f.Value != "store" {
- t.Errorf("store.default_store_name Value = %v, want 'store'", f.Value)
- }
- return
- }
- }
- t.Error("store.default_store_name not found in fields")
-}
diff --git a/cmd/config_migrate.go b/cmd/config_migrate.go
deleted file mode 100644
index 6050152..0000000
--- a/cmd/config_migrate.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package cmd
-
-import "strings"
-
-type deprecation struct {
- Old string // e.g. "list.list_all_stores"
- New string // e.g. "list.always_show_all_stores"
-}
-
-type migration struct {
- Old string // key that was removed
- New string // key that holds the value
- Conflict bool // both old and new were present; new key wins
-}
-
-var deprecations = []deprecation{
- {"list.list_all_stores", "list.always_show_all_stores"},
-}
-
-func migrateRawConfig(raw map[string]any) []migration {
- var migrations []migration
- for _, dep := range deprecations {
- oldParts := strings.Split(dep.Old, ".")
- newParts := strings.Split(dep.New, ".")
-
- _, ok := nestedGet(raw, oldParts)
- if !ok {
- continue
- }
- m := migration{Old: dep.Old, New: dep.New}
- if _, exists := nestedGet(raw, newParts); exists {
- m.Conflict = true
- } else {
- nestedSet(raw, newParts, nestedMustGet(raw, oldParts))
- }
- nestedDelete(raw, oldParts)
- migrations = append(migrations, m)
- }
- return migrations
-}
-
-func nestedMustGet(m map[string]any, parts []string) any {
- v, _ := nestedGet(m, parts)
- return v
-}
-
-func nestedGet(m map[string]any, parts []string) (any, bool) {
- for i, p := range parts {
- v, ok := m[p]
- if !ok {
- return nil, false
- }
- if i == len(parts)-1 {
- return v, true
- }
- sub, ok := v.(map[string]any)
- if !ok {
- return nil, false
- }
- m = sub
- }
- return nil, false
-}
-
-func nestedSet(m map[string]any, parts []string, val any) {
- for i, p := range parts {
- if i == len(parts)-1 {
- m[p] = val
- return
- }
- sub, ok := m[p].(map[string]any)
- if !ok {
- sub = make(map[string]any)
- m[p] = sub
- }
- m = sub
- }
-}
-
-func nestedDelete(m map[string]any, parts []string) {
- for i, p := range parts {
- if i == len(parts)-1 {
- delete(m, p)
- return
- }
- sub, ok := m[p].(map[string]any)
- if !ok {
- return
- }
- m = sub
- }
-}
diff --git a/cmd/del-db.go b/cmd/del-db.go
index e094370..e563f33 100644
--- a/cmd/del-db.go
+++ b/cmd/del-db.go
@@ -31,47 +31,43 @@ import (
"github.com/spf13/cobra"
)
-// delStoreCmd represents the set command
-var delStoreCmd = &cobra.Command{
- Use: "remove-store STORE",
- Short: "Delete a store",
- Aliases: []string{"rms"},
- Args: cobra.ExactArgs(1),
- ValidArgsFunction: completeStores,
- RunE: delStore,
- SilenceUsage: true,
+// delDbCmd represents the set command
+var delDbCmd = &cobra.Command{
+ Use: "del-db DB",
+ Short: "Delete a database.",
+ Aliases: []string{"delete-db", "rm-db", "remove-db"},
+ Args: cobra.ExactArgs(1),
+ RunE: delDb,
+ SilenceUsage: true,
}
-func delStore(cmd *cobra.Command, args []string) error {
+func delDb(cmd *cobra.Command, args []string) error {
store := &Store{}
dbName, err := store.parseDB(args[0], false)
if err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", args[0], err)
+ return fmt.Errorf("cannot delete-db '%s': %v", args[0], err)
}
var notFound errNotFound
path, err := store.FindStore(dbName)
if errors.As(err, ¬Found) {
- return fmt.Errorf("cannot delete store '%s': %w", dbName, err)
+ return fmt.Errorf("cannot delete-db '%s': %v", dbName, err)
}
if err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", dbName, err)
+ return fmt.Errorf("cannot delete-db '%s': %v", dbName, err)
}
interactive, err := cmd.Flags().GetBool("interactive")
if err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", dbName, err)
- }
- yes, err := cmd.Flags().GetBool("yes")
- if err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", dbName, err)
+ return fmt.Errorf("cannot delete-db '%s': %v", dbName, err)
}
- if !yes && (interactive || config.Store.AlwaysPromptDelete) {
- promptf("delete store '%s'? (y/n)", args[0])
+ if interactive || config.Store.AlwaysPromptDelete {
+ message := fmt.Sprintf("delete-db '%s': are you sure? (y/n)", args[0])
+ fmt.Println(message)
var confirm string
- if err := scanln(&confirm); err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", dbName, err)
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot delete-db '%s': %v", dbName, err)
}
if strings.ToLower(confirm) != "y" {
return nil
@@ -80,18 +76,17 @@ func delStore(cmd *cobra.Command, args []string) error {
if err := executeDeletion(path); err != nil {
return err
}
- return autoSync(fmt.Sprintf("removed @%s", dbName))
+ return autoSync()
}
func executeDeletion(path string) error {
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("cannot delete store '%s': %v", path, err)
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("cannot delete-db '%s': %v", path, err)
}
return nil
}
func init() {
- delStoreCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion")
- delStoreCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
- rootCmd.AddCommand(delStoreCmd)
+ delDbCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion")
+ rootCmd.AddCommand(delDbCmd)
}
diff --git a/cmd/del.go b/cmd/del.go
index fba342c..a2e6072 100644
--- a/cmd/del.go
+++ b/cmd/del.go
@@ -23,21 +23,22 @@ THE SOFTWARE.
package cmd
import (
+ "errors"
"fmt"
"strings"
+ "github.com/dgraph-io/badger/v4"
"github.com/gobwas/glob"
"github.com/spf13/cobra"
)
-// delCmd represents the remove command
+// delCmd represents the set command
var delCmd = &cobra.Command{
- Use: "remove KEY[@STORE] [KEY[@STORE] ...]",
- Short: "Delete one or more keys",
- Aliases: []string{"rm"},
- Args: cobra.ArbitraryArgs,
- ValidArgsFunction: completeKeys,
- RunE: del,
+ Use: "del KEY[@DB] [KEY[@DB] ...]",
+ Short: "Delete one or more keys. Optionally specify a db.",
+ Aliases: []string{"delete", "rm", "remove"},
+ Args: cobra.ArbitraryArgs,
+ RunE: del,
SilenceUsage: true,
}
@@ -48,133 +49,121 @@ func del(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
- yes, err := cmd.Flags().GetBool("yes")
+ globPatterns, err := cmd.Flags().GetStringSlice("glob")
if err != nil {
return err
}
- keyPatterns, err := cmd.Flags().GetStringSlice("key")
- if err != nil {
- return err
- }
- valuePatterns, err := cmd.Flags().GetStringSlice("value")
- if err != nil {
- return err
- }
- storePatterns, err := cmd.Flags().GetStringSlice("store")
+ separators, err := parseGlobSeparators(cmd)
if err != nil {
return err
}
- hasFilters := len(keyPatterns) > 0 || len(valuePatterns) > 0 || len(storePatterns) > 0
- if len(args) == 0 && !hasFilters {
+ if len(args) == 0 && len(globPatterns) == 0 {
return fmt.Errorf("cannot remove: no keys provided")
}
- targets, err := resolveDeleteTargets(store, args, keyPatterns, valuePatterns, storePatterns)
+ targets, err := resolveDeleteTargets(store, args, globPatterns, separators)
if err != nil {
return err
}
if len(targets) == 0 {
- return fmt.Errorf("cannot remove: no such key")
+ return fmt.Errorf("cannot remove: No such key")
}
- // Group targets by store for batch deletes.
- type storeTargets struct {
- targets []resolvedTarget
- }
- byStore := make(map[string]*storeTargets)
- var storeOrder []string
- promptGlob := hasFilters && config.Key.AlwaysPromptGlobDelete
+ var processed []resolvedTarget
for _, target := range targets {
- if !yes && (interactive || config.Key.AlwaysPromptDelete || promptGlob) {
+ if interactive || config.Key.AlwaysPromptDelete {
var confirm string
- promptf("remove '%s'? (y/n)", target.display)
- if err := scanln(&confirm); err != nil {
+ message := fmt.Sprintf("remove %q: are you sure? (y/n)", target.display)
+ fmt.Println(message)
+ if _, err := fmt.Scanln(&confirm); err != nil {
return fmt.Errorf("cannot remove '%s': %v", target.full, err)
}
if strings.ToLower(confirm) != "y" {
continue
}
}
- if _, ok := byStore[target.db]; !ok {
- byStore[target.db] = &storeTargets{}
- storeOrder = append(storeOrder, target.db)
+ trans := TransactionArgs{
+ key: target.full,
+ readonly: false,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ if err := tx.Delete(k); errors.Is(err, badger.ErrKeyNotFound) {
+ return fmt.Errorf("cannot remove '%s': No such key", target.full)
+ }
+ if err != nil {
+ return fmt.Errorf("cannot remove '%s': %v", target.full, err)
+ }
+ return nil
+ },
}
- byStore[target.db].targets = append(byStore[target.db].targets, target)
+
+ if err := store.Transaction(trans); err != nil {
+ return err
+ }
+ processed = append(processed, target)
}
- if len(byStore) == 0 {
+ if len(processed) == 0 {
return nil
}
- force, _ := cmd.Flags().GetBool("force")
-
- var removedNames []string
- for _, dbName := range storeOrder {
- st := byStore[dbName]
- p, err := store.storePath(dbName)
+ var dbs []string
+ var labels []string
+ for _, t := range processed {
+ spec, err := store.parseKey(t.full, true)
if err != nil {
return err
}
- entries, err := readStoreFile(p, nil)
- if err != nil {
- return err
- }
- for _, t := range st.targets {
- idx := findEntry(entries, t.key)
- 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)
- }
- if err := writeStoreFile(p, entries, nil); err != nil {
- return err
- }
+ dbs = append(dbs, spec.DB)
+ labels = append(labels, t.display)
}
-
- return autoSync("removed " + strings.Join(removedNames, ", "))
+ return autoSync()
}
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.RegisterFlagCompletionFunc("store", completeStoreFlag)
- delCmd.Flags().StringSliceP("value", "v", nil, "delete entries matching value glob pattern (repeatable)")
+ delCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion")
+ delCmd.Flags().StringSliceP("glob", "g", nil, "Delete keys matching glob pattern (repeatable)")
+ delCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay()))
rootCmd.AddCommand(delCmd)
}
type resolvedTarget struct {
full string
display string
- key string
- db string
}
func keyExists(store *Store, arg string) (bool, error) {
- spec, err := store.parseKey(arg, true)
- if err != nil {
+ var notFound bool
+ trans := TransactionArgs{
+ key: arg,
+ readonly: true,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ if _, err := tx.Get(k); errors.Is(err, badger.ErrKeyNotFound) {
+ notFound = true
+ return nil
+ } else {
+ return err
+ }
+ },
+ }
+ if err := store.Transaction(trans); err != nil {
return false, err
}
- p, err := store.storePath(spec.DB)
- if err != nil {
- return false, err
- }
- entries, err := readStoreFile(p, nil)
- if err != nil {
- return false, err
- }
- return findEntry(entries, spec.Key) >= 0, nil
+ return !notFound, nil
}
-func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, valuePatterns []string, storePatterns []string) ([]resolvedTarget, error) {
+func formatKeyForPrompt(store *Store, arg string) (string, error) {
+ spec, err := store.parseKey(arg, true)
+ if err != nil {
+ return "", err
+ }
+ return spec.Display(), nil
+}
+
+func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, separators []rune) ([]resolvedTarget, error) {
targetSet := make(map[string]struct{})
var targets []resolvedTarget
@@ -187,8 +176,6 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin
targets = append(targets, resolvedTarget{
full: full,
display: spec.Display(),
- key: spec.Key,
- db: spec.DB,
})
}
@@ -207,32 +194,16 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin
addTarget(spec)
}
- if len(globPatterns) == 0 && len(valuePatterns) == 0 && len(storePatterns) == 0 {
+ if len(globPatterns) == 0 {
return targets, nil
}
- // Resolve --store patterns into a list of target stores.
- storeMatchers, err := compileGlobMatchers(storePatterns)
- if err != nil {
- return nil, fmt.Errorf("cannot remove: %v", err)
- }
-
- valueMatchers, err := compileValueMatchers(valuePatterns)
- if err != nil {
- return nil, fmt.Errorf("cannot remove: %v", err)
- }
-
type compiledPattern struct {
rawArg string
db string
matcher glob.Glob
}
- // When --store or --value is given without --key, match all keys.
- if len(globPatterns) == 0 {
- globPatterns = []string{"**"}
- }
-
var compiled []compiledPattern
for _, raw := range globPatterns {
spec, err := store.parseKey(raw, true)
@@ -240,54 +211,41 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin
return nil, err
}
pattern := spec.Key
- m, err := glob.Compile(pattern, defaultGlobSeparators...)
+ m, err := glob.Compile(pattern, separators...)
if err != nil {
return nil, fmt.Errorf("cannot remove '%s': %v", raw, err)
}
- if len(storeMatchers) > 0 && !strings.Contains(raw, "@") {
- // --store given and pattern has no explicit @STORE: expand across matching stores.
- allStores, err := store.AllStores()
- if err != nil {
- return nil, fmt.Errorf("cannot remove: %v", err)
- }
- for _, s := range allStores {
- if globMatch(storeMatchers, s) {
- compiled = append(compiled, compiledPattern{rawArg: raw, db: s, matcher: m})
- }
- }
- } else {
- compiled = append(compiled, compiledPattern{rawArg: raw, db: spec.DB, matcher: m})
- }
+ compiled = append(compiled, compiledPattern{
+ rawArg: raw,
+ db: spec.DB,
+ matcher: m,
+ })
}
- entriesByDB := make(map[string][]Entry)
- getEntries := func(db string) ([]Entry, error) {
- if entries, ok := entriesByDB[db]; ok {
- return entries, nil
+ keysByDB := make(map[string][]string)
+ getKeys := func(db string) ([]string, error) {
+ if keys, ok := keysByDB[db]; ok {
+ return keys, nil
}
- p, err := store.storePath(db)
+ keys, err := store.Keys(db)
if err != nil {
return nil, err
}
- entries, err := readStoreFile(p, nil)
- if err != nil {
- return nil, err
- }
- entriesByDB[db] = entries
- return entries, nil
+ keysByDB[db] = keys
+ return keys, nil
}
for _, p := range compiled {
- entries, err := getEntries(p.db)
+ keys, err := getKeys(p.db)
if err != nil {
return nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err)
}
- for _, e := range entries {
- if p.matcher.Match(e.Key) && valueMatch(valueMatchers, e) {
+ for _, k := range keys {
+ if p.matcher.Match(k) {
addTarget(KeySpec{
- Raw: e.Key,
- RawKey: e.Key,
- Key: e.Key,
+ Raw: k,
+ RawKey: k,
+ Key: k,
DB: p.db,
})
}
diff --git a/cmd/doctor.go b/cmd/doctor.go
deleted file mode 100644
index 8e6fd7f..0000000
--- a/cmd/doctor.go
+++ /dev/null
@@ -1,361 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "io"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
-
- "github.com/spf13/cobra"
- "golang.org/x/term"
-)
-
-var doctorCmd = &cobra.Command{
- Use: "doctor",
- Short: "Check environment health",
- RunE: doctor,
- SilenceUsage: true,
-}
-
-func init() {
- rootCmd.AddCommand(doctorCmd)
-}
-
-func doctor(cmd *cobra.Command, args []string) error {
- if runDoctor(os.Stdout) {
- os.Exit(1)
- }
- return nil
-}
-
-func runDoctor(w io.Writer) bool {
- tty := false
- if f, ok := w.(*os.File); ok {
- tty = term.IsTerminal(int(f.Fd()))
- }
- hasError := false
-
- lastFail := false
-
- emit := func(level, msg string) {
- var code string
- switch level {
- case "ok":
- code = "32"
- lastFail = false
- case "WARN":
- code = "33"
- lastFail = false
- case "FAIL":
- code = "31"
- hasError = true
- lastFail = true
- }
- if lastFail && tty {
- fmt.Fprintf(w, "%s \033[1m%s\033[0m\n", keyword(code, level, tty), msg)
- } else {
- fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg)
- }
- }
-
- tree := func(items []string) {
- for i, item := range items {
- connector := "├── "
- if i == len(items)-1 {
- connector = "└── "
- }
- if lastFail && tty {
- fmt.Fprintf(w, "\033[1m %s%s\033[0m\n", connector, item)
- } else {
- fmt.Fprintf(w, " %s%s\n", connector, item)
- }
- }
- }
-
- // 1. Version + platform
- emit("ok", fmt.Sprintf("%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH))
-
- // 2. OS detail
- switch runtime.GOOS {
- case "linux":
- if out, err := exec.Command("uname", "-r").Output(); err == nil {
- emit("ok", fmt.Sprintf("OS: Linux %s", strings.TrimSpace(string(out))))
- }
- case "darwin":
- if out, err := exec.Command("sw_vers", "-productVersion").Output(); err == nil {
- emit("ok", fmt.Sprintf("OS: macOS %s", strings.TrimSpace(string(out))))
- }
- }
-
- // 3. Go version
- emit("ok", fmt.Sprintf("Go: %s", runtime.Version()))
-
- // 4. Git dependency
- gitAvailable := false
- if out, err := exec.Command("git", "--version").Output(); err == nil {
- gitVer := strings.TrimSpace(string(out))
- if after, ok := strings.CutPrefix(gitVer, "git version "); ok {
- gitVer = after
- }
- emit("ok", fmt.Sprintf("Git: %s", gitVer))
- gitAvailable = true
- } else {
- emit("WARN", "git not found on PATH")
- }
-
- // 5. Shell
- if shell := os.Getenv("SHELL"); shell != "" {
- emit("ok", fmt.Sprintf("Shell: %s", shell))
- } else {
- emit("WARN", "SHELL not set")
- }
-
- // 6. Config directory and file
- cfgPath, err := configPath()
- if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot determine config path: %v", err))
- } else {
- cfgDir := filepath.Dir(cfgPath)
- envSuffix := ""
- if os.Getenv("PDA_CONFIG") != "" {
- envSuffix = " (PDA_CONFIG)"
- }
-
- var issues []string
- if _, statErr := os.Stat(cfgPath); statErr != nil && !os.IsNotExist(statErr) {
- issues = append(issues, fmt.Sprintf("Config file unreadable: %s", cfgPath))
- }
- if configErr != nil {
- issues = append(issues, fmt.Sprintf("Parse error: %v", configErr))
- issues = append(issues, "While broken, ONLY 'doctor', 'config edit', and 'config init' will function")
- issues = append(issues, "Fix with 'pda config edit' or 'pda config init --new'")
- }
- if unexpectedFiles(cfgDir, map[string]bool{
- "config.toml": true,
- }) {
- issues = append(issues, "Unexpected file(s) in directory")
- }
-
- if len(issues) > 0 {
- emit("FAIL", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
- tree(issues)
- } else {
- emit("ok", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
- }
-
- if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
- emit("ok", "Using default configuration")
- }
- }
-
- // 7. Non-default config values (skip if config failed to parse)
- if configErr == nil {
- defaults := defaultConfig()
- if diffs := configDiffStrings(configFields(&config, &defaults)); len(diffs) > 0 {
- emit("ok", "Non-default config:")
- tree(diffs)
- }
- }
-
- // 7b. Unrecognised config keys
- if len(configUndecodedKeys) > 0 {
- emit("WARN", fmt.Sprintf("Unrecognised config key(s) (ignored):"))
- tree(configUndecodedKeys)
- }
-
- // 8. Data directory
- store := &Store{}
- dataDir, err := store.path()
- if err != nil {
- emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err))
- } else {
- envSuffix := ""
- if os.Getenv("PDA_DATA") != "" {
- envSuffix = " (PDA_DATA)"
- }
-
- if unexpectedDataFiles(dataDir) {
- emit("FAIL", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
- tree([]string{"Unexpected file(s) in directory"})
- } else {
- emit("ok", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
- }
- }
-
- // 9. Identity file
- idPath, err := identityPath()
- if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot determine identity path: %v", err))
- } else if _, err := os.Stat(idPath); os.IsNotExist(err) {
- emit("WARN", "No identity file found")
- } else if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err))
- } else {
- info, _ := os.Stat(idPath)
- emit("ok", fmt.Sprintf("Identity: %s", idPath))
- if perm := info.Mode().Perm(); perm != 0o600 {
- emit("WARN", fmt.Sprintf("Identity file permissions %04o (should be 0600)", perm))
- }
- if _, loadErr := loadIdentity(); loadErr != nil {
- emit("WARN", fmt.Sprintf("Identity file invalid: %v", loadErr))
- }
- }
-
- // 10. Git initialised
- gitInitialised := false
- if dataDir != "" {
- gitDir := filepath.Join(dataDir, ".git")
- if _, err := os.Stat(gitDir); os.IsNotExist(err) {
- emit("WARN", "Git not initialised")
- } else if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err))
- } else {
- gitInitialised = true
- branch, _ := currentBranch(dataDir)
- if branch == "" {
- branch = "unknown"
- }
- emit("ok", fmt.Sprintf("Git initialised on %s", branch))
- }
- }
-
- // 11. Git uncommitted changes (only if git initialised)
- if gitInitialised && gitAvailable {
- ucCmd := exec.Command("git", "status", "--porcelain")
- ucCmd.Dir = dataDir
- if out, err := ucCmd.Output(); err == nil {
- if trimmed := strings.TrimSpace(string(out)); trimmed != "" {
- count := len(strings.Split(trimmed, "\n"))
- emit("WARN", fmt.Sprintf("Git %d file(s) with uncommitted changes", count))
- }
- }
- }
-
- // 12. Git remote (only if git initialised)
- hasOrigin := false
- if gitInitialised {
- var err error
- hasOrigin, err = repoHasRemote(dataDir, "origin")
- if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot check git remote: %v", err))
- } else if hasOrigin {
- emit("ok", "Git remote configured")
- } else {
- emit("WARN", "No git remote configured")
- }
- }
-
- // 13. Git sync (only if git initialised AND remote exists)
- if gitInitialised && hasOrigin && gitAvailable {
- info, err := repoRemoteInfo(dataDir)
- if err != nil || info.Ref == "" {
- emit("WARN", "Git sync status unknown")
- } else {
- ahead, behind, err := repoAheadBehind(dataDir, info.Ref)
- if err != nil {
- emit("WARN", "Git sync status unknown")
- } else if ahead == 0 && behind == 0 {
- emit("ok", "Git in sync with remote")
- } else {
- var parts []string
- if ahead > 0 {
- parts = append(parts, fmt.Sprintf("%d ahead", ahead))
- }
- if behind > 0 {
- parts = append(parts, fmt.Sprintf("%d behind", behind))
- }
- emit("WARN", fmt.Sprintf("Git %s remote", strings.Join(parts, ", ")))
- }
- }
- }
-
- // 14. Stores summary
- stores, err := store.AllStores()
- if err != nil {
- emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err))
- } else if len(stores) == 0 {
- emit("WARN", "No stores found")
- } else {
- var totalKeys, totalSecrets, parseErrors int
- var totalSize int64
- for _, name := range stores {
- p, pErr := store.storePath(name)
- if pErr != nil {
- parseErrors++
- continue
- }
- if fi, sErr := os.Stat(p); sErr == nil {
- totalSize += fi.Size()
- }
- entries, rErr := readStoreFile(p, nil)
- if rErr != nil {
- parseErrors++
- continue
- }
- totalKeys += len(entries)
- for _, e := range entries {
- if e.Secret {
- totalSecrets++
- }
- }
- }
- if parseErrors > 0 {
- emit("FAIL", fmt.Sprintf("%d store(s), %d with parse errors", len(stores), parseErrors))
- } else {
- emit("ok", fmt.Sprintf("%d store(s), %d key(s), %d secret(s), %s total size",
- len(stores), totalKeys, totalSecrets, formatSize(int(totalSize))))
- }
- }
-
- if hasError {
- emit("FAIL", "1 or more issues found")
- } else {
- emit("ok", "No issues found")
- }
-
- return hasError
-}
-
-func configDiffStrings(fields []ConfigField) []string {
- var diffs []string
- for _, f := range fields {
- if !f.IsDefault {
- diffs = append(diffs, fmt.Sprintf("%s: %v", f.Key, f.Value))
- }
- }
- return diffs
-}
-
-func unexpectedFiles(dir string, allowed map[string]bool) bool {
- entries, err := os.ReadDir(dir)
- if err != nil {
- return false
- }
- for _, e := range entries {
- if !allowed[e.Name()] {
- return true
- }
- }
- return false
-}
-
-func unexpectedDataFiles(dir string) bool {
- entries, err := os.ReadDir(dir)
- if err != nil {
- return false
- }
- for _, e := range entries {
- name := e.Name()
- if e.IsDir() && name == ".git" {
- continue
- }
- if !e.IsDir() && (name == ".gitignore" || name == "identity.txt" || name == "recipients.txt" || filepath.Ext(name) == ".ndjson") {
- continue
- }
- return true
- }
- return false
-}
diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go
deleted file mode 100644
index 3bb5f8c..0000000
--- a/cmd/doctor_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package cmd
-
-import (
- "bytes"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestDoctorCleanEnv(t *testing.T) {
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
- saved := configErr
- configErr = nil
- t.Cleanup(func() { configErr = saved })
-
- var buf bytes.Buffer
- hasError := runDoctor(&buf)
- out := buf.String()
-
- if hasError {
- t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
- }
- for _, want := range []string{
- version,
- "Using default configuration",
- "No identity file found",
- "Git not initialised",
- "No stores found",
- } {
- if !strings.Contains(out, want) {
- t.Errorf("expected %q in output, got:\n%s", want, out)
- }
- }
-}
-
-func TestDoctorWithStores(t *testing.T) {
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
- saved := configErr
- configErr = nil
- t.Cleanup(func() { configErr = saved })
-
- content := "{\"key\":\"foo\",\"value\":\"bar\",\"encoding\":\"text\"}\n" +
- "{\"key\":\"baz\",\"value\":\"qux\",\"encoding\":\"text\"}\n"
- if err := os.WriteFile(filepath.Join(dataDir, "test.ndjson"), []byte(content), 0o644); err != nil {
- t.Fatal(err)
- }
-
- var buf bytes.Buffer
- hasError := runDoctor(&buf)
- out := buf.String()
-
- if hasError {
- t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
- }
- if !strings.Contains(out, "1 store(s), 2 key(s), 0 secret(s)") {
- t.Errorf("expected store summary in output, got:\n%s", out)
- }
-}
-
-func TestDoctorIdentityPermissions(t *testing.T) {
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
-
- idPath := filepath.Join(dataDir, "identity.txt")
- if err := os.WriteFile(idPath, []byte("placeholder\n"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- var buf bytes.Buffer
- runDoctor(&buf)
- out := buf.String()
-
- if !strings.Contains(out, "Identity:") {
- t.Errorf("expected 'Identity:' in output, got:\n%s", out)
- }
- if !strings.Contains(out, "should be 0600") {
- t.Errorf("expected permissions warning in output, got:\n%s", out)
- }
-}
-
-func TestDoctorUndecodedKeys(t *testing.T) {
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
-
- // Write a config with an unknown key.
- cfgContent := "[store]\nno_such_key = true\n"
- if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(cfgContent), 0o644); err != nil {
- t.Fatal(err)
- }
-
- savedCfg, savedUndecoded, savedErr := config, configUndecodedKeys, configErr
- config, configUndecodedKeys, _, configErr = loadConfig()
- t.Cleanup(func() {
- config, configUndecodedKeys, configErr = savedCfg, savedUndecoded, savedErr
- })
-
- var buf bytes.Buffer
- runDoctor(&buf)
- out := buf.String()
-
- if !strings.Contains(out, "Unrecognised config key") {
- t.Errorf("expected undecoded key warning, got:\n%s", out)
- }
- if !strings.Contains(out, "store.no_such_key") {
- t.Errorf("expected 'store.no_such_key' in output, got:\n%s", out)
- }
-}
-
-func TestDoctorGitInitialised(t *testing.T) {
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
-
- cmd := exec.Command("git", "init")
- cmd.Dir = dataDir
- if err := cmd.Run(); err != nil {
- t.Skipf("git not available: %v", err)
- }
- cmd = exec.Command("git", "commit", "--allow-empty", "-m", "init")
- cmd.Dir = dataDir
- cmd.Env = append(os.Environ(),
- "GIT_AUTHOR_NAME=test",
- "GIT_AUTHOR_EMAIL=test@test",
- "GIT_COMMITTER_NAME=test",
- "GIT_COMMITTER_EMAIL=test@test",
- )
- if err := cmd.Run(); err != nil {
- t.Fatalf("git commit: %v", err)
- }
-
- var buf bytes.Buffer
- runDoctor(&buf)
- out := buf.String()
-
- if !strings.Contains(out, "Git initialised on") {
- t.Errorf("expected 'Git initialised on' in output, got:\n%s", out)
- }
- if !strings.Contains(out, "No git remote configured") {
- t.Errorf("expected 'No git remote configured' in output, got:\n%s", out)
- }
-}
diff --git a/cmd/dump.go b/cmd/dump.go
new file mode 100644
index 0000000..fe0f7f0
--- /dev/null
+++ b/cmd/dump.go
@@ -0,0 +1,219 @@
+/*
+Copyright © 2025 Lewis Wynne
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+package cmd
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/dgraph-io/badger/v4"
+ "github.com/gobwas/glob"
+ "github.com/spf13/cobra"
+)
+
+type dumpEntry struct {
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Encoding string `json:"encoding,omitempty"`
+ Secret bool `json:"secret,omitempty"`
+ ExpiresAt *int64 `json:"expires_at,omitempty"`
+}
+
+var dumpCmd = &cobra.Command{
+ Use: "dump [DB]",
+ Short: "Dump all key/value pairs as NDJSON",
+ Aliases: []string{"export"},
+ Args: cobra.MaximumNArgs(1),
+ RunE: dump,
+ SilenceUsage: true,
+}
+
+func dump(cmd *cobra.Command, args []string) error {
+ store := &Store{}
+ targetDB := "@" + config.Store.DefaultStoreName
+ if len(args) == 1 {
+ rawArg := args[0]
+ dbName, err := store.parseDB(rawArg, false)
+ if err != nil {
+ return fmt.Errorf("cannot dump '%s': %v", rawArg, err)
+ }
+ if _, err := store.FindStore(dbName); err != nil {
+ var notFound errNotFound
+ if errors.As(err, ¬Found) {
+ return fmt.Errorf("cannot dump '%s': %v", rawArg, err)
+ }
+ return err
+ }
+ targetDB = "@" + dbName
+ }
+
+ mode, err := cmd.Flags().GetString("encoding")
+ if err != nil {
+ return fmt.Errorf("cannot dump '%s': %v", targetDB, err)
+ }
+ switch mode {
+ case "auto", "base64", "text":
+ default:
+ return fmt.Errorf("cannot dump '%s': unsupported encoding '%s'", targetDB, mode)
+ }
+
+ includeSecret, err := cmd.Flags().GetBool("secret")
+ if err != nil {
+ return err
+ }
+ globPatterns, err := cmd.Flags().GetStringSlice("glob")
+ if err != nil {
+ return fmt.Errorf("cannot dump '%s': %v", targetDB, err)
+ }
+ separators, err := parseGlobSeparators(cmd)
+ if err != nil {
+ return fmt.Errorf("cannot dump '%s': %v", targetDB, err)
+ }
+ matchers, err := compileGlobMatchers(globPatterns, separators)
+ if err != nil {
+ return fmt.Errorf("cannot dump '%s': %v", targetDB, err)
+ }
+
+ opts := DumpOptions{
+ Encoding: mode,
+ IncludeSecret: includeSecret,
+ Matchers: matchers,
+ GlobPatterns: globPatterns,
+ }
+ return dumpDatabase(store, strings.TrimPrefix(targetDB, "@"), cmd.OutOrStdout(), opts)
+}
+
+func init() {
+ dumpCmd.Flags().StringP("encoding", "e", "auto", "value encoding: auto, base64, or text")
+ dumpCmd.Flags().Bool("secret", false, "Include entries marked as secret")
+ dumpCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)")
+ dumpCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay()))
+ rootCmd.AddCommand(dumpCmd)
+}
+
+func encodeBase64(entry *dumpEntry, v []byte) {
+ entry.Value = base64.StdEncoding.EncodeToString(v)
+ entry.Encoding = "base64"
+}
+
+func encodeText(entry *dumpEntry, key []byte, v []byte) error {
+ if !utf8.Valid(v) {
+ return fmt.Errorf("key %q contains non-UTF8 data; use --encoding=auto or base64", key)
+ }
+ entry.Value = string(v)
+ entry.Encoding = "text"
+ return nil
+}
+
+// DumpOptions controls how a database is dumped to NDJSON.
+type DumpOptions struct {
+ Encoding string
+ IncludeSecret bool
+ Matchers []glob.Glob
+ GlobPatterns []string
+}
+
+// dumpDatabase writes entries from dbName to w as NDJSON.
+func dumpDatabase(store *Store, dbName string, w io.Writer, opts DumpOptions) error {
+ targetDB := "@" + dbName
+ if opts.Encoding == "" {
+ opts.Encoding = "auto"
+ }
+
+ var matched bool
+ trans := TransactionArgs{
+ key: targetDB,
+ readonly: true,
+ sync: true,
+ transact: func(tx *badger.Txn, k []byte) error {
+ it := tx.NewIterator(badger.DefaultIteratorOptions)
+ defer it.Close()
+ for it.Rewind(); it.Valid(); it.Next() {
+ item := it.Item()
+ key := item.KeyCopy(nil)
+ if !globMatch(opts.Matchers, string(key)) {
+ continue
+ }
+ meta := item.UserMeta()
+ isSecret := meta&metaSecret != 0
+ if isSecret && !opts.IncludeSecret {
+ continue
+ }
+ expiresAt := item.ExpiresAt()
+ if err := item.Value(func(v []byte) error {
+ entry := dumpEntry{
+ Key: string(key),
+ Secret: isSecret,
+ }
+ if expiresAt > 0 {
+ ts := int64(expiresAt)
+ entry.ExpiresAt = &ts
+ }
+ switch opts.Encoding {
+ case "base64":
+ encodeBase64(&entry, v)
+ case "text":
+ if err := encodeText(&entry, key, v); err != nil {
+ return err
+ }
+ case "auto":
+ if utf8.Valid(v) {
+ entry.Encoding = "text"
+ entry.Value = string(v)
+ } else {
+ encodeBase64(&entry, v)
+ }
+ default:
+ return fmt.Errorf("unsupported encoding '%s'", opts.Encoding)
+ }
+ payload, err := json.Marshal(entry)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintln(w, string(payload))
+ if err == nil {
+ matched = true
+ }
+ return err
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+ },
+ }
+
+ if err := store.Transaction(trans); err != nil {
+ return err
+ }
+
+ if len(opts.Matchers) > 0 && !matched {
+ return fmt.Errorf("No matches for pattern %s", formatGlobPatterns(opts.GlobPatterns))
+ }
+ return nil
+}
diff --git a/cmd/edit.go b/cmd/edit.go
deleted file mode 100644
index a5cbe03..0000000
--- a/cmd/edit.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package cmd
-
-import (
- "bytes"
- "encoding/base64"
- "fmt"
- "os"
- "os/exec"
- "unicode/utf8"
-
- "filippo.io/age"
- "github.com/spf13/cobra"
-)
-
-var editCmd = &cobra.Command{
- Use: "edit KEY[@STORE]",
- Short: "Edit a key's value in $EDITOR",
- Long: `Open a key's value in $EDITOR. If the key doesn't exist, opens an
-empty file — saving non-empty content creates the key.
-
-Binary values are presented as base64 for editing and decoded back on save.
-
-Metadata flags (--ttl, --encrypt, --decrypt) can be passed alongside the edit
-to modify metadata in the same operation.`,
- Aliases: []string{"e"},
- Args: cobra.ExactArgs(1),
- ValidArgsFunction: completeKeys,
- RunE: edit,
- SilenceUsage: true,
-}
-
-func edit(cmd *cobra.Command, args []string) error {
- editor := os.Getenv("EDITOR")
- if editor == "" {
- return withHint(
- fmt.Errorf("EDITOR not set"),
- "set $EDITOR to your preferred text editor",
- )
- }
-
- store := &Store{}
-
- spec, err := store.parseKey(args[0], true)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
-
- ttlStr, _ := cmd.Flags().GetString("ttl")
- 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
- if encryptFlag {
- identity, err = ensureIdentity()
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- } else {
- identity, _ = loadIdentity()
- }
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
-
- p, err := store.storePath(spec.DB)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- entries, err := readStoreFile(p, identity)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- idx := findEntry(entries, spec.Key)
-
- creating := idx < 0
- var original []byte
- var wasBinary bool
- var entry *Entry
-
- if creating {
- 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])
- }
- original = entry.Value
- wasBinary = !utf8.Valid(original)
- }
-
- // Prepare temp file content
- var tmpContent []byte
- if wasBinary {
- tmpContent = []byte(base64.StdEncoding.EncodeToString(original))
- } else {
- tmpContent = original
- }
-
- // Write to temp file
- tmpFile, err := os.CreateTemp("", "pda-edit-*")
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- tmpPath := tmpFile.Name()
- defer os.Remove(tmpPath)
-
- if _, err := tmpFile.Write(tmpContent); err != nil {
- tmpFile.Close()
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- if err := tmpFile.Close(); err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
-
- // Launch editor
- c := exec.Command(editor, tmpPath)
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- if err := c.Run(); err != nil {
- return fmt.Errorf("cannot edit '%s': editor failed: %v", args[0], err)
- }
-
- // Read back
- edited, err := os.ReadFile(tmpPath)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
-
- // Decode base64 if original was binary; strip trailing newlines for text
- // unless --preserve-newline is set
- var newValue []byte
- if wasBinary {
- decoded, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(edited)))
- if err != nil {
- return fmt.Errorf("cannot edit '%s': invalid base64: %v", args[0], err)
- }
- newValue = decoded
- } else if preserveNewline {
- newValue = edited
- } else {
- newValue = bytes.TrimRight(edited, "\n")
- }
-
- // Check for no-op
- noMetaFlags := ttlStr == "" && !encryptFlag && !decryptFlag && !readonlyFlag && !writableFlag && !pinFlag && !unpinFlag
- if bytes.Equal(original, newValue) && noMetaFlags {
- infof("no changes to '%s'", spec.Display())
- return nil
- }
-
- // Creating: empty save means abort
- if creating && len(newValue) == 0 && noMetaFlags {
- infof("empty value, nothing saved")
- return nil
- }
-
- // Build or update entry
- if creating {
- newEntry := Entry{
- Key: spec.Key,
- Value: newValue,
- Secret: encryptFlag,
- ReadOnly: readonlyFlag,
- Pinned: pinFlag,
- }
- if ttlStr != "" {
- expiresAt, err := parseTTLString(ttlStr)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- newEntry.ExpiresAt = expiresAt
- }
- entries = append(entries, newEntry)
- } else {
- entry.Value = newValue
-
- if ttlStr != "" {
- expiresAt, err := parseTTLString(ttlStr)
- if err != nil {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
- entry.ExpiresAt = expiresAt
- }
-
- if encryptFlag {
- if entry.Secret {
- return fmt.Errorf("cannot edit '%s': already encrypted", args[0])
- }
- entry.Secret = true
- }
- if decryptFlag {
- if !entry.Secret {
- return fmt.Errorf("cannot edit '%s': not encrypted", args[0])
- }
- 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 {
- return fmt.Errorf("cannot edit '%s': %v", args[0], err)
- }
-
- if creating {
- okf("created '%s'", spec.Display())
- } else {
- okf("updated '%s'", spec.Display())
- }
-
- return autoSync("edit " + spec.Display())
-}
-
-func init() {
- editCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear")
- editCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest")
- editCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)")
- editCmd.Flags().Bool("preserve-newline", false, "keep trailing newlines added by the editor")
- editCmd.Flags().Bool("force", false, "bypass read-only protection")
- editCmd.Flags().Bool("readonly", false, "mark the key as read-only")
- editCmd.Flags().Bool("writable", false, "clear the read-only flag")
- editCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
- editCmd.Flags().Bool("unpin", false, "unpin the key")
- rootCmd.AddCommand(editCmd)
-}
diff --git a/cmd/edit_test.go b/cmd/edit_test.go
deleted file mode 100644
index a1cbcd8..0000000
--- a/cmd/edit_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package cmd
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "filippo.io/age"
-)
-
-func setupEditTest(t *testing.T) (*age.X25519Identity, string) {
- t.Helper()
- dataDir := t.TempDir()
- configDir := t.TempDir()
- t.Setenv("PDA_DATA", dataDir)
- t.Setenv("PDA_CONFIG", configDir)
-
- id, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(dataDir, "identity.txt"), []byte(id.String()+"\n"), 0o600); err != nil {
- t.Fatal(err)
- }
-
- // Reset global config to defaults with test env vars active
- config, _, _, _ = loadConfig()
-
- return id, dataDir
-}
-
-func TestEditCreatesNewKey(t *testing.T) {
- id, _ := setupEditTest(t)
-
- // Create editor script that writes "hello"
- script := filepath.Join(t.TempDir(), "editor.sh")
- if err := os.WriteFile(script, []byte("#!/bin/sh\necho hello > \"$1\"\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("EDITOR", script)
-
- // Run edit for a new key
- rootCmd.SetArgs([]string{"edit", "newkey@testedit"})
- if err := rootCmd.Execute(); err != nil {
- t.Fatalf("edit failed: %v", err)
- }
-
- // Verify key was created
- store := &Store{}
- p, _ := store.storePath("testedit")
- entries, _ := readStoreFile(p, id)
- idx := findEntry(entries, "newkey")
- if idx < 0 {
- t.Fatal("key was not created")
- }
- if string(entries[idx].Value) != "hello" {
- t.Fatalf("unexpected value: %q", entries[idx].Value)
- }
-}
-
-func TestEditModifiesExistingKey(t *testing.T) {
- id, _ := setupEditTest(t)
-
- // Create an existing key
- store := &Store{}
- p, _ := store.storePath("testedit2")
- entries := []Entry{{Key: "existing", Value: []byte("original")}}
- if err := writeStoreFile(p, entries, nil); err != nil {
- t.Fatal(err)
- }
-
- // Editor that replaces content
- script := filepath.Join(t.TempDir(), "editor.sh")
- if err := os.WriteFile(script, []byte("#!/bin/sh\necho modified > \"$1\"\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("EDITOR", script)
-
- rootCmd.SetArgs([]string{"edit", "existing@testedit2"})
- if err := rootCmd.Execute(); err != nil {
- t.Fatalf("edit failed: %v", err)
- }
-
- // Verify
- entries, _ = readStoreFile(p, id)
- idx := findEntry(entries, "existing")
- if idx < 0 {
- t.Fatal("key disappeared")
- }
- if string(entries[idx].Value) != "modified" {
- t.Fatalf("unexpected value: %q", entries[idx].Value)
- }
-}
-
-func TestEditNoChangeSkipsWrite(t *testing.T) {
- setupEditTest(t)
-
- store := &Store{}
- p, _ := store.storePath("testedit3")
- entries := []Entry{{Key: "unchanged", Value: []byte("same")}}
- if err := writeStoreFile(p, entries, nil); err != nil {
- t.Fatal(err)
- }
-
- // "true" command does nothing — file stays the same
- t.Setenv("EDITOR", "true")
-
- rootCmd.SetArgs([]string{"edit", "unchanged@testedit3"})
- if err := rootCmd.Execute(); err != nil {
- t.Fatalf("edit failed: %v", err)
- }
- // Should print "no changes" — we just verify it didn't error
-}
diff --git a/cmd/export.go b/cmd/export.go
deleted file mode 100644
index 80dade8..0000000
--- a/cmd/export.go
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "github.com/spf13/cobra"
-)
-
-var exportCmd = &cobra.Command{
- Use: "export [STORE]",
- Short: "Export store as NDJSON (alias for list --format ndjson)",
- Aliases: []string{},
- Args: cobra.MaximumNArgs(1),
- ValidArgsFunction: completeStores,
- RunE: func(cmd *cobra.Command, args []string) error {
- listFormat = "ndjson"
- return list(cmd, args)
- },
- SilenceUsage: true,
-}
-
-func init() {
- exportCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)")
- exportCmd.Flags().StringSliceP("store", "s", nil, "filter stores with glob pattern (repeatable)")
- exportCmd.RegisterFlagCompletionFunc("store", completeStoreFlag)
- exportCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)")
- rootCmd.AddCommand(exportCmd)
-}
diff --git a/cmd/get.go b/cmd/get.go
index d4d9116..7056c31 100644
--- a/cmd/get.go
+++ b/cmd/get.go
@@ -27,18 +27,20 @@ import (
"fmt"
"os"
"os/exec"
+ "slices"
+ "strconv"
"strings"
"text/template"
- "unicode/utf8"
+ "github.com/dgraph-io/badger/v4"
"github.com/spf13/cobra"
)
// getCmd represents the get command
var getCmd = &cobra.Command{
- Use: "get KEY[@STORE]",
- Short: "Get the value of a key",
- Long: `Get the value of a key. Optionally specify a store.
+ Use: "get KEY[@DB]",
+ Short: "Get a value for a key. Optionally specify a db.",
+ Long: `Get a value for a key. Optionally specify a db.
{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
additional argument after the initial KEY being fetched.
@@ -46,71 +48,50 @@ additional argument after the initial KEY being fetched.
For example:
pda set greeting 'Hello, {{ .NAME }}!'
pda get greeting NAME=World`,
- Aliases: []string{"g"},
- Args: cobra.MinimumNArgs(1),
- ValidArgsFunction: completeKeys,
- RunE: get,
- SilenceUsage: true,
-}
-
-var runCmd = &cobra.Command{
- Use: "run KEY[@STORE]",
- Short: "Get the value of a key and execute it",
- Long: `Get the value of a key and execute it as a shell command. Optionally specify a store.
-
-{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
-additional argument after the initial KEY being fetched.
-
-For example:
- pda set greeting 'Hello, {{ .NAME }}!'
- pda run greeting NAME=World`,
- Args: cobra.MinimumNArgs(1),
- ValidArgsFunction: completeKeys,
- RunE: run,
+ Aliases: []string{"g"},
+ Args: cobra.MinimumNArgs(1),
+ RunE: get,
SilenceUsage: true,
}
func get(cmd *cobra.Command, args []string) error {
store := &Store{}
- identity, _ := loadIdentity()
+ var v []byte
+ var meta byte
+ trans := TransactionArgs{
+ key: args[0],
+ readonly: true,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ item, err := tx.Get(k)
+ if err != nil {
+ return err
+ }
+ meta = item.UserMeta()
+ v, err = item.ValueCopy(nil)
+ return err
+ },
+ }
- spec, err := store.parseKey(args[0], true)
+ if err := store.Transaction(trans); err != nil {
+ return fmt.Errorf("cannot get '%s': %v", args[0], err)
+ }
+
+ includeSecret, err := cmd.Flags().GetBool("secret")
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
- p, err := store.storePath(spec.DB)
+ if meta&metaSecret != 0 && !includeSecret {
+ return fmt.Errorf("cannot get '%s': marked as secret, run with --secret", args[0])
+ }
+
+ binary, err := cmd.Flags().GetBool("include-binary")
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
- entries, err := readStoreFile(p, identity)
- if err != nil {
- return fmt.Errorf("cannot get '%s': %v", args[0], err)
- }
- idx := findEntry(entries, spec.Key)
- existsOnly, _ := cmd.Flags().GetBool("exists")
- if existsOnly {
- if idx < 0 {
- os.Exit(1)
- }
- return nil
- }
-
- if idx < 0 {
- keys := make([]string, len(entries))
- for i, e := range entries {
- keys[i] = e.Key
- }
- return fmt.Errorf("cannot get '%s': %w", args[0], suggestKey(spec.Key, keys))
- }
- entry := entries[idx]
- if entry.Locked {
- return fmt.Errorf("cannot get '%s': secret is locked (identity file missing)", spec.Display())
- }
- v := entry.Value
-
- binary, err := cmd.Flags().GetBool("base64")
+ run, err := cmd.Flags().GetBool("run")
if err != nil {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
@@ -120,7 +101,7 @@ func get(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot get '%s': %v", args[0], err)
}
- if !noTemplate && utf8.Valid(v) {
+ if !noTemplate {
var substitutions []string
if len(args) > 1 {
substitutions = args[1:]
@@ -131,8 +112,8 @@ func get(cmd *cobra.Command, args []string) error {
}
}
- if runFlag {
- return runShellCommand(string(v))
+ if run {
+ return runCmd(string(v))
}
store.Print("%s", binary, v)
@@ -144,16 +125,58 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
for _, s := range substitutions {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 || parts[0] == "" {
- warnf("invalid substitution '%s', expected KEY=VALUE", s)
+ fmt.Fprintf(os.Stderr, "invalid substitutions %q (expected KEY=VALUE)\n", s)
continue
}
key := parts[0]
val := parts[1]
vars[key] = val
}
- funcMap := templateFuncMap()
- funcMap["pda"] = func(key string) (string, error) {
- return pdaGet(key, substitutions)
+ funcMap := template.FuncMap{
+ "require": func(v any) (string, error) {
+ s := fmt.Sprint(v)
+ if s == "" {
+ return "", fmt.Errorf("required value is missing or empty")
+ }
+ return s, nil
+ },
+ "default": func(def string, v any) string {
+ s := fmt.Sprint(v)
+ if s == "" {
+ return def
+ }
+ return s
+ },
+ "env": os.Getenv,
+ "enum": func(v any, allowed ...string) (string, error) {
+ s := fmt.Sprint(v)
+ if s == "" {
+ return "", fmt.Errorf("enum value is missing or empty")
+ }
+ if slices.Contains(allowed, s) {
+ return s, nil
+ }
+ return "", fmt.Errorf("invalid value %q (allowed: %v)", s, allowed)
+ },
+ "int": func(v any) (int, error) {
+ s := fmt.Sprint(v)
+ i, err := strconv.Atoi(s)
+ if err != nil {
+ return 0, fmt.Errorf("failed to convert to int: %w", err)
+ }
+ return i, nil
+ },
+ "list": func(v any) []string {
+ s := fmt.Sprint(v)
+ if s == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ for i := range parts {
+ parts[i] = strings.TrimSpace(parts[i])
+ }
+ return parts
+ },
}
tpl, err := template.New("cmd").
Delims("{{", "}}").
@@ -166,12 +189,12 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, vars); err != nil {
- return nil, cleanTemplateError(err)
+ return nil, err
}
return buf.Bytes(), nil
}
-func runShellCommand(command string) error {
+func runCmd(command string) error {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
@@ -195,21 +218,10 @@ func runShellCommand(command string) error {
return nil
}
-func run(cmd *cobra.Command, args []string) error {
- runFlag = true
- return get(cmd, args)
-}
-
-var runFlag bool
-
func init() {
- getCmd.Flags().BoolP("base64", "b", false, "view binary data as base64")
- getCmd.Flags().BoolVarP(&runFlag, "run", "c", false, "execute the result as a shell command")
+ getCmd.Flags().BoolP("include-binary", "b", false, "include binary data in text output")
+ getCmd.Flags().Bool("secret", false, "display values marked as secret")
+ getCmd.Flags().BoolP("run", "c", false, "execute the result as a shell command")
getCmd.Flags().Bool("no-template", false, "directly output template syntax")
- getCmd.Flags().Bool("exists", false, "exit 0 if the key exists, exit 1 if not (no output)")
rootCmd.AddCommand(getCmd)
-
- runCmd.Flags().BoolP("base64", "b", false, "view binary data as base64")
- runCmd.Flags().Bool("no-template", false, "directly output template syntax")
- rootCmd.AddCommand(runCmd)
}
diff --git a/cmd/git.go b/cmd/git.go
deleted file mode 100644
index 6deca82..0000000
--- a/cmd/git.go
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "os"
- "os/exec"
-
- "github.com/spf13/cobra"
-)
-
-var gitCmd = &cobra.Command{
- Use: "git [args...]",
- Short: "Run any arbitrary command. Use with caution.",
- Long: `Run any arbitrary command. Use with caution.
-
-The Git repository lives directly in the data directory
-("PDA_DATA"). Store files (*.ndjson) are tracked by Git as-is.
-
-If you manually modify files without using the built-in
-commands, you may desync your repository.
-
-Generally prefer "pda sync".`,
- Args: cobra.ArbitraryArgs,
- DisableFlagParsing: true,
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- repoDir, err := ensureVCSInitialized()
- if err != nil {
- return err
- }
-
- gitCmd := exec.Command("git", args...)
- gitCmd.Dir = repoDir
- gitCmd.Stdin = os.Stdin
- gitCmd.Stdout = os.Stdout
- gitCmd.Stderr = os.Stderr
- return gitCmd.Run()
- },
-}
-
-func init() {
- rootCmd.AddCommand(gitCmd)
-}
diff --git a/cmd/glob.go b/cmd/glob.go
index 5c8c57f..092c9b9 100644
--- a/cmd/glob.go
+++ b/cmd/glob.go
@@ -27,14 +27,34 @@ import (
"strings"
"github.com/gobwas/glob"
+ "github.com/spf13/cobra"
)
var defaultGlobSeparators = []rune{'/', '-', '_', '.', '@', ':', ' '}
-func compileGlobMatchers(patterns []string) ([]glob.Glob, error) {
+func defaultGlobSeparatorsDisplay() string {
+ var b strings.Builder
+ for _, r := range defaultGlobSeparators {
+ b.WriteRune(r)
+ }
+ return b.String()
+}
+
+func parseGlobSeparators(cmd *cobra.Command) ([]rune, error) {
+ sepStr, err := cmd.Flags().GetString("glob-sep")
+ if err != nil {
+ return nil, err
+ }
+ if sepStr == "" {
+ return defaultGlobSeparators, nil
+ }
+ return []rune(sepStr), nil
+}
+
+func compileGlobMatchers(patterns []string, separators []rune) ([]glob.Glob, error) {
var matchers []glob.Glob
for _, pattern := range patterns {
- m, err := glob.Compile(strings.ToLower(pattern), defaultGlobSeparators...)
+ m, err := glob.Compile(strings.ToLower(pattern), separators...)
if err != nil {
return nil, err
}
diff --git a/cmd/identity.go b/cmd/identity.go
deleted file mode 100644
index 27a71c8..0000000
--- a/cmd/identity.go
+++ /dev/null
@@ -1,214 +0,0 @@
-package cmd
-
-import (
- "fmt"
-
- "filippo.io/age"
- "github.com/spf13/cobra"
-)
-
-var identityCmd = &cobra.Command{
- Use: "identity",
- Aliases: []string{"id"},
- Short: "Show or create the age encryption identity",
- Args: cobra.NoArgs,
- RunE: identityRun,
- SilenceUsage: true,
-}
-
-func identityRun(cmd *cobra.Command, args []string) error {
- showPath, err := cmd.Flags().GetBool("path")
- if err != nil {
- return err
- }
- createNew, err := cmd.Flags().GetBool("new")
- if err != nil {
- return err
- }
- addRecipient, err := cmd.Flags().GetString("add-recipient")
- if err != nil {
- return err
- }
- removeRecipient, err := cmd.Flags().GetString("remove-recipient")
- if err != nil {
- return err
- }
-
- if createNew {
- existing, err := loadIdentity()
- if err != nil {
- return fmt.Errorf("cannot create identity: %v", err)
- }
- if existing != nil {
- path, _ := identityPath()
- return withHint(
- fmt.Errorf("identity already exists at %s", path),
- "delete the file manually before creating a new one",
- )
- }
- id, err := ensureIdentity()
- if err != nil {
- return fmt.Errorf("cannot create identity: %v", err)
- }
- okf("pubkey %s", id.Recipient())
- return nil
- }
-
- if addRecipient != "" {
- return identityAddRecipient(addRecipient)
- }
-
- if removeRecipient != "" {
- return identityRemoveRecipient(removeRecipient)
- }
-
- if showPath {
- path, err := identityPath()
- if err != nil {
- return err
- }
- fmt.Println(path)
- return nil
- }
-
- // Default: show identity info
- id, err := loadIdentity()
- if err != nil {
- return fmt.Errorf("cannot load identity: %v", err)
- }
- if id == nil {
- printHint("no identity found — use 'pda identity --new' or 'pda set --encrypt' to create one")
- return nil
- }
- path, _ := identityPath()
- okf("pubkey %s", id.Recipient())
- okf("identity %s", path)
-
- extra, err := loadRecipients()
- if err != nil {
- return fmt.Errorf("cannot load recipients: %v", err)
- }
- for _, r := range extra {
- okf("recipient %s", r)
- }
-
- return nil
-}
-
-func identityAddRecipient(key string) error {
- r, err := age.ParseX25519Recipient(key)
- if err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
-
- identity, err := loadIdentity()
- if err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
- if identity == nil {
- return withHint(
- fmt.Errorf("cannot add recipient: no identity found"),
- "create one first with 'pda identity --new'",
- )
- }
-
- if r.String() == identity.Recipient().String() {
- return fmt.Errorf("cannot add recipient: key is your own identity")
- }
-
- existing, err := loadRecipients()
- if err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
- for _, e := range existing {
- if e.String() == r.String() {
- return fmt.Errorf("cannot add recipient: key already present")
- }
- }
-
- existing = append(existing, r)
- if err := saveRecipients(existing); err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
-
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
-
- count, err := reencryptAllStores(identity, recipients)
- if err != nil {
- return fmt.Errorf("cannot add recipient: %v", err)
- }
-
- okf("added recipient %s", r)
- if count > 0 {
- okf("re-encrypted %d secret(s)", count)
- }
- return autoSync("added recipient")
-}
-
-func identityRemoveRecipient(key string) error {
- r, err := age.ParseX25519Recipient(key)
- if err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
-
- identity, err := loadIdentity()
- if err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
- if identity == nil {
- return withHint(
- fmt.Errorf("cannot remove recipient: no identity found"),
- "create one first with 'pda identity --new'",
- )
- }
-
- existing, err := loadRecipients()
- if err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
-
- found := false
- var updated []*age.X25519Recipient
- for _, e := range existing {
- if e.String() == r.String() {
- found = true
- continue
- }
- updated = append(updated, e)
- }
- if !found {
- return fmt.Errorf("cannot remove recipient: key not found")
- }
-
- if err := saveRecipients(updated); err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
-
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
-
- count, err := reencryptAllStores(identity, recipients)
- if err != nil {
- return fmt.Errorf("cannot remove recipient: %v", err)
- }
-
- okf("removed recipient %s", r)
- if count > 0 {
- okf("re-encrypted %d secret(s)", count)
- }
- return autoSync("removed recipient")
-}
-
-func init() {
- identityCmd.Flags().Bool("new", false, "generate a new identity (errors if one already exists)")
- identityCmd.Flags().Bool("path", false, "print only the identity file path")
- identityCmd.Flags().String("add-recipient", "", "add an age public key as an additional encryption recipient")
- identityCmd.Flags().String("remove-recipient", "", "remove an age public key from the recipient list")
- identityCmd.MarkFlagsMutuallyExclusive("new", "path", "add-recipient", "remove-recipient")
- rootCmd.AddCommand(identityCmd)
-}
diff --git a/cmd/init.go b/cmd/init.go
deleted file mode 100644
index 3b37815..0000000
--- a/cmd/init.go
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "github.com/spf13/cobra"
-)
-
-var initCmd = &cobra.Command{
- Use: "init [remote-url]",
- Short: "Initialise pda! version control",
- SilenceUsage: true,
- Args: cobra.MaximumNArgs(1),
- RunE: vcsInit,
-}
-
-func init() {
- initCmd.Flags().Bool("clean", false, "remove .git from stores directory before initialising")
- rootCmd.AddCommand(initCmd)
-}
-
-func vcsInit(cmd *cobra.Command, args []string) error {
- store := &Store{}
- repoDir, err := store.path()
- if err != nil {
- return err
- }
-
- clean, err := cmd.Flags().GetBool("clean")
- if err != nil {
- return err
- }
-
- hasRemote := len(args) == 1
-
- if clean {
- gitDir := filepath.Join(repoDir, ".git")
- if _, err := os.Stat(gitDir); err == nil {
- promptf("remove .git from '%s'? (y/n)", repoDir)
- var confirm string
- if err := scanln(&confirm); err != nil {
- return fmt.Errorf("cannot init: %w", err)
- }
- if strings.ToLower(confirm) != "y" {
- return fmt.Errorf("cannot init: aborted")
- }
- if err := os.RemoveAll(gitDir); err != nil {
- return fmt.Errorf("cannot init: %w", err)
- }
- }
-
- if hasRemote {
- dbs, err := store.AllStores()
- if err == nil && len(dbs) > 0 {
- promptf("remove all existing stores and .gitignore, required for clone? (y/n)")
- var confirm string
- if err := scanln(&confirm); err != nil {
- return fmt.Errorf("cannot init: %w", err)
- }
- if strings.ToLower(confirm) != "y" {
- return fmt.Errorf("cannot init: aborted")
- }
- if err := wipeAllStores(store); err != nil {
- return fmt.Errorf("cannot init: %w", err)
- }
- gi := filepath.Join(repoDir, ".gitignore")
- if err := os.Remove(gi); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("cannot init: %w", err)
- }
- }
- }
- }
-
- gitDir := filepath.Join(repoDir, ".git")
- if _, err := os.Stat(gitDir); err == nil {
- warnf("vcs already initialised")
- printHint("use --clean to reinitialise")
- return nil
- }
-
- if hasRemote {
- // git clone requires the target directory to be empty
- entries, err := os.ReadDir(repoDir)
- if err == nil && len(entries) > 0 {
- return withHint(fmt.Errorf("cannot init: stores directory not empty"), "use --clean with a remote to wipe and clone")
- }
-
- remote := args[0]
- progressf("git clone %s %s", remote, repoDir)
- if err := runGit("", "clone", remote, repoDir); err != nil {
- return err
- }
- } else {
- if err := os.MkdirAll(repoDir, 0o750); err != nil {
- return err
- }
- progressf("git init")
- if err := runGit(repoDir, "init"); err != nil {
- return err
- }
- }
-
- return writeGitignore(repoDir)
-}
diff --git a/cmd/keyspec.go b/cmd/keyspec.go
index 1f6739f..ce4c4ec 100644
--- a/cmd/keyspec.go
+++ b/cmd/keyspec.go
@@ -31,17 +31,17 @@ import (
type KeySpec struct {
Raw string // Whole, unmodified user input
RawKey string // Key segment
- RawDB string // Store segment
+ RawDB string // DB segment
Key string // Normalised Key
- DB string // Normalised store
+ DB string // Normalised DB
}
-// ParseKey parses "KEY[@STORE]" into a normalized KeySpec.
-// When defaults is true, a missing store defaults to the configured default.
+// ParseKey parses "KEY[@DB]" into a normalized KeySpec.
+// When defaults is true, a missing DB defaults to the configured default.
func ParseKey(raw string, defaults bool) (KeySpec, error) {
parts := strings.Split(raw, "@")
if len(parts) > 2 {
- return KeySpec{}, fmt.Errorf("bad key format, use KEY@STORE")
+ return KeySpec{}, fmt.Errorf("bad key format, use KEY@DB")
}
rawKey := parts[0]
@@ -49,7 +49,7 @@ func ParseKey(raw string, defaults bool) (KeySpec, error) {
if len(parts) == 2 {
rawDB = parts[1]
if strings.TrimSpace(rawDB) == "" {
- return KeySpec{}, fmt.Errorf("bad key format, use KEY@STORE")
+ return KeySpec{}, fmt.Errorf("bad key format, use KEY@DB")
}
if err := validateDBName(rawDB); err != nil {
return KeySpec{}, err
@@ -80,7 +80,7 @@ func (k KeySpec) Full() string {
}
// Display returns the normalized key reference
-// but omits the default store if none was set manually
+// but omits the default database if none was set manually
func (k KeySpec) Display() string {
if k.DB == "" || k.DB == config.Store.DefaultStoreName {
return k.Key
diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go
index 15b48cd..effde4f 100644
--- a/cmd/list-dbs.go
+++ b/cmd/list-dbs.go
@@ -24,98 +24,31 @@ package cmd
import (
"fmt"
- "os"
-
"github.com/spf13/cobra"
)
-// listStoresCmd represents the list-stores command
-var listStoresCmd = &cobra.Command{
- Use: "list-stores",
- Short: "List all stores",
- Aliases: []string{"lss"},
+// delCmd represents the set command
+var listDbsCmd = &cobra.Command{
+ Use: "list-dbs",
+ Short: "List all dbs.",
+ Aliases: []string{"ls-dbs", "lsd"},
Args: cobra.NoArgs,
- RunE: listStores,
+ RunE: listDbs,
SilenceUsage: true,
}
-func listStores(cmd *cobra.Command, args []string) error {
+func listDbs(cmd *cobra.Command, args []string) error {
store := &Store{}
dbs, err := store.AllStores()
if err != nil {
- return fmt.Errorf("cannot list stores: %v", err)
+ return fmt.Errorf("cannot list-dbs: %v", err)
}
-
- short, err := cmd.Flags().GetBool("short")
- if err != nil {
- return fmt.Errorf("cannot list stores: %v", err)
- }
-
- if short {
- for _, db := range dbs {
- fmt.Println("@" + db)
- }
- return nil
- }
-
- type storeInfo struct {
- name string
- keys int
- size string
- }
-
- rows := make([]storeInfo, 0, len(dbs))
- nameW, keysW, sizeW := len("Store"), len("Keys"), len("Size")
-
for _, db := range dbs {
- p, err := store.storePath(db)
- if err != nil {
- return fmt.Errorf("cannot list stores: %v", err)
- }
- fi, err := os.Stat(p)
- if err != nil {
- return fmt.Errorf("cannot list stores: %v", err)
- }
- entries, err := readStoreFile(p, nil)
- if err != nil {
- return fmt.Errorf("cannot list stores: %v", err)
- }
- name := "@" + db
- keysStr := fmt.Sprintf("%d", len(entries))
- sizeStr := formatSize(int(fi.Size()))
- if len(name) > nameW {
- nameW = len(name)
- }
- if len(keysStr) > keysW {
- keysW = len(keysStr)
- }
- if len(sizeStr) > sizeW {
- sizeW = len(sizeStr)
- }
- rows = append(rows, storeInfo{name: name, keys: len(entries), size: sizeStr})
- }
-
- underline := func(s string) string {
- if stdoutIsTerminal() {
- return "\033[4m" + s + "\033[0m"
- }
- return s
- }
- noHeader, _ := cmd.Flags().GetBool("no-header")
- if !noHeader {
- fmt.Printf("%*s%s %*s%s %s\n",
- keysW-len("Keys"), "", underline("Keys"),
- sizeW-len("Size"), "", underline("Size"),
- underline("Store"))
- }
- for _, r := range rows {
- fmt.Printf("%*d %*s %s\n", keysW, r.keys, sizeW, r.size, r.name)
+ fmt.Println("@" + db)
}
return nil
}
func init() {
- listStoresCmd.Flags().Bool("short", false, "only print store names")
- listStoresCmd.Flags().Bool("no-header", false, "suppress the header row")
- rootCmd.AddCommand(listStoresCmd)
+ rootCmd.AddCommand(listDbsCmd)
}
diff --git a/cmd/list.go b/cmd/list.go
index 50e319b..69fc6c9 100644
--- a/cmd/list.go
+++ b/cmd/list.go
@@ -23,153 +23,27 @@ THE SOFTWARE.
package cmd
import (
- "encoding/json"
"errors"
"fmt"
- "io"
- "os"
- "slices"
- "strconv"
- "strings"
- "unicode/utf8"
+ "github.com/dgraph-io/badger/v4"
"github.com/jedib0t/go-pretty/v6/table"
- "github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra"
- "golang.org/x/term"
-)
-
-// formatEnum implements pflag.Value for format selection.
-type formatEnum string
-
-func (e *formatEnum) String() string { return string(*e) }
-
-func (e *formatEnum) Set(v string) error {
- if err := validListFormat(v); err != nil {
- return err
- }
- *e = formatEnum(v)
- return nil
-}
-
-func validListFormat(v string) error {
- switch v {
- case "table", "tsv", "csv", "html", "markdown", "ndjson", "json":
- return nil
- default:
- return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', 'ndjson', or 'json'")
- }
-}
-
-func (e *formatEnum) Type() string { return "format" }
-
-var columnNames = map[string]columnKind{
- "key": columnKey,
- "store": columnStore,
- "value": columnValue,
- "meta": columnMeta,
- "size": columnSize,
- "ttl": columnTTL,
-}
-
-func validListColumns(v string) error {
- seen := make(map[string]bool)
- for _, raw := range strings.Split(v, ",") {
- tok := strings.TrimSpace(raw)
- if _, ok := columnNames[tok]; !ok {
- return fmt.Errorf("must be a comma-separated list of 'key', 'store', 'value', 'meta', 'size', 'ttl' (got '%s')", tok)
- }
- if seen[tok] {
- return fmt.Errorf("duplicate column '%s'", tok)
- }
- seen[tok] = true
- }
- if len(seen) == 0 {
- return fmt.Errorf("at least one column is required")
- }
- return nil
-}
-
-func parseColumns(v string) []columnKind {
- var cols []columnKind
- for _, raw := range strings.Split(v, ",") {
- tok := strings.TrimSpace(raw)
- if kind, ok := columnNames[tok]; ok {
- cols = append(cols, kind)
- }
- }
- return cols
-}
-
-var (
- listBase64 bool
- listCount bool
- listNoKeys bool
- listNoStore bool
- listNoValues bool
- listNoMeta bool
- listNoSize bool
- listNoTTL bool
- listFull bool
- listAll bool
- listNoHeader bool
- listFormat formatEnum
-
- dimStyle = text.Colors{text.Faint, text.Italic}
-)
-
-type columnKind int
-
-const (
- columnKey columnKind = iota
- columnValue
- columnTTL
- columnStore
- columnMeta
- columnSize
)
var listCmd = &cobra.Command{
- Use: "list [STORE]",
- Short: "List the contents of all stores",
- Long: `List the contents of all stores.
-
-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.
-
-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),
- ValidArgsFunction: completeStores,
- RunE: list,
- SilenceUsage: true,
+ Use: "list [DB]",
+ Short: "List the contents of a db.",
+ Aliases: []string{"ls"},
+ Args: cobra.MaximumNArgs(1),
+ RunE: list,
+ SilenceUsage: true,
}
func list(cmd *cobra.Command, args []string) error {
- if listFormat == "" {
- listFormat = formatEnum(config.List.DefaultListFormat)
- }
-
store := &Store{}
-
- storePatterns, err := cmd.Flags().GetStringSlice("store")
- if err != nil {
- return fmt.Errorf("cannot ls: %v", err)
- }
- if len(storePatterns) > 0 && len(args) > 0 {
- return fmt.Errorf("cannot use --store with a store argument")
- }
-
- allStores := len(args) == 0 && (config.List.AlwaysShowAllStores || listAll)
- var targetDB string
- if allStores {
- targetDB = "all"
- } else if len(args) == 0 {
- targetDB = "@" + config.Store.DefaultStoreName
- } else {
+ targetDB := "@" + config.Store.DefaultStoreName
+ if len(args) == 1 {
rawArg := args[0]
dbName, err := store.parseDB(rawArg, false)
if err != nil {
@@ -178,615 +52,135 @@ func list(cmd *cobra.Command, args []string) error {
if _, err := store.FindStore(dbName); err != nil {
var notFound errNotFound
if errors.As(err, ¬Found) {
- return fmt.Errorf("cannot ls '%s': %w", args[0], err)
+ return fmt.Errorf("cannot ls '%s': No such DB", args[0])
}
return fmt.Errorf("cannot ls '%s': %v", args[0], err)
}
targetDB = "@" + dbName
}
- 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)
- }
- }
-
- 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")
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- matchers, err := compileGlobMatchers(keyPatterns)
+ flags, err := enrichFlags()
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
- valuePatterns, err := cmd.Flags().GetStringSlice("value")
+ globPatterns, err := cmd.Flags().GetStringSlice("glob")
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
- valueMatchers, err := compileValueMatchers(valuePatterns)
+ separators, err := parseGlobSeparators(cmd)
+ if err != nil {
+ return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
+ }
+ matchers, err := compileGlobMatchers(globPatterns, separators)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
- storeMatchers, err := compileGlobMatchers(storePatterns)
+ columnKinds, err := requireColumns(flags)
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
- identity, _ := loadIdentity()
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
-
- var entries []Entry
- if allStores {
- storeNames, err := store.AllStores()
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- for _, name := range storeNames {
- p, err := store.storePath(name)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- storeEntries, err := readStoreFile(p, identity)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- for i := range storeEntries {
- storeEntries[i].StoreName = name
- }
- entries = append(entries, storeEntries...)
- }
- slices.SortFunc(entries, func(a, b Entry) int {
- if c := strings.Compare(a.Key, b.Key); c != 0 {
- return c
- }
- return strings.Compare(a.StoreName, b.StoreName)
- })
- } else {
- dbName := targetDB[1:] // strip leading '@'
- p, err := store.storePath(dbName)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- entries, err = readStoreFile(p, identity)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- for i := range entries {
- entries[i].StoreName = dbName
- }
- }
-
- // Filter by key glob, value regex, and store glob
- var filtered []Entry
- for _, e := range entries {
- if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) && globMatch(storeMatchers, e.StoreName) {
- filtered = append(filtered, e)
- }
- }
-
- // 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
- }
-
- hasFilters := len(matchers) > 0 || len(valueMatchers) > 0 || len(storeMatchers) > 0
- if hasFilters && len(filtered) == 0 {
- var parts []string
- if len(matchers) > 0 {
- parts = append(parts, fmt.Sprintf("key pattern %s", formatGlobPatterns(keyPatterns)))
- }
- if len(valueMatchers) > 0 {
- parts = append(parts, fmt.Sprintf("value pattern %s", formatValuePatterns(valuePatterns)))
- }
- if len(storeMatchers) > 0 {
- parts = append(parts, fmt.Sprintf("store pattern %s", formatGlobPatterns(storePatterns)))
- }
- return fmt.Errorf("cannot ls '%s': no matches for %s", targetDB, strings.Join(parts, " and "))
- }
-
output := cmd.OutOrStdout()
-
- // NDJSON format: emit JSON lines directly (encrypted form for secrets)
- if listFormat.String() == "ndjson" {
- for _, e := range filtered {
- je, err := encodeJsonEntry(e, recipients)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- je.Store = e.StoreName
- data, err := json.Marshal(je)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- fmt.Fprintln(output, string(data))
- }
- return nil
- }
-
- // JSON format: emit a single JSON array
- if listFormat.String() == "json" {
- var jsonEntries []jsonEntry
- for _, e := range filtered {
- je, err := encodeJsonEntry(e, recipients)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- je.Store = e.StoreName
- jsonEntries = append(jsonEntries, je)
- }
- data, err := json.Marshal(jsonEntries)
- if err != nil {
- return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
- }
- fmt.Fprintln(output, string(data))
- return nil
- }
-
- // Table-based formats
- showValues := slices.Contains(columns, columnValue)
tw := table.NewWriter()
tw.SetOutputMirror(output)
tw.SetStyle(table.StyleDefault)
+ // Should these be settable flags?
tw.Style().Options.SeparateHeader = false
tw.Style().Options.SeparateFooter = false
tw.Style().Options.DrawBorder = false
tw.Style().Options.SeparateRows = false
tw.Style().Options.SeparateColumns = false
- tw.Style().Box.PaddingLeft = ""
- tw.Style().Box.PaddingRight = " "
- tty := stdoutIsTerminal() && listFormat.String() == "table"
+ var maxContentWidths []int
+ maxContentWidths = make([]int, len(columnKinds))
- if !(listNoHeader || config.List.AlwaysHideHeader) {
- tw.AppendHeader(headerRow(columns, tty))
- tw.Style().Format.Header = text.FormatDefault
+ if flags.header {
+ header := buildHeaderCells(columnKinds)
+ updateMaxContentWidths(maxContentWidths, header)
+ tw.AppendHeader(stringSliceToRow(header))
}
- lay := computeLayout(columns, output, filtered)
- for _, e := range filtered {
- var valueStr string
- dimValue := false
- if showValues {
- if e.Locked {
- valueStr = "locked (identity file missing)"
- dimValue = true
- } else {
- valueStr = store.FormatBytes(listBase64, e.Value)
- if !utf8.Valid(e.Value) && !listBase64 {
- dimValue = true
+ placeholder := "**********"
+ var matchedCount int
+ trans := TransactionArgs{
+ key: targetDB,
+ readonly: true,
+ sync: true,
+ transact: func(tx *badger.Txn, k []byte) error {
+ opts := badger.DefaultIteratorOptions
+ opts.PrefetchSize = 10
+ opts.PrefetchValues = flags.value
+ it := tx.NewIterator(opts)
+ defer it.Close()
+ var valueBuf []byte
+ for it.Rewind(); it.Valid(); it.Next() {
+ item := it.Item()
+ key := string(item.KeyCopy(nil))
+ if !globMatch(matchers, key) {
+ continue
}
- }
- if !(listFull || config.List.AlwaysShowFullValues) {
- valueStr = summariseValue(valueStr, lay.value, tty)
- }
- }
- row := make(table.Row, 0, len(columns))
- for _, col := range columns {
- switch col {
- case columnKey:
- if tty {
- row = append(row, text.Bold.Sprint(e.Key))
- } else {
- row = append(row, e.Key)
+ matchedCount++
+ meta := item.UserMeta()
+ isSecret := meta&metaSecret != 0
+
+ var valueStr string
+ if flags.value && (!isSecret || flags.secrets) {
+ if err := item.Value(func(v []byte) error {
+ valueBuf = append(valueBuf[:0], v...)
+ return nil
+ }); err != nil {
+ return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
+ }
+ valueStr = store.FormatBytes(flags.binary, valueBuf)
}
- case columnValue:
- if tty && dimValue {
- row = append(row, dimStyle.Sprint(valueStr))
- } else {
- row = append(row, valueStr)
- }
- case columnStore:
- if tty {
- 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)
+
+ columns := make([]string, 0, len(columnKinds))
+ for _, column := range columnKinds {
+ switch column {
+ case columnKey:
+ columns = append(columns, key)
+ case columnValue:
+ if isSecret && !flags.secrets {
+ columns = append(columns, placeholder)
+ } else {
+ columns = append(columns, valueStr)
+ }
+ case columnTTL:
+ columns = append(columns, formatExpiry(item.ExpiresAt()))
}
}
- row = append(row, sizeStr)
- case columnTTL:
- ttlStr := formatExpiry(e.ExpiresAt)
- if tty && e.ExpiresAt == 0 {
- ttlStr = dimStyle.Sprint(ttlStr)
- }
- row = append(row, ttlStr)
+ updateMaxContentWidths(maxContentWidths, columns)
+ tw.AppendRow(stringSliceToRow(columns))
}
- }
- tw.AppendRow(row)
+ return nil
+ },
}
- applyColumnWidths(tw, columns, output, lay, listFull || config.List.AlwaysShowFullValues)
- renderTable(tw)
+ if err := store.Transaction(trans); err != nil {
+ return err
+ }
+
+ if len(matchers) > 0 && matchedCount == 0 {
+ return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns))
+ }
+
+ applyColumnConstraints(tw, columnKinds, output, maxContentWidths)
+
+ flags.render(tw)
return nil
}
-// summariseValue flattens a value to its first line and, when maxWidth > 0,
-// truncates to fit. In both cases it appends "(..N more chars)" showing the
-// total number of omitted characters.
-func summariseValue(s string, maxWidth int, tty bool) string {
- first := s
- if i := strings.IndexByte(s, '\n'); i >= 0 {
- first = s[:i]
- }
-
- totalRunes := utf8.RuneCountInString(s)
- firstRunes := utf8.RuneCountInString(first)
-
- // Nothing omitted and fits (or no width constraint).
- if firstRunes == totalRunes && (maxWidth <= 0 || firstRunes <= maxWidth) {
- return first
- }
-
- // How many runes of first can we show?
- showRunes := firstRunes
- if maxWidth > 0 && showRunes > maxWidth {
- showRunes = maxWidth
- }
-
- style := func(s string) string {
- if tty {
- return dimStyle.Sprint(s)
- }
- return s
- }
-
- // Iteratively make room for the suffix (at most two passes since
- // the digit count can change by one at a boundary like 9→10).
- for range 2 {
- omitted := totalRunes - showRunes
- if omitted <= 0 {
- return first
- }
- suffix := fmt.Sprintf(" (..%d more chars)", omitted)
- suffixRunes := utf8.RuneCountInString(suffix)
- if maxWidth <= 0 {
- return first + style(suffix)
- }
- if showRunes+suffixRunes <= maxWidth {
- runes := []rune(first)
- if showRunes < len(runes) {
- first = string(runes[:showRunes])
- }
- return first + style(suffix)
- }
- avail := maxWidth - suffixRunes
- if avail <= 0 {
- // Suffix alone exceeds maxWidth; fall through to hard trim.
- break
- }
- showRunes = avail
- }
-
- // Column too narrow for the suffix — just truncate with an ellipsis.
- if maxWidth >= 2 {
- return text.Trim(first, maxWidth-1) + style("…")
- }
- return text.Trim(first, maxWidth)
-}
-
-func headerRow(columns []columnKind, tty bool) table.Row {
- h := func(s string) interface{} {
- if tty {
- return text.Underline.Sprint(s)
- }
- return s
- }
- row := make(table.Row, 0, len(columns))
- for _, col := range columns {
- switch col {
- case columnKey:
- row = append(row, h("Key"))
- case columnStore:
- 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"))
- }
- }
- return 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, meta, size, ttl int
-}
-
-// computeLayout derives column widths from the terminal size and actual
-// content widths of the key/TTL columns (capped at fixed maximums). This
-// avoids reserving 30+40 chars for key+TTL when the real content is narrower.
-func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnLayout {
- var lay columnLayout
- termWidth := detectTerminalWidth(out)
-
- // 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
- }
- 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
- }
- }
- if lay.key > keyColumnWidthCap {
- lay.key = keyColumnWidthCap
- }
- if lay.store > storeColumnWidthCap {
- lay.store = storeColumnWidthCap
- }
- if lay.size > sizeColumnWidthCap {
- lay.size = sizeColumnWidthCap
- }
- if lay.ttl > ttlColumnWidthCap {
- lay.ttl = ttlColumnWidthCap
- }
-
- if termWidth <= 0 {
- return lay
- }
-
- padding := len(columns) * 2
- available := termWidth - padding
- if available < len(columns) {
- return lay
- }
-
- // Give the value column whatever is left after fixed-width columns.
- lay.value = available
- for _, col := range columns {
- switch col {
- case columnKey:
- 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
- }
- }
- if lay.value < 10 {
- lay.value = 10
- }
- return lay
-}
-
-func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay columnLayout, full bool) {
- termWidth := detectTerminalWidth(out)
- if termWidth <= 0 {
- return
- }
- tw.SetAllowedRowLength(termWidth)
-
- var configs []table.ColumnConfig
- for i, col := range columns {
- cc := table.ColumnConfig{Number: i + 1}
- switch col {
- case columnKey:
- cc.WidthMax = lay.key
- cc.WidthMaxEnforcer = text.Trim
- case columnStore:
- cc.WidthMax = lay.store
- cc.WidthMaxEnforcer = text.Trim
- cc.Align = text.AlignRight
- cc.AlignHeader = text.AlignRight
- case columnValue:
- cc.WidthMax = lay.value
- if full {
- 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:
- cc.WidthMax = lay.ttl
- cc.WidthMaxEnforcer = text.Trim
- cc.Align = text.AlignRight
- cc.AlignHeader = text.AlignRight
- }
- configs = append(configs, cc)
- }
- tw.SetColumnConfigs(configs)
-}
-
-func detectTerminalWidth(out io.Writer) int {
- type fd interface{ Fd() uintptr }
- if f, ok := out.(fd); ok {
- if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
- return w
- }
- }
- if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
- return w
- }
- if cols := os.Getenv("COLUMNS"); cols != "" {
- if parsed, err := strconv.Atoi(cols); err == nil && parsed > 0 {
- return parsed
- }
- }
- 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":
- tw.RenderTSV()
- case "csv":
- tw.RenderCSV()
- case "html":
- tw.RenderHTML()
- case "markdown":
- tw.RenderMarkdown()
- default:
- tw.Render()
- }
-}
-
func init() {
- listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "list across all stores")
- 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")
- listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson|json)")
- listCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)")
- listCmd.Flags().StringSliceP("store", "s", nil, "filter stores with glob pattern (repeatable)")
- listCmd.RegisterFlagCompletionFunc("store", completeStoreFlag)
- listCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)")
+ listCmd.Flags().BoolVarP(&binary, "binary", "b", false, "include binary data in text output")
+ listCmd.Flags().BoolVarP(&secret, "secret", "S", false, "display values marked as secret")
+ listCmd.Flags().BoolVar(&noKeys, "no-keys", false, "suppress the key column")
+ listCmd.Flags().BoolVar(&noValues, "no-values", false, "suppress the value column")
+ listCmd.Flags().BoolVarP(&ttl, "ttl", "t", false, "append a TTL column when entries expire")
+ listCmd.Flags().BoolVar(&header, "header", false, "include header row")
+ listCmd.Flags().VarP(&format, "format", "o", "output format (table|tsv|csv|markdown|html)")
+ listCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)")
+ listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay()))
rootCmd.AddCommand(listCmd)
}
diff --git a/cmd/list_flags.go b/cmd/list_flags.go
new file mode 100644
index 0000000..91f29aa
--- /dev/null
+++ b/cmd/list_flags.go
@@ -0,0 +1,101 @@
+/*
+Copyright © 2025 Lewis Wynne
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/jedib0t/go-pretty/v6/table"
+)
+
+// ListArgs tracks the resolved flag configuration for the list command.
+type ListArgs struct {
+ header bool
+ key bool
+ value bool
+ ttl bool
+ binary bool
+ secrets bool
+ render func(table.Writer)
+}
+
+// formatEnum implements pflag.Value for format selection.
+type formatEnum string
+
+func (e *formatEnum) String() string {
+ return string(*e)
+}
+
+func (e *formatEnum) Set(v string) error {
+ switch v {
+ case "table", "tsv", "csv", "html", "markdown":
+ *e = formatEnum(v)
+ return nil
+ default:
+ return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", or \"markdown\"")
+ }
+}
+
+func (e *formatEnum) Type() string {
+ return "format"
+}
+
+var (
+ binary bool = false
+ secret bool = false
+ noKeys bool = false
+ noValues bool = false
+ ttl bool = false
+ header bool = false
+ format formatEnum = "table"
+)
+
+func enrichFlags() (ListArgs, error) {
+ var renderFunc func(tw table.Writer)
+ switch format.String() {
+ case "tsv":
+ renderFunc = func(tw table.Writer) { tw.RenderTSV() }
+ case "csv":
+ renderFunc = func(tw table.Writer) { tw.RenderCSV() }
+ case "html":
+ renderFunc = func(tw table.Writer) { tw.RenderHTML() }
+ case "markdown":
+ renderFunc = func(tw table.Writer) { tw.RenderMarkdown() }
+ case "table":
+ renderFunc = func(tw table.Writer) { tw.Render() }
+ }
+
+ if noKeys && noValues && !ttl {
+ return ListArgs{}, fmt.Errorf("no columns selected; disable --no-keys/--no-values or pass --ttl")
+ }
+
+ return ListArgs{
+ header: header,
+ key: !noKeys,
+ value: !noValues,
+ ttl: ttl,
+ binary: binary,
+ render: renderFunc,
+ secrets: secret,
+ }, nil
+}
diff --git a/cmd/list_table.go b/cmd/list_table.go
new file mode 100644
index 0000000..427c1a0
--- /dev/null
+++ b/cmd/list_table.go
@@ -0,0 +1,270 @@
+/*
+Copyright © 2025 Lewis Wynne
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+package cmd
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "strconv"
+
+ "github.com/jedib0t/go-pretty/v6/table"
+ "github.com/jedib0t/go-pretty/v6/text"
+ "golang.org/x/term"
+)
+
+type columnKind int
+
+const (
+ columnKey columnKind = iota
+ columnValue
+ columnTTL
+)
+
+func requireColumns(args ListArgs) ([]columnKind, error) {
+ var columns []columnKind
+ if args.key {
+ columns = append(columns, columnKey)
+ }
+ if args.value {
+ columns = append(columns, columnValue)
+ }
+ if args.ttl {
+ columns = append(columns, columnTTL)
+ }
+ if len(columns) == 0 {
+ return nil, fmt.Errorf("no columns selected; enable key, value, or ttl output")
+ }
+ return columns, nil
+}
+
+func buildHeaderCells(columnKinds []columnKind) []string {
+ labels := make([]string, 0, len(columnKinds))
+ for _, column := range columnKinds {
+ switch column {
+ case columnKey:
+ labels = append(labels, "Key")
+ case columnValue:
+ labels = append(labels, "Value")
+ case columnTTL:
+ labels = append(labels, "TTL")
+ }
+ }
+ return labels
+}
+
+func stringSliceToRow(values []string) table.Row {
+ row := make(table.Row, len(values))
+ for i, val := range values {
+ row[i] = val
+ }
+ return row
+}
+
+func updateMaxContentWidths(maxWidths []int, values []string) {
+ if len(maxWidths) == 0 {
+ return
+ }
+ limit := min(len(values), len(maxWidths))
+ for i := range limit {
+ width := text.LongestLineLen(values[i])
+ if width > maxWidths[i] {
+ maxWidths[i] = width
+ }
+ }
+}
+
+func applyColumnConstraints(tw table.Writer, columns []columnKind, out io.Writer, maxContentWidths []int) {
+ totalWidth := detectTerminalWidth(out)
+ if totalWidth <= 0 {
+ totalWidth = 100
+ }
+ contentWidth := contentWidthForStyle(totalWidth, tw, len(columns))
+ widths := distributeWidths(contentWidth, columns)
+
+ used := 0
+ for idx, width := range widths {
+ if width <= 0 {
+ width = 1
+ }
+ if idx < len(maxContentWidths) {
+ if actual := maxContentWidths[idx]; actual > 0 && width > actual {
+ width = actual
+ }
+ }
+ widths[idx] = width
+ used += width
+ }
+
+ remaining := contentWidth - used
+ for remaining > 0 {
+ progressed := false
+ for idx := range widths {
+ actual := 0
+ if idx < len(maxContentWidths) {
+ actual = maxContentWidths[idx]
+ }
+ if actual > 0 && widths[idx] >= actual {
+ continue
+ }
+ widths[idx]++
+ remaining--
+ progressed = true
+ if remaining == 0 {
+ break
+ }
+ }
+ if !progressed {
+ break
+ }
+ }
+
+ configs := make([]table.ColumnConfig, 0, len(columns))
+ for idx, width := range widths {
+ configs = append(configs, table.ColumnConfig{
+ Number: idx + 1,
+ WidthMax: width,
+ WidthMaxEnforcer: text.WrapText,
+ })
+ }
+ tw.SetColumnConfigs(configs)
+ tw.SetAllowedRowLength(totalWidth)
+}
+
+func contentWidthForStyle(totalWidth int, tw table.Writer, columnCount int) int {
+ if columnCount == 0 {
+ return totalWidth
+ }
+ style := tw.Style()
+ if style != nil {
+ totalWidth -= tableRowOverhead(style, columnCount)
+ }
+ if totalWidth < columnCount {
+ totalWidth = columnCount
+ }
+ return totalWidth
+}
+
+func tableRowOverhead(style *table.Style, columnCount int) int {
+ if style == nil || columnCount == 0 {
+ return 0
+ }
+ paddingWidth := text.StringWidthWithoutEscSequences(style.Box.PaddingLeft + style.Box.PaddingRight)
+ overhead := paddingWidth * columnCount
+ if style.Options.SeparateColumns && columnCount > 1 {
+ overhead += (columnCount - 1) * maxSeparatorWidth(style)
+ }
+ if style.Options.DrawBorder {
+ overhead += text.StringWidthWithoutEscSequences(style.Box.Left + style.Box.Right)
+ }
+ return overhead
+}
+
+func maxSeparatorWidth(style *table.Style) int {
+ widest := 0
+ separators := []string{
+ style.Box.MiddleSeparator,
+ style.Box.EmptySeparator,
+ style.Box.MiddleHorizontal,
+ style.Box.TopSeparator,
+ style.Box.BottomSeparator,
+ style.Box.MiddleVertical,
+ style.Box.LeftSeparator,
+ style.Box.RightSeparator,
+ }
+ for _, sep := range separators {
+ if width := text.StringWidthWithoutEscSequences(sep); width > widest {
+ widest = width
+ }
+ }
+ return widest
+}
+
+type fdWriter interface {
+ Fd() uintptr
+}
+
+func detectTerminalWidth(out io.Writer) int {
+ if f, ok := out.(fdWriter); ok {
+ if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
+ return w
+ }
+ }
+ if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
+ return w
+ }
+ if cols := os.Getenv("COLUMNS"); cols != "" {
+ if parsed, err := strconv.Atoi(cols); err == nil && parsed > 0 {
+ return parsed
+ }
+ }
+ return 0
+}
+
+func distributeWidths(total int, columns []columnKind) []int {
+ if total <= 0 {
+ total = 100
+ }
+ hasTTL := slices.Contains(columns, columnTTL)
+ base := make([]float64, len(columns))
+ sum := 0.0
+ for i, c := range columns {
+ pct := basePercentageForColumn(c, hasTTL)
+ base[i] = pct
+ sum += pct
+ }
+ if sum == 0 {
+ sum = 1
+ }
+ widths := make([]int, len(columns))
+ remaining := total
+ const minColWidth = 10
+ for i := range columns {
+ width := max(int((base[i]/sum)*float64(total)), minColWidth)
+ widths[i] = width
+ remaining -= width
+ }
+ for i := 0; remaining > 0 && len(columns) > 0; i++ {
+ idx := i % len(columns)
+ widths[idx]++
+ remaining--
+ }
+ return widths
+}
+
+func basePercentageForColumn(c columnKind, hasTTL bool) float64 {
+ switch c {
+ case columnKey:
+ return 0.25
+ case columnValue:
+ if hasTTL {
+ return 0.5
+ }
+ return 0.75
+ case columnTTL:
+ return 0.25
+ default:
+ return 0.25
+ }
+}
diff --git a/cmd/match.go b/cmd/match.go
deleted file mode 100644
index c04595b..0000000
--- a/cmd/match.go
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "fmt"
- "strings"
- "unicode/utf8"
-
- "github.com/gobwas/glob"
-)
-
-func compileValueMatchers(patterns []string) ([]glob.Glob, error) {
- var matchers []glob.Glob
- for _, pattern := range patterns {
- m, err := glob.Compile(strings.ToLower(pattern), defaultGlobSeparators...)
- if err != nil {
- return nil, err
- }
- matchers = append(matchers, m)
- }
- return matchers, nil
-}
-
-func valueMatch(matchers []glob.Glob, e Entry) bool {
- if len(matchers) == 0 {
- return true
- }
- if e.Locked {
- return false
- }
- if !utf8.Valid(e.Value) {
- return false
- }
- s := strings.ToLower(string(e.Value))
- for _, m := range matchers {
- if m.Match(s) {
- return true
- }
- }
- return false
-}
-
-func formatValuePatterns(patterns []string) string {
- quoted := make([]string, 0, len(patterns))
- for _, pattern := range patterns {
- quoted = append(quoted, fmt.Sprintf("'%s'", pattern))
- }
- return strings.Join(quoted, ", ")
-}
diff --git a/cmd/meta.go b/cmd/meta.go
deleted file mode 100644
index 91efc26..0000000
--- a/cmd/meta.go
+++ /dev/null
@@ -1,179 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "strings"
-
- "github.com/spf13/cobra"
-)
-
-var metaCmd = &cobra.Command{
- Use: "meta KEY[@STORE]",
- Short: "View or modify metadata for a key",
- 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. Pass flags to modify.`,
- Args: cobra.ExactArgs(1),
- ValidArgsFunction: completeKeys,
- RunE: meta,
- SilenceUsage: true,
-}
-
-func meta(cmd *cobra.Command, args []string) error {
- store := &Store{}
-
- spec, err := store.parseKey(args[0], true)
- if err != nil {
- return fmt.Errorf("cannot meta '%s': %v", args[0], err)
- }
-
- identity, _ := loadIdentity()
-
- p, err := store.storePath(spec.DB)
- if err != nil {
- return fmt.Errorf("cannot meta '%s': %v", args[0], err)
- }
- entries, err := readStoreFile(p, identity)
- if err != nil {
- return fmt.Errorf("cannot meta '%s': %v", args[0], err)
- }
- idx := findEntry(entries, spec.Key)
- if idx < 0 {
- keys := make([]string, len(entries))
- for i, e := range entries {
- keys[i] = e.Key
- }
- return fmt.Errorf("cannot meta '%s': %w", args[0], suggestKey(spec.Key, keys))
- }
- entry := &entries[idx]
-
- 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
- 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()
- if err != nil {
- return fmt.Errorf("cannot meta '%s': %v", args[0], err)
- }
- }
- recipients, err := allRecipients(identity)
- if err != nil {
- 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 {
- if entry.Secret {
- return fmt.Errorf("cannot meta '%s': already encrypted", args[0])
- }
- if entry.Locked {
- return fmt.Errorf("cannot meta '%s': secret is locked (identity file missing)", args[0])
- }
- entry.Secret = true
- changes = append(changes, "encrypted")
- }
-
- if decryptFlag {
- if !entry.Secret {
- return fmt.Errorf("cannot meta '%s': not encrypted", args[0])
- }
- if entry.Locked {
- 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)
- }
-
- summary := strings.Join(changes, ", ")
- okf("%s %s", summary, spec.Display())
- return autoSync(summary + " " + spec.Display())
-}
-
-func init() {
- metaCmd.Flags().String("ttl", "", "set expiry (e.g. 30m, 2h) or 'never' to clear")
- metaCmd.Flags().BoolP("encrypt", "e", false, "encrypt the value at rest")
- metaCmd.Flags().BoolP("decrypt", "d", false, "decrypt the value (store as plaintext)")
- metaCmd.Flags().Bool("readonly", false, "mark the key as read-only")
- metaCmd.Flags().Bool("writable", false, "clear the read-only flag")
- metaCmd.Flags().Bool("pin", false, "pin the key (sorts to top in list)")
- metaCmd.Flags().Bool("unpin", false, "unpin the key")
- metaCmd.Flags().Bool("force", false, "bypass read-only protection for metadata changes")
- rootCmd.AddCommand(metaCmd)
-}
diff --git a/cmd/msg.go b/cmd/msg.go
deleted file mode 100644
index 20dff20..0000000
--- a/cmd/msg.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package cmd
-
-import (
- "errors"
- "fmt"
- "os"
- "strings"
-
- "golang.org/x/term"
-)
-
-// hinted wraps an error with an actionable hint shown on a separate line.
-type hinted struct {
- err error
- hint string
-}
-
-func (h hinted) Error() string { return h.err.Error() }
-func (h hinted) Unwrap() error { return h.err }
-
-func withHint(err error, hint string) error {
- return hinted{err: err, hint: hint}
-}
-
-func stderrIsTerminal() bool {
- return term.IsTerminal(int(os.Stderr.Fd()))
-}
-
-func stdoutIsTerminal() bool {
- return term.IsTerminal(int(os.Stdout.Fd()))
-}
-
-// keyword returns a right-aligned, colored keyword (color only on TTY).
-// All keywords are bold except dim (code "2").
-//
-// FAIL bold red (stderr)
-// hint dim (stderr)
-// WARN bold yellow (stderr)
-// info bold blue (stderr)
-// ok bold green (stderr)
-// ? bold cyan (stdout)
-// > dim (stdout)
-func keyword(code, word string, tty bool) string {
- padded := fmt.Sprintf("%4s", word)
- if tty {
- if code != "2" {
- code = "1;" + code
- }
- return fmt.Sprintf("\033[%sm%s\033[0m", code, padded)
- }
- return padded
-}
-
-func printError(err error) {
- tty := stderrIsTerminal()
- if tty {
- fmt.Fprintf(os.Stderr, "%s \033[1m%s\033[0m\n", keyword("31", "FAIL", true), err)
- } else {
- fmt.Fprintf(os.Stderr, "%s %s\n", keyword("31", "FAIL", false), err)
- }
-}
-
-func printHint(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stderr, "%s %s\n", keyword("2", "hint", stderrIsTerminal()), msg)
-}
-
-func warnf(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stderr, "%s %s\n", keyword("33", "WARN", stderrIsTerminal()), msg)
-}
-
-func infof(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stderr, "%s %s\n", keyword("34", "info", stderrIsTerminal()), msg)
-}
-
-func okf(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stderr, "%s %s\n", keyword("32", "ok", stderrIsTerminal()), msg)
-}
-
-func promptf(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stdout, "%s %s\n", keyword("36", "???", stdoutIsTerminal()), msg)
-}
-
-func progressf(format string, args ...any) {
- msg := fmt.Sprintf(format, args...)
- fmt.Fprintf(os.Stdout, "%s %s\n", keyword("2", ">", stdoutIsTerminal()), msg)
-}
-
-func scanln(dest *string) error {
- fmt.Fprintf(os.Stdout, "%s ", keyword("2", "==>", stdoutIsTerminal()))
- _, err := fmt.Scanln(dest)
- return err
-}
-
-// printErrorWithHints prints the error and any hints found in the error chain.
-func printErrorWithHints(err error) {
- printError(err)
- var h hinted
- if errors.As(err, &h) {
- printHint("%s", h.hint)
- }
- var nf errNotFound
- if errors.As(err, &nf) && len(nf.suggestions) > 0 {
- printHint("did you mean '%s'?", strings.Join(nf.suggestions, "', '"))
- }
-}
diff --git a/cmd/mv-db.go b/cmd/mv-db.go
deleted file mode 100644
index 1e1db1e..0000000
--- a/cmd/mv-db.go
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "errors"
- "fmt"
- "os"
- "strings"
-
- "github.com/spf13/cobra"
-)
-
-// mvStoreCmd represents the move-store command
-var mvStoreCmd = &cobra.Command{
- Use: "move-store FROM TO",
- Short: "Rename a store",
- Aliases: []string{"mvs"},
- Args: cobra.ExactArgs(2),
- ValidArgsFunction: completeStores,
- RunE: mvStore,
- SilenceUsage: true,
-}
-
-func mvStore(cmd *cobra.Command, args []string) error {
- store := &Store{}
-
- fromName, err := store.parseDB(args[0], false)
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", args[0], err)
- }
- toName, err := store.parseDB(args[1], false)
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", args[1], err)
- }
-
- if fromName == toName {
- return fmt.Errorf("cannot rename store '%s': source and destination are the same", fromName)
- }
-
- var notFound errNotFound
- fromPath, err := store.FindStore(fromName)
- if errors.As(err, ¬Found) {
- return fmt.Errorf("cannot rename store '%s': %w", fromName, err)
- }
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
-
- interactive, err := cmd.Flags().GetBool("interactive")
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- safe, err := cmd.Flags().GetBool("safe")
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- yes, err := cmd.Flags().GetBool("yes")
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- promptOverwrite := !yes && (interactive || config.Store.AlwaysPromptOverwrite)
-
- toPath, err := store.storePath(toName)
- if err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- if _, err := os.Stat(toPath); err == nil {
- if safe {
- infof("skipped '@%s': already exists", toName)
- return nil
- }
- if promptOverwrite {
- promptf("overwrite store '%s'? (y/n)", toName)
- var confirm string
- if err := scanln(&confirm); err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- if strings.ToLower(confirm) != "y" {
- return nil
- }
- }
- }
-
- copy, _ := cmd.Flags().GetBool("copy")
- var summary string
- if copy {
- data, err := os.ReadFile(fromPath)
- if err != nil {
- return fmt.Errorf("cannot copy store '%s': %v", fromName, err)
- }
- if err := os.WriteFile(toPath, data, 0o640); err != nil {
- return fmt.Errorf("cannot copy store '%s': %v", fromName, err)
- }
- okf("copied @%s to @%s", fromName, toName)
- summary = fmt.Sprintf("copied @%s to @%s", fromName, toName)
- } else {
- if err := os.Rename(fromPath, toPath); err != nil {
- return fmt.Errorf("cannot rename store '%s': %v", fromName, err)
- }
- okf("renamed @%s to @%s", fromName, toName)
- summary = fmt.Sprintf("moved @%s to @%s", fromName, toName)
- }
- return autoSync(summary)
-}
-
-func init() {
- mvStoreCmd.Flags().Bool("copy", false, "copy instead of move (keeps source)")
- mvStoreCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting destination")
- mvStoreCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts")
- mvStoreCmd.Flags().Bool("safe", false, "do not overwrite if the destination store already exists")
- rootCmd.AddCommand(mvStoreCmd)
-}
diff --git a/cmd/mv.go b/cmd/mv.go
index 1594962..d902b3c 100644
--- a/cmd/mv.go
+++ b/cmd/mv.go
@@ -26,61 +26,38 @@ import (
"fmt"
"strings"
+ "github.com/dgraph-io/badger/v4"
"github.com/spf13/cobra"
)
var cpCmd = &cobra.Command{
- Use: "copy FROM[@STORE] TO[@STORE]",
- Aliases: []string{"cp"},
- Short: "Make a copy of a key",
- Args: cobra.ExactArgs(2),
- ValidArgsFunction: completeKeys,
- RunE: cp,
- SilenceUsage: true,
+ Use: "cp FROM[@DB] TO[@DB]",
+ Short: "Make a copy of a key.",
+ Args: cobra.ExactArgs(2),
+ RunE: cp,
}
var mvCmd = &cobra.Command{
- Use: "move FROM[@STORE] TO[@STORE]",
- Aliases: []string{"mv"},
- Short: "Move a key",
- Args: cobra.ExactArgs(2),
- ValidArgsFunction: completeKeys,
- RunE: mv,
- SilenceUsage: true,
+ Use: "mv FROM[@DB] TO[@DB]",
+ Short: "Move a key between (or within) databases.",
+ Args: cobra.ExactArgs(2),
+ RunE: mv,
+ SilenceUsage: true,
}
func cp(cmd *cobra.Command, args []string) error {
- return mvImpl(cmd, args, true)
+ copy = true
+ return mv(cmd, args)
}
func mv(cmd *cobra.Command, args []string) error {
- keepSource, _ := cmd.Flags().GetBool("copy")
- return mvImpl(cmd, args, keepSource)
-}
-
-func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
store := &Store{}
interactive, err := cmd.Flags().GetBool("interactive")
if err != nil {
return err
}
- safe, err := cmd.Flags().GetBool("safe")
- if err != nil {
- return err
- }
- yes, err := cmd.Flags().GetBool("yes")
- if err != nil {
- return err
- }
- force, _ := cmd.Flags().GetBool("force")
- promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite)
-
- identity, _ := loadIdentity()
- recipients, err := allRecipients(identity)
- if err != nil {
- return err
- }
+ promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
fromSpec, err := store.parseKey(args[0], true)
if err != nil {
@@ -91,57 +68,36 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
return err
}
- // Read source
- srcPath, err := store.storePath(fromSpec.DB)
- if err != nil {
- return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
- }
- srcEntries, err := readStoreFile(srcPath, identity)
- if err != nil {
- return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
- }
- srcIdx := findEntry(srcEntries, fromSpec.Key)
- if srcIdx < 0 {
- return fmt.Errorf("cannot move '%s': no such key", fromSpec.Key)
- }
- srcEntry := srcEntries[srcIdx]
+ var srcVal []byte
+ var srcMeta byte
+ var srcExpires uint64
+ fromRef := fromSpec.Full()
+ toRef := toSpec.Full()
- // 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
- dstPath := srcPath
- dstEntries := srcEntries
- if !sameStore {
- dstPath, err = store.storePath(toSpec.DB)
- if err != nil {
- return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
- }
- dstEntries, err = readStoreFile(dstPath, identity)
- if err != nil {
- return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
+ var destExists bool
+ if promptOverwrite {
+ existsErr := store.Transaction(TransactionArgs{
+ key: toRef,
+ readonly: true,
+ transact: func(tx *badger.Txn, k []byte) error {
+ if _, err := tx.Get(k); err == nil {
+ destExists = true
+ return nil
+ } else if err == badger.ErrKeyNotFound {
+ return nil
+ }
+ return err
+ },
+ })
+ if existsErr != nil {
+ return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, existsErr)
}
}
- 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
- }
-
- if promptOverwrite && dstIdx >= 0 {
+ if promptOverwrite && destExists {
var confirm string
- promptf("overwrite '%s'? (y/n)", toSpec.Display())
- if err := scanln(&confirm); err != nil {
+ fmt.Printf("overwrite '%s'? (y/n)\n", toSpec.Display())
+ if _, err := fmt.Scanln(&confirm); err != nil {
return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
}
if strings.ToLower(confirm) != "y" {
@@ -149,73 +105,68 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
}
}
- // 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,
+ readErr := store.Transaction(TransactionArgs{
+ key: fromRef,
+ readonly: true,
+ transact: func(tx *badger.Txn, k []byte) error {
+ item, err := tx.Get(k)
+ if err != nil {
+ return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err)
+ }
+ srcMeta = item.UserMeta()
+ srcExpires = item.ExpiresAt()
+ return item.Value(func(v []byte) error {
+ srcVal = append(srcVal[:0], v...)
+ return nil
+ })
+ },
+ })
+ if readErr != nil {
+ return readErr
}
- if sameStore {
- // Both source and dest in same file
- if dstIdx >= 0 {
- dstEntries[dstIdx] = newEntry
- } else {
- dstEntries = append(dstEntries, newEntry)
- }
- if !keepSource {
- // Remove source - find it again since indices may have changed
- idx := findEntry(dstEntries, fromSpec.Key)
- if idx >= 0 {
- dstEntries = append(dstEntries[:idx], dstEntries[idx+1:]...)
+ writeErr := store.Transaction(TransactionArgs{
+ key: toRef,
+ readonly: false,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ entry := badger.NewEntry(k, srcVal).WithMeta(srcMeta)
+ if srcExpires > 0 {
+ entry.ExpiresAt = srcExpires
}
- }
- if err := writeStoreFile(dstPath, dstEntries, recipients); err != nil {
- return err
- }
- } else {
- // Different stores
- if dstIdx >= 0 {
- dstEntries[dstIdx] = newEntry
- } else {
- dstEntries = append(dstEntries, newEntry)
- }
- if err := writeStoreFile(dstPath, dstEntries, recipients); err != nil {
- return err
- }
- if !keepSource {
- srcEntries = append(srcEntries[:srcIdx], srcEntries[srcIdx+1:]...)
- if err := writeStoreFile(srcPath, srcEntries, recipients); err != nil {
- return err
- }
- }
+ return tx.SetEntry(entry)
+ },
+ })
+ if writeErr != nil {
+ return writeErr
}
- var summary string
- if keepSource {
- okf("copied %s to %s", fromSpec.Display(), toSpec.Display())
- summary = "copied " + fromSpec.Display() + " to " + toSpec.Display()
- } else {
- okf("renamed %s to %s", fromSpec.Display(), toSpec.Display())
- summary = "moved " + fromSpec.Display() + " to " + toSpec.Display()
+ if copy {
+ return autoSync()
}
- return autoSync(summary)
+
+ if err := store.Transaction(TransactionArgs{
+ key: fromRef,
+ readonly: false,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ return tx.Delete(k)
+ },
+ }); err != nil {
+ return err
+ }
+
+ return autoSync()
}
+var (
+ copy bool = false
+)
+
func init() {
- mvCmd.Flags().Bool("copy", false, "copy instead of move (keeps source)")
- 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")
+ mvCmd.Flags().BoolVar(©, "copy", false, "Copy instead of move (keeps source)")
+ mvCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting destination")
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")
+ cpCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting destination")
rootCmd.AddCommand(cpCmd)
}
diff --git a/cmd/ndjson.go b/cmd/ndjson.go
deleted file mode 100644
index 334e10e..0000000
--- a/cmd/ndjson.go
+++ /dev/null
@@ -1,243 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "bufio"
- "encoding/base64"
- "encoding/json"
- "fmt"
- "os"
- "slices"
- "strings"
- "time"
- "unicode/utf8"
-
- "filippo.io/age"
-)
-
-// Entry is the in-memory representation of a stored key-value pair.
-type Entry struct {
- Key string
- Value []byte
- 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
-}
-
-// jsonEntry is the NDJSON on-disk format.
-type jsonEntry struct {
- Key string `json:"key"`
- 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"`
-}
-
-// readStoreFile reads all non-expired entries from an NDJSON file.
-// Returns empty slice (not error) if file does not exist.
-// If identity is nil, secret entries are returned as locked.
-func readStoreFile(path string, identity *age.X25519Identity) ([]Entry, error) {
- f, err := os.Open(path)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, nil
- }
- return nil, err
- }
- defer f.Close()
-
- now := uint64(time.Now().Unix())
- var entries []Entry
- scanner := bufio.NewScanner(f)
- scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
- lineNo := 0
- for scanner.Scan() {
- lineNo++
- line := scanner.Bytes()
- if len(line) == 0 {
- continue
- }
- var je jsonEntry
- if err := json.Unmarshal(line, &je); err != nil {
- return nil, fmt.Errorf("line %d: %w", lineNo, err)
- }
- entry, err := decodeJsonEntry(je, identity)
- if err != nil {
- return nil, fmt.Errorf("line %d: %w", lineNo, err)
- }
- // Skip expired entries
- if entry.ExpiresAt > 0 && entry.ExpiresAt <= now {
- continue
- }
- entries = append(entries, entry)
- }
- return entries, scanner.Err()
-}
-
-// writeStoreFile atomically writes entries to an NDJSON file, sorted by key.
-// Expired entries are excluded. Empty entry list writes an empty file.
-// If recipients is empty, secret entries are written as-is (locked passthrough).
-func writeStoreFile(path string, entries []Entry, recipients []age.Recipient) error {
- // Sort by key for deterministic output
- slices.SortFunc(entries, func(a, b Entry) int {
- return strings.Compare(a.Key, b.Key)
- })
-
- tmp := path + ".tmp"
- f, err := os.Create(tmp)
- if err != nil {
- return err
- }
- defer func() {
- f.Close()
- os.Remove(tmp) // clean up on failure; no-op after successful rename
- }()
-
- w := bufio.NewWriter(f)
- now := uint64(time.Now().Unix())
- for _, e := range entries {
- if e.ExpiresAt > 0 && e.ExpiresAt <= now {
- continue
- }
- je, err := encodeJsonEntry(e, recipients)
- if err != nil {
- return fmt.Errorf("key '%s': %w", e.Key, err)
- }
- data, err := json.Marshal(je)
- if err != nil {
- return fmt.Errorf("key '%s': %w", e.Key, err)
- }
- w.Write(data)
- w.WriteByte('\n')
- }
- if err := w.Flush(); err != nil {
- return err
- }
- if err := f.Sync(); err != nil {
- return err
- }
- if err := f.Close(); err != nil {
- return err
- }
- return os.Rename(tmp, path)
-}
-
-func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) {
- var expiresAt uint64
- 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)
- if err != nil {
- 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, 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, ReadOnly: readOnly, Pinned: pinned}, nil
- }
- return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true, ReadOnly: readOnly, Pinned: pinned}, nil
- }
-
- var value []byte
- switch je.Encoding {
- case "", "text":
- value = []byte(je.Value)
- case "base64":
- var err error
- value, err = base64.StdEncoding.DecodeString(je.Value)
- if err != nil {
- return Entry{}, fmt.Errorf("decode base64 for '%s': %w", je.Key, err)
- }
- default:
- return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key)
- }
- return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt, ReadOnly: readOnly, Pinned: pinned}, nil
-}
-
-func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) {
- je := jsonEntry{Key: e.Key}
- if e.ExpiresAt > 0 {
- 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
- je.Value = base64.StdEncoding.EncodeToString(e.Value)
- je.Encoding = "secret"
- return je, nil
- }
- if e.Secret {
- if len(recipients) == 0 {
- return je, fmt.Errorf("no recipient available to encrypt")
- }
- ciphertext, err := encrypt(e.Value, recipients...)
- if err != nil {
- return je, fmt.Errorf("encrypt: %w", err)
- }
- je.Value = base64.StdEncoding.EncodeToString(ciphertext)
- je.Encoding = "secret"
- return je, nil
- }
-
- if utf8.Valid(e.Value) {
- je.Value = string(e.Value)
- je.Encoding = "text"
- } else {
- je.Value = base64.StdEncoding.EncodeToString(e.Value)
- je.Encoding = "base64"
- }
- return je, nil
-}
-
-// findEntry returns the index of the entry with the given key, or -1.
-func findEntry(entries []Entry, key string) int {
- for i, e := range entries {
- if e.Key == key {
- return i
- }
- }
- return -1
-}
diff --git a/cmd/ndjson_test.go b/cmd/ndjson_test.go
deleted file mode 100644
index a1acabe..0000000
--- a/cmd/ndjson_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestReadWriteRoundtrip(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "alpha", Value: []byte("hello")},
- {Key: "beta", Value: []byte("world"), ExpiresAt: uint64(time.Now().Add(time.Hour).Unix())},
- {Key: "gamma", Value: []byte{0xff, 0xfe}}, // binary
- }
-
- if err := writeStoreFile(path, entries, nil); err != nil {
- t.Fatal(err)
- }
-
- got, err := readStoreFile(path, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(got) != len(entries) {
- t.Fatalf("got %d entries, want %d", len(got), len(entries))
- }
- for i := range entries {
- if got[i].Key != entries[i].Key {
- t.Errorf("entry %d: key = %q, want %q", i, got[i].Key, entries[i].Key)
- }
- if string(got[i].Value) != string(entries[i].Value) {
- t.Errorf("entry %d: value mismatch", i)
- }
- }
-}
-
-func TestReadStoreFileSkipsExpired(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "alive", Value: []byte("yes")},
- {Key: "dead", Value: []byte("no"), ExpiresAt: 1}, // expired long ago
- }
-
- if err := writeStoreFile(path, entries, nil); err != nil {
- t.Fatal(err)
- }
-
- got, err := readStoreFile(path, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- if len(got) != 1 || got[0].Key != "alive" {
- t.Fatalf("expected only 'alive', got %v", got)
- }
-}
-
-func TestReadStoreFileNotExist(t *testing.T) {
- got, err := readStoreFile("/nonexistent/path.ndjson", nil)
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 0 {
- t.Fatalf("expected empty, got %d entries", len(got))
- }
-}
-
-func TestWriteStoreFileSortsKeys(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "charlie", Value: []byte("3")},
- {Key: "alpha", Value: []byte("1")},
- {Key: "bravo", Value: []byte("2")},
- }
-
- if err := writeStoreFile(path, entries, nil); err != nil {
- t.Fatal(err)
- }
-
- got, err := readStoreFile(path, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- if got[0].Key != "alpha" || got[1].Key != "bravo" || got[2].Key != "charlie" {
- t.Fatalf("entries not sorted: %v", got)
- }
-}
-
-func TestWriteStoreFileAtomic(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- // Write initial data
- if err := writeStoreFile(path, []Entry{{Key: "a", Value: []byte("1")}}, nil); err != nil {
- t.Fatal(err)
- }
-
- // Overwrite — should not leave .tmp files
- if err := writeStoreFile(path, []Entry{{Key: "b", Value: []byte("2")}}, nil); err != nil {
- t.Fatal(err)
- }
-
- // Check no .tmp file remains
- matches, _ := filepath.Glob(filepath.Join(dir, "*.tmp"))
- if len(matches) > 0 {
- t.Fatalf("leftover tmp files: %v", matches)
- }
-}
diff --git a/cmd/restore.go b/cmd/restore.go
index 03d1d30..b42c983 100644
--- a/cmd/restore.go
+++ b/cmd/restore.go
@@ -24,55 +24,47 @@ package cmd
import (
"bufio"
+ "encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
- "filippo.io/age"
-
- "github.com/gobwas/glob"
+ "github.com/dgraph-io/badger/v4"
"github.com/spf13/cobra"
)
var restoreCmd = &cobra.Command{
- Use: "import [STORE]",
- Short: "Restore key/value pairs from an NDJSON dump",
- Aliases: []string{},
- Args: cobra.MaximumNArgs(1),
- ValidArgsFunction: completeStores,
- RunE: restore,
- SilenceUsage: true,
+ Use: "restore [DB]",
+ Short: "Restore key/value pairs from an NDJSON dump",
+ Aliases: []string{"import"},
+ Args: cobra.MaximumNArgs(1),
+ RunE: restore,
+ SilenceUsage: true,
}
func restore(cmd *cobra.Command, args []string) error {
store := &Store{}
- explicitStore := len(args) == 1
- targetDB := config.Store.DefaultStoreName
- if explicitStore {
+ dbName := config.Store.DefaultStoreName
+ if len(args) == 1 {
parsed, err := store.parseDB(args[0], false)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", args[0], err)
}
- targetDB = parsed
+ dbName = parsed
}
- displayTarget := "@" + targetDB
+ displayTarget := "@" + dbName
- keyPatterns, err := cmd.Flags().GetStringSlice("key")
+ globPatterns, err := cmd.Flags().GetStringSlice("glob")
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
- matchers, err := compileGlobMatchers(keyPatterns)
+ separators, err := parseGlobSeparators(cmd)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
-
- storePatterns, err := cmd.Flags().GetStringSlice("store")
- if err != nil {
- return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
- }
- storeMatchers, err := compileGlobMatchers(storePatterns)
+ matchers, err := compileGlobMatchers(globPatterns, separators)
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
@@ -85,93 +77,95 @@ func restore(cmd *cobra.Command, args []string) error {
defer closer.Close()
}
+ db, err := store.open(dbName)
+ if err != nil {
+ return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
+ }
+ defer db.Close()
+
decoder := json.NewDecoder(bufio.NewReaderSize(reader, 8*1024*1024))
+ wb := db.NewWriteBatch()
+ defer wb.Cancel()
+
interactive, err := cmd.Flags().GetBool("interactive")
if err != nil {
return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
- drop, err := cmd.Flags().GetBool("drop")
- if err != nil {
- return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
- }
+ entryNo := 0
+ var restored int
+ var matched bool
- identity, _ := loadIdentity()
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
- }
-
- var promptReader io.Reader
- if promptOverwrite {
- filePath, _ := cmd.Flags().GetString("file")
- if strings.TrimSpace(filePath) == "" {
- tty, err := os.Open("/dev/tty")
- if err != nil {
- return fmt.Errorf("cannot restore '%s': --interactive requires --file (-f) when reading from stdin on this platform", displayTarget)
+ for {
+ var entry dumpEntry
+ if err := decoder.Decode(&entry); err != nil {
+ if err == io.EOF {
+ break
}
- defer tty.Close()
- promptReader = tty
+ return fmt.Errorf("cannot restore '%s': entry %d: %w", displayTarget, entryNo+1, err)
+ }
+ entryNo++
+ if entry.Key == "" {
+ return fmt.Errorf("cannot restore '%s': entry %d: missing key", displayTarget, entryNo)
+ }
+ if !globMatch(matchers, entry.Key) {
+ continue
}
- }
- opts := restoreOpts{
- matchers: matchers,
- storeMatchers: storeMatchers,
- promptOverwrite: promptOverwrite,
- drop: drop,
- identity: identity,
- recipients: recipients,
- promptReader: promptReader,
- }
+ if promptOverwrite {
+ exists, err := keyExistsInDB(db, entry.Key)
+ if err != nil {
+ return fmt.Errorf("cannot restore '%s': entry %d: %v", displayTarget, entryNo, err)
+ }
+ if exists {
+ fmt.Printf("overwrite '%s'? (y/n)\n", entry.Key)
+ var confirm string
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot restore '%s': entry %d: %v", displayTarget, entryNo, err)
+ }
+ if strings.ToLower(confirm) != "y" {
+ continue
+ }
+ }
+ }
- // When a specific store is given, all entries go there (original behaviour).
- // Otherwise, route entries to their original store via the "store" field.
- var summary string
- if explicitStore {
- p, err := store.storePath(targetDB)
+ value, err := decodeEntryValue(entry)
if err != nil {
- return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
+ return fmt.Errorf("cannot restore '%s': entry %d: %w", displayTarget, entryNo, err)
}
- restored, err := restoreEntries(decoder, map[string]string{targetDB: p}, targetDB, opts)
- if err != nil {
- return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
+
+ entryMeta := byte(0x0)
+ if entry.Secret {
+ entryMeta = metaSecret
}
- if err := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil {
- return err
+
+ writeEntry := badger.NewEntry([]byte(entry.Key), value).WithMeta(entryMeta)
+ if entry.ExpiresAt != nil {
+ if *entry.ExpiresAt < 0 {
+ return fmt.Errorf("cannot restore '%s': entry %d: expires_at must be >= 0", displayTarget, entryNo)
+ }
+ writeEntry.ExpiresAt = uint64(*entry.ExpiresAt)
}
- okf("restored %d entries into @%s", restored, targetDB)
- summary = fmt.Sprintf("imported %d entries into @%s", restored, targetDB)
- } else {
- restored, err := restoreEntries(decoder, nil, targetDB, opts)
- if err != nil {
- return fmt.Errorf("cannot restore: %v", err)
+
+ if err := wb.SetEntry(writeEntry); err != nil {
+ return fmt.Errorf("cannot restore '%s': entry %d: %w", displayTarget, entryNo, err)
}
- if err := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil {
- return err
- }
- okf("restored %d entries", restored)
- summary = fmt.Sprintf("imported %d entries", restored)
+ restored++
+ matched = true
}
- return autoSync(summary)
-}
-
-func reportRestoreFilters(displayTarget string, restored int, matchers []glob.Glob, keyPatterns []string, storeMatchers []glob.Glob, storePatterns []string) error {
- hasFilters := len(matchers) > 0 || len(storeMatchers) > 0
- if hasFilters && restored == 0 {
- var parts []string
- if len(matchers) > 0 {
- parts = append(parts, fmt.Sprintf("key pattern %s", formatGlobPatterns(keyPatterns)))
- }
- if len(storeMatchers) > 0 {
- parts = append(parts, fmt.Sprintf("store pattern %s", formatGlobPatterns(storePatterns)))
- }
- return fmt.Errorf("cannot restore '%s': no matches for %s", displayTarget, strings.Join(parts, " and "))
+ if err := wb.Flush(); err != nil {
+ return fmt.Errorf("cannot restore '%s': %v", displayTarget, err)
}
- return nil
+
+ if len(matchers) > 0 && !matched {
+ return fmt.Errorf("cannot restore '%s': No matches for pattern %s", displayTarget, formatGlobPatterns(globPatterns))
+ }
+
+ fmt.Fprintf(cmd.ErrOrStderr(), "Restored %d entries into @%s\n", restored, dbName)
+ return autoSync()
}
func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) {
@@ -189,143 +183,41 @@ func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) {
return f, f, nil
}
-type restoreOpts struct {
- matchers []glob.Glob
- storeMatchers []glob.Glob
- promptOverwrite bool
- drop bool
- identity *age.X25519Identity
- recipients []age.Recipient
- promptReader io.Reader
-}
-
-// restoreEntries decodes NDJSON entries and writes them to store files.
-// storePaths maps store names to file paths. If nil, entries are routed to
-// their original store (from the "store" field), falling back to defaultDB.
-func restoreEntries(decoder *json.Decoder, storePaths map[string]string, defaultDB string, opts restoreOpts) (int, error) {
- s := &Store{}
-
- // Per-store accumulator.
- type storeAcc struct {
- path string
- entries []Entry
- loaded bool
- }
- stores := make(map[string]*storeAcc)
-
- getStore := func(dbName string) (*storeAcc, error) {
- if acc, ok := stores[dbName]; ok {
- return acc, nil
- }
- var p string
- if storePaths != nil {
- var ok bool
- p, ok = storePaths[dbName]
- if !ok {
- return nil, fmt.Errorf("unexpected store '%s'", dbName)
- }
- } else {
- var err error
- p, err = s.storePath(dbName)
- if err != nil {
- return nil, err
- }
- }
- acc := &storeAcc{path: p}
- if !opts.drop {
- existing, err := readStoreFile(p, opts.identity)
- if err != nil {
- return nil, err
- }
- acc.entries = existing
- }
- acc.loaded = true
- stores[dbName] = acc
- return acc, nil
- }
-
- entryNo := 0
- restored := 0
-
- for {
- var je jsonEntry
- if err := decoder.Decode(&je); err != nil {
- if err == io.EOF {
- break
- }
- return 0, fmt.Errorf("entry %d: %w", entryNo+1, err)
- }
- entryNo++
- if je.Key == "" {
- return 0, fmt.Errorf("entry %d: missing key", entryNo)
- }
- if !globMatch(opts.matchers, je.Key) {
- continue
- }
- if !globMatch(opts.storeMatchers, je.Store) {
- continue
- }
-
- // Determine target store.
- targetDB := defaultDB
- if storePaths == nil && je.Store != "" {
- targetDB = je.Store
- }
-
- entry, err := decodeJsonEntry(je, opts.identity)
+func decodeEntryValue(entry dumpEntry) ([]byte, error) {
+ switch entry.Encoding {
+ case "", "text":
+ return []byte(entry.Value), nil
+ case "base64":
+ b, err := base64.StdEncoding.DecodeString(entry.Value)
if err != nil {
- return 0, fmt.Errorf("entry %d: %w", entryNo, err)
+ return nil, err
}
-
- acc, err := getStore(targetDB)
- if err != nil {
- return 0, fmt.Errorf("entry %d: %v", entryNo, err)
- }
-
- idx := findEntry(acc.entries, entry.Key)
-
- if opts.promptOverwrite && idx >= 0 {
- promptf("overwrite '%s'? (y/n)", entry.Key)
- var confirm string
- if opts.promptReader != nil {
- fmt.Fprintf(os.Stdout, "%s ", keyword("2", "==>", stdoutIsTerminal()))
- if _, err := fmt.Fscanln(opts.promptReader, &confirm); err != nil {
- return 0, fmt.Errorf("entry %d: %v", entryNo, err)
- }
- } else {
- if err := scanln(&confirm); err != nil {
- return 0, fmt.Errorf("entry %d: %v", entryNo, err)
- }
- }
- if strings.ToLower(confirm) != "y" {
- continue
- }
- }
-
- if idx >= 0 {
- acc.entries[idx] = entry
- } else {
- acc.entries = append(acc.entries, entry)
- }
- restored++
+ return b, nil
+ default:
+ return nil, fmt.Errorf("unsupported encoding %q", entry.Encoding)
}
-
- for _, acc := range stores {
- if restored > 0 || opts.drop {
- if err := writeStoreFile(acc.path, acc.entries, opts.recipients); err != nil {
- return 0, err
- }
- }
- }
- return restored, nil
}
func init() {
- restoreCmd.Flags().StringP("file", "f", "", "path to an NDJSON dump (defaults to stdin)")
- restoreCmd.Flags().StringSliceP("key", "k", nil, "restore keys matching glob pattern (repeatable)")
- restoreCmd.Flags().StringSliceP("store", "s", nil, "restore entries from stores matching glob pattern (repeatable)")
- restoreCmd.RegisterFlagCompletionFunc("store", completeStoreFlag)
- restoreCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting existing keys")
- restoreCmd.Flags().Bool("drop", false, "drop existing entries before restoring (full replace)")
+ restoreCmd.Flags().StringP("file", "f", "", "Path to an NDJSON dump (defaults to stdin)")
+ restoreCmd.Flags().StringSliceP("glob", "g", nil, "Restore keys matching glob pattern (repeatable)")
+ restoreCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay()))
+ restoreCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting existing keys")
rootCmd.AddCommand(restoreCmd)
}
+
+func keyExistsInDB(db *badger.DB, key string) (bool, error) {
+ var exists bool
+ err := db.View(func(tx *badger.Txn) error {
+ _, err := tx.Get([]byte(key))
+ if err == nil {
+ exists = true
+ return nil
+ }
+ if err == badger.ErrKeyNotFound {
+ return nil
+ }
+ return err
+ })
+ return exists, err
+}
diff --git a/cmd/root.go b/cmd/root.go
index 0fa6b76..80d9a79 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -31,65 +31,20 @@ import (
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
- Use: "pda",
- Short: "A key-value store tool",
- Long: asciiArt,
- SilenceErrors: true, // we print errors ourselves
+ Use: "pda",
+ Short: "A key-value store.",
+ Long: asciiArt,
}
func Execute() {
if configErr != nil {
- cmd, _, _ := rootCmd.Find(os.Args[1:])
- if !configSafeCmd(cmd) {
- printError(fmt.Errorf("fatal problem: running pda! doctor automatically"))
- runDoctor(os.Stderr)
- os.Exit(1)
- }
+ fmt.Fprintln(os.Stderr, "failed to load config:", configErr)
+ os.Exit(1)
}
err := rootCmd.Execute()
if err != nil {
- printErrorWithHints(err)
os.Exit(1)
}
}
-// configSafeCmd reports whether cmd can run with a broken config.
-// Only non-destructive commands that don't depend on parsed config values.
-func configSafeCmd(cmd *cobra.Command) bool {
- return cmd == configEditCmd || cmd == configInitCmd ||
- cmd == configPathCmd || cmd == doctorCmd
-}
-
-func init() {
- rootCmd.AddGroup(&cobra.Group{ID: "keys", Title: "Key commands:"})
-
- setCmd.GroupID = "keys"
- getCmd.GroupID = "keys"
- runCmd.GroupID = "keys"
- mvCmd.GroupID = "keys"
- cpCmd.GroupID = "keys"
- delCmd.GroupID = "keys"
- listCmd.GroupID = "keys"
- editCmd.GroupID = "keys"
- metaCmd.GroupID = "keys"
- identityCmd.GroupID = "keys"
-
- rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"})
-
- listStoresCmd.GroupID = "stores"
- delStoreCmd.GroupID = "stores"
- mvStoreCmd.GroupID = "stores"
- exportCmd.GroupID = "stores"
- restoreCmd.GroupID = "stores"
-
- rootCmd.AddGroup(&cobra.Group{ID: "git", Title: "Git commands:"})
-
- initCmd.GroupID = "git"
- syncCmd.GroupID = "git"
- gitCmd.GroupID = "git"
-
- rootCmd.AddGroup(&cobra.Group{ID: "env", Title: "Environment commands:"})
-
- configCmd.GroupID = "env"
- doctorCmd.GroupID = "env"
-}
+func init() {}
diff --git a/cmd/secret.go b/cmd/secret.go
deleted file mode 100644
index 52eb848..0000000
--- a/cmd/secret.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package cmd
-
-import (
- "bufio"
- "bytes"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
-
- "filippo.io/age"
- gap "github.com/muesli/go-app-paths"
-)
-
-// identityPath returns the path to the age identity file,
-// respecting PDA_DATA the same way Store.path() does.
-func identityPath() (string, error) {
- if override := os.Getenv("PDA_DATA"); override != "" {
- return filepath.Join(override, "identity.txt"), nil
- }
- scope := gap.NewScope(gap.User, "pda")
- dir, err := scope.DataPath("")
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, "identity.txt"), nil
-}
-
-// loadIdentity loads the age identity from disk.
-// Returns (nil, nil) if the identity file does not exist.
-func loadIdentity() (*age.X25519Identity, error) {
- path, err := identityPath()
- if err != nil {
- return nil, err
- }
- data, err := os.ReadFile(path)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, nil
- }
- return nil, err
- }
- identity, err := age.ParseX25519Identity(string(bytes.TrimSpace(data)))
- if err != nil {
- return nil, fmt.Errorf("parse identity %s: %w", path, err)
- }
- return identity, nil
-}
-
-// ensureIdentity loads an existing identity or generates a new one.
-// On first creation prints an ok message with the file path.
-func ensureIdentity() (*age.X25519Identity, error) {
- id, err := loadIdentity()
- if err != nil {
- return nil, err
- }
- if id != nil {
- return id, nil
- }
-
- id, err = age.GenerateX25519Identity()
- if err != nil {
- return nil, fmt.Errorf("generate identity: %w", err)
- }
-
- path, err := identityPath()
- if err != nil {
- return nil, err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return nil, err
- }
- if err := os.WriteFile(path, []byte(id.String()+"\n"), 0o600); err != nil {
- return nil, err
- }
-
- okf("created identity at %s", path)
- return id, nil
-}
-
-// recipientsPath returns the path to the additional recipients file,
-// respecting PDA_DATA the same way identityPath does.
-func recipientsPath() (string, error) {
- if override := os.Getenv("PDA_DATA"); override != "" {
- return filepath.Join(override, "recipients.txt"), nil
- }
- scope := gap.NewScope(gap.User, "pda")
- dir, err := scope.DataPath("")
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, "recipients.txt"), nil
-}
-
-// loadRecipients loads additional age recipients from disk.
-// Returns (nil, nil) if the recipients file does not exist.
-func loadRecipients() ([]*age.X25519Recipient, error) {
- path, err := recipientsPath()
- if err != nil {
- return nil, err
- }
- f, err := os.Open(path)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, nil
- }
- return nil, err
- }
- defer f.Close()
-
- var recipients []*age.X25519Recipient
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- r, err := age.ParseX25519Recipient(line)
- if err != nil {
- return nil, fmt.Errorf("parse recipient %q: %w", line, err)
- }
- recipients = append(recipients, r)
- }
- return recipients, scanner.Err()
-}
-
-// saveRecipients writes the recipients file. If the list is empty,
-// the file is deleted.
-func saveRecipients(recipients []*age.X25519Recipient) error {
- path, err := recipientsPath()
- if err != nil {
- return err
- }
- if len(recipients) == 0 {
- if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
- return err
- }
- return nil
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return err
- }
- var buf bytes.Buffer
- for _, r := range recipients {
- fmt.Fprintln(&buf, r.String())
- }
- return os.WriteFile(path, buf.Bytes(), 0o600)
-}
-
-// allRecipients combines the identity's own recipient with any additional
-// recipients from the recipients file into a single []age.Recipient slice.
-// Returns nil if identity is nil and no recipients file exists.
-func allRecipients(identity *age.X25519Identity) ([]age.Recipient, error) {
- extra, err := loadRecipients()
- if err != nil {
- return nil, err
- }
- if identity == nil && len(extra) == 0 {
- return nil, nil
- }
- var recipients []age.Recipient
- if identity != nil {
- recipients = append(recipients, identity.Recipient())
- }
- for _, r := range extra {
- recipients = append(recipients, r)
- }
- return recipients, nil
-}
-
-// encrypt encrypts plaintext for the given recipients using age.
-func encrypt(plaintext []byte, recipients ...age.Recipient) ([]byte, error) {
- var buf bytes.Buffer
- w, err := age.Encrypt(&buf, recipients...)
- if err != nil {
- return nil, err
- }
- if _, err := w.Write(plaintext); err != nil {
- return nil, err
- }
- if err := w.Close(); err != nil {
- return nil, err
- }
- return buf.Bytes(), nil
-}
-
-// reencryptAllStores decrypts all secrets across all stores with the
-// given identity, then re-encrypts them for the new recipient list.
-// Returns the count of re-encrypted secrets.
-func reencryptAllStores(identity *age.X25519Identity, recipients []age.Recipient) (int, error) {
- store := &Store{}
- storeNames, err := store.AllStores()
- if err != nil {
- return 0, err
- }
-
- count := 0
- for _, name := range storeNames {
- p, err := store.storePath(name)
- if err != nil {
- return 0, err
- }
- entries, err := readStoreFile(p, identity)
- if err != nil {
- return 0, err
- }
- hasSecrets := false
- for _, e := range entries {
- if e.Secret {
- if e.Locked {
- return 0, fmt.Errorf("cannot re-encrypt: secret '%s@%s' is locked (identity cannot decrypt it)", e.Key, name)
- }
- hasSecrets = true
- }
- }
- if !hasSecrets {
- continue
- }
- if err := writeStoreFile(p, entries, recipients); err != nil {
- return 0, err
- }
- for _, e := range entries {
- if e.Secret {
- spec := KeySpec{Key: e.Key, DB: name}
- okf("re-encrypted %s", spec.Display())
- count++
- }
- }
- }
- return count, nil
-}
-
-// decrypt decrypts age ciphertext with the given identity.
-func decrypt(ciphertext []byte, identity *age.X25519Identity) ([]byte, error) {
- r, err := age.Decrypt(bytes.NewReader(ciphertext), identity)
- if err != nil {
- return nil, err
- }
- return io.ReadAll(r)
-}
diff --git a/cmd/secret_test.go b/cmd/secret_test.go
deleted file mode 100644
index fb209a1..0000000
--- a/cmd/secret_test.go
+++ /dev/null
@@ -1,463 +0,0 @@
-package cmd
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "filippo.io/age"
-)
-
-func TestEncryptDecryptRoundtrip(t *testing.T) {
- id, err := generateTestIdentity(t)
- if err != nil {
- t.Fatal(err)
- }
- recipient := id.Recipient()
-
- tests := []struct {
- name string
- plaintext []byte
- }{
- {"simple text", []byte("hello world")},
- {"empty", []byte("")},
- {"binary", []byte{0x00, 0xff, 0xfe, 0xfd}},
- {"large", make([]byte, 64*1024)},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- ciphertext, err := encrypt(tt.plaintext, recipient)
- if err != nil {
- t.Fatalf("encrypt: %v", err)
- }
- if len(ciphertext) == 0 && len(tt.plaintext) > 0 {
- t.Fatal("ciphertext is empty for non-empty plaintext")
- }
- got, err := decrypt(ciphertext, id)
- if err != nil {
- t.Fatalf("decrypt: %v", err)
- }
- if string(got) != string(tt.plaintext) {
- t.Errorf("roundtrip mismatch: got %q, want %q", got, tt.plaintext)
- }
- })
- }
-}
-
-func TestLoadIdentityMissing(t *testing.T) {
- t.Setenv("PDA_DATA", t.TempDir())
- id, err := loadIdentity()
- if err != nil {
- t.Fatal(err)
- }
- if id != nil {
- t.Fatal("expected nil identity for missing file")
- }
-}
-
-func TestEnsureIdentityCreatesFile(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- id, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
- if id == nil {
- t.Fatal("expected non-nil identity")
- }
-
- path := filepath.Join(dir, "identity.txt")
- info, err := os.Stat(path)
- if err != nil {
- t.Fatalf("identity file not created: %v", err)
- }
- if perm := info.Mode().Perm(); perm != 0o600 {
- t.Errorf("identity file permissions = %o, want 0600", perm)
- }
-
- // Second call should return same identity
- id2, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
- if id2.Recipient().String() != id.Recipient().String() {
- t.Error("second ensureIdentity returned different identity")
- }
-}
-
-func TestEnsureIdentityIdempotent(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- id1, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
- id2, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
- if id1.String() != id2.String() {
- t.Error("ensureIdentity is not idempotent")
- }
-}
-
-func TestSecretEntryRoundtrip(t *testing.T) {
- id, err := generateTestIdentity(t)
- if err != nil {
- t.Fatal(err)
- }
- recipients := []age.Recipient{id.Recipient()}
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "plain", Value: []byte("hello")},
- {Key: "encrypted", Value: []byte("secret-value"), Secret: true},
- }
-
- if err := writeStoreFile(path, entries, recipients); err != nil {
- t.Fatal(err)
- }
-
- // Read with identity — should decrypt
- got, err := readStoreFile(path, id)
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 2 {
- t.Fatalf("got %d entries, want 2", len(got))
- }
-
- plain := got[findEntry(got, "plain")]
- if string(plain.Value) != "hello" || plain.Secret || plain.Locked {
- t.Errorf("plain entry unexpected: %+v", plain)
- }
-
- secret := got[findEntry(got, "encrypted")]
- if string(secret.Value) != "secret-value" {
- t.Errorf("secret value = %q, want %q", secret.Value, "secret-value")
- }
- if !secret.Secret {
- t.Error("secret entry should have Secret=true")
- }
- if secret.Locked {
- t.Error("secret entry should not be locked when identity available")
- }
-}
-
-func TestSecretEntryLockedWithoutIdentity(t *testing.T) {
- id, err := generateTestIdentity(t)
- if err != nil {
- t.Fatal(err)
- }
- recipients := []age.Recipient{id.Recipient()}
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "encrypted", Value: []byte("secret-value"), Secret: true},
- }
- if err := writeStoreFile(path, entries, recipients); err != nil {
- t.Fatal(err)
- }
-
- // Read without identity — should be locked
- got, err := readStoreFile(path, nil)
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 {
- t.Fatalf("got %d entries, want 1", len(got))
- }
- if !got[0].Secret || !got[0].Locked {
- t.Errorf("expected Secret=true, Locked=true, got Secret=%v, Locked=%v", got[0].Secret, got[0].Locked)
- }
- if string(got[0].Value) == "secret-value" {
- t.Error("locked entry should not contain plaintext")
- }
-}
-
-func TestLockedPassthrough(t *testing.T) {
- id, err := generateTestIdentity(t)
- if err != nil {
- t.Fatal(err)
- }
- recipients := []age.Recipient{id.Recipient()}
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- // Write with encryption
- entries := []Entry{
- {Key: "encrypted", Value: []byte("secret-value"), Secret: true},
- }
- if err := writeStoreFile(path, entries, recipients); err != nil {
- t.Fatal(err)
- }
-
- // Read without identity (locked)
- locked, err := readStoreFile(path, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- // Write back without identity (passthrough)
- if err := writeStoreFile(path, locked, nil); err != nil {
- t.Fatal(err)
- }
-
- // Read with identity — should still decrypt
- got, err := readStoreFile(path, id)
- if err != nil {
- t.Fatal(err)
- }
- if len(got) != 1 {
- t.Fatalf("got %d entries, want 1", len(got))
- }
- if string(got[0].Value) != "secret-value" {
- t.Errorf("after passthrough: value = %q, want %q", got[0].Value, "secret-value")
- }
- if !got[0].Secret || got[0].Locked {
- t.Error("entry should be Secret=true, Locked=false after decryption")
- }
-}
-
-func TestMultiRecipientEncryptDecrypt(t *testing.T) {
- id1, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- id2, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
-
- recipients := []age.Recipient{id1.Recipient(), id2.Recipient()}
- plaintext := []byte("shared secret")
-
- ciphertext, err := encrypt(plaintext, recipients...)
- if err != nil {
- t.Fatalf("encrypt: %v", err)
- }
-
- // Both identities should be able to decrypt
- for i, id := range []*age.X25519Identity{id1, id2} {
- got, err := decrypt(ciphertext, id)
- if err != nil {
- t.Fatalf("identity %d decrypt: %v", i, err)
- }
- if string(got) != string(plaintext) {
- t.Errorf("identity %d: got %q, want %q", i, got, plaintext)
- }
- }
-}
-
-func TestMultiRecipientStoreRoundtrip(t *testing.T) {
- id1, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- id2, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
-
- recipients := []age.Recipient{id1.Recipient(), id2.Recipient()}
- dir := t.TempDir()
- path := filepath.Join(dir, "test.ndjson")
-
- entries := []Entry{
- {Key: "secret", Value: []byte("multi-recipient-value"), Secret: true},
- }
- if err := writeStoreFile(path, entries, recipients); err != nil {
- t.Fatal(err)
- }
-
- // Both identities should decrypt the store
- for i, id := range []*age.X25519Identity{id1, id2} {
- got, err := readStoreFile(path, id)
- if err != nil {
- t.Fatalf("identity %d read: %v", i, err)
- }
- if len(got) != 1 {
- t.Fatalf("identity %d: got %d entries, want 1", i, len(got))
- }
- if string(got[0].Value) != "multi-recipient-value" {
- t.Errorf("identity %d: value = %q, want %q", i, got[0].Value, "multi-recipient-value")
- }
- }
-}
-
-func TestLoadRecipientsMissing(t *testing.T) {
- t.Setenv("PDA_DATA", t.TempDir())
- recipients, err := loadRecipients()
- if err != nil {
- t.Fatal(err)
- }
- if recipients != nil {
- t.Fatal("expected nil recipients for missing file")
- }
-}
-
-func TestSaveLoadRecipientsRoundtrip(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- id1, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- id2, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
-
- toSave := []*age.X25519Recipient{id1.Recipient(), id2.Recipient()}
- if err := saveRecipients(toSave); err != nil {
- t.Fatal(err)
- }
-
- // Check file permissions
- path := filepath.Join(dir, "recipients.txt")
- info, err := os.Stat(path)
- if err != nil {
- t.Fatalf("recipients file not created: %v", err)
- }
- if perm := info.Mode().Perm(); perm != 0o600 {
- t.Errorf("recipients file permissions = %o, want 0600", perm)
- }
-
- loaded, err := loadRecipients()
- if err != nil {
- t.Fatal(err)
- }
- if len(loaded) != 2 {
- t.Fatalf("got %d recipients, want 2", len(loaded))
- }
- if loaded[0].String() != id1.Recipient().String() {
- t.Errorf("recipient 0 = %s, want %s", loaded[0], id1.Recipient())
- }
- if loaded[1].String() != id2.Recipient().String() {
- t.Errorf("recipient 1 = %s, want %s", loaded[1], id2.Recipient())
- }
-}
-
-func TestSaveRecipientsEmptyDeletesFile(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- // Create a recipients file first
- id, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- if err := saveRecipients([]*age.X25519Recipient{id.Recipient()}); err != nil {
- t.Fatal(err)
- }
-
- // Save empty list should delete the file
- if err := saveRecipients(nil); err != nil {
- t.Fatal(err)
- }
-
- path := filepath.Join(dir, "recipients.txt")
- if _, err := os.Stat(path); !os.IsNotExist(err) {
- t.Error("expected recipients file to be deleted")
- }
-}
-
-func TestAllRecipientsNoIdentityNoFile(t *testing.T) {
- t.Setenv("PDA_DATA", t.TempDir())
- recipients, err := allRecipients(nil)
- if err != nil {
- t.Fatal(err)
- }
- if recipients != nil {
- t.Fatal("expected nil recipients")
- }
-}
-
-func TestAllRecipientsCombines(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- id, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
-
- extra, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- if err := saveRecipients([]*age.X25519Recipient{extra.Recipient()}); err != nil {
- t.Fatal(err)
- }
-
- recipients, err := allRecipients(id)
- if err != nil {
- t.Fatal(err)
- }
- if len(recipients) != 2 {
- t.Fatalf("got %d recipients, want 2", len(recipients))
- }
-}
-
-func TestReencryptAllStores(t *testing.T) {
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
-
- id, err := ensureIdentity()
- if err != nil {
- t.Fatal(err)
- }
-
- // Write a store with a secret
- storePath := filepath.Join(dir, "test.ndjson")
- entries := []Entry{
- {Key: "plain", Value: []byte("hello")},
- {Key: "secret", Value: []byte("secret-value"), Secret: true},
- }
- if err := writeStoreFile(storePath, entries, []age.Recipient{id.Recipient()}); err != nil {
- t.Fatal(err)
- }
-
- // Generate a second identity and re-encrypt for both
- id2, err := age.GenerateX25519Identity()
- if err != nil {
- t.Fatal(err)
- }
- newRecipients := []age.Recipient{id.Recipient(), id2.Recipient()}
-
- count, err := reencryptAllStores(id, newRecipients)
- if err != nil {
- t.Fatal(err)
- }
- if count != 1 {
- t.Fatalf("re-encrypted %d secrets, want 1", count)
- }
-
- // Both identities should be able to decrypt
- for i, identity := range []*age.X25519Identity{id, id2} {
- got, err := readStoreFile(storePath, identity)
- if err != nil {
- t.Fatalf("identity %d read: %v", i, err)
- }
- idx := findEntry(got, "secret")
- if idx < 0 {
- t.Fatalf("identity %d: secret key not found", i)
- }
- if string(got[idx].Value) != "secret-value" {
- t.Errorf("identity %d: value = %q, want %q", i, got[idx].Value, "secret-value")
- }
- }
-}
-
-func generateTestIdentity(t *testing.T) (*age.X25519Identity, error) {
- t.Helper()
- dir := t.TempDir()
- t.Setenv("PDA_DATA", dir)
- return ensureIdentity()
-}
diff --git a/cmd/set.go b/cmd/set.go
index 9435c94..66064ae 100644
--- a/cmd/set.go
+++ b/cmd/set.go
@@ -25,23 +25,18 @@ package cmd
import (
"fmt"
"io"
- "os"
"strings"
- "time"
- "filippo.io/age"
+ "github.com/dgraph-io/badger/v4"
"github.com/spf13/cobra"
)
// setCmd represents the set command
var setCmd = &cobra.Command{
- Use: "set KEY[@STORE] [VALUE]",
- Short: "Set a key to a given value",
- Long: `Set a key to a given value or stdin. Optionally specify a store.
-
-Pass --encrypt to encrypt the value at rest using age. An identity file
-is generated automatically on first use.
-
+ Use: "set KEY[@DB] [VALUE]",
+ Short: "Set a value for a key by passing VALUE or Stdin. Optionally specify a db.",
+ Long: `Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+
PDA supports parsing Go templates. Actions are delimited with {{ }}.
For example:
@@ -50,10 +45,9 @@ For example:
'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
'Hello, {{ require .NAME }}' will error if NAME is blank.
'{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.`,
- Aliases: []string{"s"},
- Args: cobra.RangeArgs(1, 2),
- ValidArgsFunction: completeKeys,
- RunE: set,
+ Aliases: []string{"s"},
+ Args: cobra.RangeArgs(1, 2),
+ RunE: set,
SilenceUsage: true,
}
@@ -64,40 +58,17 @@ func set(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
- safe, err := cmd.Flags().GetBool("safe")
- if err != nil {
- return err
- }
promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite
- secret, err := cmd.Flags().GetBool("encrypt")
- if err != nil {
- return err
- }
- secret = secret || config.Key.AlwaysEncrypt
-
spec, err := store.parseKey(args[0], true)
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
- filePath, err := cmd.Flags().GetString("file")
- if err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
-
var value []byte
- switch {
- case filePath != "" && len(args) == 2:
- return fmt.Errorf("cannot set '%s': --file and VALUE argument are mutually exclusive", args[0])
- case filePath != "":
- value, err = os.ReadFile(filePath)
- if err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
- case len(args) == 2:
+ if len(args) == 2 {
value = []byte(args[1])
- default:
+ } else {
bytes, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
@@ -105,100 +76,58 @@ func set(cmd *cobra.Command, args []string) error {
value = bytes
}
+ secret, err := cmd.Flags().GetBool("secret")
+ if err != nil {
+ return fmt.Errorf("cannot set '%s': %v", args[0], err)
+ }
ttl, err := cmd.Flags().GetDuration("ttl")
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
- // Load or create identity depending on --encrypt flag
- var identity *age.X25519Identity
- if secret {
- identity, err = ensureIdentity()
+ if promptOverwrite {
+ exists, err := keyExists(store, spec.Full())
if err != nil {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
- } else {
- identity, _ = loadIdentity()
- }
- recipients, err := allRecipients(identity)
- if err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
-
- p, err := store.storePath(spec.DB)
- if err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
- entries, err := readStoreFile(p, identity)
- if err != nil {
- 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
- }
-
- // Warn if overwriting an encrypted key without --encrypt
- if idx >= 0 && entries[idx].Secret && !secret {
- warnf("overwriting encrypted key '%s' as plaintext", spec.Display())
- printHint("pass --encrypt to keep it encrypted")
- }
-
- if promptOverwrite && idx >= 0 {
- promptf("overwrite '%s'? (y/n)", spec.Display())
- var confirm string
- if err := scanln(&confirm); err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
- if strings.ToLower(confirm) != "y" {
- return nil
+ if exists {
+ fmt.Printf("overwrite '%s'? (y/n)\n", spec.Display())
+ var confirm string
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot set '%s': %v", args[0], err)
+ }
+ if strings.ToLower(confirm) != "y" {
+ return nil
+ }
}
}
- pinFlag, _ := cmd.Flags().GetBool("pin")
- readonlyFlag, _ := cmd.Flags().GetBool("readonly")
-
- entry := Entry{
- Key: spec.Key,
- Value: value,
- Secret: secret,
- ReadOnly: readonlyFlag,
- Pinned: pinFlag,
- }
- if ttl != 0 {
- entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix())
+ trans := TransactionArgs{
+ key: args[0],
+ readonly: false,
+ sync: false,
+ transact: func(tx *badger.Txn, k []byte) error {
+ entry := badger.NewEntry(k, value)
+ if secret {
+ entry = entry.WithMeta(metaSecret)
+ }
+ if ttl != 0 {
+ entry = entry.WithTTL(ttl)
+ }
+ return tx.SetEntry(entry)
+ },
}
- if idx >= 0 {
- entries[idx] = entry
- } else {
- entries = append(entries, entry)
+ if err := store.Transaction(trans); err != nil {
+ return err
}
- if err := writeStoreFile(p, entries, recipients); err != nil {
- return fmt.Errorf("cannot set '%s': %v", args[0], err)
- }
-
- return autoSync("set " + spec.Display())
+ return autoSync()
}
func init() {
rootCmd.AddCommand(setCmd)
- setCmd.Flags().DurationP("ttl", "t", 0, "expire the key after the provided duration (e.g. 24h, 30m)")
- 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")
+ setCmd.Flags().Bool("secret", false, "Mark the stored value as a secret")
+ setCmd.Flags().DurationP("ttl", "t", 0, "Expire the key after the provided duration (e.g. 24h, 30m)")
+ setCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting an existing key")
}
diff --git a/cmd/shared.go b/cmd/shared.go
index 805261e..67760ca 100644
--- a/cmd/shared.go
+++ b/cmd/shared.go
@@ -23,10 +23,8 @@ THE SOFTWARE.
package cmd
import (
- "encoding/base64"
"fmt"
"io"
- "net/http"
"os"
"path/filepath"
"strings"
@@ -34,21 +32,68 @@ import (
"unicode/utf8"
"github.com/agnivade/levenshtein"
+ "github.com/dgraph-io/badger/v4"
gap "github.com/muesli/go-app-paths"
"golang.org/x/term"
)
type errNotFound struct {
- what string // "key" or "store"
suggestions []string
}
+const (
+ metaSecret byte = 0x1
+)
+
func (err errNotFound) Error() string {
- return fmt.Sprintf("no such %s", err.what)
+ if len(err.suggestions) == 0 {
+ return "No such key"
+ }
+ return fmt.Sprintf("No such key. Did you mean '%s'?", strings.Join(err.suggestions, ", "))
}
type Store struct{}
+type TransactionArgs struct {
+ key string
+ readonly bool
+ sync bool
+ transact func(tx *badger.Txn, key []byte) error
+}
+
+func (s *Store) Transaction(args TransactionArgs) error {
+ spec, err := s.parseKey(args.key, true)
+ if err != nil {
+ return err
+ }
+
+ db, err := s.open(spec.DB)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ if args.sync {
+ err = db.Sync()
+ if err != nil {
+ return err
+ }
+ }
+
+ tx := db.NewTransaction(!args.readonly)
+ defer tx.Discard()
+
+ if err := args.transact(tx, []byte(spec.Key)); err != nil {
+ return err
+ }
+
+ if args.readonly {
+ return nil
+ }
+
+ return tx.Commit()
+}
+
func (s *Store) Print(pf string, includeBinary bool, vs ...[]byte) {
s.PrintTo(os.Stdout, pf, includeBinary, vs...)
}
@@ -69,72 +114,28 @@ func (s *Store) FormatBytes(includeBinary bool, v []byte) string {
return s.formatBytes(includeBinary, v)
}
-func (s *Store) formatBytes(base64Flag bool, v []byte) string {
- if !utf8.Valid(v) {
- tty := term.IsTerminal(int(os.Stdout.Fd()))
- if !tty {
- return string(v)
- }
- if base64Flag {
- return base64.StdEncoding.EncodeToString(v)
- }
- mime := http.DetectContentType(v)
- return fmt.Sprintf("(binary: %s, %s)", formatSize(len(v)), mime)
+func (s *Store) formatBytes(includeBinary bool, v []byte) string {
+ tty := term.IsTerminal(int(os.Stdout.Fd()))
+ if tty && !includeBinary && !utf8.Valid(v) {
+ return "(omitted binary data)"
}
return string(v)
}
-func formatSize(n int) string {
- const (
- ki = 1024
- mi = 1024 * ki
- gi = 1024 * mi
- )
- switch {
- case n < ki:
- return fmt.Sprintf("%d", n)
- case n < mi:
- return fmt.Sprintf("%.1fk", float64(n)/float64(ki))
- case n < gi:
- return fmt.Sprintf("%.1fM", float64(n)/float64(mi))
- default:
- return fmt.Sprintf("%.1fG", float64(n)/float64(gi))
- }
-}
-
-func (s *Store) storePath(name string) (string, error) {
- if name == "" {
- name = config.Store.DefaultStoreName
- }
- dir, err := s.path()
- if err != nil {
- return "", err
- }
- target := filepath.Join(dir, name+".ndjson")
- if err := ensureSubpath(dir, target); err != nil {
- return "", err
- }
- return target, nil
-}
-
func (s *Store) AllStores() ([]string, error) {
- dir, err := s.path()
+ path, err := s.path()
if err != nil {
return nil, err
}
- entries, err := os.ReadDir(dir)
+ dirs, err := os.ReadDir(path)
if err != nil {
- if os.IsNotExist(err) {
- return nil, nil
- }
return nil, err
}
var stores []string
- for _, e := range entries {
- if e.IsDir() || filepath.Ext(e.Name()) != ".ndjson" {
- continue
+ for _, e := range dirs {
+ if e.IsDir() {
+ stores = append(stores, e.Name())
}
- stores = append(stores, strings.TrimSuffix(e.Name(), ".ndjson"))
}
return stores, nil
}
@@ -144,22 +145,22 @@ func (s *Store) FindStore(k string) (string, error) {
if err != nil {
return "", err
}
- p, err := s.storePath(n)
+ path, err := s.path(n)
if err != nil {
return "", err
}
- _, statErr := os.Stat(p)
- if strings.TrimSpace(n) == "" || os.IsNotExist(statErr) {
+ info, statErr := os.Stat(path)
+ if strings.TrimSpace(n) == "" || os.IsNotExist(statErr) || (statErr == nil && !info.IsDir()) {
suggestions, err := s.suggestStores(n)
if err != nil {
return "", err
}
- return "", errNotFound{what: "store", suggestions: suggestions}
+ return "", errNotFound{suggestions}
}
if statErr != nil {
return "", statErr
}
- return p, nil
+ return path, nil
}
func (s *Store) parseKey(raw string, defaults bool) (KeySpec, error) {
@@ -175,22 +176,37 @@ func (s *Store) parseDB(v string, defaults bool) (string, error) {
if defaults {
return config.Store.DefaultStoreName, nil
}
- return "", fmt.Errorf("cannot parse store: bad store format, use STORE or @STORE")
+ return "", fmt.Errorf("cannot parse db: bad db format, use DB or @DB")
}
if err := validateDBName(db); err != nil {
- return "", fmt.Errorf("cannot parse store: %w", err)
+ return "", fmt.Errorf("cannot parse db: %w", err)
}
return strings.ToLower(db), nil
}
-func (s *Store) path() (string, error) {
+func (s *Store) open(name string) (*badger.DB, error) {
+ if name == "" {
+ name = config.Store.DefaultStoreName
+ }
+ path, err := s.path(name)
+ if err != nil {
+ return nil, err
+ }
+ return badger.Open(badger.DefaultOptions(path).WithLoggingLevel(badger.ERROR))
+}
+
+func (s *Store) path(args ...string) (string, error) {
if override := os.Getenv("PDA_DATA"); override != "" {
if err := os.MkdirAll(override, 0o750); err != nil {
return "", err
}
- return override, nil
+ target := filepath.Join(append([]string{override}, args...)...)
+ if err := ensureSubpath(override, target); err != nil {
+ return "", err
+ }
+ return target, nil
}
- scope := gap.NewScope(gap.User, "pda")
+ scope := gap.NewVendorScope(gap.User, "pda", "stores")
dir, err := scope.DataPath("")
if err != nil {
return "", err
@@ -198,7 +214,11 @@ func (s *Store) path() (string, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
- return dir, nil
+ target := filepath.Join(append([]string{dir}, args...)...)
+ if err := ensureSubpath(dir, target); err != nil {
+ return "", err
+ }
+ return target, nil
}
func (s *Store) suggestStores(target string) ([]string, error) {
@@ -220,19 +240,6 @@ func (s *Store) suggestStores(target string) ([]string, error) {
return suggestions, nil
}
-func suggestKey(target string, keys []string) error {
- minThreshold := 1
- maxThreshold := 4
- threshold := min(max(len(target)/3, minThreshold), maxThreshold)
- var suggestions []string
- for _, k := range keys {
- if levenshtein.ComputeDistance(target, k) <= threshold {
- suggestions = append(suggestions, k)
- }
- }
- return errNotFound{what: "key", suggestions: suggestions}
-}
-
func ensureSubpath(base, target string) error {
absBase, err := filepath.Abs(base)
if err != nil {
@@ -255,54 +262,42 @@ func ensureSubpath(base, target string) error {
func validateDBName(name string) error {
if strings.ContainsAny(name, `/\~`) {
- return fmt.Errorf("bad store format, use STORE or @STORE")
+ return fmt.Errorf("bad db format, use DB or @DB")
}
return nil
}
func formatExpiry(expiresAt uint64) string {
if expiresAt == 0 {
- return "-"
+ return "never"
}
expiry := time.Unix(int64(expiresAt), 0).UTC()
remaining := time.Until(expiry)
if remaining <= 0 {
- return "expired"
+ return fmt.Sprintf("%s (expired)", expiry.Format(time.RFC3339))
}
- return remaining.Round(time.Second).String()
+ return fmt.Sprintf("%s (in %s)", expiry.Format(time.RFC3339), remaining.Round(time.Second))
}
-// parseTTLString parses a TTL string that is either a duration (e.g. "30m", "2h")
-// or the special value "never" to clear expiry. Returns the new ExpiresAt value
-// (0 means no expiry).
-func parseTTLString(s string) (uint64, error) {
- if strings.ToLower(s) == "never" {
- return 0, nil
- }
- d, err := time.ParseDuration(s)
- if err != nil {
- return 0, fmt.Errorf("invalid ttl '%s': expected a duration (e.g. 30m, 2h) or 'never'", s)
- }
- if d <= 0 {
- return 0, fmt.Errorf("invalid ttl '%s': duration must be positive", s)
- }
- return uint64(time.Now().Add(d).Unix()), nil
-}
-
-// Keys returns all keys for the provided store name (or default if empty).
+// Keys returns all keys for the provided database name (or default if empty).
// Keys are returned in lowercase to mirror stored key format.
func (s *Store) Keys(dbName string) ([]string, error) {
- p, err := s.storePath(dbName)
+ db, err := s.open(dbName)
if err != nil {
return nil, err
}
- entries, err := readStoreFile(p, nil)
- if err != nil {
- return nil, err
- }
- keys := make([]string, len(entries))
- for i, e := range entries {
- keys[i] = e.Key
+ defer db.Close()
+
+ tx := db.NewTransaction(false)
+ defer tx.Discard()
+
+ it := tx.NewIterator(badger.DefaultIteratorOptions)
+ defer it.Close()
+
+ var keys []string
+ for it.Rewind(); it.Valid(); it.Next() {
+ item := it.Item()
+ keys = append(keys, string(item.Key()))
}
return keys, nil
}
diff --git a/cmd/sync.go b/cmd/sync.go
deleted file mode 100644
index 937e2be..0000000
--- a/cmd/sync.go
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "github.com/spf13/cobra"
-)
-
-var syncCmd = &cobra.Command{
- Use: "sync",
- Short: "Manually sync your stores with Git",
- SilenceUsage: true,
- RunE: func(cmd *cobra.Command, args []string) error {
- msg, _ := cmd.Flags().GetString("message")
- return sync(true, msg, "sync")
- },
-}
-
-func init() {
- syncCmd.Flags().StringP("message", "m", "", "custom commit message (defaults to timestamp)")
- rootCmd.AddCommand(syncCmd)
-}
-
-func sync(manual bool, customMsg string, summary string) error {
- repoDir, err := ensureVCSInitialized()
- if err != nil {
- return err
- }
-
- remoteInfo, err := repoRemoteInfo(repoDir)
- if err != nil {
- return err
- }
-
- // Commit local changes first so nothing is lost.
- if err := runGit(repoDir, "add", "-A"); err != nil {
- return err
- }
- changed, err := repoHasStagedChanges(repoDir)
- if err != nil {
- return err
- }
- if changed {
- msg := customMsg
- if msg == "" {
- msg = renderCommitMessage(config.Git.DefaultCommitMessage, summary)
- if manual {
- printHint("use -m to set a custom commit message")
- }
- }
- if err := runGit(repoDir, "commit", "-m", msg); err != nil {
- return err
- }
- } else if manual {
- okf("no changes to commit")
- }
-
- if remoteInfo.Ref == "" {
- if manual {
- warnf("no remote configured, skipping push")
- }
- return nil
- }
-
- // Fetch remote state.
- if manual || config.Git.AutoFetch {
- if err := runGit(repoDir, "fetch", "--prune"); err != nil {
- return err
- }
- }
-
- // Rebase local commits onto remote if behind.
- ahead, behind, err := repoAheadBehind(repoDir, remoteInfo.Ref)
- if err != nil {
- // Remote ref doesn't exist yet (first push).
- ahead = 1
- } else if behind > 0 {
- if err := pullRemote(repoDir, remoteInfo); err != nil {
- return err
- }
- ahead, _, err = repoAheadBehind(repoDir, remoteInfo.Ref)
- if err != nil {
- return err
- }
- }
-
- // Push if ahead.
- if manual || config.Git.AutoPush {
- if ahead > 0 {
- return pushRemote(repoDir, remoteInfo)
- }
- if manual {
- okf("nothing to push")
- }
- }
-
- if manual {
- okf("in sync!")
- }
- return nil
-}
-
-func autoSync(summary string) error {
- if !config.Git.AutoCommit {
- return nil
- }
- if _, err := ensureVCSInitialized(); err != nil {
- return nil
- }
- return sync(false, "", summary)
-}
diff --git a/cmd/template.go b/cmd/template.go
deleted file mode 100644
index d8cef0d..0000000
--- a/cmd/template.go
+++ /dev/null
@@ -1,157 +0,0 @@
-/*
-Copyright © 2025 Lewis Wynne
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-*/
-
-package cmd
-
-import (
- "fmt"
- "os"
- "os/exec"
- "slices"
- "strconv"
- "strings"
- "text/template"
- "time"
-)
-
-// templateFuncMap returns the shared FuncMap used by both value templates
-// (pda get) and commit message templates.
-func templateFuncMap() template.FuncMap {
- return template.FuncMap{
- "require": func(v any) (string, error) {
- s := fmt.Sprint(v)
- if s == "" {
- return "", fmt.Errorf("required value is missing or empty")
- }
- return s, nil
- },
- "default": func(def string, v any) string {
- s := fmt.Sprint(v)
- if s == "" {
- return def
- }
- return s
- },
- "env": os.Getenv,
- "enum": func(v any, allowed ...string) (string, error) {
- s := fmt.Sprint(v)
- if s == "" {
- return "", fmt.Errorf("enum value is missing or empty")
- }
- if slices.Contains(allowed, s) {
- return s, nil
- }
- return "", fmt.Errorf("invalid value '%s', allowed: %v", s, allowed)
- },
- "int": func(v any) (int, error) {
- s := fmt.Sprint(v)
- i, err := strconv.Atoi(s)
- if err != nil {
- return 0, fmt.Errorf("cannot convert to int: %w", err)
- }
- return i, nil
- },
- "list": func(v any) []string {
- s := fmt.Sprint(v)
- if s == "" {
- return nil
- }
- parts := strings.Split(s, ",")
- for i := range parts {
- parts[i] = strings.TrimSpace(parts[i])
- }
- return parts
- },
- "time": func() string { return time.Now().UTC().Format(time.RFC3339) },
- "shell": func(command string) (string, error) {
- sh := os.Getenv("SHELL")
- if sh == "" {
- sh = "/bin/sh"
- }
- out, err := exec.Command(sh, "-c", command).Output()
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
- return "", fmt.Errorf("shell %q: %s", command, strings.TrimSpace(string(exitErr.Stderr)))
- }
- return "", fmt.Errorf("shell %q: %w", command, err)
- }
- return strings.TrimRight(string(out), "\n"), nil
- },
- "pda": func(key string) (string, error) {
- return pdaGet(key, nil)
- },
- }
-}
-
-// cleanTemplateError strips Go template engine internals from function call
-// errors, returning just the inner error message. Template execution errors
-// look like: "template: cmd:1:3: executing "cmd" at : error calling func: "
-// We extract just for cleaner user-facing output.
-func cleanTemplateError(err error) error {
- msg := err.Error()
- const marker = "error calling "
- if i := strings.Index(msg, marker); i >= 0 {
- rest := msg[i+len(marker):]
- if j := strings.Index(rest, ": "); j >= 0 {
- return fmt.Errorf("%s", rest[j+2:])
- }
- }
- return err
-}
-
-const maxTemplateDepth = 16
-
-func templateDepth() int {
- s := os.Getenv("PDA_TEMPLATE_DEPTH")
- if s == "" {
- return 0
- }
- n, _ := strconv.Atoi(s)
- return n
-}
-
-func pdaGet(key string, substitutions []string) (string, error) {
- depth := templateDepth()
- if depth >= maxTemplateDepth {
- return "", fmt.Errorf("pda: max template depth (%d) exceeded", maxTemplateDepth)
- }
- exe, err := os.Executable()
- if err != nil {
- return "", fmt.Errorf("pda: %w", err)
- }
- args := append([]string{"get", key}, substitutions...)
- cmd := exec.Command(exe, args...)
- cmd.Env = append(os.Environ(), fmt.Sprintf("PDA_TEMPLATE_DEPTH=%d", depth+1))
- out, err := cmd.Output()
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
- msg := strings.TrimSpace(string(exitErr.Stderr))
- msg = strings.TrimPrefix(msg, "FAIL ")
- if strings.Contains(msg, "max template depth") {
- return "", fmt.Errorf("pda: max template depth (%d) exceeded (possible circular reference involving %q)", maxTemplateDepth, key)
- }
- return "", fmt.Errorf("pda: %s", msg)
- }
- return "", fmt.Errorf("pda: %w", err)
- }
- return strings.TrimRight(string(out), "\n"), nil
-}
diff --git a/cmd/vcs.go b/cmd/vcs.go
index 3e0c22f..dc7c291 100644
--- a/cmd/vcs.go
+++ b/cmd/vcs.go
@@ -1,6 +1,9 @@
package cmd
import (
+ "bufio"
+ "bytes"
+ "encoding/json"
"fmt"
"io"
"os"
@@ -8,16 +11,221 @@ import (
"path/filepath"
"strconv"
"strings"
+ "time"
+
+ "github.com/dgraph-io/badger/v4"
+ gap "github.com/muesli/go-app-paths"
+ "github.com/spf13/cobra"
)
+var vcsCmd = &cobra.Command{
+ Use: "vcs",
+ Short: "Version control utilities",
+}
+
+var vcsInitCmd = &cobra.Command{
+ Use: "init [remote-url]",
+ Short: "Initialise or fetch a Git repo for version control",
+ SilenceUsage: true,
+ Args: cobra.MaximumNArgs(1),
+ RunE: vcsInit,
+}
+
+var vcsSyncCmd = &cobra.Command{
+ Use: "sync",
+ Short: "export, commit, pull, restore, and push changes",
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return sync(true)
+ },
+}
+
+func sync(manual bool) error {
+ store := &Store{}
+ repoDir, err := ensureVCSInitialized()
+ if err != nil {
+ return err
+ }
+
+ remoteInfo, err := repoRemoteInfo(repoDir)
+ if err != nil {
+ return err
+ }
+
+ var ahead int
+ if remoteInfo.Ref != "" {
+ if manual || config.Git.AutoFetch {
+ if err := runGit(repoDir, "fetch", "--prune"); err != nil {
+ return err
+ }
+ }
+ remoteAhead, behind, err := repoAheadBehind(repoDir, remoteInfo.Ref)
+ if err != nil {
+ return err
+ }
+ ahead = remoteAhead
+ if behind > 0 {
+ if ahead > 0 {
+ return fmt.Errorf("repo diverged from remote (ahead %d, behind %d); resolve manually", ahead, behind)
+ }
+ fmt.Printf("remote has %d commit(s) not present locally; discard local changes and pull? (y/n)\n", behind)
+ var confirm string
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot continue sync: %w", err)
+ }
+ if strings.ToLower(confirm) != "y" {
+ return fmt.Errorf("aborted sync")
+ }
+ dirty, err := repoHasChanges(repoDir)
+ if err != nil {
+ return err
+ }
+ if dirty {
+ stashMsg := fmt.Sprintf("pda sync: %s", time.Now().UTC().Format(time.RFC3339))
+ if err := runGit(repoDir, "stash", "push", "-u", "-m", stashMsg); err != nil {
+ return err
+ }
+ }
+ if err := pullRemote(repoDir, remoteInfo); err != nil {
+ return err
+ }
+ return restoreAllSnapshots(store, repoDir)
+ }
+ }
+
+ if err := exportAllStores(store, repoDir); err != nil {
+ return err
+ }
+ if err := runGit(repoDir, "add", storeDirName); err != nil {
+ return err
+ }
+ changed, err := repoHasStagedChanges(repoDir)
+ if err != nil {
+ return err
+ }
+ madeCommit := false
+ if !changed {
+ fmt.Println("no changes to commit")
+ } else {
+ msg := fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339))
+ if err := runGit(repoDir, "commit", "-m", msg); err != nil {
+ return err
+ }
+ madeCommit = true
+ }
+ if manual || config.Git.AutoPush {
+ if remoteInfo.Ref != "" && (madeCommit || ahead > 0) {
+ return pushRemote(repoDir, remoteInfo)
+ }
+ fmt.Println("no remote configured; skipping push")
+ }
+ return nil
+}
+
+const storeDirName = "stores"
+
+func init() {
+ vcsInitCmd.Flags().Bool("clean", false, "Remove existing VCS directory before initialising")
+ vcsCmd.AddCommand(vcsInitCmd)
+ vcsCmd.AddCommand(vcsSyncCmd)
+ rootCmd.AddCommand(vcsCmd)
+}
+
+func vcsInit(cmd *cobra.Command, args []string) error {
+ repoDir, err := vcsRepoRoot()
+ if err != nil {
+ return err
+ }
+ store := &Store{}
+
+ clean, err := cmd.Flags().GetBool("clean")
+ if err != nil {
+ return err
+ }
+ if clean {
+ entries, err := os.ReadDir(repoDir)
+ if err == nil && len(entries) > 0 {
+ fmt.Printf("remove existing VCS directory '%s'? (y/n)\n", repoDir)
+ var confirm string
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot clean vcs dir: %w", err)
+ }
+ if strings.ToLower(confirm) != "y" {
+ return fmt.Errorf("aborted cleaning vcs dir")
+ }
+ }
+ if err := os.RemoveAll(repoDir); err != nil {
+ return fmt.Errorf("cannot clean vcs dir: %w", err)
+ }
+
+ dbs, err := store.AllStores()
+ if err == nil && len(dbs) > 0 {
+ fmt.Printf("remove all existing stores? (y/n)\n")
+ var confirm string
+ if _, err := fmt.Scanln(&confirm); err != nil {
+ return fmt.Errorf("cannot clean stores: %w", err)
+ }
+ if strings.ToLower(confirm) != "y" {
+ return fmt.Errorf("aborted cleaning stores")
+ }
+ if err := wipeAllStores(store); err != nil {
+ return fmt.Errorf("cannot clean stores: %w", err)
+ }
+ }
+ }
+ if err := os.MkdirAll(filepath.Join(repoDir), 0o750); err != nil {
+ return err
+ }
+
+ gitDir := filepath.Join(repoDir, ".git")
+ if _, err := os.Stat(gitDir); os.IsNotExist(err) {
+ if len(args) == 1 {
+ remote := args[0]
+ fmt.Printf("running: git clone %s %s\n", remote, repoDir)
+ if err := runGit("", "clone", remote, repoDir); err != nil {
+ return err
+ }
+ } else {
+ fmt.Printf("running: git init\n")
+ if err := runGit(repoDir, "init"); err != nil {
+ return err
+ }
+ }
+ } else {
+ fmt.Println("vcs already initialised; use --clean to reinitialise")
+ return nil
+ }
+
+ if err := writeGitignore(repoDir); err != nil {
+ return err
+ }
+
+ if len(args) == 0 {
+ return nil
+ }
+ return restoreAllSnapshots(store, repoDir)
+}
+
+func vcsRepoRoot() (string, error) {
+ scope := gap.NewVendorScope(gap.User, "pda", "vcs")
+ dir, err := scope.DataPath("")
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return "", err
+ }
+ return dir, nil
+}
+
func ensureVCSInitialized() (string, error) {
- repoDir, err := (&Store{}).path()
+ repoDir, err := vcsRepoRoot()
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil {
if os.IsNotExist(err) {
- return "", withHint(fmt.Errorf("vcs not initialised"), "run 'pda init' first")
+ return "", fmt.Errorf("vcs repository not initialised; run 'pda vcs init' first")
}
return "", err
}
@@ -30,8 +238,10 @@ func writeGitignore(repoDir string) error {
content := strings.Join([]string{
"# generated by pda",
"*",
+ "!/",
"!.gitignore",
- "!*.ndjson",
+ "!" + storeDirName + "/",
+ "!" + storeDirName + "/*",
"",
}, "\n")
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
@@ -41,9 +251,73 @@ func writeGitignore(repoDir string) error {
if err := runGit(repoDir, "add", ".gitignore"); err != nil {
return err
}
- return runGit(repoDir, "commit", "-m", "generated gitignore")
+ return runGit(repoDir, "commit", "--allow-empty", "-m", "generated gitignore")
}
- okf("existing .gitignore found")
+ fmt.Println("Existing .gitignore found.")
+ return nil
+}
+
+func snapshotDB(store *Store, repoDir, db string) error {
+ targetDir := filepath.Join(repoDir, storeDirName)
+ if err := os.MkdirAll(targetDir, 0o750); err != nil {
+ return err
+ }
+ target := filepath.Join(targetDir, fmt.Sprintf("%s.ndjson", db))
+ f, err := os.Create(target)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ opts := DumpOptions{
+ Encoding: "auto",
+ IncludeSecret: false,
+ }
+ if err := dumpDatabase(store, db, f, opts); err != nil {
+ return err
+ }
+
+ return f.Sync()
+}
+
+// exportAllStores writes every Badger store to ndjson files under repoDir/stores
+// and removes stale snapshot files for deleted databases.
+func exportAllStores(store *Store, repoDir string) error {
+ stores, err := store.AllStores()
+ if err != nil {
+ return err
+ }
+
+ targetDir := filepath.Join(repoDir, storeDirName)
+ if err := os.MkdirAll(targetDir, 0o750); err != nil {
+ return err
+ }
+
+ current := make(map[string]struct{})
+ for _, db := range stores {
+ current[db] = struct{}{}
+ if err := snapshotDB(store, repoDir, db); err != nil {
+ return fmt.Errorf("snapshot %q: %w", db, err)
+ }
+ }
+
+ entries, err := os.ReadDir(targetDir)
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() || filepath.Ext(e.Name()) != ".ndjson" {
+ continue
+ }
+ dbName := strings.TrimSuffix(e.Name(), ".ndjson")
+ if _, ok := current[dbName]; ok {
+ continue
+ }
+ if err := os.Remove(filepath.Join(targetDir, e.Name())); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+
return nil
}
@@ -113,6 +387,16 @@ func repoAheadBehind(dir, ref string) (int, int, error) {
return ahead, behind, nil
}
+func repoHasChanges(dir string) (bool, error) {
+ cmd := exec.Command("git", "status", "--porcelain")
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ return false, err
+ }
+ return len(bytes.TrimSpace(out)) > 0, nil
+}
+
func repoHasStagedChanges(dir string) (bool, error) {
cmd := exec.Command("git", "diff", "--cached", "--quiet")
cmd.Dir = dir
@@ -128,16 +412,26 @@ func repoHasStagedChanges(dir string) (bool, error) {
func pullRemote(dir string, info gitRemoteInfo) error {
if info.HasUpstream {
- return runGit(dir, "pull", "--rebase")
+ return runGit(dir, "pull", "--ff-only")
}
- return runGit(dir, "pull", "--rebase", info.Remote, info.Branch)
+ if info.Remote != "" && info.Branch != "" {
+ fmt.Printf("running: git pull --ff-only %s %s\n", info.Remote, info.Branch)
+ return runGit(dir, "pull", "--ff-only", info.Remote, info.Branch)
+ }
+ fmt.Println("no remote configured; skipping pull")
+ return nil
}
func pushRemote(dir string, info gitRemoteInfo) error {
if info.HasUpstream {
return runGit(dir, "push")
}
- return runGit(dir, "push", "-u", info.Remote, info.Branch)
+ if info.Remote != "" && info.Branch != "" {
+ fmt.Printf("running: git push -u %s %s\n", info.Remote, info.Branch)
+ return runGit(dir, "push", "-u", info.Remote, info.Branch)
+ }
+ fmt.Println("no remote configured; skipping push")
+ return nil
}
func repoHasUpstream(dir string) (bool, error) {
@@ -184,19 +478,150 @@ func currentBranch(dir string) (string, error) {
return branch, nil
}
+func restoreAllSnapshots(store *Store, repoDir string) error {
+ targetDir := filepath.Join(repoDir, storeDirName)
+ entries, err := os.ReadDir(targetDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ fmt.Printf("no existing stores found, not restoring")
+ return nil
+ }
+ return err
+ }
+ snapshotDBs := make(map[string]struct{})
+
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if filepath.Ext(e.Name()) != ".ndjson" {
+ continue
+ }
+ dbName := strings.TrimSuffix(e.Name(), ".ndjson")
+ snapshotDBs[dbName] = struct{}{}
+
+ dbPath, err := store.FindStore(dbName)
+ if err == nil {
+ _ = os.RemoveAll(dbPath)
+ }
+
+ if err := restoreSnapshot(store, filepath.Join(targetDir, e.Name()), dbName); err != nil {
+ return fmt.Errorf("restore %q: %w", dbName, err)
+ }
+ }
+
+ localDBs, err := store.AllStores()
+ if err != nil {
+ return err
+ }
+ for _, db := range localDBs {
+ if _, ok := snapshotDBs[db]; ok {
+ continue
+ }
+ dbPath, err := store.FindStore(db)
+ if err != nil {
+ return err
+ }
+ if err := os.RemoveAll(dbPath); err != nil {
+ return fmt.Errorf("remove db '%s': %w", db, err)
+ }
+ }
+
+ return nil
+}
+
func wipeAllStores(store *Store) error {
dbs, err := store.AllStores()
if err != nil {
return err
}
for _, db := range dbs {
- p, err := store.storePath(db)
+ path, err := store.FindStore(db)
if err != nil {
return err
}
- if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("cannot remove store '%s': %w", db, err)
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("remove db '%s': %w", db, err)
}
}
return nil
}
+
+func restoreSnapshot(store *Store, path string, dbName string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ db, err := store.open(dbName)
+ if err != nil {
+ return err
+ }
+ defer db.Close()
+
+ decoder := json.NewDecoder(bufio.NewReader(f))
+ wb := db.NewWriteBatch()
+ defer wb.Cancel()
+
+ entryNo := 0
+ for {
+ var entry dumpEntry
+ if err := decoder.Decode(&entry); err != nil {
+ if err == io.EOF {
+ break
+ }
+ return fmt.Errorf("entry %d: %w", entryNo+1, err)
+ }
+ entryNo++
+ if entry.Key == "" {
+ return fmt.Errorf("entry %d: missing key", entryNo)
+ }
+
+ value, err := decodeEntryValue(entry)
+ if err != nil {
+ return fmt.Errorf("entry %d: %w", entryNo, err)
+ }
+
+ entryMeta := byte(0x0)
+ if entry.Secret {
+ entryMeta = metaSecret
+ }
+
+ writeEntry := badger.NewEntry([]byte(entry.Key), value).WithMeta(entryMeta)
+ if entry.ExpiresAt != nil {
+ if *entry.ExpiresAt < 0 {
+ return fmt.Errorf("entry %d: expires_at must be >= 0", entryNo)
+ }
+ writeEntry.ExpiresAt = uint64(*entry.ExpiresAt)
+ }
+
+ if err := wb.SetEntry(writeEntry); err != nil {
+ return fmt.Errorf("entry %d: %w", entryNo, err)
+ }
+ }
+
+ if err := wb.Flush(); err != nil {
+ return err
+ }
+ return nil
+}
+
+// hasMergeConflicts returns true if there are files with unresolved merge
+// conflicts in the working tree.
+func hasMergeConflicts(dir string) (bool, error) {
+ cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=U")
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ return false, err
+ }
+ return len(bytes.TrimSpace(out)) > 0, nil
+}
+
+func autoSync() error {
+ if !config.Git.AutoCommit {
+ return nil
+ }
+ return sync(false)
+}
diff --git a/cmd/version.go b/cmd/version.go
index 5e27c90..a43a5f3 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -28,7 +28,7 @@ import (
)
var (
- version = "pda! 2026.14"
+ version = "pda! 2025.51 release"
)
// versionCmd represents the version command
@@ -36,8 +36,7 @@ var versionCmd = &cobra.Command{
Use: "version",
Short: "Display pda! version",
Run: func(cmd *cobra.Command, args []string) {
- short, _ := cmd.Flags().GetBool("short")
- if !short && config.DisplayAsciiArt {
+ if config.DisplayAsciiArt {
fmt.Print(asciiArt + "\n ")
}
fmt.Printf("%s\n", version)
@@ -45,6 +44,5 @@ var versionCmd = &cobra.Command{
}
func init() {
- versionCmd.Flags().Bool("short", false, "print only the version string")
rootCmd.AddCommand(versionCmd)
}
diff --git a/go.mod b/go.mod
index d2043ff..68d61c1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,27 +3,38 @@ module github.com/llywelwyn/pda
go 1.25.3
require (
- filippo.io/age v1.3.1
- github.com/BurntSushi/toml v1.6.0
github.com/agnivade/levenshtein v1.2.1
+ github.com/dgraph-io/badger/v4 v4.8.0
github.com/gobwas/glob v0.2.3
github.com/google/go-cmdtest v0.4.0
github.com/jedib0t/go-pretty/v6 v6.7.0
github.com/muesli/go-app-paths v0.2.2
github.com/spf13/cobra v1.10.1
- golang.org/x/term v0.37.0
+ golang.org/x/term v0.36.0
)
require (
- filippo.io/hpke v0.4.0 // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/renameio v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
- golang.org/x/crypto v0.45.0 // indirect
- golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.31.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/otel v1.37.0 // indirect
+ go.opentelemetry.io/otel/metric v1.37.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ golang.org/x/net v0.41.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/text v0.26.0 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
)
diff --git a/go.sum b/go.sum
index d6e8549..8b34406 100644
--- a/go.sum
+++ b/go.sum
@@ -1,22 +1,33 @@
-c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
-c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
-filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
-filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
-filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
-filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
+github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
+github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
+github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
+github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmdtest v0.4.0 h1:ToXh6W5spLp3npJV92tk6d5hIpUPYEzHLkD+rncbyhI=
github.com/google/go-cmdtest v0.4.0/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -28,6 +39,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jedib0t/go-pretty/v6 v6.7.0 h1:DanoN1RnjXTwDN+B8yqtixXzXqNBCs2Vxo2ARsnrpsY=
github.com/jedib0t/go-pretty/v6 v6.7.0/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -46,14 +59,24 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
-golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
+golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
+golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
+golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/main_test.go b/main_test.go
index 9ecb6ed..822623e 100644
--- a/main_test.go
+++ b/main_test.go
@@ -24,18 +24,18 @@ package main
import (
"flag"
- "os"
"os/exec"
"path/filepath"
"testing"
- "filippo.io/age"
cmdtest "github.com/google/go-cmdtest"
)
var update = flag.Bool("update", false, "update test files with results")
func TestMain(t *testing.T) {
+ t.Setenv("PDA_DATA", t.TempDir())
+ t.Setenv("PDA_CONFIG", t.TempDir())
ts, err := cmdtest.Read("testdata")
if err != nil {
t.Fatalf("read testdata: %v", err)
@@ -45,30 +45,5 @@ func TestMain(t *testing.T) {
t.Fatal(err)
}
ts.Commands["pda"] = cmdtest.Program(bin)
-
- // Each .ct file gets its own isolated data and config directories
- // inside its ROOTDIR, so tests cannot leak state to each other.
- ts.Setup = func(rootDir string) error {
- dataDir := filepath.Join(rootDir, "data")
- configDir := filepath.Join(rootDir, "config")
- if err := os.MkdirAll(dataDir, 0o755); err != nil {
- return err
- }
- if err := os.MkdirAll(configDir, 0o755); err != nil {
- return err
- }
- os.Setenv("PDA_DATA", dataDir)
- os.Setenv("PDA_CONFIG", configDir)
- os.Unsetenv("EDITOR")
-
- // Pre-create an age identity so encryption tests don't print
- // a creation message with a non-deterministic path.
- id, err := age.GenerateX25519Identity()
- if err != nil {
- return err
- }
- return os.WriteFile(filepath.Join(dataDir, "identity.txt"), []byte(id.String()+"\n"), 0o600)
- }
-
ts.Run(t, *update)
}
diff --git a/testdata/config-get.ct b/testdata/config-get.ct
deleted file mode 100644
index 5edaf00..0000000
--- a/testdata/config-get.ct
+++ /dev/null
@@ -1,18 +0,0 @@
-$ pda config get display_ascii_art
-true
-
-$ pda config get store.default_store_name
-store
-
-$ pda config get git.auto_commit
-false
-
-# Unknown key with suggestion (typo)
-$ pda config get git.auto_comit --> FAIL
-FAIL unknown config key 'git.auto_comit'
-hint did you mean 'git.auto_commit'?
-
-# Unknown key with suggestion (leaf match, no prefix)
-$ pda config get auto_commit --> FAIL
-FAIL unknown config key 'auto_commit'
-hint did you mean 'git.auto_commit'?
diff --git a/testdata/config-init.ct b/testdata/config-init.ct
deleted file mode 100644
index ce99235..0000000
--- a/testdata/config-init.ct
+++ /dev/null
@@ -1,30 +0,0 @@
-# Init creates a config file
-$ pda config init
- ok generated config: ${ROOTDIR}/config/config.toml
-
-# Second init fails
-$ pda config init --> FAIL
-FAIL config file already exists
-hint use '--update' to update your config, or '--new' to get a fresh copy
-
-# Init --new overwrites
-$ pda config init --new
- ok generated config: ${ROOTDIR}/config/config.toml
-
-# --update preserves user changes
-$ pda config set list.always_show_all_stores false
-$ pda config get list.always_show_all_stores
- ok list.always_show_all_stores set to 'false'
-false
-$ pda config init --update
-$ pda config get list.always_show_all_stores
- ok updated config: ${ROOTDIR}/config/config.toml
-false
-
-# --new and --update are mutually exclusive
-$ pda config init --new --update --> FAIL
-FAIL --new and --update are mutually exclusive
-
-# Reset for other tests
-$ pda config init --new
- ok generated config: ${ROOTDIR}/config/config.toml
diff --git a/testdata/config-list.ct b/testdata/config-list.ct
deleted file mode 100644
index 50a4c4d..0000000
--- a/testdata/config-list.ct
+++ /dev/null
@@ -1,18 +0,0 @@
-$ pda config list
-display_ascii_art = true
-key.always_prompt_delete = false
-key.always_prompt_glob_delete = true
-key.always_prompt_overwrite = false
-key.always_encrypt = false
-store.default_store_name = 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 = meta,size,ttl,store,key,value
-git.auto_fetch = false
-git.auto_commit = false
-git.auto_push = false
-git.default_commit_message = {{ summary }} {{ time }}
diff --git a/testdata/config-set.ct b/testdata/config-set.ct
deleted file mode 100644
index 0e05785..0000000
--- a/testdata/config-set.ct
+++ /dev/null
@@ -1,58 +0,0 @@
-# Set a bool value and verify with get
-$ pda config set git.auto_commit true
-$ pda config get git.auto_commit
- ok git.auto_commit set to 'true'
-true
-
-# Set a string value
-$ pda config set store.default_store_name mystore
-$ pda config get store.default_store_name
- ok store.default_store_name set to 'mystore'
-mystore
-
-# Set back to original
-$ pda config set git.auto_commit false
-$ pda config get git.auto_commit
- ok git.auto_commit set to 'false'
-false
-
-# Bad type
-$ pda config set git.auto_commit yes --> FAIL
-FAIL cannot set 'git.auto_commit': expected bool (true/false), got 'yes'
-
-# Invalid list format
-$ pda config set list.default_list_format yaml --> FAIL
-FAIL cannot set 'list.default_list_format': must be one of 'table', 'tsv', 'csv', 'html', 'markdown', 'ndjson', or 'json'
-
-# Valid list format
-$ pda config set list.default_list_format json
-$ pda config get list.default_list_format
- ok list.default_list_format set to 'json'
-json
-
-# Invalid list columns
-$ pda config set list.default_columns foo --> FAIL
-FAIL cannot set 'list.default_columns': must be a comma-separated list of 'key', 'store', 'value', 'meta', 'size', 'ttl' (got 'foo')
-
-# Duplicate columns
-$ pda config set list.default_columns key,key --> FAIL
-FAIL cannot set 'list.default_columns': duplicate column 'key'
-
-# Valid list columns
-$ pda config set list.default_columns key,value
-$ pda config get list.default_columns
- ok list.default_columns set to 'key,value'
-key,value
-
-# Unknown key
-$ pda config set git.auto_comit true --> FAIL
-FAIL unknown config key 'git.auto_comit'
-hint did you mean 'git.auto_commit'?
-
-# Reset changed values so subsequent tests see defaults
-$ pda config set store.default_store_name store
-$ pda config set list.default_list_format table
-$ 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 'meta,size,ttl,store,key,value'
diff --git a/testdata/cp-cross-store.ct b/testdata/cp-cross-store.ct
deleted file mode 100644
index ee2d8ea..0000000
--- a/testdata/cp-cross-store.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Cross-store copy
-$ pda set key@src value
-$ pda cp key@src key@dst
- ok copied key@src to key@dst
-$ pda get key@src
-value
-$ pda get key@dst
-value
diff --git a/testdata/cp-encrypt.ct b/testdata/cp-encrypt.ct
deleted file mode 100644
index 7686f8e..0000000
--- a/testdata/cp-encrypt.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copy an encrypted key; both keys should decrypt.
-$ pda set --encrypt secret-key@cpe hidden-value
-$ pda cp secret-key@cpe copied-key@cpe
- ok copied secret-key@cpe to copied-key@cpe
-$ pda get secret-key@cpe
-hidden-value
-$ pda get copied-key@cpe
-hidden-value
diff --git a/testdata/cp-missing-err.ct b/testdata/cp-missing-err.ct
deleted file mode 100644
index 73e403e..0000000
--- a/testdata/cp-missing-err.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-# Copy non-existent key
-$ pda cp nonexistent dest --> FAIL
-FAIL cannot move 'nonexistent': no such key
diff --git a/testdata/cp-safe.ct b/testdata/cp-safe.ct
deleted file mode 100644
index b4b057f..0000000
--- a/testdata/cp-safe.ct
+++ /dev/null
@@ -1,6 +0,0 @@
-$ pda set src@csf hello
-$ pda set dst@csf existing
-$ pda cp src@csf dst@csf --safe
-info skipped 'dst@csf': already exists
-$ pda get dst@csf
-existing
diff --git a/testdata/cp.ct b/testdata/cp.ct
deleted file mode 100644
index 92d18b4..0000000
--- a/testdata/cp.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Basic copy
-$ pda set source@cpok value
-$ pda cp source@cpok dest@cpok
- ok copied source@cpok to dest@cpok
-$ pda get source@cpok
-value
-$ pda get dest@cpok
-value
diff --git a/testdata/del-db__err__with__invalid_db.ct b/testdata/del-db__err__with__invalid_db.ct
new file mode 100644
index 0000000..0d8d373
--- /dev/null
+++ b/testdata/del-db__err__with__invalid_db.ct
@@ -0,0 +1,2 @@
+$ pda del-db foo/bar --> FAIL
+Error: cannot delete-db 'foo/bar': cannot parse db: bad db format, use DB or @DB
diff --git a/testdata/del__dedupe__ok.ct b/testdata/del__dedupe__ok.ct
new file mode 100644
index 0000000..fe20f7a
--- /dev/null
+++ b/testdata/del__dedupe__ok.ct
@@ -0,0 +1,10 @@
+$ pda set foo 1
+$ pda set bar 2
+$ pda ls
+ bar 2
+ foo 1
+$ pda del foo --glob "*"
+$ pda get bar --> FAIL
+Error: cannot get 'bar': Key not found
+$ pda get foo --> FAIL
+Error: cannot get 'foo': Key not found
diff --git a/testdata/del__glob__mixed__ok.ct b/testdata/del__glob__mixed__ok.ct
new file mode 100644
index 0000000..69a77fa
--- /dev/null
+++ b/testdata/del__glob__mixed__ok.ct
@@ -0,0 +1,10 @@
+$ pda set foo 1
+$ pda set bar1 2
+$ pda set bar2 3
+$ pda del foo --glob bar*
+$ pda get foo --> FAIL
+Error: cannot get 'foo': Key not found
+$ pda get bar1 --> FAIL
+Error: cannot get 'bar1': Key not found
+$ pda get bar2 --> FAIL
+Error: cannot get 'bar2': Key not found
diff --git a/testdata/del__glob__ok.ct b/testdata/del__glob__ok.ct
new file mode 100644
index 0000000..c6cb99c
--- /dev/null
+++ b/testdata/del__glob__ok.ct
@@ -0,0 +1,10 @@
+$ pda set a1 1
+$ pda set a2 2
+$ pda set b1 3
+$ pda del --glob a*
+$ pda get a1 --> FAIL
+Error: cannot get 'a1': Key not found
+$ pda get a2 --> FAIL
+Error: cannot get 'a2': Key not found
+$ pda get b1
+3
diff --git a/testdata/del__multiple__ok.ct b/testdata/del__multiple__ok.ct
new file mode 100644
index 0000000..80660d1
--- /dev/null
+++ b/testdata/del__multiple__ok.ct
@@ -0,0 +1,7 @@
+$ pda set a 1
+$ pda set b 2
+$ pda del a b
+$ pda get a --> FAIL
+Error: cannot get 'a': Key not found
+$ pda get b --> FAIL
+Error: cannot get 'b': Key not found
diff --git a/testdata/del__ok.ct b/testdata/del__ok.ct
new file mode 100644
index 0000000..38d30cc
--- /dev/null
+++ b/testdata/del__ok.ct
@@ -0,0 +1,2 @@
+$ pda set a b
+$ pda del a
diff --git a/testdata/dump__glob__ok.ct b/testdata/dump__glob__ok.ct
new file mode 100644
index 0000000..b58c19c
--- /dev/null
+++ b/testdata/dump__glob__ok.ct
@@ -0,0 +1,8 @@
+$ pda set a1 1
+$ pda set a2 2
+$ pda set b1 3
+$ pda dump --glob a*
+{"key":"a1","value":"1","encoding":"text"}
+{"key":"a2","value":"2","encoding":"text"}
+$ pda dump --glob c* --> FAIL
+Error: No matches for pattern 'c*'
diff --git a/testdata/edit-no-editor-err.ct b/testdata/edit-no-editor-err.ct
deleted file mode 100644
index 5b7a2ed..0000000
--- a/testdata/edit-no-editor-err.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# Error when EDITOR is not set
-$ pda set hello@e world
-$ pda edit hello@e --> FAIL
-FAIL EDITOR not set
-hint set $EDITOR to your preferred text editor
diff --git a/testdata/export-key-filter.ct b/testdata/export-key-filter.ct
deleted file mode 100644
index 4bc3759..0000000
--- a/testdata/export-key-filter.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set a1@ekf 1
-$ pda set a2@ekf 2
-$ pda set b1@ekf 3
-$ pda export ekf --key "a*"
-{"key":"a1","value":"1","encoding":"text","store":"ekf"}
-{"key":"a2","value":"2","encoding":"text","store":"ekf"}
-$ pda export ekf --key "c*" --> FAIL
-FAIL cannot ls '@ekf': no matches for key pattern 'c*'
diff --git a/testdata/export-value-filter.ct b/testdata/export-value-filter.ct
deleted file mode 100644
index 7a87b6c..0000000
--- a/testdata/export-value-filter.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set url@evf https://example.com
-$ fecho tmpval hello world
-$ pda set greeting@evf < tmpval
-$ pda set number@evf 42
-$ pda export evf --value "**https**"
-{"key":"url","value":"https://example.com","encoding":"text","store":"evf"}
-$ pda export evf --value "**world**"
-{"key":"greeting","value":"hello world\n","encoding":"text","store":"evf"}
diff --git a/testdata/export.ct b/testdata/export.ct
deleted file mode 100644
index d5c9792..0000000
--- a/testdata/export.ct
+++ /dev/null
@@ -1,6 +0,0 @@
-# Unfiltered export outputs all entries as NDJSON
-$ pda set a@exp 1
-$ pda set b@exp 2
-$ pda export exp
-{"key":"a","value":"1","encoding":"text","store":"exp"}
-{"key":"b","value":"2","encoding":"text","store":"exp"}
diff --git a/testdata/get-base64-run.ct b/testdata/get-base64-run.ct
deleted file mode 100644
index a086bb9..0000000
--- a/testdata/get-base64-run.ct
+++ /dev/null
@@ -1,4 +0,0 @@
-$ fecho cmd echo hello
-$ pda set foo@gbr < cmd
-$ pda get foo@gbr --base64 --run
-hello
diff --git a/testdata/get-base64.ct b/testdata/get-base64.ct
deleted file mode 100644
index fdefa8e..0000000
--- a/testdata/get-base64.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-$ pda set a@gb b
-$ pda get a@gb --base64
-b
diff --git a/testdata/get-exists.ct b/testdata/get-exists.ct
deleted file mode 100644
index a975b42..0000000
--- a/testdata/get-exists.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-$ pda set found@ge "hello"
-$ pda get found@ge --exists
-$ pda get missing@ge --exists --> FAIL
diff --git a/testdata/get-invalid-store-err.ct b/testdata/get-invalid-store-err.ct
deleted file mode 100644
index 7c93ff2..0000000
--- a/testdata/get-invalid-store-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda get key@foo/bar --> FAIL
-FAIL cannot get 'key@foo/bar': bad store format, use STORE or @STORE
diff --git a/testdata/get-missing-all-flags-err.ct b/testdata/get-missing-all-flags-err.ct
deleted file mode 100644
index 55891a5..0000000
--- a/testdata/get-missing-all-flags-err.ct
+++ /dev/null
@@ -1,14 +0,0 @@
-$ pda get foobar --> FAIL
-$ pda get foobar --base64 --> FAIL
-$ pda get foobar --base64 --run --> FAIL
-$ pda get foobar --base64 --run --secret --> FAIL
-$ pda get foobar --run --> FAIL
-$ pda get foobar --run --secret --> FAIL
-$ pda get foobar --secret --> FAIL
-FAIL cannot get 'foobar': no such key
-FAIL cannot get 'foobar': no such key
-FAIL cannot get 'foobar': no such key
-FAIL unknown flag: --secret
-FAIL cannot get 'foobar': no such key
-FAIL unknown flag: --secret
-FAIL unknown flag: --secret
diff --git a/testdata/get-missing-err.ct b/testdata/get-missing-err.ct
deleted file mode 100644
index 20b7acf..0000000
--- a/testdata/get-missing-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda get foobar --> FAIL
-FAIL cannot get 'foobar': no such key
diff --git a/testdata/get-run.ct b/testdata/get-run.ct
deleted file mode 100644
index 22b3b4b..0000000
--- a/testdata/get-run.ct
+++ /dev/null
@@ -1,6 +0,0 @@
-$ fecho cmd echo hello
-$ pda set a@gr < cmd
-$ pda get a@gr
-echo hello
-$ pda get a@gr --run
-hello
diff --git a/testdata/get.ct b/testdata/get.ct
deleted file mode 100644
index 4ce93b0..0000000
--- a/testdata/get.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-$ pda set foo@g bar
-$ pda get foo@g
-bar
diff --git a/testdata/get__err__with__invalid_db.ct b/testdata/get__err__with__invalid_db.ct
new file mode 100644
index 0000000..c082ae3
--- /dev/null
+++ b/testdata/get__err__with__invalid_db.ct
@@ -0,0 +1,2 @@
+$ pda get key@foo/bar --> FAIL
+Error: cannot get 'key@foo/bar': bad db format, use DB or @DB
diff --git a/testdata/get__missing__err.ct b/testdata/get__missing__err.ct
new file mode 100644
index 0000000..ced6568
--- /dev/null
+++ b/testdata/get__missing__err.ct
@@ -0,0 +1,2 @@
+$ pda get foobar --> FAIL
+Error: cannot get 'foobar': Key not found
diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get__missing__err__with__any.ct
new file mode 100644
index 0000000..d4c9284
--- /dev/null
+++ b/testdata/get__missing__err__with__any.ct
@@ -0,0 +1,14 @@
+$ pda get foobar --> FAIL
+$ pda get foobar --include-binary --> FAIL
+$ pda get foobar --include-binary --run --> FAIL
+$ pda get foobar --include-binary --run --secret --> FAIL
+$ pda get foobar --run --> FAIL
+$ pda get foobar --run --secret --> FAIL
+$ pda get foobar --secret --> FAIL
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
+Error: cannot get 'foobar': Key not found
diff --git a/testdata/get__ok.ct b/testdata/get__ok.ct
new file mode 100644
index 0000000..2ba3573
--- /dev/null
+++ b/testdata/get__ok.ct
@@ -0,0 +1,3 @@
+$ pda set foo bar
+$ pda get foo
+bar
diff --git a/testdata/get__ok__with__binary.ct b/testdata/get__ok__with__binary.ct
new file mode 100644
index 0000000..ce97ada
--- /dev/null
+++ b/testdata/get__ok__with__binary.ct
@@ -0,0 +1,3 @@
+$ pda set a b
+$ pda get a --include-binary
+b
diff --git a/testdata/get__ok__with__binary_run.ct b/testdata/get__ok__with__binary_run.ct
new file mode 100644
index 0000000..a398a54
--- /dev/null
+++ b/testdata/get__ok__with__binary_run.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello
+$ pda set foo < cmd
+$ pda get foo --include-binary --run
+hello
diff --git a/testdata/get__ok__with__binary_run_secret.ct b/testdata/get__ok__with__binary_run_secret.ct
new file mode 100644
index 0000000..76e0976
--- /dev/null
+++ b/testdata/get__ok__with__binary_run_secret.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello
+$ pda set foo < cmd
+$ pda get foo --include-binary --run --secret
+hello
diff --git a/testdata/get__ok__with__run.ct b/testdata/get__ok__with__run.ct
new file mode 100644
index 0000000..e74e0c1
--- /dev/null
+++ b/testdata/get__ok__with__run.ct
@@ -0,0 +1,6 @@
+$ fecho cmd echo hello
+$ pda set a < cmd
+$ pda get a
+echo hello
+$ pda get a --run
+hello
diff --git a/testdata/get__ok__with__run_secret.ct b/testdata/get__ok__with__run_secret.ct
new file mode 100644
index 0000000..aaff747
--- /dev/null
+++ b/testdata/get__ok__with__run_secret.ct
@@ -0,0 +1,6 @@
+$ fecho cmd echo hello
+$ pda set a < cmd
+$ pda get a
+echo hello
+$ pda get a --run --secret
+hello
diff --git a/testdata/get__ok__with__secret.ct b/testdata/get__ok__with__secret.ct
new file mode 100644
index 0000000..afc0731
--- /dev/null
+++ b/testdata/get__ok__with__secret.ct
@@ -0,0 +1,3 @@
+$ pda set foo bar
+$ pda get foo --secret
+bar
diff --git a/testdata/get__secret__err.ct b/testdata/get__secret__err.ct
new file mode 100644
index 0000000..1dc4e6e
--- /dev/null
+++ b/testdata/get__secret__err.ct
@@ -0,0 +1,3 @@
+$ pda set a b --secret
+$ pda get a --> FAIL
+Error: cannot get 'a': marked as secret, run with --secret
diff --git a/testdata/get__secret__err__with__binary.ct b/testdata/get__secret__err__with__binary.ct
new file mode 100644
index 0000000..065b3fc
--- /dev/null
+++ b/testdata/get__secret__err__with__binary.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --include-binary --> FAIL
+Error: cannot get 'a': marked as secret, run with --secret
diff --git a/testdata/get__secret__err__with__binary_run.ct b/testdata/get__secret__err__with__binary_run.ct
new file mode 100644
index 0000000..877ac77
--- /dev/null
+++ b/testdata/get__secret__err__with__binary_run.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --include-binary --run --> FAIL
+Error: cannot get 'a': marked as secret, run with --secret
diff --git a/testdata/get__secret__err__with__run.ct b/testdata/get__secret__err__with__run.ct
new file mode 100644
index 0000000..67e2d25
--- /dev/null
+++ b/testdata/get__secret__err__with__run.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --run --> FAIL
+Error: cannot get 'a': marked as secret, run with --secret
diff --git a/testdata/get__secret__ok__with__binary_run_secret.ct b/testdata/get__secret__ok__with__binary_run_secret.ct
new file mode 100644
index 0000000..94460bd
--- /dev/null
+++ b/testdata/get__secret__ok__with__binary_run_secret.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --secret --run --include-binary
+hello world
diff --git a/testdata/get__secret__ok__with__binary_secret.ct b/testdata/get__secret__ok__with__binary_secret.ct
new file mode 100644
index 0000000..943fb74
--- /dev/null
+++ b/testdata/get__secret__ok__with__binary_secret.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --include-binary --secret
+echo hello world
diff --git a/testdata/get__secret__ok__with__run_secret.ct b/testdata/get__secret__ok__with__run_secret.ct
new file mode 100644
index 0000000..a7ab85a
--- /dev/null
+++ b/testdata/get__secret__ok__with__run_secret.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --run --secret
+hello world
diff --git a/testdata/get__secret__ok__with__secret.ct b/testdata/get__secret__ok__with__secret.ct
new file mode 100644
index 0000000..1aae59d
--- /dev/null
+++ b/testdata/get__secret__ok__with__secret.ct
@@ -0,0 +1,4 @@
+$ fecho cmd echo hello world
+$ pda set a --secret < cmd
+$ pda get a --secret
+echo hello world
diff --git a/testdata/help-export.ct b/testdata/help-export.ct
deleted file mode 100644
index 9a8d8e5..0000000
--- a/testdata/help-export.ct
+++ /dev/null
@@ -1,22 +0,0 @@
-$ pda help export
-$ pda export --help
-Export store as NDJSON (alias for list --format ndjson)
-
-Usage:
- pda export [STORE] [flags]
-
-Flags:
- -h, --help help for export
- -k, --key strings filter keys with glob pattern (repeatable)
- -s, --store strings filter stores with glob pattern (repeatable)
- -v, --value strings filter values with glob pattern (repeatable)
-Export store as NDJSON (alias for list --format ndjson)
-
-Usage:
- pda export [STORE] [flags]
-
-Flags:
- -h, --help help for export
- -k, --key strings filter keys with glob pattern (repeatable)
- -s, --store strings filter stores with glob pattern (repeatable)
- -v, --value strings filter values with glob pattern (repeatable)
diff --git a/testdata/help-get.ct b/testdata/help-get.ct
deleted file mode 100644
index 3fa513d..0000000
--- a/testdata/help-get.ct
+++ /dev/null
@@ -1,44 +0,0 @@
-$ pda help get
-$ pda get --help
-Get the value of a key. Optionally specify a store.
-
-{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
-additional argument after the initial KEY being fetched.
-
-For example:
- pda set greeting 'Hello, {{ .NAME }}!'
- pda get greeting NAME=World
-
-Usage:
- pda get KEY[@STORE] [flags]
-
-Aliases:
- get, g
-
-Flags:
- -b, --base64 view binary data as base64
- --exists exit 0 if the key exists, exit 1 if not (no output)
- -h, --help help for get
- --no-template directly output template syntax
- -c, --run execute the result as a shell command
-Get the value of a key. Optionally specify a store.
-
-{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
-additional argument after the initial KEY being fetched.
-
-For example:
- pda set greeting 'Hello, {{ .NAME }}!'
- pda get greeting NAME=World
-
-Usage:
- pda get KEY[@STORE] [flags]
-
-Aliases:
- get, g
-
-Flags:
- -b, --base64 view binary data as base64
- --exists exit 0 if the key exists, exit 1 if not (no output)
- -h, --help help for get
- --no-template directly output template syntax
- -c, --run execute the result as a shell command
diff --git a/testdata/help-import.ct b/testdata/help-import.ct
deleted file mode 100644
index c3c70f8..0000000
--- a/testdata/help-import.ct
+++ /dev/null
@@ -1,26 +0,0 @@
-$ pda help import
-$ pda import --help
-Restore key/value pairs from an NDJSON dump
-
-Usage:
- pda import [STORE] [flags]
-
-Flags:
- --drop drop existing entries before restoring (full replace)
- -f, --file string path to an NDJSON dump (defaults to stdin)
- -h, --help help for import
- -i, --interactive prompt before overwriting existing keys
- -k, --key strings restore keys matching glob pattern (repeatable)
- -s, --store strings restore entries from stores matching glob pattern (repeatable)
-Restore key/value pairs from an NDJSON dump
-
-Usage:
- pda import [STORE] [flags]
-
-Flags:
- --drop drop existing entries before restoring (full replace)
- -f, --file string path to an NDJSON dump (defaults to stdin)
- -h, --help help for import
- -i, --interactive prompt before overwriting existing keys
- -k, --key strings restore keys matching glob pattern (repeatable)
- -s, --store strings restore entries from stores matching glob pattern (repeatable)
diff --git a/testdata/help-list-stores.ct b/testdata/help-list-stores.ct
deleted file mode 100644
index 5e57786..0000000
--- a/testdata/help-list-stores.ct
+++ /dev/null
@@ -1,26 +0,0 @@
-$ pda help list-stores
-$ pda list-stores --help
-List all stores
-
-Usage:
- pda list-stores [flags]
-
-Aliases:
- list-stores, lss
-
-Flags:
- -h, --help help for list-stores
- --no-header suppress the header row
- --short only print store names
-List all stores
-
-Usage:
- pda list-stores [flags]
-
-Aliases:
- list-stores, lss
-
-Flags:
- -h, --help help for list-stores
- --no-header suppress the header row
- --short only print store names
diff --git a/testdata/help-list.ct b/testdata/help-list.ct
deleted file mode 100644
index d2fbec5..0000000
--- a/testdata/help-list.ct
+++ /dev/null
@@ -1,68 +0,0 @@
-$ pda help list
-$ pda list --help
-List the contents of all stores.
-
-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.
-
-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]
-
-Aliases:
- list, ls
-
-Flags:
- -a, --all list across all stores
- -b, --base64 view binary data as base64
- -c, --count print only the count of matching entries
- -o, --format format output format (table|tsv|csv|markdown|html|ndjson|json)
- -f, --full show full values without truncation
- -h, --help help for list
- -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)
- -v, --value strings filter values with glob pattern (repeatable)
-List the contents of all stores.
-
-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.
-
-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]
-
-Aliases:
- list, ls
-
-Flags:
- -a, --all list across all stores
- -b, --base64 view binary data as base64
- -c, --count print only the count of matching entries
- -o, --format format output format (table|tsv|csv|markdown|html|ndjson|json)
- -f, --full show full values without truncation
- -h, --help help for list
- -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)
- -v, --value strings filter values with glob pattern (repeatable)
diff --git a/testdata/help-remove-store.ct b/testdata/help-remove-store.ct
deleted file mode 100644
index d367770..0000000
--- a/testdata/help-remove-store.ct
+++ /dev/null
@@ -1,26 +0,0 @@
-$ pda help rms
-$ pda rms --help
-Delete a store
-
-Usage:
- pda remove-store STORE [flags]
-
-Aliases:
- remove-store, rms
-
-Flags:
- -h, --help help for remove-store
- -i, --interactive prompt yes/no for each deletion
- -y, --yes skip all confirmation prompts
-Delete a store
-
-Usage:
- pda remove-store STORE [flags]
-
-Aliases:
- remove-store, rms
-
-Flags:
- -h, --help help for remove-store
- -i, --interactive prompt yes/no for each deletion
- -y, --yes skip all confirmation prompts
diff --git a/testdata/help-remove.ct b/testdata/help-remove.ct
deleted file mode 100644
index 3170253..0000000
--- a/testdata/help-remove.ct
+++ /dev/null
@@ -1,34 +0,0 @@
-$ pda help rm
-$ pda rm --help
-Delete one or more keys
-
-Usage:
- pda remove KEY[@STORE] [KEY[@STORE] ...] [flags]
-
-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)
- -s, --store strings target stores matching glob pattern (repeatable)
- -v, --value strings delete entries matching value glob pattern (repeatable)
- -y, --yes skip all confirmation prompts
-Delete one or more keys
-
-Usage:
- pda remove KEY[@STORE] [KEY[@STORE] ...] [flags]
-
-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)
- -s, --store strings target stores matching glob pattern (repeatable)
- -v, --value strings delete entries matching value glob pattern (repeatable)
- -y, --yes skip all confirmation prompts
diff --git a/testdata/help-set.ct b/testdata/help-set.ct
deleted file mode 100644
index e37b51a..0000000
--- a/testdata/help-set.ct
+++ /dev/null
@@ -1,62 +0,0 @@
-$ pda help set
-$ pda set --help
-Set a key to a given value or stdin. Optionally specify a store.
-
-Pass --encrypt to encrypt the value at rest using age. An identity file
-is generated automatically on first use.
-
-PDA supports parsing Go templates. Actions are delimited with {{ }}.
-
-For example:
- 'Hello, {{ .NAME }}' can be substituted with NAME="John Doe".
- 'Hello, {{ env "USER" }}' will fetch the USER env variable.
- 'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
- 'Hello, {{ require .NAME }}' will error if NAME is blank.
- '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.
-
-Usage:
- pda set KEY[@STORE] [VALUE] [flags]
-
-Aliases:
- set, s
-
-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.
-
-Pass --encrypt to encrypt the value at rest using age. An identity file
-is generated automatically on first use.
-
-PDA supports parsing Go templates. Actions are delimited with {{ }}.
-
-For example:
- 'Hello, {{ .NAME }}' can be substituted with NAME="John Doe".
- 'Hello, {{ env "USER" }}' will fetch the USER env variable.
- 'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
- 'Hello, {{ require .NAME }}' will error if NAME is blank.
- '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.
-
-Usage:
- pda set KEY[@STORE] [VALUE] [flags]
-
-Aliases:
- set, s
-
-Flags:
- -e, --encrypt encrypt the value at rest using age
- -f, --file string read value from a file
- --force bypass read-only protection
- -h, --help help for set
- -i, --interactive prompt before overwriting an existing key
- --pin pin the key (sorts to top in list)
- --readonly mark the key as read-only
- --safe do not overwrite if the key already exists
- -t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m)
diff --git a/testdata/help.ct b/testdata/help.ct
deleted file mode 100644
index f5a8254..0000000
--- a/testdata/help.ct
+++ /dev/null
@@ -1,100 +0,0 @@
-$ pda help
-$ pda --help
- ▄▄
- ██
- ██▄███▄ ▄███▄██ ▄█████▄
- ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
- ██ ██ ██ ██ ▄██▀▀▀██
- ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
- ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
- ██ (c) 2025 Lewis Wynne
-
-Usage:
- pda [command]
-
-Key commands:
- copy Make a copy of a key
- edit Edit a key's value in $EDITOR
- get Get the value of a key
- identity Show or create the age encryption identity
- list List the contents of all stores
- meta View or modify metadata for a key
- move Move a key
- remove Delete one or more keys
- run Get the value of a key and execute it
- set Set a key to a given value
-
-Store commands:
- export Export store as NDJSON (alias for list --format ndjson)
- import Restore key/value pairs from an NDJSON dump
- list-stores List all stores
- move-store Rename a store
- remove-store Delete a store
-
-Git commands:
- git Run any arbitrary command. Use with caution.
- init Initialise pda! version control
- sync Manually sync your stores with Git
-
-Environment commands:
- config View and modify configuration
- doctor Check environment health
-
-Additional Commands:
- completion Generate the autocompletion script for the specified shell
- help Help about any command
- version Display pda! version
-
-Flags:
- -h, --help help for pda
-
-Use "pda [command] --help" for more information about a command.
- ▄▄
- ██
- ██▄███▄ ▄███▄██ ▄█████▄
- ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
- ██ ██ ██ ██ ▄██▀▀▀██
- ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
- ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
- ██ (c) 2025 Lewis Wynne
-
-Usage:
- pda [command]
-
-Key commands:
- copy Make a copy of a key
- edit Edit a key's value in $EDITOR
- get Get the value of a key
- identity Show or create the age encryption identity
- list List the contents of all stores
- meta View or modify metadata for a key
- move Move a key
- remove Delete one or more keys
- run Get the value of a key and execute it
- set Set a key to a given value
-
-Store commands:
- export Export store as NDJSON (alias for list --format ndjson)
- import Restore key/value pairs from an NDJSON dump
- list-stores List all stores
- move-store Rename a store
- remove-store Delete a store
-
-Git commands:
- git Run any arbitrary command. Use with caution.
- init Initialise pda! version control
- sync Manually sync your stores with Git
-
-Environment commands:
- config View and modify configuration
- doctor Check environment health
-
-Additional Commands:
- completion Generate the autocompletion script for the specified shell
- help Help about any command
- version Display pda! version
-
-Flags:
- -h, --help help for pda
-
-Use "pda [command] --help" for more information about a command.
diff --git a/testdata/help__del-db__ok.ct b/testdata/help__del-db__ok.ct
new file mode 100644
index 0000000..c758df0
--- /dev/null
+++ b/testdata/help__del-db__ok.ct
@@ -0,0 +1,24 @@
+$ pda help del-db
+$ pda del-db --help
+Delete a database.
+
+Usage:
+ pda del-db DB [flags]
+
+Aliases:
+ del-db, delete-db, rm-db, remove-db
+
+Flags:
+ -h, --help help for del-db
+ -i, --interactive Prompt yes/no for each deletion
+Delete a database.
+
+Usage:
+ pda del-db DB [flags]
+
+Aliases:
+ del-db, delete-db, rm-db, remove-db
+
+Flags:
+ -h, --help help for del-db
+ -i, --interactive Prompt yes/no for each deletion
diff --git a/testdata/help__del__ok.ct b/testdata/help__del__ok.ct
new file mode 100644
index 0000000..6ec88b7
--- /dev/null
+++ b/testdata/help__del__ok.ct
@@ -0,0 +1,28 @@
+$ pda help del
+$ pda del --help
+Delete one or more keys. Optionally specify a db.
+
+Usage:
+ pda del KEY[@DB] [KEY[@DB] ...] [flags]
+
+Aliases:
+ del, delete, rm, remove
+
+Flags:
+ -g, --glob strings Delete keys matching glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for del
+ -i, --interactive Prompt yes/no for each deletion
+Delete one or more keys. Optionally specify a db.
+
+Usage:
+ pda del KEY[@DB] [KEY[@DB] ...] [flags]
+
+Aliases:
+ del, delete, rm, remove
+
+Flags:
+ -g, --glob strings Delete keys matching glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for del
+ -i, --interactive Prompt yes/no for each deletion
diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct
new file mode 100644
index 0000000..c471a6d
--- /dev/null
+++ b/testdata/help__dump__ok.ct
@@ -0,0 +1,30 @@
+$ pda help dump
+$ pda dump --help
+Dump all key/value pairs as NDJSON
+
+Usage:
+ pda dump [DB] [flags]
+
+Aliases:
+ dump, export
+
+Flags:
+ -e, --encoding string value encoding: auto, base64, or text (default "auto")
+ -g, --glob strings Filter keys with glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for dump
+ --secret Include entries marked as secret
+Dump all key/value pairs as NDJSON
+
+Usage:
+ pda dump [DB] [flags]
+
+Aliases:
+ dump, export
+
+Flags:
+ -e, --encoding string value encoding: auto, base64, or text (default "auto")
+ -g, --glob strings Filter keys with glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for dump
+ --secret Include entries marked as secret
diff --git a/testdata/help__get__ok.ct b/testdata/help__get__ok.ct
new file mode 100644
index 0000000..0edc4f2
--- /dev/null
+++ b/testdata/help__get__ok.ct
@@ -0,0 +1,44 @@
+$ pda help get
+$ pda get --help
+Get a value for a key. Optionally specify a db.
+
+{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
+additional argument after the initial KEY being fetched.
+
+For example:
+ pda set greeting 'Hello, {{ .NAME }}!'
+ pda get greeting NAME=World
+
+Usage:
+ pda get KEY[@DB] [flags]
+
+Aliases:
+ get, g
+
+Flags:
+ -h, --help help for get
+ -b, --include-binary include binary data in text output
+ --no-template directly output template syntax
+ -c, --run execute the result as a shell command
+ --secret display values marked as secret
+Get a value for a key. Optionally specify a db.
+
+{{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an
+additional argument after the initial KEY being fetched.
+
+For example:
+ pda set greeting 'Hello, {{ .NAME }}!'
+ pda get greeting NAME=World
+
+Usage:
+ pda get KEY[@DB] [flags]
+
+Aliases:
+ get, g
+
+Flags:
+ -h, --help help for get
+ -b, --include-binary include binary data in text output
+ --no-template directly output template syntax
+ -c, --run execute the result as a shell command
+ --secret display values marked as secret
diff --git a/testdata/help-help.ct b/testdata/help__help__ok.ct
similarity index 100%
rename from testdata/help-help.ct
rename to testdata/help__help__ok.ct
diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help__list-dbs__ok.ct
new file mode 100644
index 0000000..bf0f0de
--- /dev/null
+++ b/testdata/help__list-dbs__ok.ct
@@ -0,0 +1,22 @@
+$ pda help list-dbs
+$ pda list-dbs --help
+List all dbs.
+
+Usage:
+ pda list-dbs [flags]
+
+Aliases:
+ list-dbs, ls-dbs, lsd
+
+Flags:
+ -h, --help help for list-dbs
+List all dbs.
+
+Usage:
+ pda list-dbs [flags]
+
+Aliases:
+ list-dbs, ls-dbs, lsd
+
+Flags:
+ -h, --help help for list-dbs
diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct
new file mode 100644
index 0000000..349f4cb
--- /dev/null
+++ b/testdata/help__list__ok.ct
@@ -0,0 +1,40 @@
+$ pda help list
+$ pda list --help
+List the contents of a db.
+
+Usage:
+ pda list [DB] [flags]
+
+Aliases:
+ list, ls
+
+Flags:
+ -b, --binary include binary data in text output
+ -o, --format format output format (table|tsv|csv|markdown|html) (default table)
+ -g, --glob strings Filter keys with glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ --header include header row
+ -h, --help help for list
+ --no-keys suppress the key column
+ --no-values suppress the value column
+ -S, --secret display values marked as secret
+ -t, --ttl append a TTL column when entries expire
+List the contents of a db.
+
+Usage:
+ pda list [DB] [flags]
+
+Aliases:
+ list, ls
+
+Flags:
+ -b, --binary include binary data in text output
+ -o, --format format output format (table|tsv|csv|markdown|html) (default table)
+ -g, --glob strings Filter keys with glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ --header include header row
+ -h, --help help for list
+ --no-keys suppress the key column
+ --no-values suppress the value column
+ -S, --secret display values marked as secret
+ -t, --ttl append a TTL column when entries expire
diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct
new file mode 100644
index 0000000..61f9903
--- /dev/null
+++ b/testdata/help__ok.ct
@@ -0,0 +1,66 @@
+$ pda help
+$ pda --help
+ ▄▄
+ ██
+ ██▄███▄ ▄███▄██ ▄█████▄
+ ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
+ ██ ██ ██ ██ ▄██▀▀▀██
+ ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
+ ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
+ ██ (c) 2025 Lewis Wynne
+
+Usage:
+ pda [command]
+
+Available Commands:
+ completion Generate the autocompletion script for the specified shell
+ cp Make a copy of a key.
+ del Delete one or more keys. Optionally specify a db.
+ del-db Delete a database.
+ dump Dump all key/value pairs as NDJSON
+ get Get a value for a key. Optionally specify a db.
+ help Help about any command
+ list List the contents of a db.
+ list-dbs List all dbs.
+ mv Move a key between (or within) databases.
+ restore Restore key/value pairs from an NDJSON dump
+ set Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+ vcs Version control utilities
+ version Display pda! version
+
+Flags:
+ -h, --help help for pda
+
+Use "pda [command] --help" for more information about a command.
+ ▄▄
+ ██
+ ██▄███▄ ▄███▄██ ▄█████▄
+ ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
+ ██ ██ ██ ██ ▄██▀▀▀██
+ ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
+ ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
+ ██ (c) 2025 Lewis Wynne
+
+Usage:
+ pda [command]
+
+Available Commands:
+ completion Generate the autocompletion script for the specified shell
+ cp Make a copy of a key.
+ del Delete one or more keys. Optionally specify a db.
+ del-db Delete a database.
+ dump Dump all key/value pairs as NDJSON
+ get Get a value for a key. Optionally specify a db.
+ help Help about any command
+ list List the contents of a db.
+ list-dbs List all dbs.
+ mv Move a key between (or within) databases.
+ restore Restore key/value pairs from an NDJSON dump
+ set Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+ vcs Version control utilities
+ version Display pda! version
+
+Flags:
+ -h, --help help for pda
+
+Use "pda [command] --help" for more information about a command.
diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct
new file mode 100644
index 0000000..63a3771
--- /dev/null
+++ b/testdata/help__restore__ok.ct
@@ -0,0 +1,30 @@
+$ pda help restore
+$ pda restore --help
+Restore key/value pairs from an NDJSON dump
+
+Usage:
+ pda restore [DB] [flags]
+
+Aliases:
+ restore, import
+
+Flags:
+ -f, --file string Path to an NDJSON dump (defaults to stdin)
+ -g, --glob strings Restore keys matching glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for restore
+ -i, --interactive Prompt before overwriting existing keys
+Restore key/value pairs from an NDJSON dump
+
+Usage:
+ pda restore [DB] [flags]
+
+Aliases:
+ restore, import
+
+Flags:
+ -f, --file string Path to an NDJSON dump (defaults to stdin)
+ -g, --glob strings Restore keys matching glob pattern (repeatable)
+ --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
+ -h, --help help for restore
+ -i, --interactive Prompt before overwriting existing keys
diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct
new file mode 100644
index 0000000..3eb19fd
--- /dev/null
+++ b/testdata/help__set__ok.ct
@@ -0,0 +1,46 @@
+$ pda help set
+$ pda set --help
+Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+
+PDA supports parsing Go templates. Actions are delimited with {{ }}.
+
+For example:
+ 'Hello, {{ .NAME }}' can be substituted with NAME="John Doe".
+ 'Hello, {{ env "USER" }}' will fetch the USER env variable.
+ 'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
+ 'Hello, {{ require .NAME }}' will error if NAME is blank.
+ '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.
+
+Usage:
+ pda set KEY[@DB] [VALUE] [flags]
+
+Aliases:
+ set, s
+
+Flags:
+ -h, --help help for set
+ -i, --interactive Prompt before overwriting an existing key
+ --secret Mark the stored value as a secret
+ -t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m)
+Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+
+PDA supports parsing Go templates. Actions are delimited with {{ }}.
+
+For example:
+ 'Hello, {{ .NAME }}' can be substituted with NAME="John Doe".
+ 'Hello, {{ env "USER" }}' will fetch the USER env variable.
+ 'Hello, {{ default "World" .NAME }}' will default to World if NAME is blank.
+ 'Hello, {{ require .NAME }}' will error if NAME is blank.
+ '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob.
+
+Usage:
+ pda set KEY[@DB] [VALUE] [flags]
+
+Aliases:
+ set, s
+
+Flags:
+ -h, --help help for set
+ -i, --interactive Prompt before overwriting an existing key
+ --secret Mark the stored value as a secret
+ -t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m)
diff --git a/testdata/import-drop.ct b/testdata/import-drop.ct
deleted file mode 100644
index 4466ad1..0000000
--- a/testdata/import-drop.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-$ pda set existing@idr keep-me
-$ pda set other@idr also-keep
-$ fecho dumpfile {"key":"new","value":"hello","encoding":"text"}
-$ pda import idr --drop --file dumpfile
- ok restored 1 entries into @idr
-$ pda get new@idr
-hello
-$ pda get existing@idr --> FAIL
-FAIL cannot get 'existing@idr': no such key
diff --git a/testdata/import-key-filter.ct b/testdata/import-key-filter.ct
deleted file mode 100644
index 98258df..0000000
--- a/testdata/import-key-filter.ct
+++ /dev/null
@@ -1,16 +0,0 @@
-$ pda set a1@ikf 1
-$ pda set a2@ikf 2
-$ pda set b1@ikf 3
-$ fecho dumpfile {"key":"a1","value":"1","encoding":"text"} {"key":"a2","value":"2","encoding":"text"} {"key":"b1","value":"3","encoding":"text"}
-$ pda rm a1@ikf a2@ikf b1@ikf
-$ pda import ikf --key "a*" --file dumpfile
- ok restored 2 entries into @ikf
-$ pda get a1@ikf
-1
-$ pda get a2@ikf
-2
-$ pda get b1@ikf --> FAIL
-FAIL cannot get 'b1@ikf': no such key
-hint did you mean 'a1'?
-$ pda import ikf --key "c*" --file dumpfile --> FAIL
-FAIL cannot restore '@ikf': no matches for key pattern 'c*'
diff --git a/testdata/import-merge.ct b/testdata/import-merge.ct
deleted file mode 100644
index 7a66e5f..0000000
--- a/testdata/import-merge.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-# Merge import updates existing entries and adds new ones
-$ pda set existing@mrg old-value
-$ fecho dumpfile {"key":"existing","value":"updated","encoding":"text"} {"key":"new","value":"hello","encoding":"text"}
-$ pda import mrg --file dumpfile
- ok restored 2 entries into @mrg
-$ pda get existing@mrg
-updated
-$ pda get new@mrg
-hello
diff --git a/testdata/import-stdin.ct b/testdata/import-stdin.ct
deleted file mode 100644
index 18383c5..0000000
--- a/testdata/import-stdin.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-# Import from stdin preserves existing entries
-$ pda set existing@stn keep-me
-$ fecho dumpfile {"key":"new","value":"hello","encoding":"text"}
-$ pda import stn < dumpfile
- ok restored 1 entries into @stn
-$ pda get existing@stn
-keep-me
-$ pda get new@stn
-hello
diff --git a/testdata/invalid-command-err.ct b/testdata/invalid-command-err.ct
deleted file mode 100644
index d39ba9c..0000000
--- a/testdata/invalid-command-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda invalidcmd --> FAIL
-FAIL unknown command "invalidcmd" for "pda"
diff --git a/testdata/invalid__err.ct b/testdata/invalid__err.ct
new file mode 100644
index 0000000..1bd0417
--- /dev/null
+++ b/testdata/invalid__err.ct
@@ -0,0 +1,3 @@
+$ pda invalidcmd --> FAIL
+Error: unknown command "invalidcmd" for "pda"
+Run 'pda --help' for usage.
diff --git a/testdata/list-all-suppressed-err.ct b/testdata/list-all-suppressed-err.ct
deleted file mode 100644
index 300b688..0000000
--- a/testdata/list-all-suppressed-err.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# Error when all columns are suppressed
-$ pda set a@las 1
-$ 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 some --no-* flags
diff --git a/testdata/list-all.ct b/testdata/list-all.ct
deleted file mode 100644
index 7bbd52e..0000000
--- a/testdata/list-all.ct
+++ /dev/null
@@ -1,29 +0,0 @@
-# List defaults to all stores
-$ pda set lax@laa 1
-$ pda set lax@lab 2
-$ pda ls --key "lax" --format tsv
-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
-Meta Size TTL Store Key Value
--w-- 1 - laa lax 1
-# --store glob filter
-$ pda ls --store "la?" --key "lax" --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - laa lax 1
--w-- 1 - lab lax 2
-$ pda ls --store "laa" --key "lax" --format tsv
-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
-# --store no matches
-$ pda ls --store "nonexistent" --key "lax" --> FAIL
-FAIL cannot ls 'all': no matches for key pattern 'lax' and store pattern 'nonexistent'
diff --git a/testdata/list-config-columns.ct b/testdata/list-config-columns.ct
deleted file mode 100644
index d213ecc..0000000
--- a/testdata/list-config-columns.ct
+++ /dev/null
@@ -1,11 +0,0 @@
-# default_columns = "key,value" shows only key and value
-$ pda config set list.default_columns key,value
-$ pda set a@lcc 1
-$ pda ls lcc --format tsv
- ok list.default_columns set to 'key,value'
-Key Value
-a 1
-
-# Reset
-$ pda config set list.default_columns meta,size,ttl,store,key,value
- ok list.default_columns set to 'meta,size,ttl,store,key,value'
diff --git a/testdata/list-config-hide-header.ct b/testdata/list-config-hide-header.ct
deleted file mode 100644
index c918f58..0000000
--- a/testdata/list-config-hide-header.ct
+++ /dev/null
@@ -1,10 +0,0 @@
-# always_hide_header config suppresses the header row
-$ pda config set list.always_hide_header true
-$ pda set a@lchh 1
-$ pda ls lchh --format tsv
- ok list.always_hide_header set to 'true'
--w-- 1 - lchh a 1
-
-# Reset
-$ pda config set list.always_hide_header false
- ok list.always_hide_header set to 'false'
diff --git a/testdata/list-count.ct b/testdata/list-count.ct
deleted file mode 100644
index 988bdd9..0000000
--- a/testdata/list-count.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-$ pda set a@lc val-a
-$ pda set b@lc val-b
-$ pda set c@lc val-c
-$ pda ls lc --count
-3
-$ pda ls lc --count --key "a*"
-1
-$ pda ls lc --count --key "z*"
-0
diff --git a/testdata/list-format-csv.ct b/testdata/list-format-csv.ct
deleted file mode 100644
index 284580b..0000000
--- a/testdata/list-format-csv.ct
+++ /dev/null
@@ -1,7 +0,0 @@
-# CSV format output
-$ pda set a@csv 1
-$ pda set b@csv 2
-$ pda ls csv --format csv
-Meta,Size,TTL,Store,Key,Value
--w--,1,-,csv,a,1
--w--,1,-,csv,b,2
diff --git a/testdata/list-format-json.ct b/testdata/list-format-json.ct
deleted file mode 100644
index 2db4304..0000000
--- a/testdata/list-format-json.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# JSON array format output via list
-$ pda set a@jf 1
-$ pda set b@jf 2
-$ pda ls jf --format json
-[{"key":"a","value":"1","encoding":"text","store":"jf"},{"key":"b","value":"2","encoding":"text","store":"jf"}]
diff --git a/testdata/list-format-markdown.ct b/testdata/list-format-markdown.ct
deleted file mode 100644
index 698525e..0000000
--- a/testdata/list-format-markdown.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Markdown format output
-$ pda set a@md 1
-$ pda set b@md 2
-$ pda ls md --format markdown
-| Meta | Size | TTL | Store | Key | Value |
-| --- | --- | --- | --- | --- | --- |
-| -w-- | 1 | - | md | a | 1 |
-| -w-- | 1 | - | md | b | 2 |
diff --git a/testdata/list-format-ndjson.ct b/testdata/list-format-ndjson.ct
deleted file mode 100644
index cd949e5..0000000
--- a/testdata/list-format-ndjson.ct
+++ /dev/null
@@ -1,6 +0,0 @@
-# NDJSON format output via list
-$ pda set a@nj 1
-$ pda set b@nj 2
-$ pda ls nj --format ndjson
-{"key":"a","value":"1","encoding":"text","store":"nj"}
-{"key":"b","value":"2","encoding":"text","store":"nj"}
diff --git a/testdata/list-invalid-store-err.ct b/testdata/list-invalid-store-err.ct
deleted file mode 100644
index eaf1a60..0000000
--- a/testdata/list-invalid-store-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda ls foo/bar --> FAIL
-FAIL cannot ls 'foo/bar': cannot parse store: bad store format, use STORE or @STORE
diff --git a/testdata/list-key-filter.ct b/testdata/list-key-filter.ct
deleted file mode 100644
index 81adf66..0000000
--- a/testdata/list-key-filter.ct
+++ /dev/null
@@ -1,12 +0,0 @@
-$ pda set a1@lg 1
-$ pda set a2@lg 2
-$ pda set b1@lg 3
-$ pda ls lg --key "a*" --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - lg a1 1
--w-- 1 - lg a2 2
-$ pda ls lg --key "b*" --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - lg b1 3
-$ pda ls lg --key "c*" --> FAIL
-FAIL cannot ls '@lg': no matches for key pattern 'c*'
diff --git a/testdata/list-key-value-filter.ct b/testdata/list-key-value-filter.ct
deleted file mode 100644
index 64ab066..0000000
--- a/testdata/list-key-value-filter.ct
+++ /dev/null
@@ -1,11 +0,0 @@
-$ 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
-Meta Size TTL Store Key Value
--w-- 25 - kv dburl postgres://localhost:5432
-$ pda ls kv -k "*url*" -v "**example**" --format tsv
-Meta Size TTL Store Key Value
--w-- 23 - kv apiurl https://api.example.com
-$ pda ls kv -k "db*" -v "**nomatch**" --> FAIL
-FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**'
diff --git a/testdata/list-meta-column.ct b/testdata/list-meta-column.ct
deleted file mode 100644
index e4aa116..0000000
--- a/testdata/list-meta-column.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-# Meta column shows ewtp flags
-$ pda set plain@lmc hello
-$ pda set readonly@lmc world --readonly
-$ pda set pinned@lmc foo --pin
-$ pda ls lmc --format tsv --no-ttl --no-size
-Meta Store Key Value
--w-p lmc pinned foo
--w-- lmc plain hello
----- lmc readonly world
diff --git a/testdata/list-no-header.ct b/testdata/list-no-header.ct
deleted file mode 100644
index ed7d7e6..0000000
--- a/testdata/list-no-header.ct
+++ /dev/null
@@ -1,4 +0,0 @@
-# --no-header suppresses the header row
-$ pda set a@nh 1
-$ pda ls nh --format tsv --no-header
--w-- 1 - nh a 1
diff --git a/testdata/list-no-keys.ct b/testdata/list-no-keys.ct
deleted file mode 100644
index fe2f435..0000000
--- a/testdata/list-no-keys.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# --no-keys suppresses the key column
-$ pda set a@nk 1
-$ pda ls nk --format tsv --no-keys
-Meta Size TTL Store Value
--w-- 1 - nk 1
diff --git a/testdata/list-no-ttl.ct b/testdata/list-no-ttl.ct
deleted file mode 100644
index e74c6bd..0000000
--- a/testdata/list-no-ttl.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# --no-ttl suppresses the TTL column
-$ pda set a@nt 1
-$ pda ls nt --format tsv --no-ttl
-Meta Size Store Key Value
--w-- 1 nt a 1
diff --git a/testdata/list-no-values.ct b/testdata/list-no-values.ct
deleted file mode 100644
index 35a2ed3..0000000
--- a/testdata/list-no-values.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# --no-values suppresses the value column
-$ pda set a@nv 1
-$ pda ls nv --format tsv --no-values
-Meta Size TTL Store Key
--w-- 1 - nv a
diff --git a/testdata/list-pinned-sort.ct b/testdata/list-pinned-sort.ct
deleted file mode 100644
index 73ddaf1..0000000
--- a/testdata/list-pinned-sort.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-# Pinned entries sort to the top
-$ pda set alpha@lps one
-$ pda set beta@lps two
-$ pda set gamma@lps three --pin
-$ pda ls lps --format tsv --no-meta --no-size
-TTL Store Key Value
-- lps gamma three
-- lps alpha one
-- lps beta two
diff --git a/testdata/list-stores.ct b/testdata/list-stores.ct
deleted file mode 100644
index 0e813b1..0000000
--- a/testdata/list-stores.ct
+++ /dev/null
@@ -1,9 +0,0 @@
-# Functional list-stores: verify created stores appear
-$ pda set a@lsalpha 1
-$ pda set b@lsbeta 2
-$ pda ls lsalpha --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - lsalpha a 1
-$ pda ls lsbeta --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - lsbeta b 2
diff --git a/testdata/list-value-filter.ct b/testdata/list-value-filter.ct
deleted file mode 100644
index 472a2ae..0000000
--- a/testdata/list-value-filter.ct
+++ /dev/null
@@ -1,15 +0,0 @@
-$ pda set url@vt https://example.com
-$ fecho tmpval hello world
-$ pda set greeting@vt < tmpval
-$ pda set number@vt 42
-$ pda ls vt --value "**world**" --format tsv
-Meta Size TTL Store Key Value
--w-- 12 - vt greeting hello world (..1 more chars)
-$ pda ls vt --value "**https**" --format tsv
-Meta Size TTL Store Key Value
--w-- 19 - vt url https://example.com
-$ pda ls vt --value "*" --format tsv
-Meta Size TTL Store Key Value
--w-- 2 - vt number 42
-$ pda ls vt --value "**nomatch**" --> FAIL
-FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'
diff --git a/testdata/list-value-multi-filter.ct b/testdata/list-value-multi-filter.ct
deleted file mode 100644
index d193479..0000000
--- a/testdata/list-value-multi-filter.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set url@vm https://example.com
-$ fecho tmpval hello world
-$ pda set greeting@vm < tmpval
-$ pda set number@vm 42
-$ pda ls vm --value "**world**" --value "42" --format tsv
-Meta Size TTL Store Key Value
--w-- 12 - vm greeting hello world (..1 more chars)
--w-- 2 - vm number 42
diff --git a/testdata/list__err__with__invalid_db.ct b/testdata/list__err__with__invalid_db.ct
new file mode 100644
index 0000000..b2594dc
--- /dev/null
+++ b/testdata/list__err__with__invalid_db.ct
@@ -0,0 +1,2 @@
+$ pda ls foo/bar --> FAIL
+Error: cannot ls 'foo/bar': cannot parse db: bad db format, use DB or @DB
diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct
new file mode 100644
index 0000000..924d72c
--- /dev/null
+++ b/testdata/list__glob__ok.ct
@@ -0,0 +1,10 @@
+$ pda set a1@lg 1
+$ pda set a2@lg 2
+$ pda set b1@lg 3
+$ pda ls lg --glob a* --format tsv
+a1 1
+a2 2
+$ pda ls lg --glob b* --format tsv
+b1 3
+$ pda ls lg --glob c* --> FAIL
+Error: cannot ls '@lg': No matches for pattern 'c*'
diff --git a/testdata/meta-decrypt.ct b/testdata/meta-decrypt.ct
deleted file mode 100644
index ac2d5e0..0000000
--- a/testdata/meta-decrypt.ct
+++ /dev/null
@@ -1,12 +0,0 @@
-# Decrypt an existing encrypted key
-$ pda set --encrypt hello@md world
-$ pda meta hello@md --decrypt
- ok decrypted hello@md
-$ pda meta hello@md
- key: hello@md
- secret: false
- writable: true
- pinned: false
- expires: never
-$ pda get hello@md
-world
diff --git a/testdata/meta-encrypt.ct b/testdata/meta-encrypt.ct
deleted file mode 100644
index 20fba04..0000000
--- a/testdata/meta-encrypt.ct
+++ /dev/null
@@ -1,13 +0,0 @@
-# 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
-world
diff --git a/testdata/meta-err.ct b/testdata/meta-err.ct
deleted file mode 100644
index 7f5cfba..0000000
--- a/testdata/meta-err.ct
+++ /dev/null
@@ -1,21 +0,0 @@
-# Error: key doesn't exist
-$ pda meta nonexistent@me --> FAIL
-FAIL cannot meta 'nonexistent@me': no such key
-
-# Error: --encrypt and --decrypt are mutually exclusive
-$ pda set hello@me world
-$ pda meta hello@me --encrypt --decrypt --> FAIL
-FAIL cannot meta 'hello@me': --encrypt and --decrypt are mutually exclusive
-
-# Error: already encrypted
-$ pda set --encrypt secret@me val
-$ pda meta secret@me --encrypt --> FAIL
-FAIL cannot meta 'secret@me': already encrypted
-
-# Error: not encrypted (can't decrypt)
-$ pda meta hello@me --decrypt --> FAIL
-FAIL cannot meta 'hello@me': not encrypted
-
-# Error: invalid TTL
-$ pda meta hello@me --ttl "abc" --> FAIL
-FAIL cannot meta 'hello@me': invalid ttl '"abc"': expected a duration (e.g. 30m, 2h) or 'never'
diff --git a/testdata/meta-pin.ct b/testdata/meta-pin.ct
deleted file mode 100644
index cb1fa9d..0000000
--- a/testdata/meta-pin.ct
+++ /dev/null
@@ -1,24 +0,0 @@
-# --pin marks a key as pinned
-$ pda set a@mp hello
-$ pda meta a@mp --pin
- ok pinned a@mp
-$ pda meta a@mp
- key: a@mp
- secret: false
- writable: true
- pinned: true
- expires: never
-
-# --unpin clears the pinned flag
-$ pda meta a@mp --unpin
- ok unpinned a@mp
-$ pda meta a@mp
- key: a@mp
- secret: false
- writable: true
- pinned: false
- expires: never
-
-# --pin and --unpin are mutually exclusive
-$ pda meta a@mp --pin --unpin --> FAIL
-FAIL cannot meta 'a@mp': --pin and --unpin are mutually exclusive
diff --git a/testdata/meta-readonly.ct b/testdata/meta-readonly.ct
deleted file mode 100644
index 08bd4d7..0000000
--- a/testdata/meta-readonly.ct
+++ /dev/null
@@ -1,24 +0,0 @@
-# --readonly marks a key as read-only
-$ pda set a@mro hello
-$ pda meta a@mro --readonly
- ok made readonly a@mro
-$ pda meta a@mro
- key: a@mro
- secret: false
- writable: false
- pinned: false
- expires: never
-
-# --writable clears the read-only flag
-$ pda meta a@mro --writable
- ok made writable a@mro
-$ pda meta a@mro
- key: a@mro
- secret: false
- writable: true
- pinned: false
- expires: never
-
-# --readonly and --writable are mutually exclusive
-$ pda meta a@mro --readonly --writable --> FAIL
-FAIL cannot meta 'a@mro': --readonly and --writable are mutually exclusive
diff --git a/testdata/meta-ttl.ct b/testdata/meta-ttl.ct
deleted file mode 100644
index 70128f2..0000000
--- a/testdata/meta-ttl.ct
+++ /dev/null
@@ -1,15 +0,0 @@
-# Set TTL on a key, then view it (just verify no error, can't match dynamic time)
-$ pda set hello@mt world
-$ pda meta hello@mt --ttl 1h
- ok set ttl to 1h hello@mt
-
-# Clear TTL with --ttl never
-$ pda set --ttl 1h expiring@mt val
-$ pda meta expiring@mt --ttl never
- ok cleared ttl expiring@mt
-$ pda meta expiring@mt
- key: expiring@mt
- secret: false
- writable: true
- pinned: false
- expires: never
diff --git a/testdata/meta.ct b/testdata/meta.ct
deleted file mode 100644
index 6d4b352..0000000
--- a/testdata/meta.ct
+++ /dev/null
@@ -1,17 +0,0 @@
-# View metadata for a plaintext key
-$ 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
-$ pda set --encrypt secret@m hunter2
-$ pda meta secret@m
- key: secret@m
- secret: true
- writable: true
- pinned: false
- expires: never
diff --git a/testdata/multistore.ct b/testdata/multistore.ct
deleted file mode 100644
index 9a84cde..0000000
--- a/testdata/multistore.ct
+++ /dev/null
@@ -1,10 +0,0 @@
-# Operations across multiple stores
-$ pda set foo@ms1 bar
-$ pda set x@ms2 y
-$ pda get foo@ms1
-bar
-$ pda get x@ms2
-y
-$ pda ls ms2 --format tsv
-Meta Size TTL Store Key Value
--w-- 1 - ms2 x y
diff --git a/testdata/mv-cross-store.ct b/testdata/mv-cross-store.ct
deleted file mode 100644
index 2e43987..0000000
--- a/testdata/mv-cross-store.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Cross-store move
-$ pda set key@src value
-$ pda mv key@src key@dst
- ok renamed key@src to key@dst
-$ pda get key@dst
-value
-$ pda get key@src --> FAIL
-FAIL cannot get 'key@src': no such key
diff --git a/testdata/mv-encrypt.ct b/testdata/mv-encrypt.ct
deleted file mode 100644
index 99ae9aa..0000000
--- a/testdata/mv-encrypt.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Move an encrypted key; the new key should still decrypt.
-$ pda set --encrypt secret-key@mve hidden-value
-$ pda mv secret-key@mve moved-key@mve
- ok renamed secret-key@mve to moved-key@mve
-$ pda get moved-key@mve
-hidden-value
-$ pda get secret-key@mve --> FAIL
-FAIL cannot get 'secret-key@mve': no such key
diff --git a/testdata/mv-missing-err.ct b/testdata/mv-missing-err.ct
deleted file mode 100644
index 9267bc0..0000000
--- a/testdata/mv-missing-err.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-# Move non-existent key
-$ pda mv nonexistent dest --> FAIL
-FAIL cannot move 'nonexistent': no such key
diff --git a/testdata/mv-readonly.ct b/testdata/mv-readonly.ct
deleted file mode 100644
index 20c3141..0000000
--- a/testdata/mv-readonly.ct
+++ /dev/null
@@ -1,23 +0,0 @@
-# Cannot move a read-only key without --force
-$ pda set a@mvro hello --readonly
-$ pda mv a@mvro b@mvro --> FAIL
-FAIL cannot move 'a': key is read-only
-
-# --force bypasses read-only protection
-$ pda mv a@mvro b@mvro --force
- ok renamed a@mvro to b@mvro
-
-# Copy preserves readonly metadata
-$ pda cp b@mvro c@mvro
- ok copied b@mvro to c@mvro
-$ pda meta c@mvro
- key: c@mvro
- secret: false
- writable: false
- pinned: false
- expires: never
-
-# Cannot overwrite a read-only destination
-$ pda set d@mvro new
-$ pda mv d@mvro c@mvro --> FAIL
-FAIL cannot overwrite 'c': key is read-only
diff --git a/testdata/mv-safe.ct b/testdata/mv-safe.ct
deleted file mode 100644
index 98cf125..0000000
--- a/testdata/mv-safe.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set src@msf hello
-$ pda set dst@msf existing
-$ pda mv src@msf dst@msf --safe
-info skipped 'dst@msf': already exists
-$ pda get src@msf
-hello
-$ pda get dst@msf
-existing
diff --git a/testdata/mv-store-copy.ct b/testdata/mv-store-copy.ct
deleted file mode 100644
index 618396a..0000000
--- a/testdata/mv-store-copy.ct
+++ /dev/null
@@ -1,7 +0,0 @@
-$ pda set key@msc1 value
-$ pda move-store msc1 msc2 --copy
- ok copied @msc1 to @msc2
-$ pda get key@msc1
-value
-$ pda get key@msc2
-value
diff --git a/testdata/mv-store-missing-err.ct b/testdata/mv-store-missing-err.ct
deleted file mode 100644
index cb4baa0..0000000
--- a/testdata/mv-store-missing-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda move-store nonexistent dest --> FAIL
-FAIL cannot rename store 'nonexistent': no such store
diff --git a/testdata/mv-store-safe.ct b/testdata/mv-store-safe.ct
deleted file mode 100644
index 3415aba..0000000
--- a/testdata/mv-store-safe.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set a@mssf1 1
-$ pda set b@mssf2 2
-$ pda move-store mssf1 mssf2 --safe
-info skipped '@mssf2': already exists
-$ pda get a@mssf1
-1
-$ pda get b@mssf2
-2
diff --git a/testdata/mv-store-same-err.ct b/testdata/mv-store-same-err.ct
deleted file mode 100644
index d7f0c6b..0000000
--- a/testdata/mv-store-same-err.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-$ pda set a@mss same
-$ pda move-store mss mss --> FAIL
-FAIL cannot rename store 'mss': source and destination are the same
diff --git a/testdata/mv-store.ct b/testdata/mv-store.ct
deleted file mode 100644
index 7ae0855..0000000
--- a/testdata/mv-store.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-$ pda set key@mvs1 value
-$ pda move-store mvs1 mvs2
- ok renamed @mvs1 to @mvs2
-$ pda get key@mvs2
-value
diff --git a/testdata/mv.ct b/testdata/mv.ct
deleted file mode 100644
index 3679ffd..0000000
--- a/testdata/mv.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# Basic move
-$ pda set source@mvok value
-$ pda mv source@mvok dest@mvok
- ok renamed source@mvok to dest@mvok
-$ pda get dest@mvok
-value
-$ pda get source@mvok --> FAIL
-FAIL cannot get 'source@mvok': no such key
diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct
deleted file mode 100644
index 8ea5595..0000000
--- a/testdata/remove-dedupe.ct
+++ /dev/null
@@ -1,12 +0,0 @@
-# Remove deduplicates positional args and glob matches
-$ pda set foo@rdd 1
-$ pda set bar@rdd 2
-$ pda ls rdd --format tsv
-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
-$ pda get foo@rdd --> FAIL
-FAIL cannot get 'foo@rdd': no such key
diff --git a/testdata/remove-key-glob.ct b/testdata/remove-key-glob.ct
deleted file mode 100644
index 84b90d0..0000000
--- a/testdata/remove-key-glob.ct
+++ /dev/null
@@ -1,11 +0,0 @@
-$ pda set a1@rkg 1
-$ pda set a2@rkg 2
-$ pda set b1@rkg 3
-$ pda rm --key "a*@rkg" -y
-$ pda get a1@rkg --> FAIL
-FAIL cannot get 'a1@rkg': no such key
-hint did you mean 'b1'?
-$ pda get a2@rkg --> FAIL
-FAIL cannot get 'a2@rkg': no such key
-$ pda get b1@rkg
-3
diff --git a/testdata/remove-key-mixed.ct b/testdata/remove-key-mixed.ct
deleted file mode 100644
index 638e136..0000000
--- a/testdata/remove-key-mixed.ct
+++ /dev/null
@@ -1,10 +0,0 @@
-$ pda set foo@rkm 1
-$ pda set bar1@rkm 2
-$ pda set bar2@rkm 3
-$ pda rm foo@rkm --key "bar*@rkm" -y
-$ pda get foo@rkm --> FAIL
-FAIL cannot get 'foo@rkm': no such key
-$ pda get bar1@rkm --> FAIL
-FAIL cannot get 'bar1@rkm': no such key
-$ pda get bar2@rkm --> FAIL
-FAIL cannot get 'bar2@rkm': no such key
diff --git a/testdata/remove-multiple.ct b/testdata/remove-multiple.ct
deleted file mode 100644
index 2c2fa89..0000000
--- a/testdata/remove-multiple.ct
+++ /dev/null
@@ -1,7 +0,0 @@
-$ pda set a@rmm 1
-$ pda set b@rmm 2
-$ pda rm a@rmm b@rmm
-$ pda get a@rmm --> FAIL
-FAIL cannot get 'a@rmm': no such key
-$ pda get b@rmm --> FAIL
-FAIL cannot get 'b@rmm': no such key
diff --git a/testdata/remove-readonly.ct b/testdata/remove-readonly.ct
deleted file mode 100644
index a01de99..0000000
--- a/testdata/remove-readonly.ct
+++ /dev/null
@@ -1,7 +0,0 @@
-# Cannot remove a read-only key without --force
-$ pda set a@rmro hello --readonly
-$ pda rm a@rmro --> FAIL
-FAIL cannot remove 'a@rmro': key is read-only
-
-# --force bypasses read-only protection
-$ pda rm a@rmro --force
diff --git a/testdata/remove-store-invalid-err.ct b/testdata/remove-store-invalid-err.ct
deleted file mode 100644
index fcdda4b..0000000
--- a/testdata/remove-store-invalid-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda rms foo/bar --> FAIL
-FAIL cannot delete store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE
diff --git a/testdata/remove-yes.ct b/testdata/remove-yes.ct
deleted file mode 100644
index 10e3650..0000000
--- a/testdata/remove-yes.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-$ pda set a@ry "1"
-$ pda set b@ry "2"
-$ pda rm a@ry -i -y
-$ pda get a@ry --> FAIL
-FAIL cannot get 'a@ry': no such key
-hint did you mean 'b'?
-$ pda get b@ry
-"2"
diff --git a/testdata/remove.ct b/testdata/remove.ct
deleted file mode 100644
index 1f1eecc..0000000
--- a/testdata/remove.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda set a@rm b
-$ pda rm a@rm
diff --git a/testdata/restore__glob__ok.ct b/testdata/restore__glob__ok.ct
new file mode 100644
index 0000000..eefd176
--- /dev/null
+++ b/testdata/restore__glob__ok.ct
@@ -0,0 +1,15 @@
+$ pda set a1 1
+$ pda set a2 2
+$ pda set b1 3
+$ fecho dumpfile {"key":"a1","value":"1","encoding":"text"} {"key":"a2","value":"2","encoding":"text"} {"key":"b1","value":"3","encoding":"text"}
+$ pda del a1 a2 b1
+$ pda restore --glob a* --file dumpfile
+Restored 2 entries into @default
+$ pda get a1
+1
+$ pda get a2
+2
+$ pda get b1 --> FAIL
+Error: cannot get 'b1': Key not found
+$ pda restore --glob c* --file dumpfile --> FAIL
+Error: cannot restore '@default': No matches for pattern 'c*'
diff --git a/testdata/root.ct b/testdata/root.ct
deleted file mode 100644
index 97b531e..0000000
--- a/testdata/root.ct
+++ /dev/null
@@ -1,50 +0,0 @@
-$ pda
- ▄▄
- ██
- ██▄███▄ ▄███▄██ ▄█████▄
- ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
- ██ ██ ██ ██ ▄██▀▀▀██
- ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
- ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
- ██ (c) 2025 Lewis Wynne
-
-Usage:
- pda [command]
-
-Key commands:
- copy Make a copy of a key
- edit Edit a key's value in $EDITOR
- get Get the value of a key
- identity Show or create the age encryption identity
- list List the contents of all stores
- meta View or modify metadata for a key
- move Move a key
- remove Delete one or more keys
- run Get the value of a key and execute it
- set Set a key to a given value
-
-Store commands:
- export Export store as NDJSON (alias for list --format ndjson)
- import Restore key/value pairs from an NDJSON dump
- list-stores List all stores
- move-store Rename a store
- remove-store Delete a store
-
-Git commands:
- git Run any arbitrary command. Use with caution.
- init Initialise pda! version control
- sync Manually sync your stores with Git
-
-Environment commands:
- config View and modify configuration
- doctor Check environment health
-
-Additional Commands:
- completion Generate the autocompletion script for the specified shell
- help Help about any command
- version Display pda! version
-
-Flags:
- -h, --help help for pda
-
-Use "pda [command] --help" for more information about a command.
diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct
new file mode 100644
index 0000000..fac97c5
--- /dev/null
+++ b/testdata/root__ok.ct
@@ -0,0 +1,33 @@
+$ pda
+ ▄▄
+ ██
+ ██▄███▄ ▄███▄██ ▄█████▄
+ ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██
+ ██ ██ ██ ██ ▄██▀▀▀██
+ ███▄▄██▀ ▀██▄▄███ ██▄▄▄███
+ ██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀
+ ██ (c) 2025 Lewis Wynne
+
+Usage:
+ pda [command]
+
+Available Commands:
+ completion Generate the autocompletion script for the specified shell
+ cp Make a copy of a key.
+ del Delete one or more keys. Optionally specify a db.
+ del-db Delete a database.
+ dump Dump all key/value pairs as NDJSON
+ get Get a value for a key. Optionally specify a db.
+ help Help about any command
+ list List the contents of a db.
+ list-dbs List all dbs.
+ mv Move a key between (or within) databases.
+ restore Restore key/value pairs from an NDJSON dump
+ set Set a value for a key by passing VALUE or Stdin. Optionally specify a db.
+ vcs Version control utilities
+ version Display pda! version
+
+Flags:
+ -h, --help help for pda
+
+Use "pda [command] --help" for more information about a command.
diff --git a/testdata/set-config-encrypt.ct b/testdata/set-config-encrypt.ct
deleted file mode 100644
index 75307f2..0000000
--- a/testdata/set-config-encrypt.ct
+++ /dev/null
@@ -1,10 +0,0 @@
-# always_encrypt config encrypts without --encrypt flag
-$ pda config set key.always_encrypt true
-$ pda set secret-key@sce mysecretvalue
-$ pda get secret-key@sce
- ok key.always_encrypt set to 'true'
-mysecretvalue
-
-# Reset
-$ pda config set key.always_encrypt false
- ok key.always_encrypt set to 'false'
diff --git a/testdata/set-encrypt-ttl.ct b/testdata/set-encrypt-ttl.ct
deleted file mode 100644
index 18fc9a8..0000000
--- a/testdata/set-encrypt-ttl.ct
+++ /dev/null
@@ -1,4 +0,0 @@
-# Set an encrypted key with TTL, then retrieve it.
-$ pda set --encrypt --ttl 1h api-key@set sk-ttl-test
-$ pda get api-key@set
-sk-ttl-test
diff --git a/testdata/set-encrypt.ct b/testdata/set-encrypt.ct
deleted file mode 100644
index 0ece430..0000000
--- a/testdata/set-encrypt.ct
+++ /dev/null
@@ -1,4 +0,0 @@
-# Set an encrypted key, then retrieve it (transparent decryption).
-$ pda set --encrypt api-key@se sk-test-123
-$ pda get api-key@se
-sk-test-123
diff --git a/testdata/set-file-conflict-err.ct b/testdata/set-file-conflict-err.ct
deleted file mode 100644
index f8a971e..0000000
--- a/testdata/set-file-conflict-err.ct
+++ /dev/null
@@ -1,3 +0,0 @@
-$ fecho myfile contents
-$ pda set key@sfc value --file myfile --> FAIL
-FAIL cannot set 'key@sfc': --file and VALUE argument are mutually exclusive
diff --git a/testdata/set-file.ct b/testdata/set-file.ct
deleted file mode 100644
index d6b913e..0000000
--- a/testdata/set-file.ct
+++ /dev/null
@@ -1,4 +0,0 @@
-$ fecho myfile hello from file
-$ pda set key@sf --file myfile
-$ pda get key@sf
-hello from file
diff --git a/testdata/set-invalid-ttl-err.ct b/testdata/set-invalid-ttl-err.ct
deleted file mode 100644
index 781623b..0000000
--- a/testdata/set-invalid-ttl-err.ct
+++ /dev/null
@@ -1,2 +0,0 @@
-$ pda set a b --ttl 3343r --> FAIL
-FAIL invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r"
diff --git a/testdata/set-pin.ct b/testdata/set-pin.ct
deleted file mode 100644
index 67e7f95..0000000
--- a/testdata/set-pin.ct
+++ /dev/null
@@ -1,8 +0,0 @@
-# --pin marks a key as pinned
-$ pda set b@sp beta --pin
-$ pda meta b@sp
- key: b@sp
- secret: false
- writable: true
- pinned: true
- expires: never
diff --git a/testdata/set-readonly.ct b/testdata/set-readonly.ct
deleted file mode 100644
index ac6e1bc..0000000
--- a/testdata/set-readonly.ct
+++ /dev/null
@@ -1,17 +0,0 @@
-# --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
diff --git a/testdata/set-safe.ct b/testdata/set-safe.ct
deleted file mode 100644
index 9a08cb7..0000000
--- a/testdata/set-safe.ct
+++ /dev/null
@@ -1,10 +0,0 @@
-$ pda set key@ss "original" --safe
-$ pda get key@ss
-"original"
-$ pda set key@ss "overwritten" --safe
-info skipped 'key@ss': already exists
-$ pda get key@ss
-"original"
-$ pda set newkey@ss "fresh" --safe
-$ pda get newkey@ss
-"fresh"
diff --git a/testdata/set-ttl.ct b/testdata/set-ttl.ct
deleted file mode 100644
index 0533744..0000000
--- a/testdata/set-ttl.ct
+++ /dev/null
@@ -1 +0,0 @@
-$ pda set a@st b --ttl 30m
diff --git a/testdata/set.ct b/testdata/set.ct
deleted file mode 100644
index 8e6bdd7..0000000
--- a/testdata/set.ct
+++ /dev/null
@@ -1 +0,0 @@
-$ pda set a@s b
diff --git a/testdata/set__err__with__invalid-ttl.ct b/testdata/set__err__with__invalid-ttl.ct
new file mode 100644
index 0000000..a27ea1d
--- /dev/null
+++ b/testdata/set__err__with__invalid-ttl.ct
@@ -0,0 +1,2 @@
+$ pda set a b --ttl 3343r --> FAIL
+Error: invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r"
diff --git a/testdata/set__ok.ct b/testdata/set__ok.ct
new file mode 100644
index 0000000..d42cee6
--- /dev/null
+++ b/testdata/set__ok.ct
@@ -0,0 +1 @@
+$ pda set a b
diff --git a/testdata/set__ok__with__secret.ct b/testdata/set__ok__with__secret.ct
new file mode 100644
index 0000000..07eb921
--- /dev/null
+++ b/testdata/set__ok__with__secret.ct
@@ -0,0 +1 @@
+$ pda set foo foobar --secret
diff --git a/testdata/set__ok__with__secret_ttl.ct b/testdata/set__ok__with__secret_ttl.ct
new file mode 100644
index 0000000..7ad9b4f
--- /dev/null
+++ b/testdata/set__ok__with__secret_ttl.ct
@@ -0,0 +1 @@
+$ pda set a b --secret --ttl 10m
diff --git a/testdata/set__ok__with__ttl.ct b/testdata/set__ok__with__ttl.ct
new file mode 100644
index 0000000..ba410d8
--- /dev/null
+++ b/testdata/set__ok__with__ttl.ct
@@ -0,0 +1 @@
+$ pda set a b --ttl 30m
diff --git a/testdata/set-stdin.ct b/testdata/set__stdin__ok.ct
similarity index 51%
rename from testdata/set-stdin.ct
rename to testdata/set__stdin__ok.ct
index 3e9c843..86d11ba 100644
--- a/testdata/set-stdin.ct
+++ b/testdata/set__stdin__ok.ct
@@ -1,2 +1,2 @@
$ fecho cmd hello world
-$ pda set foo@ss < cmd
+$ pda set foo < cmd
diff --git a/testdata/template-enum-err.ct b/testdata/template-enum-err.ct
deleted file mode 100644
index b0597cf..0000000
--- a/testdata/template-enum-err.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# enum errors on invalid value
-$ fecho tpl {{ enum .LEVEL "info" "warn" }}
-$ pda set level@tple < tpl
-$ pda get level@tple LEVEL=debug --> FAIL
-FAIL cannot get 'level@tple': invalid value 'debug', allowed: [info warn]
diff --git a/testdata/template-no-template.ct b/testdata/template-no-template.ct
deleted file mode 100644
index 3615ef1..0000000
--- a/testdata/template-no-template.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# --no-template outputs raw template syntax without evaluation
-$ fecho tpl Hello, {{ .NAME }}
-$ pda set tmpl@tplnt < tpl
-$ pda get tmpl@tplnt --no-template
-Hello, {{ .NAME }}
diff --git a/testdata/template-pda-ref-err.ct b/testdata/template-pda-ref-err.ct
deleted file mode 100644
index e779297..0000000
--- a/testdata/template-pda-ref-err.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# pda errors on missing key
-$ fecho tpl1 {{ pda "missing" }}
-$ pda set ref@tplre < tpl1
-$ pda get ref@tplre --> FAIL
-FAIL cannot get 'ref@tplre': pda: cannot get 'missing': no such key
diff --git a/testdata/template-pda-ref.ct b/testdata/template-pda-ref.ct
deleted file mode 100644
index eccba79..0000000
--- a/testdata/template-pda-ref.ct
+++ /dev/null
@@ -1,13 +0,0 @@
-# pda function cross-references another key
-$ pda set base https://example.com
-$ fecho tpl1 {{ pda "base" }}/api
-$ pda set endpoint@tplr < tpl1
-$ pda get endpoint@tplr
-https://example.com/api
-# pda with substitution vars passed through
-$ fecho tpl2 Hello, {{ default "World" .NAME }}
-$ pda set greeting@tplr < tpl2
-$ fecho tpl3 {{ pda "greeting@tplr" }}!
-$ pda set shout@tplr < tpl3
-$ pda get shout@tplr NAME=Alice
-Hello, Alice!
diff --git a/testdata/template-require-err.ct b/testdata/template-require-err.ct
deleted file mode 100644
index 255cf57..0000000
--- a/testdata/template-require-err.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# require errors when variable is missing
-$ fecho tpl {{ require .FILE }}
-$ pda set tmpl@tplr < tpl
-$ pda get tmpl@tplr --> FAIL
-FAIL cannot get 'tmpl@tplr': required value is missing or empty
diff --git a/testdata/template-shell.ct b/testdata/template-shell.ct
deleted file mode 100644
index ad5c933..0000000
--- a/testdata/template-shell.ct
+++ /dev/null
@@ -1,5 +0,0 @@
-# Shell function executes a command and returns stdout
-$ fecho tpl1 {{ shell "echo hello" }}
-$ pda set shelltest@tpls < tpl1
-$ pda get shelltest@tpls
-hello
diff --git a/testdata/template.ct b/testdata/template.ct
deleted file mode 100644
index 324a9fb..0000000
--- a/testdata/template.ct
+++ /dev/null
@@ -1,27 +0,0 @@
-# Basic template substitution
-$ fecho tpl1 Hello, {{ .NAME }}
-$ pda set greeting@tpl < tpl1
-$ pda get greeting@tpl NAME=Alice
-Hello, Alice
-# Default function provides fallback value
-$ fecho tpl2 Hello, {{ default "World" .NAME }}
-$ pda set defval@tpl < tpl2
-$ pda get defval@tpl
-Hello, World
-$ pda get defval@tpl NAME=Bob
-Hello, Bob
-# Enum function restricts to allowed values
-$ fecho tpl3 {{ enum .LEVEL "info" "warn" }}
-$ pda set level@tpl < tpl3
-$ pda get level@tpl LEVEL=info
-info
-# Int function parses integer
-$ fecho tpl4 {{ int .N }}
-$ pda set number@tpl < tpl4
-$ pda get number@tpl N=42
-42
-# List function parses CSV
-$ fecho tpl5 {{ range list .NAMES }}{{.}},{{ end }}
-$ pda set names@tpl < tpl5
-$ pda get names@tpl NAMES=Bob,Alice
-Bob,Alice,