diff --git a/README.md b/README.md
index 82acb99..550b72d 100644
--- a/README.md
+++ b/README.md
@@ -394,7 +394,7 @@ Values support effectively all of Go's `text/template` syntax. Templates are eva
`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.
+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, `time`, `lists`, among others. These same functions are also available in `git.default_commit_message` templates, along with `{{ summary }}` which returns the action that triggered the commit (e.g. "set foo", "removed bar").
Below is more detail on the extra functions added by this tool.
@@ -438,6 +438,15 @@ pda get my_name
+`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` restricts acceptable values.
```bash
pda set level "Log level: {{ enum .LEVEL "info" "warn" "error" }}"
@@ -854,8 +863,9 @@ auto_fetch = false
auto_commit = false
# auto push after committing
auto_push = false
-# commit message template ({{.Time}} is replaced with RFC3339 timestamp)
-default_commit_message = "sync: {{.Time}}"
+# commit message if none manually specified
+# supports templates, see: #templates section
+default_commit_message = "{{ summary }} {{ time }}"
```
diff --git a/cmd/commit_message.go b/cmd/commit_message.go
new file mode 100644
index 0000000..4b3c0f5
--- /dev/null
+++ b/cmd/commit_message.go
@@ -0,0 +1,48 @@
+/*
+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
new file mode 100644
index 0000000..2c75d14
--- /dev/null
+++ b/cmd/commit_message_test.go
@@ -0,0 +1,53 @@
+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/config.go b/cmd/config.go
index 6a42a97..da42dcf 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -117,7 +117,7 @@ func defaultConfig() Config {
AutoFetch: false,
AutoCommit: false,
AutoPush: false,
- DefaultCommitMessage: "sync: {{.Time}}",
+ DefaultCommitMessage: "{{ summary }} {{ time }}",
},
}
}
diff --git a/cmd/del-db.go b/cmd/del-db.go
index b1ccd2b..31fe227 100644
--- a/cmd/del-db.go
+++ b/cmd/del-db.go
@@ -79,7 +79,7 @@ func delStore(cmd *cobra.Command, args []string) error {
if err := executeDeletion(path); err != nil {
return err
}
- return autoSync()
+ return autoSync(fmt.Sprintf("removed @%s", dbName))
}
func executeDeletion(path string) error {
diff --git a/cmd/del.go b/cmd/del.go
index e91d392..01f35a6 100644
--- a/cmd/del.go
+++ b/cmd/del.go
@@ -107,6 +107,7 @@ func del(cmd *cobra.Command, args []string) error {
return nil
}
+ var removedNames []string
for _, dbName := range storeOrder {
st := byStore[dbName]
p, err := store.storePath(dbName)
@@ -123,13 +124,14 @@ func del(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot remove '%s': no such key", t.full)
}
entries = append(entries[:idx], entries[idx+1:]...)
+ removedNames = append(removedNames, t.display)
}
if err := writeStoreFile(p, entries, nil); err != nil {
return err
}
}
- return autoSync()
+ return autoSync("removed " + strings.Join(removedNames, ", "))
}
func init() {
diff --git a/cmd/get.go b/cmd/get.go
index 116404f..5a3a85d 100644
--- a/cmd/get.go
+++ b/cmd/get.go
@@ -27,8 +27,6 @@ import (
"fmt"
"os"
"os/exec"
- "slices"
- "strconv"
"strings"
"text/template"
@@ -150,57 +148,11 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) {
val := parts[1]
vars[key] = val
}
- 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 '%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
- },
- }
tpl, err := template.New("cmd").
Delims("{{", "}}").
// Render missing map keys as zero values so the default helper can decide on fallbacks.
Option("missingkey=zero").
- Funcs(funcMap).
+ Funcs(templateFuncMap()).
Parse(string(tplBytes))
if err != nil {
return nil, err
diff --git a/cmd/mv-db.go b/cmd/mv-db.go
index 22c3cdc..f3a360e 100644
--- a/cmd/mv-db.go
+++ b/cmd/mv-db.go
@@ -102,6 +102,7 @@ func mvStore(cmd *cobra.Command, args []string) error {
}
copy, _ := cmd.Flags().GetBool("copy")
+ var summary string
if copy {
data, err := os.ReadFile(fromPath)
if err != nil {
@@ -111,13 +112,15 @@ func mvStore(cmd *cobra.Command, args []string) error {
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()
+ return autoSync(summary)
}
func init() {
diff --git a/cmd/mv.go b/cmd/mv.go
index 1d549db..ecc5e92 100644
--- a/cmd/mv.go
+++ b/cmd/mv.go
@@ -182,12 +182,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error {
}
}
+ 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()
}
- return autoSync()
+ return autoSync(summary)
}
func init() {
diff --git a/cmd/restore.go b/cmd/restore.go
index 90d7e3a..ba4a577 100644
--- a/cmd/restore.go
+++ b/cmd/restore.go
@@ -127,6 +127,7 @@ func restore(cmd *cobra.Command, args []string) error {
// 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)
if err != nil {
@@ -140,6 +141,7 @@ func restore(cmd *cobra.Command, args []string) error {
return err
}
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 {
@@ -149,9 +151,10 @@ func restore(cmd *cobra.Command, args []string) error {
return err
}
okf("restored %d entries", restored)
+ summary = fmt.Sprintf("imported %d entries", restored)
}
- return autoSync()
+ return autoSync(summary)
}
func reportRestoreFilters(displayTarget string, restored int, matchers []glob.Glob, keyPatterns []string, storeMatchers []glob.Glob, storePatterns []string) error {
diff --git a/cmd/set.go b/cmd/set.go
index 4f8c88d..e39586f 100644
--- a/cmd/set.go
+++ b/cmd/set.go
@@ -176,7 +176,7 @@ func set(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot set '%s': %v", args[0], err)
}
- return autoSync()
+ return autoSync("set " + spec.Display())
}
func init() {
diff --git a/cmd/sync.go b/cmd/sync.go
index 0353470..937e2be 100644
--- a/cmd/sync.go
+++ b/cmd/sync.go
@@ -23,9 +23,6 @@ THE SOFTWARE.
package cmd
import (
- "strings"
- "time"
-
"github.com/spf13/cobra"
)
@@ -35,7 +32,7 @@ var syncCmd = &cobra.Command{
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
msg, _ := cmd.Flags().GetString("message")
- return sync(true, msg)
+ return sync(true, msg, "sync")
},
}
@@ -44,7 +41,7 @@ func init() {
rootCmd.AddCommand(syncCmd)
}
-func sync(manual bool, customMsg string) error {
+func sync(manual bool, customMsg string, summary string) error {
repoDir, err := ensureVCSInitialized()
if err != nil {
return err
@@ -66,7 +63,7 @@ func sync(manual bool, customMsg string) error {
if changed {
msg := customMsg
if msg == "" {
- msg = strings.ReplaceAll(config.Git.DefaultCommitMessage, "{{.Time}}", time.Now().UTC().Format(time.RFC3339))
+ msg = renderCommitMessage(config.Git.DefaultCommitMessage, summary)
if manual {
printHint("use -m to set a custom commit message")
}
@@ -123,12 +120,12 @@ func sync(manual bool, customMsg string) error {
return nil
}
-func autoSync() error {
+func autoSync(summary string) error {
if !config.Git.AutoCommit {
return nil
}
if _, err := ensureVCSInitialized(); err != nil {
return nil
}
- return sync(false, "")
+ return sync(false, "", summary)
}
diff --git a/cmd/template.go b/cmd/template.go
new file mode 100644
index 0000000..0bd1209
--- /dev/null
+++ b/cmd/template.go
@@ -0,0 +1,85 @@
+/*
+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"
+ "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) },
+ }
+}
diff --git a/main_test.go b/main_test.go
index 72c9d4e..0eee94b 100644
--- a/main_test.go
+++ b/main_test.go
@@ -36,20 +36,6 @@ import (
var update = flag.Bool("update", false, "update test files with results")
func TestMain(t *testing.T) {
- t.Setenv("PDA_DATA", t.TempDir())
- configDir := t.TempDir()
- t.Setenv("PDA_CONFIG", configDir)
-
- // 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 {
- t.Fatalf("generate identity: %v", err)
- }
- if err := os.WriteFile(filepath.Join(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600); err != nil {
- t.Fatalf("write identity: %v", err)
- }
-
ts, err := cmdtest.Read("testdata")
if err != nil {
t.Fatalf("read testdata: %v", err)
@@ -59,5 +45,29 @@ 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)
+
+ // 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(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600)
+ }
+
ts.Run(t, *update)
}
diff --git a/testdata/config-init.ct b/testdata/config-init.ct
index 5d5eb85..ce99235 100644
--- a/testdata/config-init.ct
+++ b/testdata/config-init.ct
@@ -1,6 +1,6 @@
# Init creates a config file
$ pda config init
- ok generated config: /tmp/TestMain2533282848/002/config.toml
+ ok generated config: ${ROOTDIR}/config/config.toml
# Second init fails
$ pda config init --> FAIL
@@ -9,7 +9,7 @@ hint use '--update' to update your config, or '--new' to get a fresh copy
# Init --new overwrites
$ pda config init --new
- ok generated config: /tmp/TestMain2533282848/002/config.toml
+ ok generated config: ${ROOTDIR}/config/config.toml
# --update preserves user changes
$ pda config set list.always_show_all_stores false
@@ -18,7 +18,7 @@ $ pda config get list.always_show_all_stores
false
$ pda config init --update
$ pda config get list.always_show_all_stores
- ok updated config: /tmp/TestMain2533282848/002/config.toml
+ ok updated config: ${ROOTDIR}/config/config.toml
false
# --new and --update are mutually exclusive
@@ -27,4 +27,4 @@ FAIL --new and --update are mutually exclusive
# Reset for other tests
$ pda config init --new
- ok generated config: /tmp/TestMain2533282848/002/config.toml
+ ok generated config: ${ROOTDIR}/config/config.toml
diff --git a/testdata/config-list.ct b/testdata/config-list.ct
index 6a909d7..1bce175 100644
--- a/testdata/config-list.ct
+++ b/testdata/config-list.ct
@@ -15,4 +15,4 @@ list.default_columns = key,store,value,ttl
git.auto_fetch = false
git.auto_commit = false
git.auto_push = false
-git.default_commit_message = sync: {{.Time}}
+git.default_commit_message = {{ summary }} {{ time }}
diff --git a/testdata/list-all.ct b/testdata/list-all.ct
index 1694b4f..d6a4023 100644
--- a/testdata/list-all.ct
+++ b/testdata/list-all.ct
@@ -3,8 +3,8 @@ $ pda set lax@laa 1
$ pda set lax@lab 2
$ pda ls --key "lax" --format tsv
Key Store Value TTL
-lax laa 1 no expiry
-lax lab 2 no expiry
+lax laa 1 none
+lax lab 2 none
$ pda ls --key "lax" --count
2
$ pda ls --key "lax" --format json
@@ -12,15 +12,15 @@ $ pda ls --key "lax" --format json
# Positional arg narrows to one store
$ pda ls laa --key "lax" --format tsv
Key Store Value TTL
-lax laa 1 no expiry
+lax laa 1 none
# --store glob filter
$ pda ls --store "la?" --key "lax" --format tsv
Key Store Value TTL
-lax laa 1 no expiry
-lax lab 2 no expiry
+lax laa 1 none
+lax lab 2 none
$ pda ls --store "laa" --key "lax" --format tsv
Key Store Value TTL
-lax laa 1 no expiry
+lax laa 1 none
# --store cannot be combined with positional arg
$ pda ls --store "laa" laa --> FAIL
FAIL cannot use --store with a store argument
diff --git a/testdata/list-config-hide-header.ct b/testdata/list-config-hide-header.ct
index 6f8b61e..6a2c4f9 100644
--- a/testdata/list-config-hide-header.ct
+++ b/testdata/list-config-hide-header.ct
@@ -3,7 +3,7 @@ $ pda config set list.always_hide_header true
$ pda set a@lchh 1
$ pda ls lchh --format tsv
ok list.always_hide_header set to 'true'
-a lchh 1 no expiry
+a lchh 1 none
# Reset
$ pda config set list.always_hide_header false
diff --git a/testdata/list-format-csv.ct b/testdata/list-format-csv.ct
index 6d5fd73..e0cff1f 100644
--- a/testdata/list-format-csv.ct
+++ b/testdata/list-format-csv.ct
@@ -3,5 +3,5 @@ $ pda set a@csv 1
$ pda set b@csv 2
$ pda ls csv --format csv
Key,Store,Value,TTL
-a,csv,1,no expiry
-b,csv,2,no expiry
+a,csv,1,none
+b,csv,2,none
diff --git a/testdata/list-format-markdown.ct b/testdata/list-format-markdown.ct
index c27f045..67da1f2 100644
--- a/testdata/list-format-markdown.ct
+++ b/testdata/list-format-markdown.ct
@@ -4,5 +4,5 @@ $ pda set b@md 2
$ pda ls md --format markdown
| Key | Store | Value | TTL |
| --- | --- | --- | --- |
-| a | md | 1 | no expiry |
-| b | md | 2 | no expiry |
+| a | md | 1 | none |
+| b | md | 2 | none |
diff --git a/testdata/list-key-filter.ct b/testdata/list-key-filter.ct
index d10e229..57e931e 100644
--- a/testdata/list-key-filter.ct
+++ b/testdata/list-key-filter.ct
@@ -3,10 +3,10 @@ $ pda set a2@lg 2
$ pda set b1@lg 3
$ pda ls lg --key "a*" --format tsv
Key Store Value TTL
-a1 lg 1 no expiry
-a2 lg 2 no expiry
+a1 lg 1 none
+a2 lg 2 none
$ pda ls lg --key "b*" --format tsv
Key Store Value TTL
-b1 lg 3 no expiry
+b1 lg 3 none
$ pda ls lg --key "c*" --> FAIL
FAIL cannot ls '@lg': no matches for key pattern 'c*'
diff --git a/testdata/list-key-value-filter.ct b/testdata/list-key-value-filter.ct
index 5fde71a..1a9d094 100644
--- a/testdata/list-key-value-filter.ct
+++ b/testdata/list-key-value-filter.ct
@@ -3,9 +3,9 @@ $ pda set apiurl@kv https://api.example.com
$ pda set dbpass@kv s3cret
$ pda ls kv -k "db*" -v "**localhost**" --format tsv
Key Store Value TTL
-dburl kv postgres://localhost:5432 no expiry
+dburl kv postgres://localhost:5432 none
$ pda ls kv -k "*url*" -v "**example**" --format tsv
Key Store Value TTL
-apiurl kv https://api.example.com no expiry
+apiurl kv https://api.example.com none
$ 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-no-header.ct b/testdata/list-no-header.ct
index 6b80e52..92ca62b 100644
--- a/testdata/list-no-header.ct
+++ b/testdata/list-no-header.ct
@@ -1,4 +1,4 @@
# --no-header suppresses the header row
$ pda set a@nh 1
$ pda ls nh --format tsv --no-header
-a nh 1 no expiry
+a nh 1 none
diff --git a/testdata/list-no-keys.ct b/testdata/list-no-keys.ct
index c1ffe62..f444f6c 100644
--- a/testdata/list-no-keys.ct
+++ b/testdata/list-no-keys.ct
@@ -2,4 +2,4 @@
$ pda set a@nk 1
$ pda ls nk --format tsv --no-keys
Store Value TTL
-nk 1 no expiry
+nk 1 none
diff --git a/testdata/list-no-values.ct b/testdata/list-no-values.ct
index b76c081..388f330 100644
--- a/testdata/list-no-values.ct
+++ b/testdata/list-no-values.ct
@@ -2,4 +2,4 @@
$ pda set a@nv 1
$ pda ls nv --format tsv --no-values
Key Store TTL
-a nv no expiry
+a nv none
diff --git a/testdata/list-stores.ct b/testdata/list-stores.ct
index e5c7412..744109a 100644
--- a/testdata/list-stores.ct
+++ b/testdata/list-stores.ct
@@ -3,7 +3,7 @@ $ pda set a@lsalpha 1
$ pda set b@lsbeta 2
$ pda ls lsalpha --format tsv
Key Store Value TTL
-a lsalpha 1 no expiry
+a lsalpha 1 none
$ pda ls lsbeta --format tsv
Key Store Value TTL
-b lsbeta 2 no expiry
+b lsbeta 2 none
diff --git a/testdata/list-value-filter.ct b/testdata/list-value-filter.ct
index 0e29856..ecb31b8 100644
--- a/testdata/list-value-filter.ct
+++ b/testdata/list-value-filter.ct
@@ -4,12 +4,12 @@ $ pda set greeting@vt < tmpval
$ pda set number@vt 42
$ pda ls vt --value "**world**" --format tsv
Key Store Value TTL
-greeting vt hello world (..1 more chars) no expiry
+greeting vt hello world (..1 more chars) none
$ pda ls vt --value "**https**" --format tsv
Key Store Value TTL
-url vt https://example.com no expiry
+url vt https://example.com none
$ pda ls vt --value "*" --format tsv
Key Store Value TTL
-number vt 42 no expiry
+number vt 42 none
$ pda ls vt --value "**nomatch**" --> FAIL
FAIL cannot ls '@vt': no matches for value pattern '**nomatch**'
diff --git a/testdata/list-value-multi-filter.ct b/testdata/list-value-multi-filter.ct
index df1fe0b..4725bc7 100644
--- a/testdata/list-value-multi-filter.ct
+++ b/testdata/list-value-multi-filter.ct
@@ -4,5 +4,5 @@ $ pda set greeting@vm < tmpval
$ pda set number@vm 42
$ pda ls vm --value "**world**" --value "42" --format tsv
Key Store Value TTL
-greeting vm hello world (..1 more chars) no expiry
-number vm 42 no expiry
+greeting vm hello world (..1 more chars) none
+number vm 42 none
diff --git a/testdata/multistore.ct b/testdata/multistore.ct
index cde1df3..79e7f63 100644
--- a/testdata/multistore.ct
+++ b/testdata/multistore.ct
@@ -7,4 +7,4 @@ $ pda get x@ms2
y
$ pda ls ms2 --format tsv
Key Store Value TTL
-x ms2 y no expiry
+x ms2 y none
diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct
index 6f8fe32..c30a1e5 100644
--- a/testdata/remove-dedupe.ct
+++ b/testdata/remove-dedupe.ct
@@ -3,8 +3,8 @@ $ pda set foo@rdd 1
$ pda set bar@rdd 2
$ pda ls rdd --format tsv
Key Store Value TTL
-bar rdd 2 no expiry
-foo rdd 1 no expiry
+bar rdd 2 none
+foo rdd 1 none
$ pda rm foo@rdd --key "*@rdd" -y
$ pda get bar@rdd --> FAIL
FAIL cannot get 'bar@rdd': no such key