From 41ffa9341263d48c405cac1bf5fec02441152af5 Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 20 Dec 2025 02:23:10 +0000 Subject: [PATCH 001/107] docs(README): documents `pda vcs sync` --- README.md | 45 +++++++++++---------------------------------- 1 file changed, 11 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3d39640..539a07d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`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. +`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.

@@ -256,45 +256,22 @@ pda vcs init --clean

-`pda vcs snapshot` to save a copy of your pda. +`pda vcs sync` conducts a best-effort syncing of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!` itself, I recommend syncing. + +If you're ahead of your Git repo, syncing will add your changes, commit them, and push to remote if a remote is set. If you use multiple devices or otherwise end up behind your Git repo, syncing will detect this and give you a prompt: either stash your local changes and pull the latest commit from version control, or abort and fix the issue manually. + ```bash -pda vcs snapshot -# functionally, dumps all databases into pda/vcs -# to convert them into text, and commits them to -# your local pda! repository. +# Sync with Git +pda vcs sync ``` -

+`pda!` supports some automation via its config There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means exporting and versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. -`pda vcs push` and `pda vcs pull` -```bash -# Push to a remote repository. -pda vcs push +Auto-commit will commit changes immediately to the local Git repository any time `pda!` data is changed. Auto-fetch will fetch before committing any changes, but incurs a significant slowdown in operations simply due to the time a fetch takes. Auto-push will automatically push committed changes to the remote repository, if one is set. -# Pull all keys from version control and restore them. -pda vcs pull +If auto-commit is set to false, auto-fetch and auto-push will do nothing. They can be considered to be additional steps taken during the commit process. -# Or --clean to delete your existing stores before restoring. -pda vcs pull --clean -``` - -

- -`pda vcs gitignore` to generate a .gitignore for pda! -```bash -# Generate if not present. -pda vcs gitignore - -# Rewrite an existing .gitignore -pda vcs gitignore --rewrite -``` - -

- -`pda vcs log` to view pda!'s Git log. -```bash -pda vcs log -``` +Running `pda vcs sync` manually will always fetch, commit, and push - or if behind it will fetch, stash, and pull - regardless of config.

From d0a55afcbf246ce576595cec60f931d419006227 Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 20 Dec 2025 02:25:28 +0000 Subject: [PATCH 002/107] docs(README): missing period --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 539a07d..09b93db 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ If you're ahead of your Git repo, syncing will add your changes, commit them, an pda vcs sync ``` -`pda!` supports some automation via its config There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means exporting and versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. +`pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means exporting and versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. Auto-commit will commit changes immediately to the local Git repository any time `pda!` data is changed. Auto-fetch will fetch before committing any changes, but incurs a significant slowdown in operations simply due to the time a fetch takes. Auto-push will automatically push committed changes to the remote repository, if one is set. From ada4c6846d98ee6cde8db1ac8ead6dd09e428a46 Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 20 Dec 2025 02:28:31 +0000 Subject: [PATCH 003/107] docs(README): general sync recommendation --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 09b93db..df8b149 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,8 @@ If auto-commit is set to false, auto-fetch and auto-push will do nothing. They c Running `pda vcs sync` manually will always fetch, commit, and push - or if behind it will fetch, stash, and pull - regardless of config. +My general recommendation would be to enable `git.auto_commit`, and to run a manual `pda vcs sync` any time you're preparing to switch machines, or loading up a new one. +

### Templates From 5a1c5565932360ce6e4092a4610ea0ef1f8d1d8f Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 08:28:40 +0000 Subject: [PATCH 004/107] feat(vcs): extracts VCS cmds out. Exposes git command for running arbitrary git command. --- cmd/git.go | 55 +++++++++++++++ cmd/init.go | 120 ++++++++++++++++++++++++++++++++ cmd/sync.go | 133 +++++++++++++++++++++++++++++++++++ cmd/vcs.go | 197 +--------------------------------------------------- 4 files changed, 309 insertions(+), 196 deletions(-) create mode 100644 cmd/git.go create mode 100644 cmd/init.go create mode 100644 cmd/sync.go diff --git a/cmd/git.go b/cmd/git.go new file mode 100644 index 0000000..fc00b9e --- /dev/null +++ b/cmd/git.go @@ -0,0 +1,55 @@ +/* +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 git in the pda VCS repository", + 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/init.go b/cmd/init.go new file mode 100644 index 0000000..6f07de8 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,120 @@ +/* +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 (or fetch) Git-backed version control.", + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: vcsInit, +} + +func init() { + initCmd.Flags().Bool("clean", false, "Remove existing VCS directory before initialising") + rootCmd.AddCommand(initCmd) +} + +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) +} diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..9f7e532 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,133 @@ +/* +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" + "time" + + "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 { + return sync(true) + }, +} + +func init() { + rootCmd.AddCommand(syncCmd) +} + +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 +} + +func autoSync() error { + if !config.Git.AutoCommit { + return nil + } + return sync(false) +} diff --git a/cmd/vcs.go b/cmd/vcs.go index dc7c291..53c47e1 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -11,201 +11,13 @@ 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("") @@ -225,7 +37,7 @@ func ensureVCSInitialized() (string, error) { } if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("vcs repository not initialised; run 'pda vcs init' first") + return "", fmt.Errorf("vcs repository not initialised; run 'pda init' first") } return "", err } @@ -618,10 +430,3 @@ func hasMergeConflicts(dir string) (bool, error) { } return len(bytes.TrimSpace(out)) > 0, nil } - -func autoSync() error { - if !config.Git.AutoCommit { - return nil - } - return sync(false) -} From 92c30d4cad2d037571c044ccaf7a7e0491aa93a8 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 08:29:38 +0000 Subject: [PATCH 005/107] docs(README): tweaks to fit extracted vcs cmds --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index df8b149..59c8c66 100644 --- a/README.md +++ b/README.md @@ -73,10 +73,12 @@ Available Commands: list-dbs # List all databases. dump # Export a database as NDJSON. restore # Imports NDJSON into a database. + init # Initialise or fetch a Git repo for version control. + sync # Export, commit, pull, restore, and push changes. + git # Run git in the pda VCS repository. completion # Generate autocompletions for a specified shell. help # Additional help for any command. version # Current version. - vcs # List version control subcommands. ```

@@ -242,27 +244,27 @@ pda del-db birthdays --force 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. +`pda init` will initialise the version control system. ```bash # Initialise an empty pda! repository. -pda vcs init +pda init # Or clone an existing one. -pda vcs init https://github.com/llywelwyn/my-repository +pda init https://github.com/llywelwyn/my-repository # --clean to replace your (existing) local repo with a new one. -pda vcs init --clean +pda init --clean ```

-`pda vcs sync` conducts a best-effort syncing of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!` itself, I recommend syncing. +`pda sync` conducts a best-effort syncing of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!` itself, I recommend syncing. If you're ahead of your Git repo, syncing will add your changes, commit them, and push to remote if a remote is set. If you use multiple devices or otherwise end up behind your Git repo, syncing will detect this and give you a prompt: either stash your local changes and pull the latest commit from version control, or abort and fix the issue manually. ```bash # Sync with Git -pda vcs sync +pda sync ``` `pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means exporting and versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. @@ -271,9 +273,9 @@ Auto-commit will commit changes immediately to the local Git repository any time If auto-commit is set to false, auto-fetch and auto-push will do nothing. They can be considered to be additional steps taken during the commit process. -Running `pda vcs sync` manually will always fetch, commit, and push - or if behind it will fetch, stash, and pull - regardless of config. +Running `pda sync` manually will always fetch, commit, and push - or if behind it will fetch, stash, and pull - regardless of config. -My general recommendation would be to enable `git.auto_commit`, and to run a manual `pda vcs sync` any time you're preparing to switch machines, or loading up a new one. +My general recommendation would be to enable `git.auto_commit`, and to run a manual `pda sync` any time you're preparing to switch machines, or loading up a new one.

From ef597c5f2262b75034d5cd1f3969ac2e4e2e5f44 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 08:29:52 +0000 Subject: [PATCH 006/107] chore(cmd shorts): simplify --- cmd/mv.go | 4 ++-- cmd/set.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/mv.go b/cmd/mv.go index d902b3c..522a1dc 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -32,14 +32,14 @@ import ( var cpCmd = &cobra.Command{ Use: "cp FROM[@DB] TO[@DB]", - Short: "Make a copy of a key.", + Short: "Make a copy of a key", Args: cobra.ExactArgs(2), RunE: cp, } var mvCmd = &cobra.Command{ Use: "mv FROM[@DB] TO[@DB]", - Short: "Move a key between (or within) databases.", + Short: "Move a key", Args: cobra.ExactArgs(2), RunE: mv, SilenceUsage: true, diff --git a/cmd/set.go b/cmd/set.go index 66064ae..1592454 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -34,8 +34,8 @@ import ( // setCmd represents the set command var setCmd = &cobra.Command{ 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. + Short: "Set a key to a given value", + Long: `Set a key to a given value or stdin. Optionally specify a db. PDA supports parsing Go templates. Actions are delimited with {{ }}. From 94676757156e930c4af249bb82f8c4c131edfdcd Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 08:42:20 +0000 Subject: [PATCH 007/107] chore(cmd): updates Shorts, and test expectations. --- cmd/del-db.go | 2 +- cmd/del.go | 2 +- cmd/get.go | 4 ++-- cmd/git.go | 9 ++++++-- cmd/init.go | 2 +- cmd/list-dbs.go | 2 +- cmd/list.go | 2 +- cmd/root.go | 2 +- testdata/help__del-db__ok.ct | 4 ++-- testdata/help__del__ok.ct | 4 ++-- testdata/help__get__ok.ct | 4 ++-- testdata/help__list-dbs__ok.ct | 4 ++-- testdata/help__list__ok.ct | 4 ++-- testdata/help__ok.ct | 40 +++++++++++++++++++--------------- testdata/help__set__ok.ct | 4 ++-- testdata/root__ok.ct | 20 +++++++++-------- 16 files changed, 60 insertions(+), 49 deletions(-) diff --git a/cmd/del-db.go b/cmd/del-db.go index e563f33..540bd9e 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -34,7 +34,7 @@ import ( // delDbCmd represents the set command var delDbCmd = &cobra.Command{ Use: "del-db DB", - Short: "Delete a database.", + Short: "Delete a database", Aliases: []string{"delete-db", "rm-db", "remove-db"}, Args: cobra.ExactArgs(1), RunE: delDb, diff --git a/cmd/del.go b/cmd/del.go index a2e6072..846216c 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -35,7 +35,7 @@ import ( // delCmd represents the set command var delCmd = &cobra.Command{ Use: "del KEY[@DB] [KEY[@DB] ...]", - Short: "Delete one or more keys. Optionally specify a db.", + Short: "Delete one or more keys", Aliases: []string{"delete", "rm", "remove"}, Args: cobra.ArbitraryArgs, RunE: del, diff --git a/cmd/get.go b/cmd/get.go index 7056c31..0f50e46 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -39,8 +39,8 @@ import ( // getCmd represents the get command var getCmd = &cobra.Command{ 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. + Short: "Get the value of a key", + Long: `Get the value of a key. Optionally specify a db. {{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an additional argument after the initial KEY being fetched. diff --git a/cmd/git.go b/cmd/git.go index fc00b9e..caa3015 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -30,8 +30,13 @@ import ( ) var gitCmd = &cobra.Command{ - Use: "git [args...]", - Short: "Run git in the pda VCS repository", + Use: "git [args...]", + Short: "Run any arbitrary command. Use with caution.", + Long: `Run any arbitrary command. Use with caution. + +Be wary of how pda! version control operates before using this. Regular data is stored in "PDA_DATA/pda/stores" as a database; the Git repository is in "PDA_DATA/pda/vcs" and contains a replica of the database stored as plaintext. + +The regular sync command (or auto-syncing) exports pda! data into plaintext in the Git repository. If you manually modify the repository without using the built-in commands, or exporting your data to the folder in the correct format first, you may desynchronize your repository.`, Args: cobra.ArbitraryArgs, DisableFlagParsing: true, SilenceUsage: true, diff --git a/cmd/init.go b/cmd/init.go index 6f07de8..d0259b2 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -33,7 +33,7 @@ import ( var initCmd = &cobra.Command{ Use: "init [remote-url]", - Short: "Initialise (or fetch) Git-backed version control.", + Short: "Initialise pda! version control", SilenceUsage: true, Args: cobra.MaximumNArgs(1), RunE: vcsInit, diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index effde4f..33a5ba1 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -30,7 +30,7 @@ import ( // delCmd represents the set command var listDbsCmd = &cobra.Command{ Use: "list-dbs", - Short: "List all dbs.", + Short: "List all databases", Aliases: []string{"ls-dbs", "lsd"}, Args: cobra.NoArgs, RunE: listDbs, diff --git a/cmd/list.go b/cmd/list.go index 69fc6c9..b5a46fa 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -33,7 +33,7 @@ import ( var listCmd = &cobra.Command{ Use: "list [DB]", - Short: "List the contents of a db.", + Short: "List the contents of a database", Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(1), RunE: list, diff --git a/cmd/root.go b/cmd/root.go index 80d9a79..4f01fe0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,7 +32,7 @@ import ( // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "pda", - Short: "A key-value store.", + Short: "A key-value store tool", Long: asciiArt, } diff --git a/testdata/help__del-db__ok.ct b/testdata/help__del-db__ok.ct index c758df0..b634f62 100644 --- a/testdata/help__del-db__ok.ct +++ b/testdata/help__del-db__ok.ct @@ -1,6 +1,6 @@ $ pda help del-db $ pda del-db --help -Delete a database. +Delete a database Usage: pda del-db DB [flags] @@ -11,7 +11,7 @@ Aliases: Flags: -h, --help help for del-db -i, --interactive Prompt yes/no for each deletion -Delete a database. +Delete a database Usage: pda del-db DB [flags] diff --git a/testdata/help__del__ok.ct b/testdata/help__del__ok.ct index 6ec88b7..42f3fb2 100644 --- a/testdata/help__del__ok.ct +++ b/testdata/help__del__ok.ct @@ -1,6 +1,6 @@ $ pda help del $ pda del --help -Delete one or more keys. Optionally specify a db. +Delete one or more keys Usage: pda del KEY[@DB] [KEY[@DB] ...] [flags] @@ -13,7 +13,7 @@ Flags: --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. +Delete one or more keys Usage: pda del KEY[@DB] [KEY[@DB] ...] [flags] diff --git a/testdata/help__get__ok.ct b/testdata/help__get__ok.ct index 0edc4f2..23557f2 100644 --- a/testdata/help__get__ok.ct +++ b/testdata/help__get__ok.ct @@ -1,6 +1,6 @@ $ pda help get $ pda get --help -Get a value for a key. Optionally specify a db. +Get the value of a key. Optionally specify a db. {{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an additional argument after the initial KEY being fetched. @@ -21,7 +21,7 @@ Flags: --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. +Get the value of a key. Optionally specify a db. {{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an additional argument after the initial KEY being fetched. diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help__list-dbs__ok.ct index bf0f0de..05945f2 100644 --- a/testdata/help__list-dbs__ok.ct +++ b/testdata/help__list-dbs__ok.ct @@ -1,6 +1,6 @@ $ pda help list-dbs $ pda list-dbs --help -List all dbs. +List all databases Usage: pda list-dbs [flags] @@ -10,7 +10,7 @@ Aliases: Flags: -h, --help help for list-dbs -List all dbs. +List all databases Usage: pda list-dbs [flags] diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index 349f4cb..f87b112 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -1,6 +1,6 @@ $ pda help list $ pda list --help -List the contents of a db. +List the contents of a database Usage: pda list [DB] [flags] @@ -19,7 +19,7 @@ Flags: --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. +List the contents of a database Usage: pda list [DB] [flags] diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index 61f9903..d701f24 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -14,18 +14,20 @@ Usage: 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. + cp Make a copy of a key + del Delete one or more keys + del-db Delete a database dump Dump all key/value pairs as NDJSON - get Get a value for a key. Optionally specify a db. + get Get the value of a key + git Run any arbitrary command. Use with caution. 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. + init Initialise pda! version control + list List the contents of a database + list-dbs List all databases + mv Move a key 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 + set Set a key to a given value + sync Manually sync your stores with Git version Display pda! version Flags: @@ -46,18 +48,20 @@ Usage: 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. + cp Make a copy of a key + del Delete one or more keys + del-db Delete a database dump Dump all key/value pairs as NDJSON - get Get a value for a key. Optionally specify a db. + get Get the value of a key + git Run any arbitrary command. Use with caution. 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. + init Initialise pda! version control + list List the contents of a database + list-dbs List all databases + mv Move a key 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 + set Set a key to a given value + sync Manually sync your stores with Git version Display pda! version Flags: diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct index 3eb19fd..9fc0925 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -1,6 +1,6 @@ $ pda help set $ pda set --help -Set a value for a key by passing VALUE or Stdin. Optionally specify a db. +Set a key to a given value or stdin. Optionally specify a db. PDA supports parsing Go templates. Actions are delimited with {{ }}. @@ -22,7 +22,7 @@ Flags: -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. +Set a key to a given value or stdin. Optionally specify a db. PDA supports parsing Go templates. Actions are delimited with {{ }}. diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index fac97c5..01aae35 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -13,18 +13,20 @@ Usage: 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. + cp Make a copy of a key + del Delete one or more keys + del-db Delete a database dump Dump all key/value pairs as NDJSON - get Get a value for a key. Optionally specify a db. + get Get the value of a key + git Run any arbitrary command. Use with caution. 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. + init Initialise pda! version control + list List the contents of a database + list-dbs List all databases + mv Move a key 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 + set Set a key to a given value + sync Manually sync your stores with Git version Display pda! version Flags: From 3d5a3f2aa17fffd984eac925a54d978cfaac2836 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 09:07:45 +0000 Subject: [PATCH 008/107] refactor(branding?): swapped all references to db/dbs to store/stores --- README.md | 18 ++++++------- cmd/del-db.go | 32 +++++++++++------------ cmd/del.go | 2 +- cmd/dump.go | 4 +-- cmd/get.go | 4 +-- cmd/git.go | 4 +-- cmd/keyspec.go | 14 +++++----- cmd/list-dbs.go | 16 ++++++------ cmd/list.go | 6 ++--- cmd/mv.go | 4 +-- cmd/restore.go | 2 +- cmd/set.go | 4 +-- cmd/shared.go | 8 +++--- cmd/vcs.go | 6 ++--- testdata/del-db__err__with__invalid_db.ct | 4 +-- testdata/get__err__with__invalid_db.ct | 2 +- testdata/help__del-db__ok.ct | 20 +++++++------- testdata/help__del__ok.ct | 4 +-- testdata/help__dump__ok.ct | 4 +-- testdata/help__get__ok.ct | 8 +++--- testdata/help__list-dbs__ok.ct | 20 +++++++------- testdata/help__list__ok.ct | 8 +++--- testdata/help__ok.ct | 12 ++++----- testdata/help__restore__ok.ct | 4 +-- testdata/help__set__ok.ct | 8 +++--- testdata/list__err__with__invalid_db.ct | 2 +- testdata/root__ok.ct | 6 ++--- 27 files changed, 113 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 59c8c66..d19c9a8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`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. +`pda!` canonically stores key-value pairs in [badger](https://github.com/dgraph-io/badger) stores 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.

@@ -69,10 +69,10 @@ Available Commands: 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. + del-store # Delete a whole store. + list-stores # List all stores. + dump # Export a store as NDJSON. + restore # Imports NDJSON into a store. init # Initialise or fetch a Git repo for version control. sync # Export, commit, pull, restore, and push changes. git # Run git in the pda VCS repository. @@ -215,11 +215,11 @@ pda restore --glob a* -f my_backup You can have as many stores as you want. ```bash -# Save to a spceific store. +# Save to a specific store. pda set alice@birthdays 11/11/1998 # See which stores have contents. -pda list-dbs +pda list-stores # @default # @birthdays @@ -235,7 +235,7 @@ pda dump birthdays > friends_birthdays pda restore birthdays < friends_birthdays # Delete it. -pda del-db birthdays --force +pda del-store birthdays --force ```

@@ -378,7 +378,7 @@ pda get hello --no-template 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. +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 store.

diff --git a/cmd/del-db.go b/cmd/del-db.go index 540bd9e..cc5d425 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -31,43 +31,43 @@ import ( "github.com/spf13/cobra" ) -// 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"}, +// delStoreCmd represents the set command +var delStoreCmd = &cobra.Command{ + Use: "del-store STORE", + Short: "Delete a store", + Aliases: []string{"delete-store", "rm-store", "remove-store"}, Args: cobra.ExactArgs(1), - RunE: delDb, + RunE: delStore, SilenceUsage: true, } -func delDb(cmd *cobra.Command, args []string) error { +func delStore(cmd *cobra.Command, args []string) error { store := &Store{} dbName, err := store.parseDB(args[0], false) if err != nil { - return fmt.Errorf("cannot delete-db '%s': %v", args[0], err) + return fmt.Errorf("cannot delete-store '%s': %v", args[0], err) } var notFound errNotFound path, err := store.FindStore(dbName) if errors.As(err, ¬Found) { - return fmt.Errorf("cannot delete-db '%s': %v", dbName, err) + return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) } if err != nil { - return fmt.Errorf("cannot delete-db '%s': %v", dbName, err) + return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) } interactive, err := cmd.Flags().GetBool("interactive") if err != nil { - return fmt.Errorf("cannot delete-db '%s': %v", dbName, err) + return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) } if interactive || config.Store.AlwaysPromptDelete { - message := fmt.Sprintf("delete-db '%s': are you sure? (y/n)", args[0]) + message := fmt.Sprintf("delete-store '%s': are you sure? (y/n)", args[0]) fmt.Println(message) var confirm string if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot delete-db '%s': %v", dbName, err) + return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) } if strings.ToLower(confirm) != "y" { return nil @@ -81,12 +81,12 @@ func delDb(cmd *cobra.Command, args []string) error { func executeDeletion(path string) error { if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("cannot delete-db '%s': %v", path, err) + return fmt.Errorf("cannot delete-store '%s': %v", path, err) } return nil } func init() { - delDbCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") - rootCmd.AddCommand(delDbCmd) + delStoreCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") + rootCmd.AddCommand(delStoreCmd) } diff --git a/cmd/del.go b/cmd/del.go index 846216c..ad27031 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -34,7 +34,7 @@ import ( // delCmd represents the set command var delCmd = &cobra.Command{ - Use: "del KEY[@DB] [KEY[@DB] ...]", + Use: "del KEY[@STORE] [KEY[@STORE] ...]", Short: "Delete one or more keys", Aliases: []string{"delete", "rm", "remove"}, Args: cobra.ArbitraryArgs, diff --git a/cmd/dump.go b/cmd/dump.go index fe0f7f0..060f6ce 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -45,7 +45,7 @@ type dumpEntry struct { } var dumpCmd = &cobra.Command{ - Use: "dump [DB]", + Use: "dump [STORE]", Short: "Dump all key/value pairs as NDJSON", Aliases: []string{"export"}, Args: cobra.MaximumNArgs(1), @@ -130,7 +130,7 @@ func encodeText(entry *dumpEntry, key []byte, v []byte) error { return nil } -// DumpOptions controls how a database is dumped to NDJSON. +// DumpOptions controls how a store is dumped to NDJSON. type DumpOptions struct { Encoding string IncludeSecret bool diff --git a/cmd/get.go b/cmd/get.go index 0f50e46..28edda2 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -38,9 +38,9 @@ import ( // getCmd represents the get command var getCmd = &cobra.Command{ - Use: "get KEY[@DB]", + Use: "get KEY[@STORE]", Short: "Get the value of a key", - Long: `Get the value of a key. Optionally specify a db. + Long: `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. diff --git a/cmd/git.go b/cmd/git.go index caa3015..1dbe4b7 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -32,9 +32,9 @@ import ( var gitCmd = &cobra.Command{ Use: "git [args...]", Short: "Run any arbitrary command. Use with caution.", - Long: `Run any arbitrary command. Use with caution. +Long: `Run any arbitrary command. Use with caution. -Be wary of how pda! version control operates before using this. Regular data is stored in "PDA_DATA/pda/stores" as a database; the Git repository is in "PDA_DATA/pda/vcs" and contains a replica of the database stored as plaintext. +Be wary of how pda! version control operates before using this. Regular data is stored in "PDA_DATA/pda/stores" as a store; the Git repository is in "PDA_DATA/pda/vcs" and contains a plaintext replica of the store data. The regular sync command (or auto-syncing) exports pda! data into plaintext in the Git repository. If you manually modify the repository without using the built-in commands, or exporting your data to the folder in the correct format first, you may desynchronize your repository.`, Args: cobra.ArbitraryArgs, diff --git a/cmd/keyspec.go b/cmd/keyspec.go index ce4c4ec..1f6739f 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 // DB segment + RawDB string // Store segment Key string // Normalised Key - DB string // Normalised DB + DB string // Normalised store } -// ParseKey parses "KEY[@DB]" into a normalized KeySpec. -// When defaults is true, a missing DB defaults to the configured default. +// ParseKey parses "KEY[@STORE]" into a normalized KeySpec. +// When defaults is true, a missing store 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@DB") + return KeySpec{}, fmt.Errorf("bad key format, use KEY@STORE") } 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@DB") + return KeySpec{}, fmt.Errorf("bad key format, use KEY@STORE") } 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 database if none was set manually +// but omits the default store 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 33a5ba1..633f0c9 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -28,20 +28,20 @@ import ( ) // delCmd represents the set command -var listDbsCmd = &cobra.Command{ - Use: "list-dbs", - Short: "List all databases", - Aliases: []string{"ls-dbs", "lsd"}, +var listStoresCmd = &cobra.Command{ + Use: "list-stores", + Short: "List all stores", + Aliases: []string{"ls-stores", "lsd"}, Args: cobra.NoArgs, - RunE: listDbs, + RunE: listStores, SilenceUsage: true, } -func listDbs(cmd *cobra.Command, args []string) error { +func listStores(cmd *cobra.Command, args []string) error { store := &Store{} dbs, err := store.AllStores() if err != nil { - return fmt.Errorf("cannot list-dbs: %v", err) + return fmt.Errorf("cannot list-stores: %v", err) } for _, db := range dbs { fmt.Println("@" + db) @@ -50,5 +50,5 @@ func listDbs(cmd *cobra.Command, args []string) error { } func init() { - rootCmd.AddCommand(listDbsCmd) + rootCmd.AddCommand(listStoresCmd) } diff --git a/cmd/list.go b/cmd/list.go index b5a46fa..3dd83a8 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -32,8 +32,8 @@ import ( ) var listCmd = &cobra.Command{ - Use: "list [DB]", - Short: "List the contents of a database", + Use: "list [STORE]", + Short: "List the contents of a store", Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(1), RunE: list, @@ -52,7 +52,7 @@ 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': No such DB", args[0]) + return fmt.Errorf("cannot ls '%s': No such store", args[0]) } return fmt.Errorf("cannot ls '%s': %v", args[0], err) } diff --git a/cmd/mv.go b/cmd/mv.go index 522a1dc..4dc1c75 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -31,14 +31,14 @@ import ( ) var cpCmd = &cobra.Command{ - Use: "cp FROM[@DB] TO[@DB]", + Use: "cp FROM[@STORE] TO[@STORE]", Short: "Make a copy of a key", Args: cobra.ExactArgs(2), RunE: cp, } var mvCmd = &cobra.Command{ - Use: "mv FROM[@DB] TO[@DB]", + Use: "mv FROM[@STORE] TO[@STORE]", Short: "Move a key", Args: cobra.ExactArgs(2), RunE: mv, diff --git a/cmd/restore.go b/cmd/restore.go index b42c983..7e994f7 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -36,7 +36,7 @@ import ( ) var restoreCmd = &cobra.Command{ - Use: "restore [DB]", + Use: "restore [STORE]", Short: "Restore key/value pairs from an NDJSON dump", Aliases: []string{"import"}, Args: cobra.MaximumNArgs(1), diff --git a/cmd/set.go b/cmd/set.go index 1592454..483c5e8 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -33,9 +33,9 @@ import ( // setCmd represents the set command var setCmd = &cobra.Command{ - Use: "set KEY[@DB] [VALUE]", + 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 db. + Long: `Set a key to a given value or stdin. Optionally specify a store. PDA supports parsing Go templates. Actions are delimited with {{ }}. diff --git a/cmd/shared.go b/cmd/shared.go index 67760ca..3dcaa61 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -176,10 +176,10 @@ func (s *Store) parseDB(v string, defaults bool) (string, error) { if defaults { return config.Store.DefaultStoreName, nil } - return "", fmt.Errorf("cannot parse db: bad db format, use DB or @DB") + return "", fmt.Errorf("cannot parse store: bad store format, use STORE or @STORE") } if err := validateDBName(db); err != nil { - return "", fmt.Errorf("cannot parse db: %w", err) + return "", fmt.Errorf("cannot parse store: %w", err) } return strings.ToLower(db), nil } @@ -262,7 +262,7 @@ func ensureSubpath(base, target string) error { func validateDBName(name string) error { if strings.ContainsAny(name, `/\~`) { - return fmt.Errorf("bad db format, use DB or @DB") + return fmt.Errorf("bad store format, use STORE or @STORE") } return nil } @@ -279,7 +279,7 @@ func formatExpiry(expiresAt uint64) string { return fmt.Sprintf("%s (in %s)", expiry.Format(time.RFC3339), remaining.Round(time.Second)) } -// Keys returns all keys for the provided database name (or default if empty). +// Keys returns all keys for the provided store name (or default if empty). // Keys are returned in lowercase to mirror stored key format. func (s *Store) Keys(dbName string) ([]string, error) { db, err := s.open(dbName) diff --git a/cmd/vcs.go b/cmd/vcs.go index 53c47e1..0e81140 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -93,7 +93,7 @@ func snapshotDB(store *Store, repoDir, db string) error { } // exportAllStores writes every Badger store to ndjson files under repoDir/stores -// and removes stale snapshot files for deleted databases. +// and removes stale snapshot files for deleted stores. func exportAllStores(store *Store, repoDir string) error { stores, err := store.AllStores() if err != nil { @@ -335,7 +335,7 @@ func restoreAllSnapshots(store *Store, repoDir string) error { return err } if err := os.RemoveAll(dbPath); err != nil { - return fmt.Errorf("remove db '%s': %w", db, err) + return fmt.Errorf("remove store '%s': %w", db, err) } } @@ -353,7 +353,7 @@ func wipeAllStores(store *Store) error { return err } if err := os.RemoveAll(path); err != nil { - return fmt.Errorf("remove db '%s': %w", db, err) + return fmt.Errorf("remove store '%s': %w", db, err) } } return nil diff --git a/testdata/del-db__err__with__invalid_db.ct b/testdata/del-db__err__with__invalid_db.ct index 0d8d373..4d8a1e1 100644 --- a/testdata/del-db__err__with__invalid_db.ct +++ b/testdata/del-db__err__with__invalid_db.ct @@ -1,2 +1,2 @@ -$ pda del-db foo/bar --> FAIL -Error: cannot delete-db 'foo/bar': cannot parse db: bad db format, use DB or @DB +$ pda del-store foo/bar --> FAIL +Error: cannot delete-store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE diff --git a/testdata/get__err__with__invalid_db.ct b/testdata/get__err__with__invalid_db.ct index c082ae3..6be4beb 100644 --- a/testdata/get__err__with__invalid_db.ct +++ b/testdata/get__err__with__invalid_db.ct @@ -1,2 +1,2 @@ $ pda get key@foo/bar --> FAIL -Error: cannot get 'key@foo/bar': bad db format, use DB or @DB +Error: cannot get 'key@foo/bar': bad store format, use STORE or @STORE diff --git a/testdata/help__del-db__ok.ct b/testdata/help__del-db__ok.ct index b634f62..1ab2233 100644 --- a/testdata/help__del-db__ok.ct +++ b/testdata/help__del-db__ok.ct @@ -1,24 +1,24 @@ -$ pda help del-db -$ pda del-db --help -Delete a database +$ pda help del-store +$ pda del-store --help +Delete a store Usage: - pda del-db DB [flags] + pda del-store STORE [flags] Aliases: - del-db, delete-db, rm-db, remove-db + del-store, delete-store, rm-store, remove-store Flags: - -h, --help help for del-db + -h, --help help for del-store -i, --interactive Prompt yes/no for each deletion -Delete a database +Delete a store Usage: - pda del-db DB [flags] + pda del-store STORE [flags] Aliases: - del-db, delete-db, rm-db, remove-db + del-store, delete-store, rm-store, remove-store Flags: - -h, --help help for del-db + -h, --help help for del-store -i, --interactive Prompt yes/no for each deletion diff --git a/testdata/help__del__ok.ct b/testdata/help__del__ok.ct index 42f3fb2..9d53415 100644 --- a/testdata/help__del__ok.ct +++ b/testdata/help__del__ok.ct @@ -3,7 +3,7 @@ $ pda del --help Delete one or more keys Usage: - pda del KEY[@DB] [KEY[@DB] ...] [flags] + pda del KEY[@STORE] [KEY[@STORE] ...] [flags] Aliases: del, delete, rm, remove @@ -16,7 +16,7 @@ Flags: Delete one or more keys Usage: - pda del KEY[@DB] [KEY[@DB] ...] [flags] + pda del KEY[@STORE] [KEY[@STORE] ...] [flags] Aliases: del, delete, rm, remove diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index c471a6d..19cc6d8 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -3,7 +3,7 @@ $ pda dump --help Dump all key/value pairs as NDJSON Usage: - pda dump [DB] [flags] + pda dump [STORE] [flags] Aliases: dump, export @@ -17,7 +17,7 @@ Flags: Dump all key/value pairs as NDJSON Usage: - pda dump [DB] [flags] + pda dump [STORE] [flags] Aliases: dump, export diff --git a/testdata/help__get__ok.ct b/testdata/help__get__ok.ct index 23557f2..c484fa9 100644 --- a/testdata/help__get__ok.ct +++ b/testdata/help__get__ok.ct @@ -1,6 +1,6 @@ $ pda help get $ pda get --help -Get the value of a key. Optionally specify a db. +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. @@ -10,7 +10,7 @@ For example: pda get greeting NAME=World Usage: - pda get KEY[@DB] [flags] + pda get KEY[@STORE] [flags] Aliases: get, g @@ -21,7 +21,7 @@ Flags: --no-template directly output template syntax -c, --run execute the result as a shell command --secret display values marked as secret -Get the value of a key. Optionally specify a db. +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. @@ -31,7 +31,7 @@ For example: pda get greeting NAME=World Usage: - pda get KEY[@DB] [flags] + pda get KEY[@STORE] [flags] Aliases: get, g diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help__list-dbs__ok.ct index 05945f2..5f4a311 100644 --- a/testdata/help__list-dbs__ok.ct +++ b/testdata/help__list-dbs__ok.ct @@ -1,22 +1,22 @@ -$ pda help list-dbs -$ pda list-dbs --help -List all databases +$ pda help list-stores +$ pda list-stores --help +List all stores Usage: - pda list-dbs [flags] + pda list-stores [flags] Aliases: - list-dbs, ls-dbs, lsd + list-stores, ls-stores, lsd Flags: - -h, --help help for list-dbs -List all databases + -h, --help help for list-stores +List all stores Usage: - pda list-dbs [flags] + pda list-stores [flags] Aliases: - list-dbs, ls-dbs, lsd + list-stores, ls-stores, lsd Flags: - -h, --help help for list-dbs + -h, --help help for list-stores diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index f87b112..f259b55 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -1,9 +1,9 @@ $ pda help list $ pda list --help -List the contents of a database +List the contents of a store Usage: - pda list [DB] [flags] + pda list [STORE] [flags] Aliases: list, ls @@ -19,10 +19,10 @@ Flags: --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 database +List the contents of a store Usage: - pda list [DB] [flags] + pda list [STORE] [flags] Aliases: list, ls diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index d701f24..e14ce8e 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -16,14 +16,14 @@ Available Commands: completion Generate the autocompletion script for the specified shell cp Make a copy of a key del Delete one or more keys - del-db Delete a database + del-store Delete a store dump Dump all key/value pairs as NDJSON get Get the value of a key git Run any arbitrary command. Use with caution. help Help about any command init Initialise pda! version control - list List the contents of a database - list-dbs List all databases + list List the contents of a store + list-stores List all stores mv Move a key restore Restore key/value pairs from an NDJSON dump set Set a key to a given value @@ -50,14 +50,14 @@ Available Commands: completion Generate the autocompletion script for the specified shell cp Make a copy of a key del Delete one or more keys - del-db Delete a database + del-store Delete a store dump Dump all key/value pairs as NDJSON get Get the value of a key git Run any arbitrary command. Use with caution. help Help about any command init Initialise pda! version control - list List the contents of a database - list-dbs List all databases + list List the contents of a store + list-stores List all stores mv Move a key restore Restore key/value pairs from an NDJSON dump set Set a key to a given value diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index 63a3771..3f1c4fb 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -3,7 +3,7 @@ $ pda restore --help Restore key/value pairs from an NDJSON dump Usage: - pda restore [DB] [flags] + pda restore [STORE] [flags] Aliases: restore, import @@ -17,7 +17,7 @@ Flags: Restore key/value pairs from an NDJSON dump Usage: - pda restore [DB] [flags] + pda restore [STORE] [flags] Aliases: restore, import diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct index 9fc0925..37c8f10 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -1,6 +1,6 @@ $ pda help set $ pda set --help -Set a key to a given value or stdin. Optionally specify a db. +Set a key to a given value or stdin. Optionally specify a store. PDA supports parsing Go templates. Actions are delimited with {{ }}. @@ -12,7 +12,7 @@ For example: '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob. Usage: - pda set KEY[@DB] [VALUE] [flags] + pda set KEY[@STORE] [VALUE] [flags] Aliases: set, s @@ -22,7 +22,7 @@ Flags: -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 key to a given value or stdin. Optionally specify a db. +Set a key to a given value or stdin. Optionally specify a store. PDA supports parsing Go templates. Actions are delimited with {{ }}. @@ -34,7 +34,7 @@ For example: '{{ enum .NAME "Alice" "Bob" }}' allows only NAME=Alice or NAME=Bob. Usage: - pda set KEY[@DB] [VALUE] [flags] + pda set KEY[@STORE] [VALUE] [flags] Aliases: set, s diff --git a/testdata/list__err__with__invalid_db.ct b/testdata/list__err__with__invalid_db.ct index b2594dc..f53a448 100644 --- a/testdata/list__err__with__invalid_db.ct +++ b/testdata/list__err__with__invalid_db.ct @@ -1,2 +1,2 @@ $ pda ls foo/bar --> FAIL -Error: cannot ls 'foo/bar': cannot parse db: bad db format, use DB or @DB +Error: cannot ls 'foo/bar': cannot parse store: bad store format, use STORE or @STORE diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index 01aae35..580ee43 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -15,14 +15,14 @@ Available Commands: completion Generate the autocompletion script for the specified shell cp Make a copy of a key del Delete one or more keys - del-db Delete a database + del-store Delete a store dump Dump all key/value pairs as NDJSON get Get the value of a key git Run any arbitrary command. Use with caution. help Help about any command init Initialise pda! version control - list List the contents of a database - list-dbs List all databases + list List the contents of a store + list-stores List all stores mv Move a key restore Restore key/value pairs from an NDJSON dump set Set a key to a given value From c2d1ec08422461d33bd6fce6b0fb6b6e2b75d786 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 09:35:31 +0000 Subject: [PATCH 009/107] refactor(del): made remove the default case --- cmd/del-db.go | 4 +- cmd/del.go | 4 +- cmd/dump.go | 4 +- cmd/list-dbs.go | 2 +- cmd/mv.go | 12 ++-- cmd/restore.go | 4 +- cmd/root.go | 24 ++++++- testdata/del-db__err__with__invalid_db.ct | 2 +- testdata/del__dedupe__ok.ct | 2 +- testdata/del__glob__mixed__ok.ct | 2 +- testdata/del__glob__ok.ct | 2 +- testdata/del__multiple__ok.ct | 2 +- testdata/del__ok.ct | 2 +- testdata/help__del-db__ok.ct | 16 ++--- testdata/help__del__ok.ct | 16 ++--- testdata/help__dump__ok.ct | 12 ++-- testdata/help__list-dbs__ok.ct | 4 +- testdata/help__ok.ct | 80 +++++++++++++---------- testdata/help__restore__ok.ct | 12 ++-- testdata/restore__glob__ok.ct | 2 +- testdata/root__ok.ct | 40 +++++++----- 21 files changed, 145 insertions(+), 103 deletions(-) diff --git a/cmd/del-db.go b/cmd/del-db.go index cc5d425..a1ac5ff 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -33,9 +33,9 @@ import ( // delStoreCmd represents the set command var delStoreCmd = &cobra.Command{ - Use: "del-store STORE", + Use: "remove-store STORE", Short: "Delete a store", - Aliases: []string{"delete-store", "rm-store", "remove-store"}, + Aliases: []string{"rm-store", "rms"}, Args: cobra.ExactArgs(1), RunE: delStore, SilenceUsage: true, diff --git a/cmd/del.go b/cmd/del.go index ad27031..640dcfd 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -34,9 +34,9 @@ import ( // delCmd represents the set command var delCmd = &cobra.Command{ - Use: "del KEY[@STORE] [KEY[@STORE] ...]", + Use: "remove KEY[@STORE] [KEY[@STORE] ...]", Short: "Delete one or more keys", - Aliases: []string{"delete", "rm", "remove"}, + Aliases: []string{"rm"}, Args: cobra.ArbitraryArgs, RunE: del, SilenceUsage: true, diff --git a/cmd/dump.go b/cmd/dump.go index 060f6ce..4db2134 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -45,9 +45,9 @@ type dumpEntry struct { } var dumpCmd = &cobra.Command{ - Use: "dump [STORE]", + Use: "export [STORE]", Short: "Dump all key/value pairs as NDJSON", - Aliases: []string{"export"}, + Aliases: []string{"dump"}, Args: cobra.MaximumNArgs(1), RunE: dump, SilenceUsage: true, diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index 633f0c9..7e36f29 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -31,7 +31,7 @@ import ( var listStoresCmd = &cobra.Command{ Use: "list-stores", Short: "List all stores", - Aliases: []string{"ls-stores", "lsd"}, + Aliases: []string{"ls-stores", "lss"}, Args: cobra.NoArgs, RunE: listStores, SilenceUsage: true, diff --git a/cmd/mv.go b/cmd/mv.go index 4dc1c75..78ae581 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -31,14 +31,16 @@ import ( ) var cpCmd = &cobra.Command{ - Use: "cp FROM[@STORE] TO[@STORE]", - Short: "Make a copy of a key", - Args: cobra.ExactArgs(2), - RunE: cp, + Use: "copy FROM[@STORE] TO[@STORE]", + Aliases: []string{"cp"}, + Short: "Make a copy of a key", + Args: cobra.ExactArgs(2), + RunE: cp, } var mvCmd = &cobra.Command{ - Use: "mv FROM[@STORE] TO[@STORE]", + Use: "move FROM[@STORE] TO[@STORE]", + Aliases: []string{"mv"}, Short: "Move a key", Args: cobra.ExactArgs(2), RunE: mv, diff --git a/cmd/restore.go b/cmd/restore.go index 7e994f7..9cd75b7 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -36,9 +36,9 @@ import ( ) var restoreCmd = &cobra.Command{ - Use: "restore [STORE]", + Use: "import [STORE]", Short: "Restore key/value pairs from an NDJSON dump", - Aliases: []string{"import"}, + Aliases: []string{"restore"}, Args: cobra.MaximumNArgs(1), RunE: restore, SilenceUsage: true, diff --git a/cmd/root.go b/cmd/root.go index 4f01fe0..82a269d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -47,4 +47,26 @@ func Execute() { } } -func init() {} +func init() { + rootCmd.AddGroup(&cobra.Group{ID: "keys", Title: "Key commands:"}) + + setCmd.GroupID = "keys" + getCmd.GroupID = "keys" + mvCmd.GroupID = "keys" + cpCmd.GroupID = "keys" + delCmd.GroupID = "keys" + listCmd.GroupID = "keys" + + rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"}) + + listStoresCmd.GroupID = "stores" + delStoreCmd.GroupID = "stores" + dumpCmd.GroupID = "stores" + restoreCmd.GroupID = "stores" + + rootCmd.AddGroup(&cobra.Group{ID: "git", Title: "Git commands:"}) + + initCmd.GroupID = "git" + syncCmd.GroupID = "git" + gitCmd.GroupID = "git" +} diff --git a/testdata/del-db__err__with__invalid_db.ct b/testdata/del-db__err__with__invalid_db.ct index 4d8a1e1..9d99bf7 100644 --- a/testdata/del-db__err__with__invalid_db.ct +++ b/testdata/del-db__err__with__invalid_db.ct @@ -1,2 +1,2 @@ -$ pda del-store foo/bar --> FAIL +$ pda rms foo/bar --> FAIL Error: cannot delete-store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE diff --git a/testdata/del__dedupe__ok.ct b/testdata/del__dedupe__ok.ct index fe20f7a..f96873c 100644 --- a/testdata/del__dedupe__ok.ct +++ b/testdata/del__dedupe__ok.ct @@ -3,7 +3,7 @@ $ pda set bar 2 $ pda ls bar 2 foo 1 -$ pda del foo --glob "*" +$ pda rm foo --glob "*" $ pda get bar --> FAIL Error: cannot get 'bar': Key not found $ pda get foo --> FAIL diff --git a/testdata/del__glob__mixed__ok.ct b/testdata/del__glob__mixed__ok.ct index 69a77fa..332a475 100644 --- a/testdata/del__glob__mixed__ok.ct +++ b/testdata/del__glob__mixed__ok.ct @@ -1,7 +1,7 @@ $ pda set foo 1 $ pda set bar1 2 $ pda set bar2 3 -$ pda del foo --glob bar* +$ pda rm foo --glob bar* $ pda get foo --> FAIL Error: cannot get 'foo': Key not found $ pda get bar1 --> FAIL diff --git a/testdata/del__glob__ok.ct b/testdata/del__glob__ok.ct index c6cb99c..81fe095 100644 --- a/testdata/del__glob__ok.ct +++ b/testdata/del__glob__ok.ct @@ -1,7 +1,7 @@ $ pda set a1 1 $ pda set a2 2 $ pda set b1 3 -$ pda del --glob a* +$ pda rm --glob a* $ pda get a1 --> FAIL Error: cannot get 'a1': Key not found $ pda get a2 --> FAIL diff --git a/testdata/del__multiple__ok.ct b/testdata/del__multiple__ok.ct index 80660d1..395e009 100644 --- a/testdata/del__multiple__ok.ct +++ b/testdata/del__multiple__ok.ct @@ -1,6 +1,6 @@ $ pda set a 1 $ pda set b 2 -$ pda del a b +$ pda rm a b $ pda get a --> FAIL Error: cannot get 'a': Key not found $ pda get b --> FAIL diff --git a/testdata/del__ok.ct b/testdata/del__ok.ct index 38d30cc..fa22746 100644 --- a/testdata/del__ok.ct +++ b/testdata/del__ok.ct @@ -1,2 +1,2 @@ $ pda set a b -$ pda del a +$ pda rm a diff --git a/testdata/help__del-db__ok.ct b/testdata/help__del-db__ok.ct index 1ab2233..0b7b0a8 100644 --- a/testdata/help__del-db__ok.ct +++ b/testdata/help__del-db__ok.ct @@ -1,24 +1,24 @@ -$ pda help del-store -$ pda del-store --help +$ pda help rms +$ pda rms --help Delete a store Usage: - pda del-store STORE [flags] + pda remove-store STORE [flags] Aliases: - del-store, delete-store, rm-store, remove-store + remove-store, rm-store, rms Flags: - -h, --help help for del-store + -h, --help help for remove-store -i, --interactive Prompt yes/no for each deletion Delete a store Usage: - pda del-store STORE [flags] + pda remove-store STORE [flags] Aliases: - del-store, delete-store, rm-store, remove-store + remove-store, rm-store, rms Flags: - -h, --help help for del-store + -h, --help help for remove-store -i, --interactive Prompt yes/no for each deletion diff --git a/testdata/help__del__ok.ct b/testdata/help__del__ok.ct index 9d53415..8556bc2 100644 --- a/testdata/help__del__ok.ct +++ b/testdata/help__del__ok.ct @@ -1,28 +1,28 @@ -$ pda help del -$ pda del --help +$ pda help rm +$ pda rm --help Delete one or more keys Usage: - pda del KEY[@STORE] [KEY[@STORE] ...] [flags] + pda remove KEY[@STORE] [KEY[@STORE] ...] [flags] Aliases: - del, delete, rm, remove + remove, rm 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 + -h, --help help for remove -i, --interactive Prompt yes/no for each deletion Delete one or more keys Usage: - pda del KEY[@STORE] [KEY[@STORE] ...] [flags] + pda remove KEY[@STORE] [KEY[@STORE] ...] [flags] Aliases: - del, delete, rm, remove + remove, rm 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 + -h, --help help for remove -i, --interactive Prompt yes/no for each deletion diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 19cc6d8..380a94c 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -3,28 +3,28 @@ $ pda dump --help Dump all key/value pairs as NDJSON Usage: - pda dump [STORE] [flags] + pda export [STORE] [flags] Aliases: - dump, export + export, dump 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 + -h, --help help for export --secret Include entries marked as secret Dump all key/value pairs as NDJSON Usage: - pda dump [STORE] [flags] + pda export [STORE] [flags] Aliases: - dump, export + export, dump 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 + -h, --help help for export --secret Include entries marked as secret diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help__list-dbs__ok.ct index 5f4a311..24ba6b5 100644 --- a/testdata/help__list-dbs__ok.ct +++ b/testdata/help__list-dbs__ok.ct @@ -6,7 +6,7 @@ Usage: pda list-stores [flags] Aliases: - list-stores, ls-stores, lsd + list-stores, ls-stores, lss Flags: -h, --help help for list-stores @@ -16,7 +16,7 @@ Usage: pda list-stores [flags] Aliases: - list-stores, ls-stores, lsd + list-stores, ls-stores, lss Flags: -h, --help help for list-stores diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index e14ce8e..339038a 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -12,23 +12,29 @@ $ pda --help 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 - del-store Delete a store - dump Dump all key/value pairs as NDJSON - get Get the value of a key - git Run any arbitrary command. Use with caution. - help Help about any command - init Initialise pda! version control - list List the contents of a store - list-stores List all stores - mv Move a key - restore Restore key/value pairs from an NDJSON dump - set Set a key to a given value - sync Manually sync your stores with Git - version Display pda! version +Key commands: + copy Make a copy of a key + get Get the value of a key + list List the contents of a store + move Move a key + remove Delete one or more keys + set Set a key to a given value + +Store commands: + export Dump all key/value pairs as NDJSON + import Restore key/value pairs from an NDJSON dump + list-stores List all stores + 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 + +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 @@ -46,23 +52,29 @@ Use "pda [command] --help" for more information about a command. 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 - del-store Delete a store - dump Dump all key/value pairs as NDJSON - get Get the value of a key - git Run any arbitrary command. Use with caution. - help Help about any command - init Initialise pda! version control - list List the contents of a store - list-stores List all stores - mv Move a key - restore Restore key/value pairs from an NDJSON dump - set Set a key to a given value - sync Manually sync your stores with Git - version Display pda! version +Key commands: + copy Make a copy of a key + get Get the value of a key + list List the contents of a store + move Move a key + remove Delete one or more keys + set Set a key to a given value + +Store commands: + export Dump all key/value pairs as NDJSON + import Restore key/value pairs from an NDJSON dump + list-stores List all stores + 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 + +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 diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index 3f1c4fb..b9fa6e5 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -3,28 +3,28 @@ $ pda restore --help Restore key/value pairs from an NDJSON dump Usage: - pda restore [STORE] [flags] + pda import [STORE] [flags] Aliases: - restore, import + import, restore 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 + -h, --help help for import -i, --interactive Prompt before overwriting existing keys Restore key/value pairs from an NDJSON dump Usage: - pda restore [STORE] [flags] + pda import [STORE] [flags] Aliases: - restore, import + import, restore 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 + -h, --help help for import -i, --interactive Prompt before overwriting existing keys diff --git a/testdata/restore__glob__ok.ct b/testdata/restore__glob__ok.ct index eefd176..3aa0b44 100644 --- a/testdata/restore__glob__ok.ct +++ b/testdata/restore__glob__ok.ct @@ -2,7 +2,7 @@ $ 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 rm a1 a2 b1 $ pda restore --glob a* --file dumpfile Restored 2 entries into @default $ pda get a1 diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index 580ee43..f288cf9 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -11,23 +11,29 @@ $ pda 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 - del-store Delete a store - dump Dump all key/value pairs as NDJSON - get Get the value of a key - git Run any arbitrary command. Use with caution. - help Help about any command - init Initialise pda! version control - list List the contents of a store - list-stores List all stores - mv Move a key - restore Restore key/value pairs from an NDJSON dump - set Set a key to a given value - sync Manually sync your stores with Git - version Display pda! version +Key commands: + copy Make a copy of a key + get Get the value of a key + list List the contents of a store + move Move a key + remove Delete one or more keys + set Set a key to a given value + +Store commands: + export Dump all key/value pairs as NDJSON + import Restore key/value pairs from an NDJSON dump + list-stores List all stores + 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 + +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 From c5aeb16e16a6425a9ceee84f67e9a73cbdaa8129 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 09:41:35 +0000 Subject: [PATCH 010/107] chore(docs): updates to new cmd names --- README.md | 63 ++++++++++++------- ...el-db__ok.ct => help__remove-store__ok.ct} | 0 .../{help__del__ok.ct => help__remove__ok.ct} | 0 ...=> remove-store__err__with__invalid_db.ct} | 0 ...l__dedupe__ok.ct => remove__dedupe__ok.ct} | 6 +- ...ixed__ok.ct => remove__glob__mixed__ok.ct} | 0 .../{del__glob__ok.ct => remove__glob__ok.ct} | 0 ...ultiple__ok.ct => remove__multiple__ok.ct} | 0 testdata/{del__ok.ct => remove__ok.ct} | 0 9 files changed, 46 insertions(+), 23 deletions(-) rename testdata/{help__del-db__ok.ct => help__remove-store__ok.ct} (100%) rename testdata/{help__del__ok.ct => help__remove__ok.ct} (100%) rename testdata/{del-db__err__with__invalid_db.ct => remove-store__err__with__invalid_db.ct} (100%) rename testdata/{del__dedupe__ok.ct => remove__dedupe__ok.ct} (68%) rename testdata/{del__glob__mixed__ok.ct => remove__glob__mixed__ok.ct} (100%) rename testdata/{del__glob__ok.ct => remove__glob__ok.ct} (100%) rename testdata/{del__multiple__ok.ct => remove__multiple__ok.ct} (100%) rename testdata/{del__ok.ct => remove__ok.ct} (100%) diff --git a/README.md b/README.md index d19c9a8..05b0ce2 100644 --- a/README.md +++ b/README.md @@ -63,22 +63,41 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr ### 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-store # Delete a whole store. - list-stores # List all stores. - dump # Export a store as NDJSON. - restore # Imports NDJSON into a store. - init # Initialise or fetch a Git repo for version control. - sync # Export, commit, pull, restore, and push changes. - git # Run git in the pda VCS repository. - completion # Generate autocompletions for a specified shell. - help # Additional help for any command. - version # Current version. + ▄▄ + ██ +██▄███▄ ▄███▄██ ▄█████▄ +██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██ +██ ██ ██ ██ ▄██▀▀▀██ +███▄▄██▀ ▀██▄▄███ ██▄▄▄███ +██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀ +██ (c) 2025 Lewis Wynne + +Usage: + pda [command] + +Key commands: + copy Make a copy of a key + get Get the value of a key + list List the contents of a store + move Move a key + remove Delete one or more keys + set Set a key to a given value + +Store commands: + export Dump all key/value pairs as NDJSON + import Restore key/value pairs from an NDJSON dump + list-stores List all stores + 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 + +Additional Commands: + completion Generate the autocompletion script for the specified shell + help Help about any command + version Display pda! version ```

@@ -145,17 +164,17 @@ pda mv name name2 --copy

-`pda del` to delete one or more keys. +`pda rm` to delete one or more keys. ```bash -pda del kitty +pda rm kitty # remove "kitty": are you sure? [y/n] # y # Or skip the prompt. -pda del kitty --force +pda rm kitty --force # Remove multiple keys, within the same or different stores. -pda del kitty dog@animals +pda rm kitty dog@animals # remove "kitty", "dog@animals": are you sure? [y/n] # y @@ -163,7 +182,7 @@ pda del kitty dog@animals pda set cog "cogs" pda set dog "doggy" pda set kitty "cat" -pda del kitty --glob ?og +pda rm kitty --glob ?og # remove "kitty", "cog", "dog": are you sure? [y/n] # y # Default glob separators: "/-_.@: " (space included). Override with --glob-sep. @@ -235,7 +254,7 @@ pda dump birthdays > friends_birthdays pda restore birthdays < friends_birthdays # Delete it. -pda del-store birthdays --force +pda rm-store birthdays --force ```

diff --git a/testdata/help__del-db__ok.ct b/testdata/help__remove-store__ok.ct similarity index 100% rename from testdata/help__del-db__ok.ct rename to testdata/help__remove-store__ok.ct diff --git a/testdata/help__del__ok.ct b/testdata/help__remove__ok.ct similarity index 100% rename from testdata/help__del__ok.ct rename to testdata/help__remove__ok.ct diff --git a/testdata/del-db__err__with__invalid_db.ct b/testdata/remove-store__err__with__invalid_db.ct similarity index 100% rename from testdata/del-db__err__with__invalid_db.ct rename to testdata/remove-store__err__with__invalid_db.ct diff --git a/testdata/del__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct similarity index 68% rename from testdata/del__dedupe__ok.ct rename to testdata/remove__dedupe__ok.ct index f96873c..0e97fd8 100644 --- a/testdata/del__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,7 +1,11 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - bar 2 + a ********** + a1 1 + a2 2 + b1 3 + bar 2 foo 1 $ pda rm foo --glob "*" $ pda get bar --> FAIL diff --git a/testdata/del__glob__mixed__ok.ct b/testdata/remove__glob__mixed__ok.ct similarity index 100% rename from testdata/del__glob__mixed__ok.ct rename to testdata/remove__glob__mixed__ok.ct diff --git a/testdata/del__glob__ok.ct b/testdata/remove__glob__ok.ct similarity index 100% rename from testdata/del__glob__ok.ct rename to testdata/remove__glob__ok.ct diff --git a/testdata/del__multiple__ok.ct b/testdata/remove__multiple__ok.ct similarity index 100% rename from testdata/del__multiple__ok.ct rename to testdata/remove__multiple__ok.ct diff --git a/testdata/del__ok.ct b/testdata/remove__ok.ct similarity index 100% rename from testdata/del__ok.ct rename to testdata/remove__ok.ct From 26871decd03db2fcb1a36619b7a65f1b009505f5 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 10:00:14 +0000 Subject: [PATCH 011/107] feat(Run): adds explicit Run command --- README.md | 10 ++++++---- cmd/get.go | 41 ++++++++++++++++++++++++++++++++--------- cmd/root.go | 1 + testdata/help__ok.ct | 2 ++ testdata/root__ok.ct | 1 + 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 05b0ce2..fed376c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Key commands: list List the contents of a store 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: @@ -143,7 +144,8 @@ pda get name # Alice # Or run it directly. -pda get name --run +pda run name +# same as: pda get name --run ```

@@ -651,14 +653,14 @@ PDA_DATA=/tmp/stores pda set key value

-`pda get --run` uses `SHELL` for command execution. +`pda run` (or `pda get --run`) uses `SHELL` for command execution. ```bash # SHELL is usually your current shell. -pda get script --run +pda run script # An empty SHELL falls back to using 'sh'. export SHELL="" -pda get script --run +pda run script ```

diff --git a/cmd/get.go b/cmd/get.go index 28edda2..2c4f8d4 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -54,6 +54,22 @@ For example: 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), + RunE: run, + SilenceUsage: true, +} + func get(cmd *cobra.Command, args []string) error { store := &Store{} @@ -91,11 +107,6 @@ func get(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot get '%s': %v", args[0], err) } - run, err := cmd.Flags().GetBool("run") - if err != nil { - return fmt.Errorf("cannot get '%s': %v", args[0], err) - } - noTemplate, err := cmd.Flags().GetBool("no-template") if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) @@ -112,8 +123,8 @@ func get(cmd *cobra.Command, args []string) error { } } - if run { - return runCmd(string(v)) + if runFlag { + return runShellCommand(string(v)) } store.Print("%s", binary, v) @@ -194,7 +205,7 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) { return buf.Bytes(), nil } -func runCmd(command string) error { +func runShellCommand(command string) error { shell := os.Getenv("SHELL") if shell == "" { shell = "/bin/sh" @@ -218,10 +229,22 @@ func runCmd(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("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().BoolVarP(&runFlag, "run", "c", false, "execute the result as a shell command") getCmd.Flags().Bool("no-template", false, "directly output template syntax") rootCmd.AddCommand(getCmd) + + runCmd.Flags().BoolP("include-binary", "b", false, "include binary data in text output") + runCmd.Flags().Bool("secret", false, "display values marked as secret") + runCmd.Flags().Bool("no-template", false, "directly output template syntax") + rootCmd.AddCommand(runCmd) } diff --git a/cmd/root.go b/cmd/root.go index 82a269d..84e1ed8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,7 @@ func init() { setCmd.GroupID = "keys" getCmd.GroupID = "keys" + runCmd.GroupID = "keys" mvCmd.GroupID = "keys" cpCmd.GroupID = "keys" delCmd.GroupID = "keys" diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index 339038a..7065f74 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -18,6 +18,7 @@ Key commands: list List the contents of a store 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: @@ -58,6 +59,7 @@ Key commands: list List the contents of a store 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: diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index f288cf9..099e74b 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -17,6 +17,7 @@ Key commands: list List the contents of a store 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: From 0c7767dc415fa1fbfc3f6f8edfdde10b4640d5e3 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 10:01:36 +0000 Subject: [PATCH 012/107] chore(version): bump to 25.52 --- cmd/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/version.go b/cmd/version.go index a43a5f3..0a6baf9 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -28,7 +28,7 @@ import ( ) var ( - version = "pda! 2025.51 release" + version = "pda! 2025.52 Christmas release" ) // versionCmd represents the version command From 2d86b3ad21276fcba4786fbe505e33aec741ac7f Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 10:45:30 +0000 Subject: [PATCH 013/107] docs(README): adds default config --- README.md | 18 ++++++++++++++++++ cmd/git.go | 15 ++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fed376c..008546e 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,24 @@ Config is stored in your user config directory in `pda/config.toml`. Usually: `~/.config/pda/config.toml` +``` +# ~/.config/pda/config.toml +display_ascii_art = true + +[key] +always_prompt_delete = false +always_prompt_overwrite = false + +[store] +default_store_name = "default" +always_prompt_delete = true + +[git] +auto_fetch = false +auto_commit = true +auto_push = false +``` + `PDA_CONFIG` overrides the default config location. pda! will look for a config.toml file in that directory. ```bash PDA_CONFIG=/tmp/config/ pda set key value diff --git a/cmd/git.go b/cmd/git.go index 1dbe4b7..489cd4e 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -32,11 +32,20 @@ import ( var gitCmd = &cobra.Command{ Use: "git [args...]", Short: "Run any arbitrary command. Use with caution.", -Long: `Run any arbitrary command. Use with caution. + Long: `Run any arbitrary command. Use with caution. -Be wary of how pda! version control operates before using this. Regular data is stored in "PDA_DATA/pda/stores" as a store; the Git repository is in "PDA_DATA/pda/vcs" and contains a plaintext replica of the store data. +Be wary of how pda! version control operates before using this. +Regular data is stored in "PDA_DATA/pda/stores" as a store; the +Git repository is in "PDA_DATA/pda/vcs" and contains a plaintext +replica of the store data. -The regular sync command (or auto-syncing) exports pda! data into plaintext in the Git repository. If you manually modify the repository without using the built-in commands, or exporting your data to the folder in the correct format first, you may desynchronize your repository.`, +The regular sync command (or auto-syncing) exports pda! data into +plaintext in the Git repository. If you manually modify the +repository without using the built-in commands, or exporting your +data to the Git folder in the correct format first, you may desync +your repository. + +Generally prefer "pda sync".`, Args: cobra.ArbitraryArgs, DisableFlagParsing: true, SilenceUsage: true, From 0bed650685ba34441440125157e4bc0038be22a6 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 10:46:28 +0000 Subject: [PATCH 014/107] fix(docs): ascii art in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 008546e..563abba 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr ### Overview ```bash - ▄▄ - ██ + ▄▄ + ██ ██▄███▄ ▄███▄██ ▄█████▄ ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██ ██ ██ ██ ██ ▄██▀▀▀██ From 07734c6ee4348e678b07761af8d48778cd1a1b05 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 23 Dec 2025 10:46:58 +0000 Subject: [PATCH 015/107] fix(docs): ascii art in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 563abba..b156558 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr ### Overview ```bash - ▄▄ - ██ + ▄▄ + ██ ██▄███▄ ▄███▄██ ▄█████▄ ██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██ ██ ██ ██ ██ ▄██▀▀▀██ From 2cc5a3270b68aec7a34ce682808ced47c9b1eb2f Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 22:11:29 +0000 Subject: [PATCH 016/107] refactor: removes some dead code --- cmd/del.go | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/cmd/del.go b/cmd/del.go index 640dcfd..ff0eaa6 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -109,16 +109,6 @@ func del(cmd *cobra.Command, args []string) error { return nil } - var dbs []string - var labels []string - for _, t := range processed { - spec, err := store.parseKey(t.full, true) - if err != nil { - return err - } - dbs = append(dbs, spec.DB) - labels = append(labels, t.display) - } return autoSync() } @@ -155,14 +145,6 @@ func keyExists(store *Store, arg string) (bool, error) { return !notFound, nil } -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 From 91d69db47590e9f43ff2186a8cdd9a32ecdf7aa3 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 22:12:16 +0000 Subject: [PATCH 017/107] refactor: copy shadows copy() --- cmd/mv.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/mv.go b/cmd/mv.go index 78ae581..d6b2810 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -48,7 +48,7 @@ var mvCmd = &cobra.Command{ } func cp(cmd *cobra.Command, args []string) error { - copy = true + copyMode = true return mv(cmd, args) } @@ -143,7 +143,7 @@ func mv(cmd *cobra.Command, args []string) error { return writeErr } - if copy { + if copyMode { return autoSync() } @@ -162,11 +162,11 @@ func mv(cmd *cobra.Command, args []string) error { } var ( - copy bool = false + copyMode bool = false ) func init() { - mvCmd.Flags().BoolVar(©, "copy", false, "Copy instead of move (keeps source)") + mvCmd.Flags().BoolVar(©Mode, "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") From 4dff61074df27bbc2b77b1011ec4743f70c92c33 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 22:13:41 +0000 Subject: [PATCH 018/107] refactor: removes some redundant IsDefined checks? --- cmd/config.go | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index fb7a876..b55b885 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -106,42 +106,13 @@ func loadConfig() (Config, error) { return cfg, err } - md, err := toml.DecodeFile(path, &cfg) + _, err = toml.DecodeFile(path, &cfg) if err != nil { return cfg, fmt.Errorf("parse %s: %w", path, err) } - if !md.IsDefined("display_ascii_art") { - cfg.DisplayAsciiArt = defaultConfig().DisplayAsciiArt - - } - - if !md.IsDefined("key", "always_prompt_delete") { - cfg.Key.AlwaysPromptDelete = defaultConfig().Key.AlwaysPromptDelete - } - - if !md.IsDefined("store", "default_store_name") || cfg.Store.DefaultStoreName == "" { + if cfg.Store.DefaultStoreName == "" { cfg.Store.DefaultStoreName = defaultConfig().Store.DefaultStoreName - - } - if !md.IsDefined("store", "always_prompt_delete") { - cfg.Store.AlwaysPromptDelete = defaultConfig().Store.AlwaysPromptDelete - } - - if !md.IsDefined("key", "always_prompt_overwrite") { - cfg.Key.AlwaysPromptOverwrite = defaultConfig().Key.AlwaysPromptOverwrite - } - - if !md.IsDefined("git", "auto_fetch") { - cfg.Git.AutoFetch = defaultConfig().Git.AutoFetch - } - - if !md.IsDefined("git", "auto_commit") { - cfg.Git.AutoCommit = defaultConfig().Git.AutoCommit - } - - if !md.IsDefined("git", "auto_push") { - cfg.Git.AutoPush = defaultConfig().Git.AutoPush } return cfg, nil From 34970ac9d90a84e4118397e8188cb6a538c25610 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 22:17:55 +0000 Subject: [PATCH 019/107] refactor: consolidates all list files together --- cmd/list.go | 214 +++++++++++++++++++++++++++++------- cmd/list_flags.go | 101 ----------------- cmd/list_table.go | 270 ---------------------------------------------- 3 files changed, 175 insertions(+), 410 deletions(-) delete mode 100644 cmd/list_flags.go delete mode 100644 cmd/list_table.go diff --git a/cmd/list.go b/cmd/list.go index 3dd83a8..3a2bd4b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -25,10 +25,50 @@ package cmd import ( "errors" "fmt" + "io" + "os" + "strconv" "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 { + 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 ( + listBinary bool + listSecret bool + listNoKeys bool + listNoValues bool + listTTL bool + listHeader bool + listFormat formatEnum = "table" +) + +type columnKind int + +const ( + columnKey columnKind = iota + columnValue + columnTTL ) var listCmd = &cobra.Command{ @@ -59,9 +99,19 @@ func list(cmd *cobra.Command, args []string) error { targetDB = "@" + dbName } - flags, err := enrichFlags() - if err != nil { - return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + if listNoKeys && listNoValues && !listTTL { + return fmt.Errorf("cannot ls '%s': no columns selected; disable --no-keys/--no-values or pass --ttl", targetDB) + } + + var columns []columnKind + if !listNoKeys { + columns = append(columns, columnKey) + } + if !listNoValues { + columns = append(columns, columnValue) + } + if listTTL { + columns = append(columns, columnTTL) } globPatterns, err := cmd.Flags().GetStringSlice("glob") @@ -77,29 +127,19 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - columnKinds, err := requireColumns(flags) - if err != nil { - return fmt.Errorf("cannot ls '%s': %v", targetDB, err) - } - + showValues := !listNoValues output := cmd.OutOrStdout() 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 - var maxContentWidths []int - maxContentWidths = make([]int, len(columnKinds)) - - if flags.header { - header := buildHeaderCells(columnKinds) - updateMaxContentWidths(maxContentWidths, header) - tw.AppendHeader(stringSliceToRow(header)) + if listHeader { + tw.AppendHeader(headerRow(columns)) } placeholder := "**********" @@ -111,7 +151,7 @@ func list(cmd *cobra.Command, args []string) error { transact: func(tx *badger.Txn, k []byte) error { opts := badger.DefaultIteratorOptions opts.PrefetchSize = 10 - opts.PrefetchValues = flags.value + opts.PrefetchValues = showValues it := tx.NewIterator(opts) defer it.Close() var valueBuf []byte @@ -126,33 +166,32 @@ func list(cmd *cobra.Command, args []string) error { isSecret := meta&metaSecret != 0 var valueStr string - if flags.value && (!isSecret || flags.secrets) { + if showValues && (!isSecret || listSecret) { 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) + valueStr = store.FormatBytes(listBinary, valueBuf) } - columns := make([]string, 0, len(columnKinds)) - for _, column := range columnKinds { - switch column { + row := make(table.Row, 0, len(columns)) + for _, col := range columns { + switch col { case columnKey: - columns = append(columns, key) + row = append(row, key) case columnValue: - if isSecret && !flags.secrets { - columns = append(columns, placeholder) + if isSecret && !listSecret { + row = append(row, placeholder) } else { - columns = append(columns, valueStr) + row = append(row, valueStr) } case columnTTL: - columns = append(columns, formatExpiry(item.ExpiresAt())) + row = append(row, formatExpiry(item.ExpiresAt())) } } - updateMaxContentWidths(maxContentWidths, columns) - tw.AppendRow(stringSliceToRow(columns)) + tw.AppendRow(row) } return nil }, @@ -166,20 +205,117 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) } - applyColumnConstraints(tw, columnKinds, output, maxContentWidths) - - flags.render(tw) + applyColumnWidths(tw, columns, output) + renderTable(tw) return nil } +func headerRow(columns []columnKind) table.Row { + row := make(table.Row, 0, len(columns)) + for _, col := range columns { + switch col { + case columnKey: + row = append(row, "Key") + case columnValue: + row = append(row, "Value") + case columnTTL: + row = append(row, "TTL") + } + } + return row +} + +func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer) { + termWidth := detectTerminalWidth(out) + if termWidth <= 0 { + return + } + tw.SetAllowedRowLength(termWidth) + + // Padding per column: go-pretty's default is one space each side. + padding := len(columns) * 2 + available := termWidth - padding + if available < len(columns) { + return + } + + // Give key and TTL columns a fixed budget; value gets the rest. + const keyWidth = 30 + const ttlWidth = 40 + valueWidth := available + for _, col := range columns { + switch col { + case columnKey: + valueWidth -= keyWidth + case columnTTL: + valueWidth -= ttlWidth + } + } + if valueWidth < 10 { + valueWidth = 10 + } + + var configs []table.ColumnConfig + for i, col := range columns { + var maxW int + switch col { + case columnKey: + maxW = keyWidth + case columnValue: + maxW = valueWidth + case columnTTL: + maxW = ttlWidth + } + configs = append(configs, table.ColumnConfig{ + Number: i + 1, + WidthMax: maxW, + WidthMaxEnforcer: text.WrapText, + }) + } + 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 +} + +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(&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().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output") + listCmd.Flags().BoolVarP(&listSecret, "secret", "S", false, "display values marked as secret") + listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") + listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") + listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") + listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") + listCmd.Flags().VarP(&listFormat, "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 deleted file mode 100644 index 91f29aa..0000000 --- a/cmd/list_flags.go +++ /dev/null @@ -1,101 +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" - - "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 deleted file mode 100644 index 427c1a0..0000000 --- a/cmd/list_table.go +++ /dev/null @@ -1,270 +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" - "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 - } -} From 4509611185bbf981d5ed0341b85044a0a02a943e Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 23:22:06 +0000 Subject: [PATCH 020/107] revert: removes --secrets - to be replaced with encryption --- cmd/dump.go | 28 +--- cmd/get.go | 12 -- cmd/list.go | 13 +- cmd/restore.go | 144 ++++++++++-------- cmd/set.go | 8 - cmd/shared.go | 4 - cmd/sync.go | 66 ++++---- cmd/vcs.go | 63 +------- testdata/get__missing__err__with__any.ct | 6 +- testdata/get__ok__with__binary_run_secret.ct | 4 - testdata/get__ok__with__run_secret.ct | 6 - testdata/get__ok__with__secret.ct | 3 - testdata/get__secret__err.ct | 3 - testdata/get__secret__err__with__binary.ct | 4 - .../get__secret__err__with__binary_run.ct | 4 - testdata/get__secret__err__with__run.ct | 4 - ...et__secret__ok__with__binary_run_secret.ct | 4 - .../get__secret__ok__with__binary_secret.ct | 4 - testdata/get__secret__ok__with__run_secret.ct | 4 - testdata/get__secret__ok__with__secret.ct | 4 - testdata/help__dump__ok.ct | 2 - testdata/help__get__ok.ct | 2 - testdata/help__list__ok.ct | 2 - testdata/help__set__ok.ct | 2 - testdata/remove__dedupe__ok.ct | 3 +- testdata/set__ok__with__secret.ct | 1 - testdata/set__ok__with__secret_ttl.ct | 1 - 27 files changed, 132 insertions(+), 269 deletions(-) delete mode 100644 testdata/get__ok__with__binary_run_secret.ct delete mode 100644 testdata/get__ok__with__run_secret.ct delete mode 100644 testdata/get__ok__with__secret.ct delete mode 100644 testdata/get__secret__err.ct delete mode 100644 testdata/get__secret__err__with__binary.ct delete mode 100644 testdata/get__secret__err__with__binary_run.ct delete mode 100644 testdata/get__secret__err__with__run.ct delete mode 100644 testdata/get__secret__ok__with__binary_run_secret.ct delete mode 100644 testdata/get__secret__ok__with__binary_secret.ct delete mode 100644 testdata/get__secret__ok__with__run_secret.ct delete mode 100644 testdata/get__secret__ok__with__secret.ct delete mode 100644 testdata/set__ok__with__secret.ct delete mode 100644 testdata/set__ok__with__secret_ttl.ct diff --git a/cmd/dump.go b/cmd/dump.go index 4db2134..7dd8d20 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -40,7 +40,6 @@ 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"` } @@ -82,10 +81,6 @@ func dump(cmd *cobra.Command, args []string) error { 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) @@ -100,17 +95,15 @@ func dump(cmd *cobra.Command, args []string) error { } opts := DumpOptions{ - Encoding: mode, - IncludeSecret: includeSecret, - Matchers: matchers, - GlobPatterns: globPatterns, + Encoding: mode, + 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) @@ -132,10 +125,9 @@ func encodeText(entry *dumpEntry, key []byte, v []byte) error { // DumpOptions controls how a store is dumped to NDJSON. type DumpOptions struct { - Encoding string - IncludeSecret bool - Matchers []glob.Glob - GlobPatterns []string + Encoding string + Matchers []glob.Glob + GlobPatterns []string } // dumpDatabase writes entries from dbName to w as NDJSON. @@ -159,16 +151,10 @@ func dumpDatabase(store *Store, dbName string, w io.Writer, opts DumpOptions) er 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, + Key: string(key), } if expiresAt > 0 { ts := int64(expiresAt) diff --git a/cmd/get.go b/cmd/get.go index 2c4f8d4..9598ed4 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -74,7 +74,6 @@ func get(cmd *cobra.Command, args []string) error { store := &Store{} var v []byte - var meta byte trans := TransactionArgs{ key: args[0], readonly: true, @@ -84,7 +83,6 @@ func get(cmd *cobra.Command, args []string) error { if err != nil { return err } - meta = item.UserMeta() v, err = item.ValueCopy(nil) return err }, @@ -94,14 +92,6 @@ func get(cmd *cobra.Command, args []string) error { 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) - } - 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) @@ -238,13 +228,11 @@ var runFlag bool func init() { 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().BoolVarP(&runFlag, "run", "c", false, "execute the result as a shell command") getCmd.Flags().Bool("no-template", false, "directly output template syntax") rootCmd.AddCommand(getCmd) runCmd.Flags().BoolP("include-binary", "b", false, "include binary data in text output") - runCmd.Flags().Bool("secret", false, "display values marked as secret") runCmd.Flags().Bool("no-template", false, "directly output template syntax") rootCmd.AddCommand(runCmd) } diff --git a/cmd/list.go b/cmd/list.go index 3a2bd4b..2ccd4ac 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -55,7 +55,6 @@ func (e *formatEnum) Type() string { return "format" } var ( listBinary bool - listSecret bool listNoKeys bool listNoValues bool listTTL bool @@ -142,7 +141,6 @@ func list(cmd *cobra.Command, args []string) error { tw.AppendHeader(headerRow(columns)) } - placeholder := "**********" var matchedCount int trans := TransactionArgs{ key: targetDB, @@ -162,11 +160,9 @@ func list(cmd *cobra.Command, args []string) error { continue } matchedCount++ - meta := item.UserMeta() - isSecret := meta&metaSecret != 0 var valueStr string - if showValues && (!isSecret || listSecret) { + if showValues { if err := item.Value(func(v []byte) error { valueBuf = append(valueBuf[:0], v...) return nil @@ -182,11 +178,7 @@ func list(cmd *cobra.Command, args []string) error { case columnKey: row = append(row, key) case columnValue: - if isSecret && !listSecret { - row = append(row, placeholder) - } else { - row = append(row, valueStr) - } + row = append(row, valueStr) case columnTTL: row = append(row, formatExpiry(item.ExpiresAt())) } @@ -310,7 +302,6 @@ func renderTable(tw table.Writer) { func init() { listCmd.Flags().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output") - listCmd.Flags().BoolVarP(&listSecret, "secret", "S", false, "display values marked as secret") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") diff --git a/cmd/restore.go b/cmd/restore.go index 9cd75b7..3a3de6f 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -32,6 +32,7 @@ import ( "strings" "github.com/dgraph-io/badger/v4" + "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -85,82 +86,21 @@ func restore(cmd *cobra.Command, args []string) error { 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 - entryNo := 0 - var restored int - var matched bool - - for { - var entry dumpEntry - if err := decoder.Decode(&entry); err != nil { - if err == io.EOF { - break - } - 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 - } - - 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 - } - } - } - - value, err := decodeEntryValue(entry) - if err != nil { - return fmt.Errorf("cannot restore '%s': entry %d: %w", displayTarget, 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("cannot restore '%s': entry %d: expires_at must be >= 0", displayTarget, entryNo) - } - writeEntry.ExpiresAt = uint64(*entry.ExpiresAt) - } - - if err := wb.SetEntry(writeEntry); err != nil { - return fmt.Errorf("cannot restore '%s': entry %d: %w", displayTarget, entryNo, err) - } - restored++ - matched = true - } - - if err := wb.Flush(); err != nil { + restored, err := restoreEntries(decoder, db, restoreOpts{ + matchers: matchers, + promptOverwrite: promptOverwrite, + }) + if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } - if len(matchers) > 0 && !matched { + if len(matchers) > 0 && restored == 0 { return fmt.Errorf("cannot restore '%s': No matches for pattern %s", displayTarget, formatGlobPatterns(globPatterns)) } @@ -198,6 +138,76 @@ func decodeEntryValue(entry dumpEntry) ([]byte, error) { } } +type restoreOpts struct { + matchers []glob.Glob + promptOverwrite bool +} + +func restoreEntries(decoder *json.Decoder, db *badger.DB, opts restoreOpts) (int, error) { + wb := db.NewWriteBatch() + defer wb.Cancel() + + entryNo := 0 + restored := 0 + + for { + var entry dumpEntry + if err := decoder.Decode(&entry); err != nil { + if err == io.EOF { + break + } + return 0, fmt.Errorf("entry %d: %w", entryNo+1, err) + } + entryNo++ + if entry.Key == "" { + return 0, fmt.Errorf("entry %d: missing key", entryNo) + } + if !globMatch(opts.matchers, entry.Key) { + continue + } + + if opts.promptOverwrite { + exists, err := keyExistsInDB(db, entry.Key) + if err != nil { + return 0, fmt.Errorf("entry %d: %v", entryNo, err) + } + if exists { + fmt.Printf("overwrite '%s'? (y/n)\n", entry.Key) + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil { + return 0, fmt.Errorf("entry %d: %v", entryNo, err) + } + if strings.ToLower(confirm) != "y" { + continue + } + } + } + + value, err := decodeEntryValue(entry) + if err != nil { + return 0, fmt.Errorf("entry %d: %w", entryNo, err) + } + + writeEntry := badger.NewEntry([]byte(entry.Key), value) + if entry.ExpiresAt != nil { + if *entry.ExpiresAt < 0 { + return 0, fmt.Errorf("entry %d: expires_at must be >= 0", entryNo) + } + writeEntry.ExpiresAt = uint64(*entry.ExpiresAt) + } + + if err := wb.SetEntry(writeEntry); err != nil { + return 0, fmt.Errorf("entry %d: %w", entryNo, err) + } + restored++ + } + + if err := wb.Flush(); 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("glob", "g", nil, "Restore keys matching glob pattern (repeatable)") diff --git a/cmd/set.go b/cmd/set.go index 483c5e8..647a435 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -76,10 +76,6 @@ 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) @@ -108,9 +104,6 @@ func set(cmd *cobra.Command, args []string) error { 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) } @@ -127,7 +120,6 @@ func set(cmd *cobra.Command, args []string) error { func init() { rootCmd.AddCommand(setCmd) - 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 3dcaa61..fb2f07b 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -41,10 +41,6 @@ type errNotFound struct { suggestions []string } -const ( - metaSecret byte = 0x1 -) - func (err errNotFound) Error() string { if len(err.suggestions) == 0 { return "No such key" diff --git a/cmd/sync.go b/cmd/sync.go index 9f7e532..222ee3c 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -64,35 +64,36 @@ func sync(manual bool) error { } 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 { + ahead = 1 // ref doesn't exist yet; just push + } else { + 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 := pullRemote(repoDir, remoteInfo); err != nil { - return err - } - return restoreAllSnapshots(store, repoDir) } } @@ -108,7 +109,9 @@ func sync(manual bool) error { } madeCommit := false if !changed { - fmt.Println("no changes to commit") + if manual { + 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 { @@ -117,10 +120,15 @@ func sync(manual bool) error { madeCommit = true } if manual || config.Git.AutoPush { - if remoteInfo.Ref != "" && (madeCommit || ahead > 0) { + if remoteInfo.Ref == "" { + if manual { + fmt.Println("no remote configured; skipping push") + } + } else if madeCommit || ahead > 0 { return pushRemote(repoDir, remoteInfo) + } else if manual { + fmt.Println("nothing to push") } - fmt.Println("no remote configured; skipping push") } return nil } diff --git a/cmd/vcs.go b/cmd/vcs.go index 0e81140..eba247d 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" - "github.com/dgraph-io/badger/v4" gap "github.com/muesli/go-app-paths" ) @@ -63,7 +62,7 @@ func writeGitignore(repoDir string) error { if err := runGit(repoDir, "add", ".gitignore"); err != nil { return err } - return runGit(repoDir, "commit", "--allow-empty", "-m", "generated gitignore") + return runGit(repoDir, "commit", "-m", "generated gitignore") } fmt.Println("Existing .gitignore found.") return nil @@ -82,8 +81,7 @@ func snapshotDB(store *Store, repoDir, db string) error { defer f.Close() opts := DumpOptions{ - Encoding: "auto", - IncludeSecret: false, + Encoding: "auto", } if err := dumpDatabase(store, db, f, opts); err != nil { return err @@ -373,60 +371,7 @@ func restoreSnapshot(store *Store, path string, dbName string) error { 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 + _, err = restoreEntries(decoder, db, restoreOpts{}) + return err } -// 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 -} diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get__missing__err__with__any.ct index d4c9284..1d3012f 100644 --- a/testdata/get__missing__err__with__any.ct +++ b/testdata/get__missing__err__with__any.ct @@ -8,7 +8,7 @@ $ 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: unknown flag: --secret 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: unknown flag: --secret +Error: unknown flag: --secret diff --git a/testdata/get__ok__with__binary_run_secret.ct b/testdata/get__ok__with__binary_run_secret.ct deleted file mode 100644 index 76e0976..0000000 --- a/testdata/get__ok__with__binary_run_secret.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ fecho cmd echo hello -$ pda set foo < cmd -$ pda get foo --include-binary --run --secret -hello diff --git a/testdata/get__ok__with__run_secret.ct b/testdata/get__ok__with__run_secret.ct deleted file mode 100644 index aaff747..0000000 --- a/testdata/get__ok__with__run_secret.ct +++ /dev/null @@ -1,6 +0,0 @@ -$ 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 deleted file mode 100644 index afc0731..0000000 --- a/testdata/get__ok__with__secret.ct +++ /dev/null @@ -1,3 +0,0 @@ -$ pda set foo bar -$ pda get foo --secret -bar diff --git a/testdata/get__secret__err.ct b/testdata/get__secret__err.ct deleted file mode 100644 index 1dc4e6e..0000000 --- a/testdata/get__secret__err.ct +++ /dev/null @@ -1,3 +0,0 @@ -$ 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 deleted file mode 100644 index 065b3fc..0000000 --- a/testdata/get__secret__err__with__binary.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index 877ac77..0000000 --- a/testdata/get__secret__err__with__binary_run.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index 67e2d25..0000000 --- a/testdata/get__secret__err__with__run.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index 94460bd..0000000 --- a/testdata/get__secret__ok__with__binary_run_secret.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index 943fb74..0000000 --- a/testdata/get__secret__ok__with__binary_secret.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index a7ab85a..0000000 --- a/testdata/get__secret__ok__with__run_secret.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ 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 deleted file mode 100644 index 1aae59d..0000000 --- a/testdata/get__secret__ok__with__secret.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ fecho cmd echo hello world -$ pda set a --secret < cmd -$ pda get a --secret -echo hello world diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 380a94c..09c7e2e 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -13,7 +13,6 @@ Flags: -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") -h, --help help for export - --secret Include entries marked as secret Dump all key/value pairs as NDJSON Usage: @@ -27,4 +26,3 @@ Flags: -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") -h, --help help for export - --secret Include entries marked as secret diff --git a/testdata/help__get__ok.ct b/testdata/help__get__ok.ct index c484fa9..46a269d 100644 --- a/testdata/help__get__ok.ct +++ b/testdata/help__get__ok.ct @@ -20,7 +20,6 @@ Flags: -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 the value of a key. Optionally specify a store. {{ .TEMPLATES }} can be filled by passing TEMPLATE=VALUE as an @@ -41,4 +40,3 @@ Flags: -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__list__ok.ct b/testdata/help__list__ok.ct index f259b55..dd943f9 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -17,7 +17,6 @@ Flags: -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 store @@ -36,5 +35,4 @@ Flags: -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__set__ok.ct b/testdata/help__set__ok.ct index 37c8f10..345ee88 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -20,7 +20,6 @@ Aliases: 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 key to a given value or stdin. Optionally specify a store. @@ -42,5 +41,4 @@ Aliases: 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/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 0e97fd8..042c536 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,7 +1,8 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - a ********** + a echo hello + a1 1 a2 2 b1 3 diff --git a/testdata/set__ok__with__secret.ct b/testdata/set__ok__with__secret.ct deleted file mode 100644 index 07eb921..0000000 --- a/testdata/set__ok__with__secret.ct +++ /dev/null @@ -1 +0,0 @@ -$ pda set foo foobar --secret diff --git a/testdata/set__ok__with__secret_ttl.ct b/testdata/set__ok__with__secret_ttl.ct deleted file mode 100644 index 7ad9b4f..0000000 --- a/testdata/set__ok__with__secret_ttl.ct +++ /dev/null @@ -1 +0,0 @@ -$ pda set a b --secret --ttl 10m From db4574b887f77d422db062f81e4e3fe458965860 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 23:30:06 +0000 Subject: [PATCH 021/107] add NDJSON storage backend --- cmd/ndjson.go | 159 +++++++++++++++++++++++++++++++++++++++++++++ cmd/ndjson_test.go | 117 +++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 cmd/ndjson.go create mode 100644 cmd/ndjson_test.go diff --git a/cmd/ndjson.go b/cmd/ndjson.go new file mode 100644 index 0000000..fa3e829 --- /dev/null +++ b/cmd/ndjson.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "slices" + "strings" + "time" + "unicode/utf8" +) + +// 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 +} + +// 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"` +} + +// readStoreFile reads all non-expired entries from an NDJSON file. +// Returns empty slice (not error) if file does not exist. +func readStoreFile(path string) ([]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) + 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. +func writeStoreFile(path string, entries []Entry) 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 := encodeJsonEntry(e) + data, err := json.Marshal(je) + if err != nil { + return fmt.Errorf("key %q: %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) (Entry, error) { + 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 %q: %w", je.Key, err) + } + default: + return Entry{}, fmt.Errorf("unsupported encoding %q for %q", je.Encoding, je.Key) + } + var expiresAt uint64 + if je.ExpiresAt != nil { + expiresAt = uint64(*je.ExpiresAt) + } + return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil +} + +func encodeJsonEntry(e Entry) jsonEntry { + je := jsonEntry{Key: e.Key} + if utf8.Valid(e.Value) { + je.Value = string(e.Value) + je.Encoding = "text" + } else { + je.Value = base64.StdEncoding.EncodeToString(e.Value) + je.Encoding = "base64" + } + if e.ExpiresAt > 0 { + ts := int64(e.ExpiresAt) + je.ExpiresAt = &ts + } + return je +} + +// 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 new file mode 100644 index 0000000..3deb593 --- /dev/null +++ b/cmd/ndjson_test.go @@ -0,0 +1,117 @@ +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); err != nil { + t.Fatal(err) + } + + got, err := readStoreFile(path) + 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); err != nil { + t.Fatal(err) + } + + got, err := readStoreFile(path) + 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") + 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); err != nil { + t.Fatal(err) + } + + got, err := readStoreFile(path) + 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")}}); err != nil { + t.Fatal(err) + } + + // Overwrite — should not leave .tmp files + if err := writeStoreFile(path, []Entry{{Key: "b", Value: []byte("2")}}); 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) + } +} From 7b1356f5af9fb8f8dbe2c2b56f76868fee660f47 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 23:44:23 +0000 Subject: [PATCH 022/107] migrate from badger to ndjson-native storage --- cmd/del-db.go | 2 +- cmd/del.go | 86 ++++++++++++--------- cmd/dump.go | 205 ------------------------------------------------- cmd/export.go | 48 ++++++++++++ cmd/get.go | 36 +++++---- cmd/list.go | 154 +++++++++++++++++++++++-------------- cmd/mv.go | 153 ++++++++++++++++++------------------ cmd/restore.go | 112 ++++++++++----------------- cmd/root.go | 2 +- cmd/set.go | 60 ++++++++------- cmd/shared.go | 137 +++++++++++++-------------------- cmd/vcs.go | 65 +++++++--------- 12 files changed, 442 insertions(+), 618 deletions(-) delete mode 100644 cmd/dump.go create mode 100644 cmd/export.go diff --git a/cmd/del-db.go b/cmd/del-db.go index a1ac5ff..334277b 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -80,7 +80,7 @@ func delStore(cmd *cobra.Command, args []string) error { } func executeDeletion(path string) error { - if err := os.RemoveAll(path); err != nil { + if err := os.Remove(path); err != nil { return fmt.Errorf("cannot delete-store '%s': %v", path, err) } return nil diff --git a/cmd/del.go b/cmd/del.go index ff0eaa6..fac2fcd 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -23,11 +23,9 @@ THE SOFTWARE. package cmd import ( - "errors" "fmt" "strings" - "github.com/dgraph-io/badger/v4" "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -71,7 +69,12 @@ func del(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot remove: No such key") } - var processed []resolvedTarget + // Group targets by store for batch deletes. + type storeTargets struct { + targets []resolvedTarget + } + byStore := make(map[string]*storeTargets) + var storeOrder []string for _, target := range targets { if interactive || config.Key.AlwaysPromptDelete { var confirm string @@ -84,31 +87,39 @@ func del(cmd *cobra.Command, args []string) error { continue } } - 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 - }, + if _, ok := byStore[target.db]; !ok { + byStore[target.db] = &storeTargets{} + storeOrder = append(storeOrder, target.db) } - - if err := store.Transaction(trans); err != nil { - return err - } - processed = append(processed, target) + byStore[target.db].targets = append(byStore[target.db].targets, target) } - if len(processed) == 0 { + if len(byStore) == 0 { return nil } + for _, dbName := range storeOrder { + st := byStore[dbName] + p, err := store.storePath(dbName) + if err != nil { + return err + } + entries, err := readStoreFile(p) + 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) + } + entries = append(entries[:idx], entries[idx+1:]...) + } + if err := writeStoreFile(p, entries); err != nil { + return err + } + } + return autoSync() } @@ -122,27 +133,24 @@ func init() { type resolvedTarget struct { full string display string + key string + db string } func keyExists(store *Store, arg string) (bool, error) { - 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 { + spec, err := store.parseKey(arg, true) + if err != nil { return false, err } - return !notFound, nil + p, err := store.storePath(spec.DB) + if err != nil { + return false, err + } + entries, err := readStoreFile(p) + if err != nil { + return false, err + } + return findEntry(entries, spec.Key) >= 0, nil } func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, separators []rune) ([]resolvedTarget, error) { @@ -158,6 +166,8 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin targets = append(targets, resolvedTarget{ full: full, display: spec.Display(), + key: spec.Key, + db: spec.DB, }) } diff --git a/cmd/dump.go b/cmd/dump.go deleted file mode 100644 index 7dd8d20..0000000 --- a/cmd/dump.go +++ /dev/null @@ -1,205 +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 ( - "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"` - ExpiresAt *int64 `json:"expires_at,omitempty"` -} - -var dumpCmd = &cobra.Command{ - Use: "export [STORE]", - Short: "Dump all key/value pairs as NDJSON", - Aliases: []string{"dump"}, - 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) - } - - 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, - 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().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 store is dumped to NDJSON. -type DumpOptions struct { - Encoding string - 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 - } - expiresAt := item.ExpiresAt() - if err := item.Value(func(v []byte) error { - entry := dumpEntry{ - Key: string(key), - } - 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/export.go b/cmd/export.go new file mode 100644 index 0000000..bdfd28d --- /dev/null +++ b/cmd/export.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 ( + "fmt" + + "github.com/spf13/cobra" +) + +var exportCmd = &cobra.Command{ + Use: "export [STORE]", + Short: "Export store as NDJSON (alias for list --format ndjson)", + Aliases: []string{"dump"}, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + listFormat = "ndjson" + return list(cmd, args) + }, + SilenceUsage: true, +} + +func init() { + exportCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") + exportCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) + exportCmd.Flags().StringVarP(&listEncoding, "encoding", "e", "auto", "value encoding: auto, base64, or text") + rootCmd.AddCommand(exportCmd) +} diff --git a/cmd/get.go b/cmd/get.go index 9598ed4..6350d69 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -32,7 +32,6 @@ import ( "strings" "text/template" - "github.com/dgraph-io/badger/v4" "github.com/spf13/cobra" ) @@ -73,24 +72,27 @@ For example: func get(cmd *cobra.Command, args []string) error { store := &Store{} - var v []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 - } - v, err = item.ValueCopy(nil) - return err - }, - } - - if err := store.Transaction(trans); err != nil { + spec, err := store.parseKey(args[0], true) + if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) } + p, err := store.storePath(spec.DB) + if err != nil { + return fmt.Errorf("cannot get '%s': %v", args[0], err) + } + entries, err := readStoreFile(p) + if err != nil { + return fmt.Errorf("cannot get '%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 get '%s': %v", args[0], suggestKey(spec.Key, keys)) + } + v := entries[idx].Value binary, err := cmd.Flags().GetBool("include-binary") if err != nil { diff --git a/cmd/list.go b/cmd/list.go index 2ccd4ac..6562aa8 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -23,13 +23,15 @@ THE SOFTWARE. package cmd import ( + "encoding/base64" + "encoding/json" "errors" "fmt" "io" "os" "strconv" + "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" @@ -43,11 +45,11 @@ func (e *formatEnum) String() string { return string(*e) } func (e *formatEnum) Set(v string) error { switch v { - case "table", "tsv", "csv", "html", "markdown": + case "table", "tsv", "csv", "html", "markdown", "ndjson": *e = formatEnum(v) return nil default: - return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", or \"markdown\"") + return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", \"markdown\", or \"ndjson\"") } } @@ -60,6 +62,7 @@ var ( listTTL bool listHeader bool listFormat formatEnum = "table" + listEncoding string ) type columnKind int @@ -126,8 +129,52 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - showValues := !listNoValues + 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) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + + // Filter by glob + var filtered []Entry + for _, e := range entries { + if globMatch(matchers, e.Key) { + filtered = append(filtered, e) + } + } + + if len(matchers) > 0 && len(filtered) == 0 { + return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) + } + output := cmd.OutOrStdout() + + // NDJSON format: emit JSON lines directly + if listFormat.String() == "ndjson" { + enc := listEncoding + if enc == "" { + enc = "auto" + } + for _, e := range filtered { + je, err := encodeJsonEntryWithEncoding(e, enc) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + data, err := json.Marshal(je) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + fmt.Fprintln(output, string(data)) + } + return nil + } + + // Table-based formats + showValues := !listNoValues tw := table.NewWriter() tw.SetOutputMirror(output) tw.SetStyle(table.StyleDefault) @@ -141,60 +188,23 @@ func list(cmd *cobra.Command, args []string) error { tw.AppendHeader(headerRow(columns)) } - 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 = showValues - 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 - } - matchedCount++ - - var valueStr string - if showValues { - 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(listBinary, valueBuf) - } - - row := make(table.Row, 0, len(columns)) - for _, col := range columns { - switch col { - case columnKey: - row = append(row, key) - case columnValue: - row = append(row, valueStr) - case columnTTL: - row = append(row, formatExpiry(item.ExpiresAt())) - } - } - tw.AppendRow(row) + for _, e := range filtered { + var valueStr string + if showValues { + valueStr = store.FormatBytes(listBinary, e.Value) + } + row := make(table.Row, 0, len(columns)) + for _, col := range columns { + switch col { + case columnKey: + row = append(row, e.Key) + case columnValue: + row = append(row, valueStr) + case columnTTL: + row = append(row, formatExpiry(e.ExpiresAt)) } - return nil - }, - } - - 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)) + } + tw.AppendRow(row) } applyColumnWidths(tw, columns, output) @@ -300,14 +310,42 @@ func renderTable(tw table.Writer) { } } +// encodeJsonEntryWithEncoding encodes an Entry to jsonEntry respecting the encoding mode. +func encodeJsonEntryWithEncoding(e Entry, mode string) (jsonEntry, error) { + switch mode { + case "base64": + je := jsonEntry{Key: e.Key, Encoding: "base64"} + je.Value = base64.StdEncoding.EncodeToString(e.Value) + if e.ExpiresAt > 0 { + ts := int64(e.ExpiresAt) + je.ExpiresAt = &ts + } + return je, nil + case "text": + if !utf8.Valid(e.Value) { + return jsonEntry{}, fmt.Errorf("key %q contains non-UTF8 data; use --encoding=auto or base64", e.Key) + } + je := jsonEntry{Key: e.Key, Encoding: "text"} + je.Value = string(e.Value) + if e.ExpiresAt > 0 { + ts := int64(e.ExpiresAt) + je.ExpiresAt = &ts + } + return je, nil + default: // "auto" + return encodeJsonEntry(e), nil + } +} + func init() { listCmd.Flags().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") - listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html)") + listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson)") 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())) + listCmd.Flags().StringVarP(&listEncoding, "encoding", "e", "auto", "value encoding for ndjson format: auto, base64, or text") rootCmd.AddCommand(listCmd) } diff --git a/cmd/mv.go b/cmd/mv.go index d6b2810..6ba3bbf 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -26,7 +26,6 @@ import ( "fmt" "strings" - "github.com/dgraph-io/badger/v4" "github.com/spf13/cobra" ) @@ -48,11 +47,15 @@ var mvCmd = &cobra.Command{ } func cp(cmd *cobra.Command, args []string) error { - copyMode = true - return mv(cmd, args) + return mvImpl(cmd, args, true) } 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") @@ -70,33 +73,40 @@ func mv(cmd *cobra.Command, args []string) error { return err } - var srcVal []byte - var srcMeta byte - var srcExpires uint64 - fromRef := fromSpec.Full() - toRef := toSpec.Full() + // 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) + 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 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) + 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) + if err != nil { + return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } } - if promptOverwrite && destExists { + dstIdx := findEntry(dstEntries, toSpec.Key) + + if promptOverwrite && dstIdx >= 0 { var confirm string fmt.Printf("overwrite '%s'? (y/n)\n", toSpec.Display()) if _, err := fmt.Scanln(&confirm); err != nil { @@ -107,66 +117,53 @@ func mv(cmd *cobra.Command, args []string) error { } } - 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) + // Write destination entry + newEntry := Entry{ + Key: toSpec.Key, + Value: srcEntry.Value, + ExpiresAt: srcEntry.ExpiresAt, + } + + 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:]...) } - 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 - } - - 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); err != nil { + return err + } + } else { + // Different stores + if dstIdx >= 0 { + dstEntries[dstIdx] = newEntry + } else { + dstEntries = append(dstEntries, newEntry) + } + if err := writeStoreFile(dstPath, dstEntries); err != nil { + return err + } + if !keepSource { + srcEntries = append(srcEntries[:srcIdx], srcEntries[srcIdx+1:]...) + if err := writeStoreFile(srcPath, srcEntries); err != nil { + return err } - return tx.SetEntry(entry) - }, - }) - if writeErr != nil { - return writeErr - } - - if copyMode { - return autoSync() - } - - 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 ( - copyMode bool = false -) - func init() { - mvCmd.Flags().BoolVar(©Mode, "copy", false, "Copy instead of move (keeps source)") + mvCmd.Flags().Bool("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") diff --git a/cmd/restore.go b/cmd/restore.go index 3a3de6f..e134822 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -24,14 +24,12 @@ package cmd import ( "bufio" - "encoding/base64" "encoding/json" "fmt" "io" "os" "strings" - "github.com/dgraph-io/badger/v4" "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -78,11 +76,10 @@ func restore(cmd *cobra.Command, args []string) error { defer closer.Close() } - db, err := store.open(dbName) + p, err := store.storePath(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)) @@ -92,9 +89,15 @@ func restore(cmd *cobra.Command, args []string) error { } promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite - restored, err := restoreEntries(decoder, db, restoreOpts{ + drop, err := cmd.Flags().GetBool("drop") + if err != nil { + return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) + } + + restored, err := restoreEntries(decoder, p, restoreOpts{ matchers: matchers, promptOverwrite: promptOverwrite, + drop: drop, }) if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) @@ -123,87 +126,71 @@ func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) { return f, f, nil } -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 nil, err - } - return b, nil - default: - return nil, fmt.Errorf("unsupported encoding %q", entry.Encoding) - } -} - type restoreOpts struct { matchers []glob.Glob promptOverwrite bool + drop bool } -func restoreEntries(decoder *json.Decoder, db *badger.DB, opts restoreOpts) (int, error) { - wb := db.NewWriteBatch() - defer wb.Cancel() +func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) { + var existing []Entry + if !opts.drop { + var err error + existing, err = readStoreFile(storePath) + if err != nil { + return 0, err + } + } entryNo := 0 restored := 0 for { - var entry dumpEntry - if err := decoder.Decode(&entry); err != nil { + 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 entry.Key == "" { + if je.Key == "" { return 0, fmt.Errorf("entry %d: missing key", entryNo) } - if !globMatch(opts.matchers, entry.Key) { + if !globMatch(opts.matchers, je.Key) { continue } - if opts.promptOverwrite { - exists, err := keyExistsInDB(db, entry.Key) - if err != nil { - return 0, fmt.Errorf("entry %d: %v", entryNo, err) - } - if exists { - fmt.Printf("overwrite '%s'? (y/n)\n", entry.Key) - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return 0, fmt.Errorf("entry %d: %v", entryNo, err) - } - if strings.ToLower(confirm) != "y" { - continue - } - } - } - - value, err := decodeEntryValue(entry) + entry, err := decodeJsonEntry(je) if err != nil { return 0, fmt.Errorf("entry %d: %w", entryNo, err) } - writeEntry := badger.NewEntry([]byte(entry.Key), value) - if entry.ExpiresAt != nil { - if *entry.ExpiresAt < 0 { - return 0, fmt.Errorf("entry %d: expires_at must be >= 0", entryNo) + idx := findEntry(existing, entry.Key) + + if opts.promptOverwrite && idx >= 0 { + fmt.Printf("overwrite '%s'? (y/n)\n", entry.Key) + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil { + return 0, fmt.Errorf("entry %d: %v", entryNo, err) + } + if strings.ToLower(confirm) != "y" { + continue } - writeEntry.ExpiresAt = uint64(*entry.ExpiresAt) } - if err := wb.SetEntry(writeEntry); err != nil { - return 0, fmt.Errorf("entry %d: %w", entryNo, err) + if idx >= 0 { + existing[idx] = entry + } else { + existing = append(existing, entry) } restored++ } - if err := wb.Flush(); err != nil { - return 0, err + if restored > 0 || opts.drop { + if err := writeStoreFile(storePath, existing); err != nil { + return 0, err + } } return restored, nil } @@ -213,21 +200,6 @@ func init() { 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") + restoreCmd.Flags().Bool("drop", false, "Drop existing entries before restoring (full replace)") 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 84e1ed8..b4dd459 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -62,7 +62,7 @@ func init() { listStoresCmd.GroupID = "stores" delStoreCmd.GroupID = "stores" - dumpCmd.GroupID = "stores" + exportCmd.GroupID = "stores" restoreCmd.GroupID = "stores" rootCmd.AddGroup(&cobra.Group{ID: "git", Title: "Git commands:"}) diff --git a/cmd/set.go b/cmd/set.go index 647a435..99fce82 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -26,8 +26,8 @@ import ( "fmt" "io" "strings" + "time" - "github.com/dgraph-io/badger/v4" "github.com/spf13/cobra" ) @@ -36,7 +36,7 @@ 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. - + PDA supports parsing Go templates. Actions are delimited with {{ }}. For example: @@ -81,38 +81,44 @@ func set(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot set '%s': %v", args[0], err) } - if promptOverwrite { - exists, err := keyExists(store, spec.Full()) - if err != nil { + p, err := store.storePath(spec.DB) + if err != nil { + return fmt.Errorf("cannot set '%s': %v", args[0], err) + } + entries, err := readStoreFile(p) + if err != nil { + return fmt.Errorf("cannot set '%s': %v", args[0], err) + } + + idx := findEntry(entries, spec.Key) + + if promptOverwrite && idx >= 0 { + 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 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 - } + if strings.ToLower(confirm) != "y" { + return nil } } - trans := TransactionArgs{ - key: args[0], - readonly: false, - sync: false, - transact: func(tx *badger.Txn, k []byte) error { - entry := badger.NewEntry(k, value) - if ttl != 0 { - entry = entry.WithTTL(ttl) - } - return tx.SetEntry(entry) - }, + entry := Entry{ + Key: spec.Key, + Value: value, + } + if ttl != 0 { + entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix()) } - if err := store.Transaction(trans); err != nil { - return err + if idx >= 0 { + entries[idx] = entry + } else { + entries = append(entries, entry) + } + + if err := writeStoreFile(p, entries); err != nil { + return fmt.Errorf("cannot set '%s': %v", args[0], err) } return autoSync() diff --git a/cmd/shared.go b/cmd/shared.go index fb2f07b..43c35f9 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -32,7 +32,6 @@ import ( "unicode/utf8" "github.com/agnivade/levenshtein" - "github.com/dgraph-io/badger/v4" gap "github.com/muesli/go-app-paths" "golang.org/x/term" ) @@ -50,46 +49,6 @@ func (err errNotFound) Error() string { 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...) } @@ -118,20 +77,39 @@ func (s *Store) formatBytes(includeBinary bool, v []byte) string { return string(v) } +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) { - path, err := s.path() + dir, err := s.path() if err != nil { return nil, err } - dirs, err := os.ReadDir(path) + entries, err := os.ReadDir(dir) if err != nil { + if os.IsNotExist(err) { + return nil, nil + } return nil, err } var stores []string - for _, e := range dirs { - if e.IsDir() { - stores = append(stores, e.Name()) + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".ndjson" { + continue } + stores = append(stores, strings.TrimSuffix(e.Name(), ".ndjson")) } return stores, nil } @@ -141,12 +119,12 @@ func (s *Store) FindStore(k string) (string, error) { if err != nil { return "", err } - path, err := s.path(n) + p, err := s.storePath(n) if err != nil { return "", err } - info, statErr := os.Stat(path) - if strings.TrimSpace(n) == "" || os.IsNotExist(statErr) || (statErr == nil && !info.IsDir()) { + _, statErr := os.Stat(p) + if strings.TrimSpace(n) == "" || os.IsNotExist(statErr) { suggestions, err := s.suggestStores(n) if err != nil { return "", err @@ -156,7 +134,7 @@ func (s *Store) FindStore(k string) (string, error) { if statErr != nil { return "", statErr } - return path, nil + return p, nil } func (s *Store) parseKey(raw string, defaults bool) (KeySpec, error) { @@ -180,27 +158,12 @@ func (s *Store) parseDB(v string, defaults bool) (string, error) { return strings.ToLower(db), nil } -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) { +func (s *Store) path() (string, error) { if override := os.Getenv("PDA_DATA"); override != "" { if err := os.MkdirAll(override, 0o750); err != nil { return "", err } - target := filepath.Join(append([]string{override}, args...)...) - if err := ensureSubpath(override, target); err != nil { - return "", err - } - return target, nil + return override, nil } scope := gap.NewVendorScope(gap.User, "pda", "stores") dir, err := scope.DataPath("") @@ -210,11 +173,7 @@ func (s *Store) path(args ...string) (string, error) { if err := os.MkdirAll(dir, 0o750); err != nil { return "", err } - target := filepath.Join(append([]string{dir}, args...)...) - if err := ensureSubpath(dir, target); err != nil { - return "", err - } - return target, nil + return dir, nil } func (s *Store) suggestStores(target string) ([]string, error) { @@ -236,6 +195,19 @@ 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{suggestions} +} + func ensureSubpath(base, target string) error { absBase, err := filepath.Abs(base) if err != nil { @@ -278,22 +250,17 @@ func formatExpiry(expiresAt uint64) string { // Keys returns all keys for the provided store name (or default if empty). // Keys are returned in lowercase to mirror stored key format. func (s *Store) Keys(dbName string) ([]string, error) { - db, err := s.open(dbName) + p, err := s.storePath(dbName) if err != nil { return nil, err } - 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())) + entries, err := readStoreFile(p) + if err != nil { + return nil, err + } + keys := make([]string, len(entries)) + for i, e := range entries { + keys[i] = e.Key } return keys, nil } diff --git a/cmd/vcs.go b/cmd/vcs.go index eba247d..757a996 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -1,9 +1,7 @@ package cmd import ( - "bufio" "bytes" - "encoding/json" "fmt" "io" "os" @@ -68,29 +66,31 @@ func writeGitignore(repoDir string) error { return nil } +// snapshotDB copies a store's .ndjson file into the VCS directory. 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) + + srcPath, err := store.storePath(db) if err != nil { return err } - defer f.Close() - opts := DumpOptions{ - Encoding: "auto", - } - if err := dumpDatabase(store, db, f, opts); err != nil { + data, err := os.ReadFile(srcPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } return err } - return f.Sync() + target := filepath.Join(targetDir, db+".ndjson") + return os.WriteFile(target, data, 0o640) } -// exportAllStores writes every Badger store to ndjson files under repoDir/stores +// exportAllStores copies every store's .ndjson file to repoDir/stores // and removes stale snapshot files for deleted stores. func exportAllStores(store *Store, repoDir string) error { stores, err := store.AllStores() @@ -288,6 +288,8 @@ func currentBranch(dir string) (string, error) { return branch, nil } +// restoreAllSnapshots copies .ndjson files from VCS snapshot dir into store paths, +// and removes local stores that are not in the snapshot. func restoreAllSnapshots(store *Store, repoDir string) error { targetDir := filepath.Join(repoDir, storeDirName) entries, err := os.ReadDir(targetDir) @@ -310,12 +312,18 @@ func restoreAllSnapshots(store *Store, repoDir string) error { dbName := strings.TrimSuffix(e.Name(), ".ndjson") snapshotDBs[dbName] = struct{}{} - dbPath, err := store.FindStore(dbName) - if err == nil { - _ = os.RemoveAll(dbPath) + srcPath := filepath.Join(targetDir, e.Name()) + data, err := os.ReadFile(srcPath) + if err != nil { + return fmt.Errorf("restore %q: %w", dbName, err) } - if err := restoreSnapshot(store, filepath.Join(targetDir, e.Name()), dbName); err != nil { + dstPath, err := store.storePath(dbName) + if err != nil { + return fmt.Errorf("restore %q: %w", dbName, err) + } + + if err := os.WriteFile(dstPath, data, 0o640); err != nil { return fmt.Errorf("restore %q: %w", dbName, err) } } @@ -328,11 +336,11 @@ func restoreAllSnapshots(store *Store, repoDir string) error { if _, ok := snapshotDBs[db]; ok { continue } - dbPath, err := store.FindStore(db) + p, err := store.storePath(db) if err != nil { return err } - if err := os.RemoveAll(dbPath); err != nil { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove store '%s': %w", db, err) } } @@ -346,32 +354,13 @@ func wipeAllStores(store *Store) error { return err } for _, db := range dbs { - path, err := store.FindStore(db) + p, err := store.storePath(db) if err != nil { return err } - if err := os.RemoveAll(path); err != nil { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove store '%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)) - _, err = restoreEntries(decoder, db, restoreOpts{}) - return err -} - From ddb75f1aebd8e65fb56095e0b70a7459b7d58af4 Mon Sep 17 00:00:00 2001 From: lew Date: Tue, 10 Feb 2026 23:58:35 +0000 Subject: [PATCH 023/107] chore: remove badger dependency --- go.mod | 16 +--------------- go.sum | 31 ------------------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/go.mod b/go.mod index 68d61c1..5a28a71 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/llywelwyn/pda go 1.25.3 require ( + 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 @@ -14,27 +14,13 @@ require ( ) require ( - 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 - 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 8b34406..125c9e8 100644 --- a/go.sum +++ b/go.sum @@ -4,30 +4,13 @@ github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KO 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= @@ -39,8 +22,6 @@ 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= @@ -59,24 +40,12 @@ 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= -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= From 32e7a79c71f65d34f24b9967d695ee43f52a9db1 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:04:43 +0000 Subject: [PATCH 024/107] test: update golden files for ndjson migration --- testdata/dump__glob__ok.ct | 2 +- testdata/get__missing__err.ct | 2 +- testdata/get__missing__err__with__any.ct | 8 ++++---- testdata/help__dump__ok.ct | 4 ++-- testdata/help__list__ok.ct | 6 ++++-- testdata/help__ok.ct | 4 ++-- testdata/help__restore__ok.ct | 2 ++ testdata/help__set__ok.ct | 4 ++-- testdata/remove__dedupe__ok.ct | 4 ++-- testdata/remove__glob__mixed__ok.ct | 6 +++--- testdata/remove__glob__ok.ct | 4 ++-- testdata/remove__multiple__ok.ct | 4 ++-- testdata/restore__glob__ok.ct | 2 +- testdata/root__ok.ct | 2 +- 14 files changed, 29 insertions(+), 25 deletions(-) diff --git a/testdata/dump__glob__ok.ct b/testdata/dump__glob__ok.ct index b58c19c..87ca9da 100644 --- a/testdata/dump__glob__ok.ct +++ b/testdata/dump__glob__ok.ct @@ -5,4 +5,4 @@ $ 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*' +Error: cannot ls '@default': No matches for pattern 'c*' diff --git a/testdata/get__missing__err.ct b/testdata/get__missing__err.ct index ced6568..0a54c6c 100644 --- a/testdata/get__missing__err.ct +++ b/testdata/get__missing__err.ct @@ -1,2 +1,2 @@ $ pda get foobar --> FAIL -Error: cannot get 'foobar': Key not found +Error: cannot get 'foobar': No such key diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get__missing__err__with__any.ct index 1d3012f..c942bcb 100644 --- a/testdata/get__missing__err__with__any.ct +++ b/testdata/get__missing__err__with__any.ct @@ -5,10 +5,10 @@ $ 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': No such key +Error: cannot get 'foobar': No such key +Error: cannot get 'foobar': No such key Error: unknown flag: --secret -Error: cannot get 'foobar': Key not found +Error: cannot get 'foobar': No such key Error: unknown flag: --secret Error: unknown flag: --secret diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 09c7e2e..2bcce3b 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -1,6 +1,6 @@ $ pda help dump $ pda dump --help -Dump all key/value pairs as NDJSON +Export store as NDJSON (alias for list --format ndjson) Usage: pda export [STORE] [flags] @@ -13,7 +13,7 @@ Flags: -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") -h, --help help for export -Dump all key/value pairs as NDJSON +Export store as NDJSON (alias for list --format ndjson) Usage: pda export [STORE] [flags] diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index dd943f9..b8b5af3 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -10,7 +10,8 @@ Aliases: Flags: -b, --binary include binary data in text output - -o, --format format output format (table|tsv|csv|markdown|html) (default table) + -e, --encoding string value encoding for ndjson format: auto, base64, or text (default "auto") + -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (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 @@ -28,7 +29,8 @@ Aliases: Flags: -b, --binary include binary data in text output - -o, --format format output format (table|tsv|csv|markdown|html) (default table) + -e, --encoding string value encoding for ndjson format: auto, base64, or text (default "auto") + -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (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 diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index 7065f74..4acbf58 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -22,7 +22,7 @@ Key commands: set Set a key to a given value Store commands: - export Dump all key/value pairs as NDJSON + export Export store as NDJSON (alias for list --format ndjson) import Restore key/value pairs from an NDJSON dump list-stores List all stores remove-store Delete a store @@ -63,7 +63,7 @@ Key commands: set Set a key to a given value Store commands: - export Dump all key/value pairs as NDJSON + export Export store as NDJSON (alias for list --format ndjson) import Restore key/value pairs from an NDJSON dump list-stores List all stores remove-store Delete a store diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index b9fa6e5..106a0f9 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -9,6 +9,7 @@ Aliases: import, restore Flags: + --drop Drop existing entries before restoring (full replace) -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 "/-_.@: ") @@ -23,6 +24,7 @@ Aliases: import, restore Flags: + --drop Drop existing entries before restoring (full replace) -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 "/-_.@: ") diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct index 345ee88..d1e2f57 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -1,7 +1,7 @@ $ pda help set $ pda set --help Set a key to a given value or stdin. Optionally specify a store. - + PDA supports parsing Go templates. Actions are delimited with {{ }}. For example: @@ -22,7 +22,7 @@ Flags: -i, --interactive Prompt before overwriting an existing key -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. - + PDA supports parsing Go templates. Actions are delimited with {{ }}. For example: diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 042c536..e5ec064 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -10,6 +10,6 @@ $ pda ls foo 1 $ pda rm foo --glob "*" $ pda get bar --> FAIL -Error: cannot get 'bar': Key not found +Error: cannot get 'bar': No such key $ pda get foo --> FAIL -Error: cannot get 'foo': Key not found +Error: cannot get 'foo': No such key diff --git a/testdata/remove__glob__mixed__ok.ct b/testdata/remove__glob__mixed__ok.ct index 332a475..9433c89 100644 --- a/testdata/remove__glob__mixed__ok.ct +++ b/testdata/remove__glob__mixed__ok.ct @@ -3,8 +3,8 @@ $ pda set bar1 2 $ pda set bar2 3 $ pda rm foo --glob bar* $ pda get foo --> FAIL -Error: cannot get 'foo': Key not found +Error: cannot get 'foo': No such key $ pda get bar1 --> FAIL -Error: cannot get 'bar1': Key not found +Error: cannot get 'bar1': No such key $ pda get bar2 --> FAIL -Error: cannot get 'bar2': Key not found +Error: cannot get 'bar2': No such key diff --git a/testdata/remove__glob__ok.ct b/testdata/remove__glob__ok.ct index 81fe095..3fda37c 100644 --- a/testdata/remove__glob__ok.ct +++ b/testdata/remove__glob__ok.ct @@ -3,8 +3,8 @@ $ pda set a2 2 $ pda set b1 3 $ pda rm --glob a* $ pda get a1 --> FAIL -Error: cannot get 'a1': Key not found +Error: cannot get 'a1': No such key. Did you mean 'b1'? $ pda get a2 --> FAIL -Error: cannot get 'a2': Key not found +Error: cannot get 'a2': No such key $ pda get b1 3 diff --git a/testdata/remove__multiple__ok.ct b/testdata/remove__multiple__ok.ct index 395e009..d61e113 100644 --- a/testdata/remove__multiple__ok.ct +++ b/testdata/remove__multiple__ok.ct @@ -2,6 +2,6 @@ $ pda set a 1 $ pda set b 2 $ pda rm a b $ pda get a --> FAIL -Error: cannot get 'a': Key not found +Error: cannot get 'a': No such key $ pda get b --> FAIL -Error: cannot get 'b': Key not found +Error: cannot get 'b': No such key. Did you mean 'b1'? diff --git a/testdata/restore__glob__ok.ct b/testdata/restore__glob__ok.ct index 3aa0b44..eae61f8 100644 --- a/testdata/restore__glob__ok.ct +++ b/testdata/restore__glob__ok.ct @@ -10,6 +10,6 @@ $ pda get a1 $ pda get a2 2 $ pda get b1 --> FAIL -Error: cannot get 'b1': Key not found +Error: cannot get 'b1': No such key. Did you mean 'a1'? $ pda restore --glob c* --file dumpfile --> FAIL Error: cannot restore '@default': No matches for pattern 'c*' diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index 099e74b..e5c16d7 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -21,7 +21,7 @@ Key commands: set Set a key to a given value Store commands: - export Dump all key/value pairs as NDJSON + export Export store as NDJSON (alias for list --format ndjson) import Restore key/value pairs from an NDJSON dump list-stores List all stores remove-store Delete a store From 84c55311d1e7a433d66045467499fab971918dc6 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:08:34 +0000 Subject: [PATCH 025/107] chore: add license headers and --drop golden test --- cmd/ndjson.go | 22 ++++++++++++++++++++++ cmd/ndjson_test.go | 22 ++++++++++++++++++++++ testdata/restore__drop__ok.ct | 9 +++++++++ 3 files changed, 53 insertions(+) create mode 100644 testdata/restore__drop__ok.ct diff --git a/cmd/ndjson.go b/cmd/ndjson.go index fa3e829..458b5a4 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -1,3 +1,25 @@ +/* +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 ( diff --git a/cmd/ndjson_test.go b/cmd/ndjson_test.go index 3deb593..bacc3aa 100644 --- a/cmd/ndjson_test.go +++ b/cmd/ndjson_test.go @@ -1,3 +1,25 @@ +/* +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 ( diff --git a/testdata/restore__drop__ok.ct b/testdata/restore__drop__ok.ct new file mode 100644 index 0000000..b5c18f1 --- /dev/null +++ b/testdata/restore__drop__ok.ct @@ -0,0 +1,9 @@ +$ pda set existing keep-me +$ pda set other also-keep +$ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} +$ pda restore --drop --file dumpfile +Restored 1 entries into @default +$ pda get new +hello +$ pda get existing --> FAIL +Error: cannot get 'existing': No such key From cb441b112c3c770a1aff69c63849cb3aba97e9fa Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:28:56 +0000 Subject: [PATCH 026/107] refactor(massive simplification of vcs now that we're using ndjson natively): --- cmd/git.go | 14 ++--- cmd/init.go | 96 +++++++++++++++++++---------------- cmd/sync.go | 11 +--- cmd/vcs.go | 143 +--------------------------------------------------- 4 files changed, 61 insertions(+), 203 deletions(-) diff --git a/cmd/git.go b/cmd/git.go index 489cd4e..4254d4d 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -34,16 +34,12 @@ var gitCmd = &cobra.Command{ Short: "Run any arbitrary command. Use with caution.", Long: `Run any arbitrary command. Use with caution. -Be wary of how pda! version control operates before using this. -Regular data is stored in "PDA_DATA/pda/stores" as a store; the -Git repository is in "PDA_DATA/pda/vcs" and contains a plaintext -replica of the store data. +The Git repository lives directly in the stores directory +("PDA_DATA/pda/stores"). Store files (*.ndjson) are tracked +by Git as-is. -The regular sync command (or auto-syncing) exports pda! data into -plaintext in the Git repository. If you manually modify the -repository without using the built-in commands, or exporting your -data to the Git folder in the correct format first, you may desync -your repository. +If you manually modify files without using the built-in +commands, you may desync your repository. Generally prefer "pda sync".`, Args: cobra.ArbitraryArgs, diff --git a/cmd/init.go b/cmd/init.go index d0259b2..07c64a9 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -40,7 +40,7 @@ var initCmd = &cobra.Command{ } func init() { - initCmd.Flags().Bool("clean", false, "Remove existing VCS directory before initialising") + initCmd.Flags().Bool("clean", false, "Remove .git from stores directory before initialising") rootCmd.AddCommand(initCmd) } @@ -55,66 +55,74 @@ func vcsInit(cmd *cobra.Command, args []string) error { if err != nil { return err } + + hasRemote := len(args) == 1 + if clean { - entries, err := os.ReadDir(repoDir) - if err == nil && len(entries) > 0 { - fmt.Printf("remove existing VCS directory '%s'? (y/n)\n", repoDir) + gitDir := filepath.Join(repoDir, ".git") + if _, err := os.Stat(gitDir); err == nil { + fmt.Printf("remove .git from '%s'? (y/n)\n", repoDir) var confirm string if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot clean vcs dir: %w", err) + return fmt.Errorf("cannot clean git dir: %w", err) } if strings.ToLower(confirm) != "y" { - return fmt.Errorf("aborted cleaning vcs dir") + return fmt.Errorf("aborted cleaning git dir") + } + if err := os.RemoveAll(gitDir); err != nil { + return fmt.Errorf("cannot clean git dir: %w", err) } - } - 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 hasRemote { + dbs, err := store.AllStores() + if err == nil && len(dbs) > 0 { + fmt.Printf("remove all existing stores and .gitignore? (required for clone) (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) + } + gi := filepath.Join(repoDir, ".gitignore") + if err := os.Remove(gi); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove .gitignore: %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 { + if _, err := os.Stat(gitDir); err == nil { fmt.Println("vcs already initialised; use --clean to reinitialise") return nil } - if err := writeGitignore(repoDir); err != nil { - return err + if hasRemote { + // git clone requires the target directory to be empty + entries, err := os.ReadDir(repoDir) + if err == nil && len(entries) > 0 { + return fmt.Errorf("stores directory is not empty; use --clean with a remote to wipe and clone") + } + + remote := args[0] + fmt.Printf("running: git clone %s %s\n", remote, repoDir) + if err := runGit("", "clone", remote, repoDir); err != nil { + return err + } + } else { + if err := os.MkdirAll(repoDir, 0o750); err != nil { + return err + } + fmt.Printf("running: git init\n") + if err := runGit(repoDir, "init"); err != nil { + return err + } } - if len(args) == 0 { - return nil - } - return restoreAllSnapshots(store, repoDir) + return writeGitignore(repoDir) } diff --git a/cmd/sync.go b/cmd/sync.go index 222ee3c..b5b696e 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -44,7 +44,6 @@ func init() { } func sync(manual bool) error { - store := &Store{} repoDir, err := ensureVCSInitialized() if err != nil { return err @@ -89,18 +88,12 @@ func sync(manual bool) error { return err } } - if err := pullRemote(repoDir, remoteInfo); err != nil { - return err - } - return restoreAllSnapshots(store, repoDir) + return pullRemote(repoDir, remoteInfo) } } } - if err := exportAllStores(store, repoDir); err != nil { - return err - } - if err := runGit(repoDir, "add", storeDirName); err != nil { + if err := runGit(repoDir, "add", "-A"); err != nil { return err } changed, err := repoHasStagedChanges(repoDir) diff --git a/cmd/vcs.go b/cmd/vcs.go index 757a996..9a82706 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -9,22 +9,10 @@ import ( "path/filepath" "strconv" "strings" - - gap "github.com/muesli/go-app-paths" ) -const storeDirName = "stores" - 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 + return (&Store{}).path() } func ensureVCSInitialized() (string, error) { @@ -47,10 +35,8 @@ func writeGitignore(repoDir string) error { content := strings.Join([]string{ "# generated by pda", "*", - "!/", "!.gitignore", - "!" + storeDirName + "/", - "!" + storeDirName + "/*", + "!*.ndjson", "", }, "\n") if err := os.WriteFile(path, []byte(content), 0o640); err != nil { @@ -66,71 +52,6 @@ func writeGitignore(repoDir string) error { return nil } -// snapshotDB copies a store's .ndjson file into the VCS directory. -func snapshotDB(store *Store, repoDir, db string) error { - targetDir := filepath.Join(repoDir, storeDirName) - if err := os.MkdirAll(targetDir, 0o750); err != nil { - return err - } - - srcPath, err := store.storePath(db) - if err != nil { - return err - } - - data, err := os.ReadFile(srcPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - - target := filepath.Join(targetDir, db+".ndjson") - return os.WriteFile(target, data, 0o640) -} - -// exportAllStores copies every store's .ndjson file to repoDir/stores -// and removes stale snapshot files for deleted stores. -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 -} - func runGit(dir string, args ...string) error { cmd := exec.Command("git", args...) cmd.Dir = dir @@ -288,66 +209,6 @@ func currentBranch(dir string) (string, error) { return branch, nil } -// restoreAllSnapshots copies .ndjson files from VCS snapshot dir into store paths, -// and removes local stores that are not in the snapshot. -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{}{} - - srcPath := filepath.Join(targetDir, e.Name()) - data, err := os.ReadFile(srcPath) - if err != nil { - return fmt.Errorf("restore %q: %w", dbName, err) - } - - dstPath, err := store.storePath(dbName) - if err != nil { - return fmt.Errorf("restore %q: %w", dbName, err) - } - - if err := os.WriteFile(dstPath, data, 0o640); 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 - } - p, err := store.storePath(db) - if err != nil { - return err - } - if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove store '%s': %w", db, err) - } - } - - return nil -} - func wipeAllStores(store *Store) error { dbs, err := store.AllStores() if err != nil { From 08025903ad1aed60cb9133a4c0fdb3ca6a95aeb4 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:31:21 +0000 Subject: [PATCH 027/107] chore: add .worktrees to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4cae49a..7e6986f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .cache .gocache .build +.worktrees From 2c9ecd7cafe951d2cf3f561cb742cdc365f2134e Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:50:06 +0000 Subject: [PATCH 028/107] refactor: remove --encoding flag from list/export commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-detection mode (encodeJsonEntry) is always correct — it uses text for valid UTF-8 and base64 for binary data. The explicit base64 and text modes added no practical value and had zero test coverage. --- cmd/export.go | 1 - cmd/list.go | 45 +++----------------------------------- testdata/help__dump__ok.ct | 2 -- testdata/help__list__ok.ct | 2 -- 4 files changed, 3 insertions(+), 47 deletions(-) diff --git a/cmd/export.go b/cmd/export.go index bdfd28d..ff27e22 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -43,6 +43,5 @@ var exportCmd = &cobra.Command{ func init() { exportCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") exportCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) - exportCmd.Flags().StringVarP(&listEncoding, "encoding", "e", "auto", "value encoding: auto, base64, or text") rootCmd.AddCommand(exportCmd) } diff --git a/cmd/list.go b/cmd/list.go index 6562aa8..88779f4 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -23,14 +23,12 @@ THE SOFTWARE. package cmd import ( - "encoding/base64" "encoding/json" "errors" "fmt" "io" "os" "strconv" - "unicode/utf8" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" @@ -60,9 +58,8 @@ var ( listNoKeys bool listNoValues bool listTTL bool - listHeader bool - listFormat formatEnum = "table" - listEncoding string + listHeader bool + listFormat formatEnum = "table" ) type columnKind int @@ -155,16 +152,8 @@ func list(cmd *cobra.Command, args []string) error { // NDJSON format: emit JSON lines directly if listFormat.String() == "ndjson" { - enc := listEncoding - if enc == "" { - enc = "auto" - } for _, e := range filtered { - je, err := encodeJsonEntryWithEncoding(e, enc) - if err != nil { - return fmt.Errorf("cannot ls '%s': %v", targetDB, err) - } - data, err := json.Marshal(je) + data, err := json.Marshal(encodeJsonEntry(e)) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -310,33 +299,6 @@ func renderTable(tw table.Writer) { } } -// encodeJsonEntryWithEncoding encodes an Entry to jsonEntry respecting the encoding mode. -func encodeJsonEntryWithEncoding(e Entry, mode string) (jsonEntry, error) { - switch mode { - case "base64": - je := jsonEntry{Key: e.Key, Encoding: "base64"} - je.Value = base64.StdEncoding.EncodeToString(e.Value) - if e.ExpiresAt > 0 { - ts := int64(e.ExpiresAt) - je.ExpiresAt = &ts - } - return je, nil - case "text": - if !utf8.Valid(e.Value) { - return jsonEntry{}, fmt.Errorf("key %q contains non-UTF8 data; use --encoding=auto or base64", e.Key) - } - je := jsonEntry{Key: e.Key, Encoding: "text"} - je.Value = string(e.Value) - if e.ExpiresAt > 0 { - ts := int64(e.ExpiresAt) - je.ExpiresAt = &ts - } - return je, nil - default: // "auto" - return encodeJsonEntry(e), nil - } -} - func init() { listCmd.Flags().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") @@ -346,6 +308,5 @@ func init() { listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson)") 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())) - listCmd.Flags().StringVarP(&listEncoding, "encoding", "e", "auto", "value encoding for ndjson format: auto, base64, or text") rootCmd.AddCommand(listCmd) } diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 2bcce3b..db48321 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -9,7 +9,6 @@ Aliases: export, dump 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 export @@ -22,7 +21,6 @@ Aliases: export, dump 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 export diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index b8b5af3..f9f51ed 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -10,7 +10,6 @@ Aliases: Flags: -b, --binary include binary data in text output - -e, --encoding string value encoding for ndjson format: auto, base64, or text (default "auto") -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") @@ -29,7 +28,6 @@ Aliases: Flags: -b, --binary include binary data in text output - -e, --encoding string value encoding for ndjson format: auto, base64, or text (default "auto") -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") From 52c108f7d37be0479bf82087884d975505bc4bc6 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 00:53:14 +0000 Subject: [PATCH 029/107] refactor: vcs simplification pass --- cmd/init.go | 4 +-- cmd/sync.go | 95 +++++++++++++++++++++++------------------------------ cmd/vcs.go | 33 +++---------------- 3 files changed, 48 insertions(+), 84 deletions(-) diff --git a/cmd/init.go b/cmd/init.go index 07c64a9..764e247 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -45,11 +45,11 @@ func init() { } func vcsInit(cmd *cobra.Command, args []string) error { - repoDir, err := vcsRepoRoot() + store := &Store{} + repoDir, err := store.path() if err != nil { return err } - store := &Store{} clean, err := cmd.Flags().GetBool("clean") if err != nil { diff --git a/cmd/sync.go b/cmd/sync.go index b5b696e..e325701 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -24,7 +24,6 @@ package cmd import ( "fmt" - "strings" "time" "github.com/spf13/cobra" @@ -54,45 +53,7 @@ func sync(manual bool) error { 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 { - ahead = 1 // ref doesn't exist yet; just push - } else { - 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 - } - } - return pullRemote(repoDir, remoteInfo) - } - } - } - + // Commit local changes first so nothing is lost. if err := runGit(repoDir, "add", "-A"); err != nil { return err } @@ -100,29 +61,54 @@ func sync(manual bool) error { if err != nil { return err } - madeCommit := false - if !changed { - if manual { - fmt.Println("no changes to commit") - } - } else { + if changed { msg := fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) if err := runGit(repoDir, "commit", "-m", msg); err != nil { return err } - madeCommit = true + } else if manual { + fmt.Println("no changes to commit") } + + if remoteInfo.Ref == "" { + if manual { + fmt.Println("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 remoteInfo.Ref == "" { - if manual { - fmt.Println("no remote configured; skipping push") - } - } else if madeCommit || ahead > 0 { + if ahead > 0 { return pushRemote(repoDir, remoteInfo) - } else if manual { + } + if manual { fmt.Println("nothing to push") } } + return nil } @@ -130,5 +116,8 @@ func autoSync() error { if !config.Git.AutoCommit { return nil } + if _, err := ensureVCSInitialized(); err != nil { + return nil + } return sync(false) } diff --git a/cmd/vcs.go b/cmd/vcs.go index 9a82706..ecb1f45 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -1,7 +1,6 @@ package cmd import ( - "bytes" "fmt" "io" "os" @@ -11,12 +10,8 @@ import ( "strings" ) -func vcsRepoRoot() (string, error) { - return (&Store{}).path() -} - func ensureVCSInitialized() (string, error) { - repoDir, err := vcsRepoRoot() + repoDir, err := (&Store{}).path() if err != nil { return "", err } @@ -118,16 +113,6 @@ 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 @@ -143,26 +128,16 @@ func repoHasStagedChanges(dir string) (bool, error) { func pullRemote(dir string, info gitRemoteInfo) error { if info.HasUpstream { - return runGit(dir, "pull", "--ff-only") + return runGit(dir, "pull", "--rebase") } - 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 + return runGit(dir, "pull", "--rebase", info.Remote, info.Branch) } func pushRemote(dir string, info gitRemoteInfo) error { if info.HasUpstream { return runGit(dir, "push") } - 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 + return runGit(dir, "push", "-u", info.Remote, info.Branch) } func repoHasUpstream(dir string) (bool, error) { From 20b65e280d4c5bfff74d495758849bd97df4b239 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 01:32:50 +0000 Subject: [PATCH 030/107] docs: update README for NDJSON migration and removed features Remove references to badger, --secret flag, --force flag, and snapshot command. Rename dump/restore to export/import as primary commands, fix rm/rm-store to document --interactive instead of --force, and remove the entire Secrets section. --- README.md | 99 ++++++++++++++----------------------------------------- 1 file changed, 25 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index b156558..04646d2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ - 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, @@ -31,7 +30,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`pda!` canonically stores key-value pairs in [badger](https://github.com/dgraph-io/badger) stores 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. +`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. The `list` command supports multiple output formats including [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_element#Tables) tables, while `export` dumps the raw NDJSON. Since stores are already NDJSON files, Git version control works directly on them — auto-commit can be enabled in the config to automatically generate a descriptive commit for every change made.

@@ -53,7 +52,6 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [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) @@ -85,7 +83,7 @@ Key commands: set Set a key to a given value Store commands: - export Dump all key/value pairs as NDJSON + export Export store as NDJSON (alias for list --format ndjson) import Restore key/value pairs from an NDJSON dump list-stores List all stores remove-store Delete a store @@ -169,25 +167,20 @@ pda mv name name2 --copy `pda rm` to delete one or more keys. ```bash pda rm kitty -# remove "kitty": are you sure? [y/n] -# y - -# Or skip the prompt. -pda rm kitty --force # Remove multiple keys, within the same or different stores. pda rm kitty dog@animals -# remove "kitty", "dog@animals": are you sure? [y/n] -# y # Mix exact keys with globs. pda set cog "cogs" pda set dog "doggy" pda set kitty "cat" pda rm kitty --glob ?og -# remove "kitty", "cog", "dog": are you sure? [y/n] -# y # Default glob separators: "/-_.@: " (space included). Override with --glob-sep. + +# Opt in to a confirmation prompt with --interactive/-i (or always_prompt_delete in config). +pda rm kitty -i +# remove "kitty": are you sure? (y/n) ```

@@ -208,28 +201,28 @@ pda ls --format csv

-`pda dump` to export everything as NDJSON. +`pda export` to export everything as NDJSON. ```bash -pda dump > my_backup +pda export > my_backup -# Dump only matching keys. -pda dump --glob a* +# Export only matching keys. +pda export --glob a* ```

-`pda restore` to import it all back. +`pda import` to import it all back. ```bash -# Restore with an argument. -pda restore -f my_backup +# Import with an argument. +pda import -f my_backup # Restored 2 entries into @default. # Or from stdin. -pda restore < my_backup +pda import < my_backup # Restored 2 entries into @default. -# Restore only matching keys. -pda restore --glob a* -f my_backup +# Import only matching keys. +pda import --glob a* -f my_backup ```

@@ -249,14 +242,14 @@ pda ls @birthdays # alice 11/11/1998 # bob 05/12/1980 -# Dump it. -pda dump birthdays > friends_birthdays +# Export it. +pda export birthdays > friends_birthdays -# Restore it. -pda restore birthdays < friends_birthdays +# Import it. +pda import birthdays < friends_birthdays # Delete it. -pda rm-store birthdays --force +pda rm-store birthdays ```

@@ -288,7 +281,7 @@ If you're ahead of your Git repo, syncing will add your changes, commit them, an pda sync ``` -`pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means exporting and versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. +`pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. Auto-commit will commit changes immediately to the local Git repository any time `pda!` data is changed. Auto-fetch will fetch before committing any changes, but incurs a significant slowdown in operations simply due to the time a fetch takes. Auto-push will automatically push committed changes to the remote repository, if one is set. @@ -523,48 +516,6 @@ pda ls --glob "*%*" --glob-sep "%"

-### 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. @@ -585,7 +536,7 @@ pda ls --ttl # 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. +`export` and `import` persist the expiry date. Expirations will continue ticking down regardless of if they're actively in a store or not - the expiry is just a timestamp, not a timer.

@@ -618,9 +569,9 @@ pda get logo --include-binary

-`dump` encodes binary data as base64. +`export` encodes binary data as base64. ```bash -pda dump +pda export # {"key":"logo","value":"89504E470D0A1A0A0000000D4948445200000001000000010802000000","encoding":"base64"} ``` From 6ccd801c8967dcf3396899c52a56a4969a30c05b Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 01:38:25 +0000 Subject: [PATCH 031/107] docs: revise list output and Git versioning description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 04646d2..80ce116 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. The `list` command supports multiple output formats including [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_element#Tables) tables, while `export` dumps the raw NDJSON. Since stores are already NDJSON files, Git version control works directly on them — auto-commit can be enabled in the config to automatically generate a descriptive commit for every change made. +`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. The `list` command 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_element#Tables) tables, and raw NDJSON. Because every store is in plaintext, Git versioning is pretty easy: auto-committing, pushing, and fetching can be enabled in the config to automatically version changes, or just `pda sync` regularly.

From b52a5bfdb707cd2093d14b64adc70c76986ff096 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 02:11:58 +0000 Subject: [PATCH 032/107] feat: huge overhaul of messaging into FAIL, WARN, hint, ok, prompt, and progress types --- cmd/del-db.go | 17 ++-- cmd/del.go | 9 +- cmd/get.go | 8 +- cmd/init.go | 31 +++--- cmd/list-dbs.go | 2 +- cmd/list.go | 8 +- cmd/msg.go | 95 +++++++++++++++++++ cmd/mv.go | 6 +- cmd/ndjson.go | 6 +- cmd/restore.go | 8 +- cmd/root.go | 10 +- cmd/set.go | 4 +- cmd/shared.go | 10 +- cmd/sync.go | 6 +- cmd/vcs.go | 6 +- testdata/dump__glob__ok.ct | 2 +- testdata/get__err__with__invalid_db.ct | 2 +- testdata/get__missing__err.ct | 2 +- testdata/get__missing__err__with__any.ct | 14 +-- testdata/invalid__err.ct | 3 +- testdata/list__err__with__invalid_db.ct | 2 +- testdata/list__glob__ok.ct | 2 +- .../remove-store__err__with__invalid_db.ct | 2 +- testdata/remove__dedupe__ok.ct | 4 +- testdata/remove__glob__mixed__ok.ct | 6 +- testdata/remove__glob__ok.ct | 5 +- testdata/remove__multiple__ok.ct | 5 +- testdata/restore__drop__ok.ct | 4 +- testdata/restore__glob__ok.ct | 7 +- testdata/set__err__with__invalid-ttl.ct | 2 +- 30 files changed, 192 insertions(+), 96 deletions(-) create mode 100644 cmd/msg.go diff --git a/cmd/del-db.go b/cmd/del-db.go index 334277b..1a254da 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -45,29 +45,28 @@ func delStore(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 store '%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': %v", dbName, err) + return fmt.Errorf("cannot delete store '%s': %w", dbName, err) } if err != nil { - return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) + return fmt.Errorf("cannot delete store '%s': %v", dbName, err) } interactive, err := cmd.Flags().GetBool("interactive") if err != nil { - return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) + return fmt.Errorf("cannot delete store '%s': %v", dbName, err) } if interactive || config.Store.AlwaysPromptDelete { - message := fmt.Sprintf("delete-store '%s': are you sure? (y/n)", args[0]) - fmt.Println(message) + promptf("delete store '%s'? (y/n)", args[0]) var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot delete-store '%s': %v", dbName, err) + if err := scanln(&confirm); err != nil { + return fmt.Errorf("cannot delete store '%s': %v", dbName, err) } if strings.ToLower(confirm) != "y" { return nil @@ -81,7 +80,7 @@ func delStore(cmd *cobra.Command, args []string) error { func executeDeletion(path string) error { if err := os.Remove(path); err != nil { - return fmt.Errorf("cannot delete-store '%s': %v", path, err) + return fmt.Errorf("cannot delete store '%s': %v", path, err) } return nil } diff --git a/cmd/del.go b/cmd/del.go index fac2fcd..8ab21d3 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -66,7 +66,7 @@ func del(cmd *cobra.Command, args []string) error { } 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. @@ -78,9 +78,8 @@ func del(cmd *cobra.Command, args []string) error { for _, target := range targets { if interactive || config.Key.AlwaysPromptDelete { var confirm string - message := fmt.Sprintf("remove %q: are you sure? (y/n)", target.display) - fmt.Println(message) - if _, err := fmt.Scanln(&confirm); err != nil { + promptf("remove '%s'? (y/n)", target.display) + if err := scanln(&confirm); err != nil { return fmt.Errorf("cannot remove '%s': %v", target.full, err) } if strings.ToLower(confirm) != "y" { @@ -111,7 +110,7 @@ func del(cmd *cobra.Command, args []string) error { for _, t := range st.targets { idx := findEntry(entries, t.key) if idx < 0 { - return fmt.Errorf("cannot remove '%s': No such key", t.full) + return fmt.Errorf("cannot remove '%s': no such key", t.full) } entries = append(entries[:idx], entries[idx+1:]...) } diff --git a/cmd/get.go b/cmd/get.go index 6350d69..036ac4d 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -90,7 +90,7 @@ func get(cmd *cobra.Command, args []string) error { for i, e := range entries { keys[i] = e.Key } - return fmt.Errorf("cannot get '%s': %v", args[0], suggestKey(spec.Key, keys)) + return fmt.Errorf("cannot get '%s': %w", args[0], suggestKey(spec.Key, keys)) } v := entries[idx].Value @@ -128,7 +128,7 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) { for _, s := range substitutions { parts := strings.SplitN(s, "=", 2) if len(parts) != 2 || parts[0] == "" { - fmt.Fprintf(os.Stderr, "invalid substitutions %q (expected KEY=VALUE)\n", s) + warnf("invalid substitution '%s', expected KEY=VALUE", s) continue } key := parts[0] @@ -159,13 +159,13 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) { if slices.Contains(allowed, s) { return s, nil } - return "", fmt.Errorf("invalid value %q (allowed: %v)", s, allowed) + 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("failed to convert to int: %w", err) + return 0, fmt.Errorf("cannot convert to int: %w", err) } return i, nil }, diff --git a/cmd/init.go b/cmd/init.go index 764e247..bec7472 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -61,36 +61,36 @@ func vcsInit(cmd *cobra.Command, args []string) error { if clean { gitDir := filepath.Join(repoDir, ".git") if _, err := os.Stat(gitDir); err == nil { - fmt.Printf("remove .git from '%s'? (y/n)\n", repoDir) + promptf("remove .git from '%s'? (y/n)", repoDir) var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot clean git dir: %w", err) + if err := scanln(&confirm); err != nil { + return fmt.Errorf("cannot init: %w", err) } if strings.ToLower(confirm) != "y" { - return fmt.Errorf("aborted cleaning git dir") + return fmt.Errorf("cannot init: aborted") } if err := os.RemoveAll(gitDir); err != nil { - return fmt.Errorf("cannot clean git dir: %w", err) + return fmt.Errorf("cannot init: %w", err) } } if hasRemote { dbs, err := store.AllStores() if err == nil && len(dbs) > 0 { - fmt.Printf("remove all existing stores and .gitignore? (required for clone) (y/n)\n") + promptf("remove all existing stores and .gitignore, required for clone? (y/n)") var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { - return fmt.Errorf("cannot clean stores: %w", err) + if err := scanln(&confirm); err != nil { + return fmt.Errorf("cannot init: %w", err) } if strings.ToLower(confirm) != "y" { - return fmt.Errorf("aborted cleaning stores") + return fmt.Errorf("cannot init: aborted") } if err := wipeAllStores(store); err != nil { - return fmt.Errorf("cannot clean stores: %w", err) + 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 remove .gitignore: %w", err) + return fmt.Errorf("cannot init: %w", err) } } } @@ -98,7 +98,8 @@ func vcsInit(cmd *cobra.Command, args []string) error { gitDir := filepath.Join(repoDir, ".git") if _, err := os.Stat(gitDir); err == nil { - fmt.Println("vcs already initialised; use --clean to reinitialise") + warnf("vcs already initialised") + printHint("use --clean to reinitialise") return nil } @@ -106,11 +107,11 @@ func vcsInit(cmd *cobra.Command, args []string) error { // git clone requires the target directory to be empty entries, err := os.ReadDir(repoDir) if err == nil && len(entries) > 0 { - return fmt.Errorf("stores directory is not empty; use --clean with a remote to wipe and clone") + return withHint(fmt.Errorf("cannot init: stores directory not empty"), "use --clean with a remote to wipe and clone") } remote := args[0] - fmt.Printf("running: git clone %s %s\n", remote, repoDir) + progressf("git clone %s %s", remote, repoDir) if err := runGit("", "clone", remote, repoDir); err != nil { return err } @@ -118,7 +119,7 @@ func vcsInit(cmd *cobra.Command, args []string) error { if err := os.MkdirAll(repoDir, 0o750); err != nil { return err } - fmt.Printf("running: git init\n") + progressf("git init") if err := runGit(repoDir, "init"); err != nil { return err } diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index 7e36f29..2ff7a22 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -41,7 +41,7 @@ func listStores(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 stores: %v", err) } for _, db := range dbs { fmt.Println("@" + db) diff --git a/cmd/list.go b/cmd/list.go index 88779f4..3d26103 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -47,7 +47,7 @@ func (e *formatEnum) Set(v string) error { *e = formatEnum(v) return nil default: - return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", \"markdown\", or \"ndjson\"") + return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', or 'ndjson'") } } @@ -91,7 +91,7 @@ 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': No such store", args[0]) + return fmt.Errorf("cannot ls '%s': %w", args[0], err) } return fmt.Errorf("cannot ls '%s': %v", args[0], err) } @@ -99,7 +99,7 @@ func list(cmd *cobra.Command, args []string) error { } if listNoKeys && listNoValues && !listTTL { - return fmt.Errorf("cannot ls '%s': no columns selected; disable --no-keys/--no-values or pass --ttl", targetDB) + return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys/--no-values or pass --ttl") } var columns []columnKind @@ -145,7 +145,7 @@ func list(cmd *cobra.Command, args []string) error { } if len(matchers) > 0 && len(filtered) == 0 { - return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) + return fmt.Errorf("cannot ls '%s': no matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) } output := cmd.OutOrStdout() diff --git a/cmd/msg.go b/cmd/msg.go new file mode 100644 index 0000000..21a44ed --- /dev/null +++ b/cmd/msg.go @@ -0,0 +1,95 @@ +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). +// +// FAIL red (stderr) +// hint dim (stderr) +// WARN yellow (stderr) +// ok green (stderr) +// ? cyan (stdout) +// > dim (stdout) +func keyword(code, word string, tty bool) string { + padded := fmt.Sprintf("%4s", word) + if tty { + return fmt.Sprintf("\033[%sm%s\033[0m", code, padded) + } + return padded +} + +func printError(err error) { + fmt.Fprintf(os.Stderr, "%s %s\n", keyword("31", "FAIL", stderrIsTerminal()), 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 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.go b/cmd/mv.go index 6ba3bbf..c6e1cd0 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -84,7 +84,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } srcIdx := findEntry(srcEntries, fromSpec.Key) if srcIdx < 0 { - return fmt.Errorf("cannot move '%s': No such key", fromSpec.Key) + return fmt.Errorf("cannot move '%s': no such key", fromSpec.Key) } srcEntry := srcEntries[srcIdx] @@ -108,8 +108,8 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { if promptOverwrite && dstIdx >= 0 { var confirm string - fmt.Printf("overwrite '%s'? (y/n)\n", toSpec.Display()) - if _, err := fmt.Scanln(&confirm); err != nil { + promptf("overwrite '%s'? (y/n)", toSpec.Display()) + if err := scanln(&confirm); err != nil { return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } if strings.ToLower(confirm) != "y" { diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 458b5a4..9e737bb 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -116,7 +116,7 @@ func writeStoreFile(path string, entries []Entry) error { je := encodeJsonEntry(e) data, err := json.Marshal(je) if err != nil { - return fmt.Errorf("key %q: %w", e.Key, err) + return fmt.Errorf("key '%s': %w", e.Key, err) } w.Write(data) w.WriteByte('\n') @@ -142,10 +142,10 @@ func decodeJsonEntry(je jsonEntry) (Entry, error) { var err error value, err = base64.StdEncoding.DecodeString(je.Value) if err != nil { - return Entry{}, fmt.Errorf("decode base64 for %q: %w", je.Key, err) + return Entry{}, fmt.Errorf("decode base64 for '%s': %w", je.Key, err) } default: - return Entry{}, fmt.Errorf("unsupported encoding %q for %q", je.Encoding, je.Key) + return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key) } var expiresAt uint64 if je.ExpiresAt != nil { diff --git a/cmd/restore.go b/cmd/restore.go index e134822..2e33b75 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -104,10 +104,10 @@ func restore(cmd *cobra.Command, args []string) error { } if len(matchers) > 0 && restored == 0 { - return fmt.Errorf("cannot restore '%s': No matches for pattern %s", displayTarget, formatGlobPatterns(globPatterns)) + 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) + okf("restored %d entries into @%s", restored, dbName) return autoSync() } @@ -169,9 +169,9 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( idx := findEntry(existing, entry.Key) if opts.promptOverwrite && idx >= 0 { - fmt.Printf("overwrite '%s'? (y/n)\n", entry.Key) + promptf("overwrite '%s'? (y/n)", entry.Key) var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { + if err := scanln(&confirm); err != nil { return 0, fmt.Errorf("entry %d: %v", entryNo, err) } if strings.ToLower(confirm) != "y" { diff --git a/cmd/root.go b/cmd/root.go index b4dd459..358ec06 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,18 +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, + Use: "pda", + Short: "A key-value store tool", + Long: asciiArt, + SilenceErrors: true, // we print errors ourselves } func Execute() { if configErr != nil { - fmt.Fprintln(os.Stderr, "failed to load config:", configErr) + printError(fmt.Errorf("cannot load config: %v", configErr)) os.Exit(1) } err := rootCmd.Execute() if err != nil { + printErrorWithHints(err) os.Exit(1) } } diff --git a/cmd/set.go b/cmd/set.go index 99fce82..9f1a123 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -93,9 +93,9 @@ func set(cmd *cobra.Command, args []string) error { idx := findEntry(entries, spec.Key) if promptOverwrite && idx >= 0 { - fmt.Printf("overwrite '%s'? (y/n)\n", spec.Display()) + promptf("overwrite '%s'? (y/n)", spec.Display()) var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { + if err := scanln(&confirm); err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) } if strings.ToLower(confirm) != "y" { diff --git a/cmd/shared.go b/cmd/shared.go index 43c35f9..511b673 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -37,14 +37,12 @@ import ( ) type errNotFound struct { + what string // "key" or "store" suggestions []string } func (err errNotFound) Error() string { - if len(err.suggestions) == 0 { - return "No such key" - } - return fmt.Sprintf("No such key. Did you mean '%s'?", strings.Join(err.suggestions, ", ")) + return fmt.Sprintf("no such %s", err.what) } type Store struct{} @@ -129,7 +127,7 @@ func (s *Store) FindStore(k string) (string, error) { if err != nil { return "", err } - return "", errNotFound{suggestions} + return "", errNotFound{what: "store", suggestions: suggestions} } if statErr != nil { return "", statErr @@ -205,7 +203,7 @@ func suggestKey(target string, keys []string) error { suggestions = append(suggestions, k) } } - return errNotFound{suggestions} + return errNotFound{what: "key", suggestions: suggestions} } func ensureSubpath(base, target string) error { diff --git a/cmd/sync.go b/cmd/sync.go index e325701..510a5dd 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -67,12 +67,12 @@ func sync(manual bool) error { return err } } else if manual { - fmt.Println("no changes to commit") + okf("no changes to commit") } if remoteInfo.Ref == "" { if manual { - fmt.Println("no remote configured; skipping push") + warnf("no remote configured, skipping push") } return nil } @@ -105,7 +105,7 @@ func sync(manual bool) error { return pushRemote(repoDir, remoteInfo) } if manual { - fmt.Println("nothing to push") + okf("nothing to push") } } diff --git a/cmd/vcs.go b/cmd/vcs.go index ecb1f45..3e0c22f 100644 --- a/cmd/vcs.go +++ b/cmd/vcs.go @@ -17,7 +17,7 @@ func ensureVCSInitialized() (string, error) { } if _, err := os.Stat(filepath.Join(repoDir, ".git")); err != nil { if os.IsNotExist(err) { - return "", fmt.Errorf("vcs repository not initialised; run 'pda init' first") + return "", withHint(fmt.Errorf("vcs not initialised"), "run 'pda init' first") } return "", err } @@ -43,7 +43,7 @@ func writeGitignore(repoDir string) error { } return runGit(repoDir, "commit", "-m", "generated gitignore") } - fmt.Println("Existing .gitignore found.") + okf("existing .gitignore found") return nil } @@ -195,7 +195,7 @@ func wipeAllStores(store *Store) error { return err } if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove store '%s': %w", db, err) + return fmt.Errorf("cannot remove store '%s': %w", db, err) } } return nil diff --git a/testdata/dump__glob__ok.ct b/testdata/dump__glob__ok.ct index 87ca9da..999d094 100644 --- a/testdata/dump__glob__ok.ct +++ b/testdata/dump__glob__ok.ct @@ -5,4 +5,4 @@ $ pda dump --glob a* {"key":"a1","value":"1","encoding":"text"} {"key":"a2","value":"2","encoding":"text"} $ pda dump --glob c* --> FAIL -Error: cannot ls '@default': No matches for pattern 'c*' +FAIL cannot ls '@default': no matches for pattern 'c*' diff --git a/testdata/get__err__with__invalid_db.ct b/testdata/get__err__with__invalid_db.ct index 6be4beb..973d83b 100644 --- a/testdata/get__err__with__invalid_db.ct +++ b/testdata/get__err__with__invalid_db.ct @@ -1,2 +1,2 @@ $ pda get key@foo/bar --> FAIL -Error: cannot get 'key@foo/bar': bad store format, use STORE or @STORE +FAIL cannot get 'key@foo/bar': bad store format, use STORE or @STORE diff --git a/testdata/get__missing__err.ct b/testdata/get__missing__err.ct index 0a54c6c..b528954 100644 --- a/testdata/get__missing__err.ct +++ b/testdata/get__missing__err.ct @@ -1,2 +1,2 @@ $ pda get foobar --> FAIL -Error: cannot get 'foobar': No such key +FAIL cannot get 'foobar': no such key diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get__missing__err__with__any.ct index c942bcb..5f03ce9 100644 --- a/testdata/get__missing__err__with__any.ct +++ b/testdata/get__missing__err__with__any.ct @@ -5,10 +5,10 @@ $ 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': No such key -Error: cannot get 'foobar': No such key -Error: cannot get 'foobar': No such key -Error: unknown flag: --secret -Error: cannot get 'foobar': No such key -Error: unknown flag: --secret -Error: unknown flag: --secret +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/invalid__err.ct b/testdata/invalid__err.ct index 1bd0417..93359ea 100644 --- a/testdata/invalid__err.ct +++ b/testdata/invalid__err.ct @@ -1,3 +1,2 @@ $ pda invalidcmd --> FAIL -Error: unknown command "invalidcmd" for "pda" -Run 'pda --help' for usage. +FAIL unknown command "invalidcmd" for "pda" diff --git a/testdata/list__err__with__invalid_db.ct b/testdata/list__err__with__invalid_db.ct index f53a448..0a90ecb 100644 --- a/testdata/list__err__with__invalid_db.ct +++ b/testdata/list__err__with__invalid_db.ct @@ -1,2 +1,2 @@ $ pda ls foo/bar --> FAIL -Error: cannot ls 'foo/bar': cannot parse store: bad store format, use STORE or @STORE +FAIL cannot ls 'foo/bar': cannot parse store: bad store format, use STORE or @STORE diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct index 924d72c..ece552c 100644 --- a/testdata/list__glob__ok.ct +++ b/testdata/list__glob__ok.ct @@ -7,4 +7,4 @@ 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*' +FAIL cannot ls '@lg': no matches for pattern 'c*' diff --git a/testdata/remove-store__err__with__invalid_db.ct b/testdata/remove-store__err__with__invalid_db.ct index 9d99bf7..6750010 100644 --- a/testdata/remove-store__err__with__invalid_db.ct +++ b/testdata/remove-store__err__with__invalid_db.ct @@ -1,2 +1,2 @@ $ pda rms foo/bar --> FAIL -Error: cannot delete-store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE +FAIL cannot delete store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index e5ec064..9324b0c 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -10,6 +10,6 @@ $ pda ls foo 1 $ pda rm foo --glob "*" $ pda get bar --> FAIL -Error: cannot get 'bar': No such key +FAIL cannot get 'bar': no such key $ pda get foo --> FAIL -Error: cannot get 'foo': No such key +FAIL cannot get 'foo': no such key diff --git a/testdata/remove__glob__mixed__ok.ct b/testdata/remove__glob__mixed__ok.ct index 9433c89..3f5fa6b 100644 --- a/testdata/remove__glob__mixed__ok.ct +++ b/testdata/remove__glob__mixed__ok.ct @@ -3,8 +3,8 @@ $ pda set bar1 2 $ pda set bar2 3 $ pda rm foo --glob bar* $ pda get foo --> FAIL -Error: cannot get 'foo': No such key +FAIL cannot get 'foo': no such key $ pda get bar1 --> FAIL -Error: cannot get 'bar1': No such key +FAIL cannot get 'bar1': no such key $ pda get bar2 --> FAIL -Error: cannot get 'bar2': No such key +FAIL cannot get 'bar2': no such key diff --git a/testdata/remove__glob__ok.ct b/testdata/remove__glob__ok.ct index 3fda37c..7ad2534 100644 --- a/testdata/remove__glob__ok.ct +++ b/testdata/remove__glob__ok.ct @@ -3,8 +3,9 @@ $ pda set a2 2 $ pda set b1 3 $ pda rm --glob a* $ pda get a1 --> FAIL -Error: cannot get 'a1': No such key. Did you mean 'b1'? +FAIL cannot get 'a1': no such key +hint did you mean 'b1'? $ pda get a2 --> FAIL -Error: cannot get 'a2': No such key +FAIL cannot get 'a2': no such key $ pda get b1 3 diff --git a/testdata/remove__multiple__ok.ct b/testdata/remove__multiple__ok.ct index d61e113..a46876a 100644 --- a/testdata/remove__multiple__ok.ct +++ b/testdata/remove__multiple__ok.ct @@ -2,6 +2,7 @@ $ pda set a 1 $ pda set b 2 $ pda rm a b $ pda get a --> FAIL -Error: cannot get 'a': No such key +FAIL cannot get 'a': no such key $ pda get b --> FAIL -Error: cannot get 'b': No such key. Did you mean 'b1'? +FAIL cannot get 'b': no such key +hint did you mean 'b1'? diff --git a/testdata/restore__drop__ok.ct b/testdata/restore__drop__ok.ct index b5c18f1..3ddadb2 100644 --- a/testdata/restore__drop__ok.ct +++ b/testdata/restore__drop__ok.ct @@ -2,8 +2,8 @@ $ pda set existing keep-me $ pda set other also-keep $ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} $ pda restore --drop --file dumpfile -Restored 1 entries into @default + ok restored 1 entries into @default $ pda get new hello $ pda get existing --> FAIL -Error: cannot get 'existing': No such key +FAIL cannot get 'existing': no such key diff --git a/testdata/restore__glob__ok.ct b/testdata/restore__glob__ok.ct index eae61f8..9f5f5da 100644 --- a/testdata/restore__glob__ok.ct +++ b/testdata/restore__glob__ok.ct @@ -4,12 +4,13 @@ $ 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 rm a1 a2 b1 $ pda restore --glob a* --file dumpfile -Restored 2 entries into @default + ok restored 2 entries into @default $ pda get a1 1 $ pda get a2 2 $ pda get b1 --> FAIL -Error: cannot get 'b1': No such key. Did you mean 'a1'? +FAIL cannot get 'b1': no such key +hint did you mean 'a1'? $ pda restore --glob c* --file dumpfile --> FAIL -Error: cannot restore '@default': No matches for pattern 'c*' +FAIL cannot restore '@default': no matches for pattern 'c*' diff --git a/testdata/set__err__with__invalid-ttl.ct b/testdata/set__err__with__invalid-ttl.ct index a27ea1d..9a33eef 100644 --- a/testdata/set__err__with__invalid-ttl.ct +++ b/testdata/set__err__with__invalid-ttl.ct @@ -1,2 +1,2 @@ $ pda set a b --ttl 3343r --> FAIL -Error: invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r" +FAIL invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r" From fb7575898691a4c8c386632ac8cabf76c863cdfe Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 02:13:32 +0000 Subject: [PATCH 033/107] docs: updates messaging in README --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 80ce116..1a733da 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,8 @@ pda rm kitty --glob ?og # Opt in to a confirmation prompt with --interactive/-i (or always_prompt_delete in config). pda rm kitty -i -# remove "kitty": are you sure? (y/n) +# ??? remove 'kitty'? (y/n) +# ==> y ```

@@ -215,11 +216,11 @@ pda export --glob a* ```bash # Import with an argument. pda import -f my_backup -# Restored 2 entries into @default. +# ok restored 2 entries into @default # Or from stdin. pda import < my_backup -# Restored 2 entries into @default. +# ok restored 2 entries into @default # Import only matching keys. pda import --glob a* -f my_backup @@ -329,7 +330,7 @@ pda get greeting NAME="Bob" ```bash pda set file "{{ require .FILE }}" pda get file -# Error: required value missing or empty +# FAIL cannot get 'file': ...required value is missing or empty ```

@@ -349,7 +350,7 @@ 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]) +# FAIL cannot get 'level': ...invalid value 'debug', allowed: [info warn error] ```

@@ -493,7 +494,10 @@ pda ls --no-keys # cogwheel pda rm cat --glob "{mouse,[cd]og}**" -# remove: 'cat', 'mouse trap', 'dog house', 'cogwheel': are you sure? [y/n] +# ??? remove 'cat'? (y/n) +# ==> y +# ??? remove 'mouse trap'? (y/n) +# ... ```

From 0114b01fb3c0a848ddcbc7958ee864a2329f940f Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 02:16:26 +0000 Subject: [PATCH 034/107] fix: silence usage on cp command --- cmd/mv.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/mv.go b/cmd/mv.go index c6e1cd0..b097dcc 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -30,11 +30,12 @@ import ( ) var cpCmd = &cobra.Command{ - Use: "copy FROM[@STORE] TO[@STORE]", - Aliases: []string{"cp"}, - Short: "Make a copy of a key", - Args: cobra.ExactArgs(2), - RunE: cp, + Use: "copy FROM[@STORE] TO[@STORE]", + Aliases: []string{"cp"}, + Short: "Make a copy of a key", + Args: cobra.ExactArgs(2), + RunE: cp, + SilenceUsage: true, } var mvCmd = &cobra.Command{ From ba93931c331d8d0f1832ca2834378e4f951b6743 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 02:17:25 +0000 Subject: [PATCH 035/107] chore: swaps out a %q for '%s' for glob-sep flags --- cmd/del.go | 2 +- cmd/export.go | 2 +- cmd/list.go | 2 +- cmd/restore.go | 2 +- testdata/help__dump__ok.ct | 4 ++-- testdata/help__list__ok.ct | 4 ++-- testdata/help__remove__ok.ct | 4 ++-- testdata/help__restore__ok.ct | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/del.go b/cmd/del.go index 8ab21d3..c5dd22c 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -125,7 +125,7 @@ func del(cmd *cobra.Command, args []string) error { func init() { delCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") delCmd.Flags().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())) + delCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) rootCmd.AddCommand(delCmd) } diff --git a/cmd/export.go b/cmd/export.go index ff27e22..968dd3f 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -42,6 +42,6 @@ var exportCmd = &cobra.Command{ func init() { exportCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") - exportCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) + exportCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) rootCmd.AddCommand(exportCmd) } diff --git a/cmd/list.go b/cmd/list.go index 3d26103..08c83a4 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -307,6 +307,6 @@ func init() { listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html|ndjson)") 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())) + listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) rootCmd.AddCommand(listCmd) } diff --git a/cmd/restore.go b/cmd/restore.go index 2e33b75..5440796 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -198,7 +198,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( func init() { 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().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) restoreCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting existing keys") restoreCmd.Flags().Bool("drop", false, "Drop existing entries before restoring (full replace)") rootCmd.AddCommand(restoreCmd) diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index db48321..891cbc2 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -10,7 +10,7 @@ Aliases: Flags: -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for export Export store as NDJSON (alias for list --format ndjson) @@ -22,5 +22,5 @@ Aliases: Flags: -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for export diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index f9f51ed..40658c4 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -12,7 +12,7 @@ Flags: -b, --binary include binary data in text output -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --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 @@ -30,7 +30,7 @@ Flags: -b, --binary include binary data in text output -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --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 diff --git a/testdata/help__remove__ok.ct b/testdata/help__remove__ok.ct index 8556bc2..19f0992 100644 --- a/testdata/help__remove__ok.ct +++ b/testdata/help__remove__ok.ct @@ -10,7 +10,7 @@ Aliases: Flags: -g, --glob strings Delete keys matching glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for remove -i, --interactive Prompt yes/no for each deletion Delete one or more keys @@ -23,6 +23,6 @@ Aliases: Flags: -g, --glob strings Delete keys matching glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for remove -i, --interactive Prompt yes/no for each deletion diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index 106a0f9..140e160 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -12,7 +12,7 @@ Flags: --drop Drop existing entries before restoring (full replace) -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 "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for import -i, --interactive Prompt before overwriting existing keys Restore key/value pairs from an NDJSON dump @@ -27,6 +27,6 @@ Flags: --drop Drop existing entries before restoring (full replace) -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 "/-_.@: ") + --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') -h, --help help for import -i, --interactive Prompt before overwriting existing keys From 9bdc9c30c6223b0b09d2f99731e60b3ce6d44714 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 12:36:42 +0000 Subject: [PATCH 036/107] feat: encryption with age --- README.md | 71 ++++++++ cmd/del.go | 13 +- cmd/get.go | 10 +- cmd/identity.go | 76 ++++++++ cmd/list.go | 23 ++- cmd/mv.go | 21 ++- cmd/ndjson.go | 76 ++++++-- cmd/ndjson_test.go | 18 +- cmd/restore.go | 17 +- cmd/root.go | 1 + cmd/secret.go | 103 +++++++++++ cmd/secret_test.go | 232 ++++++++++++++++++++++++ cmd/set.go | 40 +++- cmd/shared.go | 2 +- go.mod | 9 +- go.sum | 20 +- main_test.go | 16 +- testdata/cp__encrypt__ok.ct | 7 + testdata/help__ok.ct | 2 + testdata/help__set__ok.ct | 8 + testdata/mv__encrypt__ok.ct | 7 + testdata/remove__dedupe__ok.ct | 16 +- testdata/root__ok.ct | 1 + testdata/set__encrypt__ok.ct | 4 + testdata/set__encrypt__ok__with__ttl.ct | 4 + 25 files changed, 733 insertions(+), 64 deletions(-) create mode 100644 cmd/identity.go create mode 100644 cmd/secret.go create mode 100644 cmd/secret_test.go create mode 100644 testdata/cp__encrypt__ok.ct create mode 100644 testdata/mv__encrypt__ok.ct create mode 100644 testdata/set__encrypt__ok.ct create mode 100644 testdata/set__encrypt__ok__with__ttl.ct diff --git a/README.md b/README.md index 1a733da..679f0ae 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - plaintext exports in multiple formats, - support for [binary data](https://github.com/Llywelwyn/pda#binary), - [time-to-live](https://github.com/Llywelwyn/pda#ttl) support, +- [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age), and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). @@ -54,6 +55,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [Globs](https://github.com/Llywelwyn/pda#globs) - [TTL](https://github.com/Llywelwyn/pda#ttl) - [Binary](https://github.com/Llywelwyn/pda#binary) +- [Encryption](https://github.com/Llywelwyn/pda#encryption) - [Environment](https://github.com/Llywelwyn/pda#environment)

@@ -76,6 +78,7 @@ Usage: Key commands: copy Make a copy of a key get Get the value of a key + identity Show or create the age encryption identity list List the contents of a store move Move a key remove Delete one or more keys @@ -581,6 +584,74 @@ pda export

+### Encryption + +`pda set --encrypt` encrypts values at rest using [age](https://github.com/FiloSottile/age). Values are stored on disk as age ciphertext and decrypted automatically by commands like `get` and `list` when the correct identity file is present. An X25519 identity is generated on first use and saved at `~/.config/pda/identity.txt`. + +```bash +pda set --encrypt api-key "sk-live-abc123" +# ok created identity at ~/.config/pda/identity.txt + +pda set --encrypt token "ghp_xxxx" +``` + +

+ +`get` decrypts automatically. +```bash +pda get api-key +# sk-live-abc123 +``` + +

+ +The on-disk value is ciphertext, so encrypted entries are safe to commit and push with Git. +```bash +pda export +# {"key":"api-key","value":"YWdlLWVuY3J5cHRpb24u...","encoding":"secret"} +``` + +

+ +`mv`, `cp`, and `import` all preserve encryption. Overwriting an encrypted key without `--encrypt` will warn you. +```bash +pda cp api-key api-key-backup +# still encrypted + +pda set api-key "oops" +# WARN overwriting encrypted key 'api-key' as plaintext +# hint pass --encrypt to keep it encrypted +``` + +

+ +If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. +```bash +pda ls +# api-key locked (identity file missing) + +pda get api-key +# FAIL cannot get 'api-key': secret is locked (identity file missing) +``` + +

+ +`pda identity` to see your public key and identity file path. +```bash +pda identity +# ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +# ok identity ~/.config/pda/identity.txt + +# Just the path. +pda identity --path +# ~/.config/pda/identity.txt + +# Generate a new identity. Errors if one already exists. +pda identity --new +``` + +

+ ### Environment Config is stored in your user config directory in `pda/config.toml`. diff --git a/cmd/del.go b/cmd/del.go index c5dd22c..1f4a20d 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -26,6 +26,7 @@ import ( "fmt" "strings" + "filippo.io/age" "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -97,13 +98,19 @@ func del(cmd *cobra.Command, args []string) error { return nil } + identity, _ := loadIdentity() + var recipient *age.X25519Recipient + if identity != nil { + recipient = identity.Recipient() + } + for _, dbName := range storeOrder { st := byStore[dbName] p, err := store.storePath(dbName) if err != nil { return err } - entries, err := readStoreFile(p) + entries, err := readStoreFile(p, identity) if err != nil { return err } @@ -114,7 +121,7 @@ func del(cmd *cobra.Command, args []string) error { } entries = append(entries[:idx], entries[idx+1:]...) } - if err := writeStoreFile(p, entries); err != nil { + if err := writeStoreFile(p, entries, recipient); err != nil { return err } } @@ -145,7 +152,7 @@ func keyExists(store *Store, arg string) (bool, error) { if err != nil { return false, err } - entries, err := readStoreFile(p) + entries, err := readStoreFile(p, nil) if err != nil { return false, err } diff --git a/cmd/get.go b/cmd/get.go index 036ac4d..eeead61 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -72,6 +72,8 @@ For example: func get(cmd *cobra.Command, args []string) error { store := &Store{} + identity, _ := loadIdentity() + spec, err := store.parseKey(args[0], true) if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) @@ -80,7 +82,7 @@ func get(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) } - entries, err := readStoreFile(p) + entries, err := readStoreFile(p, identity) if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) } @@ -92,7 +94,11 @@ func get(cmd *cobra.Command, args []string) error { } return fmt.Errorf("cannot get '%s': %w", args[0], suggestKey(spec.Key, keys)) } - v := entries[idx].Value + 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("include-binary") if err != nil { diff --git a/cmd/identity.go b/cmd/identity.go new file mode 100644 index 0000000..a7a4ccf --- /dev/null +++ b/cmd/identity.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var identityCmd = &cobra.Command{ + Use: "identity", + 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 + } + + 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 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) + return nil +} + +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.MarkFlagsMutuallyExclusive("new", "path") + rootCmd.AddCommand(identityCmd) +} diff --git a/cmd/list.go b/cmd/list.go index 08c83a4..7166a80 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -30,6 +30,7 @@ import ( "os" "strconv" + "filippo.io/age" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" @@ -126,12 +127,18 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } + identity, _ := loadIdentity() + var recipient *age.X25519Recipient + if identity != nil { + recipient = identity.Recipient() + } + 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) + entries, err := readStoreFile(p, identity) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -150,10 +157,14 @@ func list(cmd *cobra.Command, args []string) error { output := cmd.OutOrStdout() - // NDJSON format: emit JSON lines directly + // NDJSON format: emit JSON lines directly (encrypted form for secrets) if listFormat.String() == "ndjson" { for _, e := range filtered { - data, err := json.Marshal(encodeJsonEntry(e)) + je, err := encodeJsonEntry(e, recipient) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + data, err := json.Marshal(je) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -180,7 +191,11 @@ func list(cmd *cobra.Command, args []string) error { for _, e := range filtered { var valueStr string if showValues { - valueStr = store.FormatBytes(listBinary, e.Value) + if e.Locked { + valueStr = "locked (identity file missing)" + } else { + valueStr = store.FormatBytes(listBinary, e.Value) + } } row := make(table.Row, 0, len(columns)) for _, col := range columns { diff --git a/cmd/mv.go b/cmd/mv.go index b097dcc..d9d5069 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -26,6 +26,7 @@ import ( "fmt" "strings" + "filippo.io/age" "github.com/spf13/cobra" ) @@ -65,6 +66,12 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite + identity, _ := loadIdentity() + var recipient *age.X25519Recipient + if identity != nil { + recipient = identity.Recipient() + } + fromSpec, err := store.parseKey(args[0], true) if err != nil { return err @@ -79,7 +86,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { if err != nil { return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } - srcEntries, err := readStoreFile(srcPath) + srcEntries, err := readStoreFile(srcPath, identity) if err != nil { return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } @@ -99,7 +106,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { if err != nil { return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } - dstEntries, err = readStoreFile(dstPath) + dstEntries, err = readStoreFile(dstPath, identity) if err != nil { return fmt.Errorf("cannot move '%s': %v", fromSpec.Key, err) } @@ -118,11 +125,13 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } } - // Write destination entry + // Write destination entry — preserve secret status newEntry := Entry{ Key: toSpec.Key, Value: srcEntry.Value, ExpiresAt: srcEntry.ExpiresAt, + Secret: srcEntry.Secret, + Locked: srcEntry.Locked, } if sameStore { @@ -139,7 +148,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { dstEntries = append(dstEntries[:idx], dstEntries[idx+1:]...) } } - if err := writeStoreFile(dstPath, dstEntries); err != nil { + if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil { return err } } else { @@ -149,12 +158,12 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } else { dstEntries = append(dstEntries, newEntry) } - if err := writeStoreFile(dstPath, dstEntries); err != nil { + if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil { return err } if !keepSource { srcEntries = append(srcEntries[:srcIdx], srcEntries[srcIdx+1:]...) - if err := writeStoreFile(srcPath, srcEntries); err != nil { + if err := writeStoreFile(srcPath, srcEntries, recipient); err != nil { return err } } diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 9e737bb..09e35d2 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -32,6 +32,8 @@ import ( "strings" "time" "unicode/utf8" + + "filippo.io/age" ) // Entry is the in-memory representation of a stored key-value pair. @@ -39,6 +41,8 @@ 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 } // jsonEntry is the NDJSON on-disk format. @@ -51,7 +55,8 @@ type jsonEntry struct { // readStoreFile reads all non-expired entries from an NDJSON file. // Returns empty slice (not error) if file does not exist. -func readStoreFile(path string) ([]Entry, error) { +// 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) { @@ -76,7 +81,7 @@ func readStoreFile(path string) ([]Entry, error) { if err := json.Unmarshal(line, &je); err != nil { return nil, fmt.Errorf("line %d: %w", lineNo, err) } - entry, err := decodeJsonEntry(je) + entry, err := decodeJsonEntry(je, identity) if err != nil { return nil, fmt.Errorf("line %d: %w", lineNo, err) } @@ -91,7 +96,8 @@ func readStoreFile(path string) ([]Entry, error) { // writeStoreFile atomically writes entries to an NDJSON file, sorted by key. // Expired entries are excluded. Empty entry list writes an empty file. -func writeStoreFile(path string, entries []Entry) error { +// If recipient is nil, secret entries are written as-is (locked passthrough). +func writeStoreFile(path string, entries []Entry, recipient *age.X25519Recipient) error { // Sort by key for deterministic output slices.SortFunc(entries, func(a, b Entry) int { return strings.Compare(a.Key, b.Key) @@ -113,7 +119,10 @@ func writeStoreFile(path string, entries []Entry) error { if e.ExpiresAt > 0 && e.ExpiresAt <= now { continue } - je := encodeJsonEntry(e) + je, err := encodeJsonEntry(e, recipient) + 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) @@ -133,7 +142,28 @@ func writeStoreFile(path string, entries []Entry) error { return os.Rename(tmp, path) } -func decodeJsonEntry(je jsonEntry) (Entry, error) { +func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) { + var expiresAt uint64 + if je.ExpiresAt != nil { + expiresAt = uint64(*je.ExpiresAt) + } + + 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}, nil + } + plaintext, err := decrypt(ciphertext, identity) + if err != nil { + warnf("cannot decrypt '%s': %v", je.Key, err) + return Entry{Key: je.Key, Value: ciphertext, ExpiresAt: expiresAt, Secret: true, Locked: true}, nil + } + return Entry{Key: je.Key, Value: plaintext, ExpiresAt: expiresAt, Secret: true}, nil + } + var value []byte switch je.Encoding { case "", "text": @@ -147,15 +177,35 @@ func decodeJsonEntry(je jsonEntry) (Entry, error) { default: return Entry{}, fmt.Errorf("unsupported encoding '%s' for '%s'", je.Encoding, je.Key) } - var expiresAt uint64 - if je.ExpiresAt != nil { - expiresAt = uint64(*je.ExpiresAt) - } return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil } -func encodeJsonEntry(e Entry) jsonEntry { +func encodeJsonEntry(e Entry, recipient *age.X25519Recipient) (jsonEntry, error) { je := jsonEntry{Key: e.Key} + if e.ExpiresAt > 0 { + ts := int64(e.ExpiresAt) + je.ExpiresAt = &ts + } + + 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 recipient == nil { + return je, fmt.Errorf("no recipient available to encrypt") + } + ciphertext, err := encrypt(e.Value, recipient) + 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" @@ -163,11 +213,7 @@ func encodeJsonEntry(e Entry) jsonEntry { je.Value = base64.StdEncoding.EncodeToString(e.Value) je.Encoding = "base64" } - if e.ExpiresAt > 0 { - ts := int64(e.ExpiresAt) - je.ExpiresAt = &ts - } - return je + return je, nil } // findEntry returns the index of the entry with the given key, or -1. diff --git a/cmd/ndjson_test.go b/cmd/ndjson_test.go index bacc3aa..a1acabe 100644 --- a/cmd/ndjson_test.go +++ b/cmd/ndjson_test.go @@ -38,11 +38,11 @@ func TestReadWriteRoundtrip(t *testing.T) { {Key: "gamma", Value: []byte{0xff, 0xfe}}, // binary } - if err := writeStoreFile(path, entries); err != nil { + if err := writeStoreFile(path, entries, nil); err != nil { t.Fatal(err) } - got, err := readStoreFile(path) + got, err := readStoreFile(path, nil) if err != nil { t.Fatal(err) } @@ -69,11 +69,11 @@ func TestReadStoreFileSkipsExpired(t *testing.T) { {Key: "dead", Value: []byte("no"), ExpiresAt: 1}, // expired long ago } - if err := writeStoreFile(path, entries); err != nil { + if err := writeStoreFile(path, entries, nil); err != nil { t.Fatal(err) } - got, err := readStoreFile(path) + got, err := readStoreFile(path, nil) if err != nil { t.Fatal(err) } @@ -84,7 +84,7 @@ func TestReadStoreFileSkipsExpired(t *testing.T) { } func TestReadStoreFileNotExist(t *testing.T) { - got, err := readStoreFile("/nonexistent/path.ndjson") + got, err := readStoreFile("/nonexistent/path.ndjson", nil) if err != nil { t.Fatal(err) } @@ -103,11 +103,11 @@ func TestWriteStoreFileSortsKeys(t *testing.T) { {Key: "bravo", Value: []byte("2")}, } - if err := writeStoreFile(path, entries); err != nil { + if err := writeStoreFile(path, entries, nil); err != nil { t.Fatal(err) } - got, err := readStoreFile(path) + got, err := readStoreFile(path, nil) if err != nil { t.Fatal(err) } @@ -122,12 +122,12 @@ func TestWriteStoreFileAtomic(t *testing.T) { path := filepath.Join(dir, "test.ndjson") // Write initial data - if err := writeStoreFile(path, []Entry{{Key: "a", Value: []byte("1")}}); err != nil { + 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")}}); err != nil { + if err := writeStoreFile(path, []Entry{{Key: "b", Value: []byte("2")}}, nil); err != nil { t.Fatal(err) } diff --git a/cmd/restore.go b/cmd/restore.go index 5440796..d8ae2c2 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -30,6 +30,7 @@ import ( "os" "strings" + "filippo.io/age" "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -94,10 +95,18 @@ func restore(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } + identity, _ := loadIdentity() + var recipient *age.X25519Recipient + if identity != nil { + recipient = identity.Recipient() + } + restored, err := restoreEntries(decoder, p, restoreOpts{ matchers: matchers, promptOverwrite: promptOverwrite, drop: drop, + identity: identity, + recipient: recipient, }) if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) @@ -130,13 +139,15 @@ type restoreOpts struct { matchers []glob.Glob promptOverwrite bool drop bool + identity *age.X25519Identity + recipient *age.X25519Recipient } func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) { var existing []Entry if !opts.drop { var err error - existing, err = readStoreFile(storePath) + existing, err = readStoreFile(storePath, opts.identity) if err != nil { return 0, err } @@ -161,7 +172,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( continue } - entry, err := decodeJsonEntry(je) + entry, err := decodeJsonEntry(je, opts.identity) if err != nil { return 0, fmt.Errorf("entry %d: %w", entryNo, err) } @@ -188,7 +199,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( } if restored > 0 || opts.drop { - if err := writeStoreFile(storePath, existing); err != nil { + if err := writeStoreFile(storePath, existing, opts.recipient); err != nil { return 0, err } } diff --git a/cmd/root.go b/cmd/root.go index 358ec06..b7a03bf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,6 +59,7 @@ func init() { cpCmd.GroupID = "keys" delCmd.GroupID = "keys" listCmd.GroupID = "keys" + identityCmd.GroupID = "keys" rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"}) diff --git a/cmd/secret.go b/cmd/secret.go new file mode 100644 index 0000000..b71f272 --- /dev/null +++ b/cmd/secret.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + + "filippo.io/age" + gap "github.com/muesli/go-app-paths" +) + +// identityPath returns the path to the age identity file, +// respecting PDA_CONFIG the same way configPath() does. +func identityPath() (string, error) { + if override := os.Getenv("PDA_CONFIG"); override != "" { + return filepath.Join(override, "identity.txt"), nil + } + scope := gap.NewScope(gap.User, "pda") + dir, err := scope.ConfigPath("") + 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 +} + +// encrypt encrypts plaintext for the given recipient using age. +func encrypt(plaintext []byte, recipient *age.X25519Recipient) ([]byte, error) { + var buf bytes.Buffer + w, err := age.Encrypt(&buf, recipient) + 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 +} + +// 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 new file mode 100644 index 0000000..6db1bb1 --- /dev/null +++ b/cmd/secret_test.go @@ -0,0 +1,232 @@ +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_CONFIG", 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_CONFIG", 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_CONFIG", 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) + } + 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, recipient); 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) + } + 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, recipient); 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) + } + 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, recipient); 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 generateTestIdentity(t *testing.T) (*age.X25519Identity, error) { + t.Helper() + dir := t.TempDir() + t.Setenv("PDA_CONFIG", dir) + return ensureIdentity() +} diff --git a/cmd/set.go b/cmd/set.go index 9f1a123..0684328 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -28,6 +28,7 @@ import ( "strings" "time" + "filippo.io/age" "github.com/spf13/cobra" ) @@ -37,6 +38,9 @@ var setCmd = &cobra.Command{ 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. + PDA supports parsing Go templates. Actions are delimited with {{ }}. For example: @@ -60,6 +64,11 @@ func set(cmd *cobra.Command, args []string) error { } promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite + secret, err := cmd.Flags().GetBool("encrypt") + if err != nil { + return err + } + spec, err := store.parseKey(args[0], true) if err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) @@ -81,17 +90,38 @@ func set(cmd *cobra.Command, args []string) error { 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 err != nil { + return fmt.Errorf("cannot set '%s': %v", args[0], err) + } + } else { + identity, _ = loadIdentity() + } + var recipient *age.X25519Recipient + if identity != nil { + recipient = identity.Recipient() + } + p, err := store.storePath(spec.DB) if err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) } - entries, err := readStoreFile(p) + entries, err := readStoreFile(p, identity) if err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) } idx := findEntry(entries, spec.Key) + // 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 @@ -104,8 +134,9 @@ func set(cmd *cobra.Command, args []string) error { } entry := Entry{ - Key: spec.Key, - Value: value, + Key: spec.Key, + Value: value, + Secret: secret, } if ttl != 0 { entry.ExpiresAt = uint64(time.Now().Add(ttl).Unix()) @@ -117,7 +148,7 @@ func set(cmd *cobra.Command, args []string) error { entries = append(entries, entry) } - if err := writeStoreFile(p, entries); err != nil { + if err := writeStoreFile(p, entries, recipient); err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) } @@ -128,4 +159,5 @@ 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") } diff --git a/cmd/shared.go b/cmd/shared.go index 511b673..3b72ad2 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -252,7 +252,7 @@ func (s *Store) Keys(dbName string) ([]string, error) { if err != nil { return nil, err } - entries, err := readStoreFile(p) + entries, err := readStoreFile(p, nil) if err != nil { return nil, err } diff --git a/go.mod b/go.mod index 5a28a71..d2043ff 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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/gobwas/glob v0.2.3 @@ -10,10 +11,11 @@ require ( 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.36.0 + golang.org/x/term v0.37.0 ) require ( + filippo.io/hpke v0.4.0 // 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 @@ -21,6 +23,7 @@ require ( 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/sys v0.37.0 // indirect - golang.org/x/text v0.26.0 // 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 ) diff --git a/go.sum b/go.sum index 125c9e8..d6e8549 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +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= @@ -40,12 +46,14 @@ 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/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= +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= 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 822623e..72c9d4e 100644 --- a/main_test.go +++ b/main_test.go @@ -24,10 +24,12 @@ package main import ( "flag" + "os" "os/exec" "path/filepath" "testing" + "filippo.io/age" cmdtest "github.com/google/go-cmdtest" ) @@ -35,7 +37,19 @@ 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()) + 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) diff --git a/testdata/cp__encrypt__ok.ct b/testdata/cp__encrypt__ok.ct new file mode 100644 index 0000000..b172e6c --- /dev/null +++ b/testdata/cp__encrypt__ok.ct @@ -0,0 +1,7 @@ +# Copy an encrypted key; both keys should decrypt. +$ pda set --encrypt secret-key hidden-value +$ pda cp secret-key copied-key +$ pda get secret-key +hidden-value +$ pda get copied-key +hidden-value diff --git a/testdata/help__ok.ct b/testdata/help__ok.ct index 4acbf58..86a7c34 100644 --- a/testdata/help__ok.ct +++ b/testdata/help__ok.ct @@ -15,6 +15,7 @@ Usage: Key commands: copy Make a copy of a key get Get the value of a key + identity Show or create the age encryption identity list List the contents of a store move Move a key remove Delete one or more keys @@ -56,6 +57,7 @@ Usage: Key commands: copy Make a copy of a key get Get the value of a key + identity Show or create the age encryption identity list List the contents of a store move Move a key remove Delete one or more keys diff --git a/testdata/help__set__ok.ct b/testdata/help__set__ok.ct index d1e2f57..34cd7d6 100644 --- a/testdata/help__set__ok.ct +++ b/testdata/help__set__ok.ct @@ -2,6 +2,9 @@ $ 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: @@ -18,11 +21,15 @@ Aliases: set, s Flags: + -e, --encrypt Encrypt the value at rest using age -h, --help help for set -i, --interactive Prompt before overwriting an existing key -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: @@ -39,6 +46,7 @@ Aliases: set, s Flags: + -e, --encrypt Encrypt the value at rest using age -h, --help help for set -i, --interactive Prompt before overwriting an existing key -t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m) diff --git a/testdata/mv__encrypt__ok.ct b/testdata/mv__encrypt__ok.ct new file mode 100644 index 0000000..a0b641f --- /dev/null +++ b/testdata/mv__encrypt__ok.ct @@ -0,0 +1,7 @@ +# Move an encrypted key; the new key should still decrypt. +$ pda set --encrypt secret-key hidden-value +$ pda mv secret-key moved-key +$ pda get moved-key +hidden-value +$ pda get secret-key --> FAIL +FAIL cannot get 'secret-key': no such key diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 9324b0c..54c5ba8 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,13 +1,15 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - a echo hello - - a1 1 - a2 2 - b1 3 - bar 2 - foo 1 + a echo hello + + a1 1 + a2 2 + b1 3 + bar 2 + copied-key hidden-value + foo 1 + moved-key hidden-value $ pda rm foo --glob "*" $ pda get bar --> FAIL FAIL cannot get 'bar': no such key diff --git a/testdata/root__ok.ct b/testdata/root__ok.ct index e5c16d7..29c2cc7 100644 --- a/testdata/root__ok.ct +++ b/testdata/root__ok.ct @@ -14,6 +14,7 @@ Usage: Key commands: copy Make a copy of a key get Get the value of a key + identity Show or create the age encryption identity list List the contents of a store move Move a key remove Delete one or more keys diff --git a/testdata/set__encrypt__ok.ct b/testdata/set__encrypt__ok.ct new file mode 100644 index 0000000..1796daf --- /dev/null +++ b/testdata/set__encrypt__ok.ct @@ -0,0 +1,4 @@ +# Set an encrypted key, then retrieve it (transparent decryption). +$ pda set --encrypt api-key sk-test-123 +$ pda get api-key +sk-test-123 diff --git a/testdata/set__encrypt__ok__with__ttl.ct b/testdata/set__encrypt__ok__with__ttl.ct new file mode 100644 index 0000000..c1af7f1 --- /dev/null +++ b/testdata/set__encrypt__ok__with__ttl.ct @@ -0,0 +1,4 @@ +# Set an encrypted key with TTL, then retrieve it. +$ pda set --encrypt --ttl 1h api-key sk-ttl-test +$ pda get api-key +sk-ttl-test From d63c1fd77b5b105a37984fb3bd1fa3752123b88c Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 12:51:27 +0000 Subject: [PATCH 037/107] fix: no need to care about identities when making a deletion --- cmd/del.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/cmd/del.go b/cmd/del.go index 1f4a20d..02c48a3 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -26,7 +26,6 @@ import ( "fmt" "strings" - "filippo.io/age" "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -98,19 +97,13 @@ func del(cmd *cobra.Command, args []string) error { return nil } - identity, _ := loadIdentity() - var recipient *age.X25519Recipient - if identity != nil { - recipient = identity.Recipient() - } - for _, dbName := range storeOrder { st := byStore[dbName] p, err := store.storePath(dbName) if err != nil { return err } - entries, err := readStoreFile(p, identity) + entries, err := readStoreFile(p, nil) if err != nil { return err } @@ -121,7 +114,7 @@ func del(cmd *cobra.Command, args []string) error { } entries = append(entries[:idx], entries[idx+1:]...) } - if err := writeStoreFile(p, entries, recipient); err != nil { + if err := writeStoreFile(p, entries, nil); err != nil { return err } } From 07330be10b6676e58c875141ca0705dd54180840 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 13:17:23 +0000 Subject: [PATCH 038/107] feat: include summary of omitted binary data --- README.md | 10 +++---- cmd/get.go | 6 ++-- cmd/list.go | 6 ++-- cmd/shared.go | 35 +++++++++++++++++++++--- testdata/get__missing__err__with__any.ct | 6 ++-- testdata/get__ok__with__binary.ct | 2 +- testdata/get__ok__with__binary_run.ct | 2 +- testdata/help__get__ok.ct | 16 +++++------ testdata/help__list__ok.ct | 4 +-- 9 files changed, 57 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 679f0ae..146eba4 100644 --- a/README.md +++ b/README.md @@ -563,15 +563,15 @@ 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. +`list` and `get` will show a summary for binary data on a TTY. If it's being piped somewhere or ran outside of a TTY, it'll output the raw bytes. -`include-binary` to show the full binary data regardless. +`--base64`/`-b` to view binary data as base64 on a TTY. ```bash pda get logo -# (omitted binary data) +# (binary: 4.2 KB, image/png) -pda get logo --include-binary -# 89504E470D0A1A0A0000000D4948445200000001000000010802000000 +pda get logo --base64 +# iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... ```

diff --git a/cmd/get.go b/cmd/get.go index eeead61..54c3b7c 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -100,7 +100,7 @@ func get(cmd *cobra.Command, args []string) error { } v := entry.Value - binary, err := cmd.Flags().GetBool("include-binary") + binary, err := cmd.Flags().GetBool("base64") if err != nil { return fmt.Errorf("cannot get '%s': %v", args[0], err) } @@ -235,12 +235,12 @@ func run(cmd *cobra.Command, args []string) error { var runFlag bool func init() { - getCmd.Flags().BoolP("include-binary", "b", false, "include binary data in text output") + 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().Bool("no-template", false, "directly output template syntax") rootCmd.AddCommand(getCmd) - runCmd.Flags().BoolP("include-binary", "b", false, "include binary data in text output") + 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/list.go b/cmd/list.go index 7166a80..b9b4bee 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -55,7 +55,7 @@ func (e *formatEnum) Set(v string) error { func (e *formatEnum) Type() string { return "format" } var ( - listBinary bool + listBase64 bool listNoKeys bool listNoValues bool listTTL bool @@ -194,7 +194,7 @@ func list(cmd *cobra.Command, args []string) error { if e.Locked { valueStr = "locked (identity file missing)" } else { - valueStr = store.FormatBytes(listBinary, e.Value) + valueStr = store.FormatBytes(listBase64, e.Value) } } row := make(table.Row, 0, len(columns)) @@ -315,7 +315,7 @@ func renderTable(tw table.Writer) { } func init() { - listCmd.Flags().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output") + listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") diff --git a/cmd/shared.go b/cmd/shared.go index 3b72ad2..76fb1cd 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -23,8 +23,10 @@ THE SOFTWARE. package cmd import ( + "encoding/base64" "fmt" "io" + "net/http" "os" "path/filepath" "strings" @@ -67,14 +69,39 @@ func (s *Store) FormatBytes(includeBinary bool, v []byte) string { return s.formatBytes(includeBinary, v) } -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)" +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) } return string(v) } +func formatSize(n int) string { + const ( + kb = 1024 + mb = 1024 * kb + gb = 1024 * mb + ) + switch { + case n < kb: + return fmt.Sprintf("%d B", n) + case n < mb: + return fmt.Sprintf("%.1f KB", float64(n)/float64(kb)) + case n < gb: + return fmt.Sprintf("%.1f MB", float64(n)/float64(mb)) + default: + return fmt.Sprintf("%.1f GB", float64(n)/float64(gb)) + } +} + func (s *Store) storePath(name string) (string, error) { if name == "" { name = config.Store.DefaultStoreName diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get__missing__err__with__any.ct index 5f03ce9..b4fe45a 100644 --- a/testdata/get__missing__err__with__any.ct +++ b/testdata/get__missing__err__with__any.ct @@ -1,7 +1,7 @@ $ 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 --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 diff --git a/testdata/get__ok__with__binary.ct b/testdata/get__ok__with__binary.ct index ce97ada..67be970 100644 --- a/testdata/get__ok__with__binary.ct +++ b/testdata/get__ok__with__binary.ct @@ -1,3 +1,3 @@ $ pda set a b -$ pda get a --include-binary +$ pda get a --base64 b diff --git a/testdata/get__ok__with__binary_run.ct b/testdata/get__ok__with__binary_run.ct index a398a54..bcc3bc5 100644 --- a/testdata/get__ok__with__binary_run.ct +++ b/testdata/get__ok__with__binary_run.ct @@ -1,4 +1,4 @@ $ fecho cmd echo hello $ pda set foo < cmd -$ pda get foo --include-binary --run +$ pda get foo --base64 --run hello diff --git a/testdata/help__get__ok.ct b/testdata/help__get__ok.ct index 46a269d..c429da8 100644 --- a/testdata/help__get__ok.ct +++ b/testdata/help__get__ok.ct @@ -16,10 +16,10 @@ 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 + -b, --base64 view binary data as base64 + -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 @@ -36,7 +36,7 @@ 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 + -b, --base64 view binary data as base64 + -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__list__ok.ct b/testdata/help__list__ok.ct index 40658c4..2201bd3 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -9,7 +9,7 @@ Aliases: list, ls Flags: - -b, --binary include binary data in text output + -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') @@ -27,7 +27,7 @@ Aliases: list, ls Flags: - -b, --binary include binary data in text output + -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -g, --glob strings Filter keys with glob pattern (repeatable) --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') From 24853bfce850dc9b96457d6d551d94fa0bb05be6 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 14:14:02 +0000 Subject: [PATCH 039/107] feat: default ttl and header visibility, and removed unnecessray padding from tab output --- README.md | 35 ++++-- cmd/list.go | 224 +++++++++++++++++++++++++++------ cmd/shared.go | 6 +- testdata/help__list__ok.ct | 10 +- testdata/list__glob__ok.ct | 8 +- testdata/remove__dedupe__ok.ct | 18 +-- 6 files changed, 232 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 146eba4..d3e801e 100644 --- a/README.md +++ b/README.md @@ -192,19 +192,34 @@ pda rm kitty -i `pda ls` to see what you've got stored. ```bash pda ls -# name Alice -# dogs four legged mammals +# KEY VALUE TTL +# name Alice no expiry +# dogs four legged mammals no expiry # Or as CSV. pda ls --format csv -# name,Alice -# dogs,four legged mammals +# Key,Value,TTL +# name,Alice,no expiry +# dogs,four legged mammals,no expiry # Or TSV, or Markdown, or HTML. ```

+Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. +```bash +pda ls +# KEY VALUE TTL +# note this is a very long (..30 more chars) no expiry + +pda ls --full +# KEY VALUE TTL +# note this is a very long value that keeps on going and going no expiry +``` + +

+ `pda export` to export everything as NDJSON. ```bash pda export > my_backup @@ -536,11 +551,12 @@ pda set session2 "xyz" --ttl 54m10s

-`list --ttl` shows expiration date in list output. +`list` shows expiration in the TTL column by default. ```bash -pda ls --ttl -# session 123 2025-11-21T15:30:00Z (in 59m30s) -# session2 xyz 2025-11-21T15:21:40Z (in 51m40s) +pda ls +# KEY VALUE TTL +# session 123 in 59m30s +# session2 xyz in 51m40s ``` `export` and `import` persist the expiry date. Expirations will continue ticking down regardless of if they're actively in a store or not - the expiry is just a timestamp, not a timer. @@ -628,7 +644,8 @@ pda set api-key "oops" If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. ```bash pda ls -# api-key locked (identity file missing) +# KEY VALUE TTL +# api-key locked (identity file missing) no expiry pda get api-key # FAIL cannot get 'api-key': secret is locked (identity file missing) diff --git a/cmd/list.go b/cmd/list.go index b9b4bee..3c6aaea 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -29,6 +29,8 @@ import ( "io" "os" "strconv" + "strings" + "unicode/utf8" "filippo.io/age" "github.com/jedib0t/go-pretty/v6/table" @@ -58,9 +60,12 @@ var ( listBase64 bool listNoKeys bool listNoValues bool - listTTL bool - listHeader bool - listFormat formatEnum = "table" + listNoTTL bool + listFull bool + listNoHeader bool + listFormat formatEnum = "table" + + dimStyle = text.Colors{text.Faint, text.Italic} ) type columnKind int @@ -99,8 +104,8 @@ func list(cmd *cobra.Command, args []string) error { targetDB = "@" + dbName } - if listNoKeys && listNoValues && !listTTL { - return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys/--no-values or pass --ttl") + if listNoKeys && listNoValues && listNoTTL { + return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl") } var columns []columnKind @@ -110,7 +115,7 @@ func list(cmd *cobra.Command, args []string) error { if !listNoValues { columns = append(columns, columnValue) } - if listTTL { + if !listNoTTL { columns = append(columns, columnTTL) } @@ -183,39 +188,131 @@ func list(cmd *cobra.Command, args []string) error { tw.Style().Options.DrawBorder = false tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateColumns = false + tw.Style().Box.PaddingLeft = "" + tw.Style().Box.PaddingRight = " " - if listHeader { + tty := stdoutIsTerminal() && listFormat.String() == "table" + + if !listNoHeader { tw.AppendHeader(headerRow(columns)) + if tty { + tw.Style().Color.Header = text.Colors{text.Bold} + } } + 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 + } + } + if !listFull { + valueStr = summariseValue(valueStr, lay.value, tty) } } row := make(table.Row, 0, len(columns)) for _, col := range columns { switch col { case columnKey: - row = append(row, e.Key) + if tty { + row = append(row, text.Bold.Sprint(e.Key)) + } else { + row = append(row, e.Key) + } case columnValue: - row = append(row, valueStr) + if tty && dimValue { + row = append(row, dimStyle.Sprint(valueStr)) + } else { + row = append(row, valueStr) + } case columnTTL: - row = append(row, formatExpiry(e.ExpiresAt)) + ttlStr := formatExpiry(e.ExpiresAt) + if tty && e.ExpiresAt == 0 { + ttlStr = dimStyle.Sprint(ttlStr) + } + row = append(row, ttlStr) } } tw.AppendRow(row) } - applyColumnWidths(tw, columns, output) + applyColumnWidths(tw, columns, output, lay, listFull) renderTable(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) table.Row { row := make(table.Row, 0, len(columns)) for _, col := range columns { @@ -231,51 +328,95 @@ func headerRow(columns []columnKind) table.Row { return row } -func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer) { +const ( + keyColumnWidthCap = 30 + ttlColumnWidthCap = 20 +) + +// columnLayout holds the resolved max widths for each column kind. +type columnLayout struct { + key, value, 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) + + // Scan entries for actual max key/TTL content widths. + for _, e := range entries { + if w := utf8.RuneCountInString(e.Key); w > lay.key { + lay.key = w + } + if w := utf8.RuneCountInString(formatExpiry(e.ExpiresAt)); w > lay.ttl { + lay.ttl = w + } + } + if lay.key > keyColumnWidthCap { + lay.key = keyColumnWidthCap + } + 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 key and TTL. + lay.value = available + for _, col := range columns { + switch col { + case columnKey: + lay.value -= lay.key + 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) - // Padding per column: go-pretty's default is one space each side. - padding := len(columns) * 2 - available := termWidth - padding - if available < len(columns) { - return - } - - // Give key and TTL columns a fixed budget; value gets the rest. - const keyWidth = 30 - const ttlWidth = 40 - valueWidth := available - for _, col := range columns { - switch col { - case columnKey: - valueWidth -= keyWidth - case columnTTL: - valueWidth -= ttlWidth - } - } - if valueWidth < 10 { - valueWidth = 10 - } - var configs []table.ColumnConfig for i, col := range columns { var maxW int + var enforcer func(string, int) string switch col { case columnKey: - maxW = keyWidth + maxW = lay.key + enforcer = text.Trim case columnValue: - maxW = valueWidth + maxW = lay.value + if full { + enforcer = text.WrapText + } + // When !full, values are already pre-truncated by + // summariseValue — no enforcer needed. case columnTTL: - maxW = ttlWidth + maxW = lay.ttl + enforcer = text.Trim } configs = append(configs, table.ColumnConfig{ Number: i + 1, WidthMax: maxW, - WidthMaxEnforcer: text.WrapText, + WidthMaxEnforcer: enforcer, }) } tw.SetColumnConfigs(configs) @@ -318,8 +459,9 @@ func init() { listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") - listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire") - listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row") + 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)") 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 '%s')", defaultGlobSeparatorsDisplay())) diff --git a/cmd/shared.go b/cmd/shared.go index 76fb1cd..517162b 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -262,14 +262,14 @@ func validateDBName(name string) error { func formatExpiry(expiresAt uint64) string { if expiresAt == 0 { - return "never" + return "no expiry" } expiry := time.Unix(int64(expiresAt), 0).UTC() remaining := time.Until(expiry) if remaining <= 0 { - return fmt.Sprintf("%s (expired)", expiry.Format(time.RFC3339)) + return "expired" } - return fmt.Sprintf("%s (in %s)", expiry.Format(time.RFC3339), remaining.Round(time.Second)) + return fmt.Sprintf("in %s", remaining.Round(time.Second)) } // Keys returns all keys for the provided store name (or default if empty). diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index 2201bd3..5cbf7d2 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -11,13 +11,14 @@ Aliases: Flags: -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -f, --full show full values without truncation -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-header suppress the header row --no-keys suppress the key column + --no-ttl suppress the TTL column --no-values suppress the value column - -t, --ttl append a TTL column when entries expire List the contents of a store Usage: @@ -29,10 +30,11 @@ Aliases: Flags: -b, --base64 view binary data as base64 -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -f, --full show full values without truncation -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-header suppress the header row --no-keys suppress the key column + --no-ttl suppress the TTL column --no-values suppress the value column - -t, --ttl append a TTL column when entries expire diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct index ece552c..d6f3564 100644 --- a/testdata/list__glob__ok.ct +++ b/testdata/list__glob__ok.ct @@ -2,9 +2,11 @@ $ 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 +Key Value TTL +a1 1 no expiry +a2 2 no expiry $ pda ls lg --glob b* --format tsv -b1 3 +Key Value TTL +b1 3 no expiry $ pda ls lg --glob c* --> FAIL FAIL cannot ls '@lg': no matches for pattern 'c*' diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index 54c5ba8..ec7d330 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,15 +1,15 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls - a echo hello - - a1 1 - a2 2 - b1 3 - bar 2 - copied-key hidden-value - foo 1 - moved-key hidden-value +KEY VALUE TTL +a echo hello (..1 more chars) no expiry +a1 1 no expiry +a2 2 no expiry +b1 3 no expiry +bar 2 no expiry +copied-key hidden-value no expiry +foo 1 no expiry +moved-key hidden-value no expiry $ pda rm foo --glob "*" $ pda get bar --> FAIL FAIL cannot get 'bar': no such key From 1f4732823daab1f4710e2b3b2d7cc7557fde3af2 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 14:17:48 +0000 Subject: [PATCH 040/107] feat: underlined header texts, and one-space right pad --- README.md | 24 ++++++++++++------------ cmd/list.go | 22 +++++++++++++--------- testdata/remove__dedupe__ok.ct | 18 +++++++++--------- 3 files changed, 34 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index d3e801e..7bb0622 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,9 @@ pda rm kitty -i `pda ls` to see what you've got stored. ```bash pda ls -# KEY VALUE TTL -# name Alice no expiry -# dogs four legged mammals no expiry +# Key Value TTL +# name Alice no expiry +# dogs four legged mammals no expiry # Or as CSV. pda ls --format csv @@ -210,12 +210,12 @@ pda ls --format csv Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. ```bash pda ls -# KEY VALUE TTL -# note this is a very long (..30 more chars) no expiry +# Key Value TTL +# note this is a very long (..30 more chars) no expiry pda ls --full -# KEY VALUE TTL -# note this is a very long value that keeps on going and going no expiry +# Key Value TTL +# note this is a very long value that keeps on going and going no expiry ```

@@ -554,9 +554,9 @@ pda set session2 "xyz" --ttl 54m10s `list` shows expiration in the TTL column by default. ```bash pda ls -# KEY VALUE TTL -# session 123 in 59m30s -# session2 xyz in 51m40s +# Key Value TTL +# session 123 in 59m30s +# session2 xyz in 51m40s ``` `export` and `import` persist the expiry date. Expirations will continue ticking down regardless of if they're actively in a store or not - the expiry is just a timestamp, not a timer. @@ -644,8 +644,8 @@ pda set api-key "oops" If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. ```bash pda ls -# KEY VALUE TTL -# api-key locked (identity file missing) no expiry +# Key Value TTL +# api-key locked (identity file missing) no expiry pda get api-key # FAIL cannot get 'api-key': secret is locked (identity file missing) diff --git a/cmd/list.go b/cmd/list.go index 3c6aaea..7a3dab3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -189,15 +189,13 @@ func list(cmd *cobra.Command, args []string) error { tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateColumns = false tw.Style().Box.PaddingLeft = "" - tw.Style().Box.PaddingRight = " " + tw.Style().Box.PaddingRight = " " tty := stdoutIsTerminal() && listFormat.String() == "table" if !listNoHeader { - tw.AppendHeader(headerRow(columns)) - if tty { - tw.Style().Color.Header = text.Colors{text.Bold} - } + tw.AppendHeader(headerRow(columns, tty)) + tw.Style().Format.Header = text.FormatDefault } lay := computeLayout(columns, output, filtered) @@ -313,16 +311,22 @@ func summariseValue(s string, maxWidth int, tty bool) string { return text.Trim(first, maxWidth) } -func headerRow(columns []columnKind) table.Row { +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, "Key") + row = append(row, h("Key")) case columnValue: - row = append(row, "Value") + row = append(row, h("Value")) case columnTTL: - row = append(row, "TTL") + row = append(row, h("TTL")) } } return row diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index ec7d330..cfd5433 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,15 +1,15 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls -KEY VALUE TTL -a echo hello (..1 more chars) no expiry -a1 1 no expiry -a2 2 no expiry -b1 3 no expiry -bar 2 no expiry -copied-key hidden-value no expiry -foo 1 no expiry -moved-key hidden-value no expiry +Key Value TTL +a echo hello (..1 more chars) no expiry +a1 1 no expiry +a2 2 no expiry +b1 3 no expiry +bar 2 no expiry +copied-key hidden-value no expiry +foo 1 no expiry +moved-key hidden-value no expiry $ pda rm foo --glob "*" $ pda get bar --> FAIL FAIL cannot get 'bar': no such key From 5145816b0a8c4156f9bb30cc4178a51820806f4a Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 15:21:05 +0000 Subject: [PATCH 041/107] feat: splits --glob into --key and --value searches --- README.md | 110 +++++++++--------- cmd/del.go | 17 +-- cmd/export.go | 6 +- cmd/glob.go | 24 +--- cmd/list.go | 30 +++-- cmd/match.go | 70 +++++++++++ cmd/restore.go | 13 +-- .../{dump__glob__ok.ct => dump__key__ok.ct} | 6 +- testdata/dump__value__ok.ct | 8 ++ testdata/help__dump__ok.ct | 12 +- testdata/help__list__ok.ct | 40 +++---- testdata/help__remove__ok.ct | 14 +-- testdata/help__restore__ok.ct | 22 ++-- testdata/list__glob__ok.ct | 12 -- testdata/list__key__ok.ct | 12 ++ testdata/list__key__value__ok.ct | 11 ++ testdata/list__value__multi__ok.ct | 8 ++ testdata/list__value__ok.ct | 15 +++ testdata/remove__dedupe__ok.ct | 23 ++-- ...mixed__ok.ct => remove__key__mixed__ok.ct} | 2 +- ...remove__glob__ok.ct => remove__key__ok.ct} | 2 +- ...store__glob__ok.ct => restore__key__ok.ct} | 6 +- 22 files changed, 275 insertions(+), 188 deletions(-) create mode 100644 cmd/match.go rename testdata/{dump__glob__ok.ct => dump__key__ok.ct} (53%) create mode 100644 testdata/dump__value__ok.ct delete mode 100644 testdata/list__glob__ok.ct create mode 100644 testdata/list__key__ok.ct create mode 100644 testdata/list__key__value__ok.ct create mode 100644 testdata/list__value__multi__ok.ct create mode 100644 testdata/list__value__ok.ct rename testdata/{remove__glob__mixed__ok.ct => remove__key__mixed__ok.ct} (89%) rename testdata/{remove__glob__ok.ct => remove__key__ok.ct} (90%) rename testdata/{restore__glob__ok.ct => restore__key__ok.ct} (69%) diff --git a/README.md b/README.md index 7bb0622..83a6125 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ `pda!` is a command-line key-value store tool with: - [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, -- support for [binary data](https://github.com/Llywelwyn/pda#binary), -- [time-to-live](https://github.com/Llywelwyn/pda#ttl) support, - [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age), +- Git-backed [version control](https://github.com/Llywelwyn/pda#git), +- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key and/or value, +- plaintext exports in multiple formats, +- support for all [binary data](https://github.com/Llywelwyn/pda#binary), +- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). @@ -52,7 +52,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [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) +- [Filtering](https://github.com/Llywelwyn/pda#filtering) - [TTL](https://github.com/Llywelwyn/pda#ttl) - [Binary](https://github.com/Llywelwyn/pda#binary) - [Encryption](https://github.com/Llywelwyn/pda#encryption) @@ -174,12 +174,11 @@ pda rm kitty # Remove multiple keys, within the same or different stores. pda rm kitty dog@animals -# Mix exact keys with globs. +# Mix exact keys with glob patterns. pda set cog "cogs" pda set dog "doggy" pda set kitty "cat" -pda rm kitty --glob ?og -# Default glob separators: "/-_.@: " (space included). Override with --glob-sep. +pda rm kitty --key "?og" # Opt in to a confirmation prompt with --interactive/-i (or always_prompt_delete in config). pda rm kitty -i @@ -210,11 +209,11 @@ pda ls --format csv Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. ```bash pda ls -# Key Value TTL -# note this is a very long (..30 more chars) no expiry +# Key Value TTL +# note this is a very long (..30 more chars) no expiry pda ls --full -# Key Value TTL +# Key Value TTL # note this is a very long value that keeps on going and going no expiry ``` @@ -225,7 +224,10 @@ pda ls --full pda export > my_backup # Export only matching keys. -pda export --glob a* +pda export --key "a*" + +# Export only entries whose values contain a URL. +pda export --value "**https**" ```

@@ -241,7 +243,7 @@ pda import < my_backup # ok restored 2 entries into @default # Import only matching keys. -pda import --glob a* -f my_backup +pda import --key "a*" -f my_backup ```

@@ -257,7 +259,7 @@ pda list-stores # @birthdays # Check out a specific store. -pda ls @birthdays +pda ls @birthdays --no-header --no-ttl # alice 11/11/1998 # bob 05/12/1980 @@ -407,17 +409,17 @@ pda get hello --no-template

-### Globs +### Filtering -Globs can be used in a few commands where their use makes sense. `gobwas/glob` is used for matching. +`--key`/`-k` and `--value`/`-v` can be used as filters with glob support. `gobwas/glob` is used for matching. Both flags are repeatable, with results matching one-or-more of the keys and one-or-more of the values passed. If a `--key` and `--value` are passed, results must match both of them. If multiple are passed, results must match at least one `--key` and `--value` pattern. -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 store. +`--key` and `--value` filters work with `list`, `remove`, `export`, and `import` commands.

-`*` wildcards a word or series of characters. +`*` wildcards a word or series of characters, stopping at separator boundaries (the default separators are `/-_.@:` and space). ```bash -pda ls --no-values +pda ls --no-values --no-header # cat # dog # cog @@ -425,16 +427,16 @@ pda ls --no-values # mouse house # foo.bar.baz -pda ls --glob "*" +pda ls --key "*" # cat # dog # cog -pda ls --glob "* *" +pda ls --key "* *" # mouse hotdog # mouse house -pda ls --glob "foo.*.baz" +pda ls --key "foo.*.baz" # foo.bar.baz ``` @@ -442,10 +444,10 @@ pda ls --glob "foo.*.baz" `**` super-wildcards ignore word boundaries. ```bash -pda ls --glob "foo**" +pda ls --key "foo**" # foo.bar.baz -pda ls --glob "**g" +pda ls --key "**g" # dog # cog # mouse hotdog @@ -455,7 +457,7 @@ pda ls --glob "**g" `?` wildcards a single letter. ```bash -pda ls --glob ?og +pda ls --key "?og" # dog # cog # frog --> fail @@ -466,13 +468,13 @@ pda ls --glob ?og `[abc]` must match one of the characters in the brackets. ```bash -pda ls --glob [dc]og +pda ls --key "[dc]og" # dog # cog # bog --> fail # Can be negated with '!' -pda ls --glob [!dc]og +pda ls --key "[!dc]og" # dog --> fail # cog --> fail # bog @@ -480,20 +482,20 @@ pda ls --glob [!dc]og

-`[a-c]` must fall within the range given in the brackets +`[a-c]` must fall within the range given in the brackets. ```bash -pda ls --glob [a-g]ag +pda ls --key "[a-g]ag" # bag # gag # wag --> fail # Can be negated with '!' -pda ls --glob [!a-g]ag +pda ls --key "[!a-g]ag" # bag --> fail # gag --> fail # wag -pda ls --glob 19[90-99] +pda ls --key "19[90-99]" # 1991 # 1992 # 2001 --> fail @@ -502,39 +504,33 @@ pda ls --glob 19[90-99]

-Globs can be arbitrarily complex, and can be combined with strict matches. +`--value` filters by value content using the same glob syntax. ```bash -pda ls --no-keys -# cat -# mouse trap -# dog house -# cat flap -# cogwheel +pda ls --value "**localhost**" +# db-url postgres://localhost:5432 no expiry -pda rm cat --glob "{mouse,[cd]og}**" +# Combine key and value filters. +pda ls --key "db*" --value "**localhost**" +# db-url postgres://localhost:5432 no expiry + +# Multiple --value patterns are OR'd. +pda ls --value "**world**" --value "42" +# greeting hello world no expiry +# number 42 no expiry +``` + +

+ +Globs can be arbitrarily complex, and `--key` can be combined with exact positional args on `rm`. +```bash +pda rm cat --key "{mouse,[cd]og}**" # ??? remove 'cat'? (y/n) # ==> y # ??? remove 'mouse trap'? (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 -``` +Locked (encrypted without an available identity) and non-UTF-8 (binary) entries are silently excluded from `--value` matching.

diff --git a/cmd/del.go b/cmd/del.go index 02c48a3..62d23ee 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -47,20 +47,16 @@ func del(cmd *cobra.Command, args []string) error { if err != nil { return err } - globPatterns, err := cmd.Flags().GetStringSlice("glob") - if err != nil { - return err - } - separators, err := parseGlobSeparators(cmd) + keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { return err } - if len(args) == 0 && len(globPatterns) == 0 { + if len(args) == 0 && len(keyPatterns) == 0 { return fmt.Errorf("cannot remove: no keys provided") } - targets, err := resolveDeleteTargets(store, args, globPatterns, separators) + targets, err := resolveDeleteTargets(store, args, keyPatterns) if err != nil { return err } @@ -124,8 +120,7 @@ func del(cmd *cobra.Command, args []string) error { func init() { delCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") - delCmd.Flags().StringSliceP("glob", "g", nil, "Delete keys matching glob pattern (repeatable)") - delCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) + delCmd.Flags().StringSliceP("key", "k", nil, "Delete keys matching glob pattern (repeatable)") rootCmd.AddCommand(delCmd) } @@ -152,7 +147,7 @@ func keyExists(store *Store, arg string) (bool, error) { return findEntry(entries, spec.Key) >= 0, nil } -func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, separators []rune) ([]resolvedTarget, error) { +func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string) ([]resolvedTarget, error) { targetSet := make(map[string]struct{}) var targets []resolvedTarget @@ -202,7 +197,7 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin return nil, err } pattern := spec.Key - m, err := glob.Compile(pattern, separators...) + m, err := glob.Compile(pattern, defaultGlobSeparators...) if err != nil { return nil, fmt.Errorf("cannot remove '%s': %v", raw, err) } diff --git a/cmd/export.go b/cmd/export.go index 968dd3f..b1a0898 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -23,8 +23,6 @@ THE SOFTWARE. package cmd import ( - "fmt" - "github.com/spf13/cobra" ) @@ -41,7 +39,7 @@ var exportCmd = &cobra.Command{ } func init() { - exportCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") - exportCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default '%s')", defaultGlobSeparatorsDisplay())) + exportCmd.Flags().StringSliceP("key", "k", nil, "Filter keys with glob pattern (repeatable)") + exportCmd.Flags().StringSliceP("value", "v", nil, "Filter values with regex pattern (repeatable)") rootCmd.AddCommand(exportCmd) } diff --git a/cmd/glob.go b/cmd/glob.go index 092c9b9..5c8c57f 100644 --- a/cmd/glob.go +++ b/cmd/glob.go @@ -27,34 +27,14 @@ import ( "strings" "github.com/gobwas/glob" - "github.com/spf13/cobra" ) var defaultGlobSeparators = []rune{'/', '-', '_', '.', '@', ':', ' '} -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) { +func compileGlobMatchers(patterns []string) ([]glob.Glob, error) { var matchers []glob.Glob for _, pattern := range patterns { - m, err := glob.Compile(strings.ToLower(pattern), separators...) + m, err := glob.Compile(strings.ToLower(pattern), defaultGlobSeparators...) if err != nil { return nil, err } diff --git a/cmd/list.go b/cmd/list.go index 7a3dab3..348879d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -119,15 +119,20 @@ func list(cmd *cobra.Command, args []string) error { columns = append(columns, columnTTL) } - globPatterns, err := cmd.Flags().GetStringSlice("glob") + keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - separators, err := parseGlobSeparators(cmd) + matchers, err := compileGlobMatchers(keyPatterns) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - matchers, err := compileGlobMatchers(globPatterns, separators) + + valuePatterns, err := cmd.Flags().GetStringSlice("value") + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + valueMatchers, err := compileValueMatchers(valuePatterns) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -148,16 +153,23 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - // Filter by glob + // Filter by key glob and value regex var filtered []Entry for _, e := range entries { - if globMatch(matchers, e.Key) { + if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) { filtered = append(filtered, e) } } - if len(matchers) > 0 && len(filtered) == 0 { - return fmt.Errorf("cannot ls '%s': no matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) + if (len(matchers) > 0 || len(valueMatchers) > 0) && len(filtered) == 0 { + switch { + case len(matchers) > 0 && len(valueMatchers) > 0: + return fmt.Errorf("cannot ls '%s': no matches for key pattern %s and value pattern %s", targetDB, formatGlobPatterns(keyPatterns), formatValuePatterns(valuePatterns)) + case len(valueMatchers) > 0: + return fmt.Errorf("cannot ls '%s': no matches for value pattern %s", targetDB, formatValuePatterns(valuePatterns)) + default: + return fmt.Errorf("cannot ls '%s': no matches for key pattern %s", targetDB, formatGlobPatterns(keyPatterns)) + } } output := cmd.OutOrStdout() @@ -467,7 +479,7 @@ func init() { 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)") - 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 '%s')", defaultGlobSeparatorsDisplay())) + listCmd.Flags().StringSliceP("key", "k", nil, "Filter keys with glob pattern (repeatable)") + listCmd.Flags().StringSliceP("value", "v", nil, "Filter values with regex pattern (repeatable)") rootCmd.AddCommand(listCmd) } diff --git a/cmd/match.go b/cmd/match.go new file mode 100644 index 0000000..c04595b --- /dev/null +++ b/cmd/match.go @@ -0,0 +1,70 @@ +/* +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/restore.go b/cmd/restore.go index d8ae2c2..11756fa 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -56,15 +56,11 @@ func restore(cmd *cobra.Command, args []string) error { } displayTarget := "@" + dbName - globPatterns, err := cmd.Flags().GetStringSlice("glob") + keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } - separators, err := parseGlobSeparators(cmd) - if err != nil { - return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) - } - matchers, err := compileGlobMatchers(globPatterns, separators) + matchers, err := compileGlobMatchers(keyPatterns) if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } @@ -113,7 +109,7 @@ func restore(cmd *cobra.Command, args []string) error { } if len(matchers) > 0 && restored == 0 { - return fmt.Errorf("cannot restore '%s': no matches for pattern %s", displayTarget, formatGlobPatterns(globPatterns)) + return fmt.Errorf("cannot restore '%s': no matches for key pattern %s", displayTarget, formatGlobPatterns(keyPatterns)) } okf("restored %d entries into @%s", restored, dbName) @@ -208,8 +204,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( func init() { 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 '%s')", defaultGlobSeparatorsDisplay())) + restoreCmd.Flags().StringSliceP("key", "k", nil, "Restore keys matching glob pattern (repeatable)") restoreCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting existing keys") restoreCmd.Flags().Bool("drop", false, "Drop existing entries before restoring (full replace)") rootCmd.AddCommand(restoreCmd) diff --git a/testdata/dump__glob__ok.ct b/testdata/dump__key__ok.ct similarity index 53% rename from testdata/dump__glob__ok.ct rename to testdata/dump__key__ok.ct index 999d094..10569d2 100644 --- a/testdata/dump__glob__ok.ct +++ b/testdata/dump__key__ok.ct @@ -1,8 +1,8 @@ $ pda set a1 1 $ pda set a2 2 $ pda set b1 3 -$ pda dump --glob a* +$ pda dump --key "a*" {"key":"a1","value":"1","encoding":"text"} {"key":"a2","value":"2","encoding":"text"} -$ pda dump --glob c* --> FAIL -FAIL cannot ls '@default': no matches for pattern 'c*' +$ pda dump --key "c*" --> FAIL +FAIL cannot ls '@default': no matches for key pattern 'c*' diff --git a/testdata/dump__value__ok.ct b/testdata/dump__value__ok.ct new file mode 100644 index 0000000..bab0072 --- /dev/null +++ b/testdata/dump__value__ok.ct @@ -0,0 +1,8 @@ +$ pda set url https://example.com +$ fecho tmpval hello world +$ pda set greeting < tmpval +$ pda set number 42 +$ pda dump --value "**https**" +{"key":"url","value":"https://example.com","encoding":"text"} +$ pda dump --value "**world**" +{"key":"greeting","value":"hello world\n","encoding":"text"} diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 891cbc2..626e89d 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -9,9 +9,9 @@ Aliases: export, dump Flags: - -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for export + -h, --help help for export + -k, --key strings Filter keys with glob pattern (repeatable) + -v, --value strings Filter values with regex pattern (repeatable) Export store as NDJSON (alias for list --format ndjson) Usage: @@ -21,6 +21,6 @@ Aliases: export, dump Flags: - -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for export + -h, --help help for export + -k, --key strings Filter keys with glob pattern (repeatable) + -v, --value strings Filter values with regex pattern (repeatable) diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index 5cbf7d2..5e817c7 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -9,16 +9,16 @@ Aliases: list, ls Flags: - -b, --base64 view binary data as base64 - -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) - -f, --full show full values without truncation - -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for list - --no-header suppress the header row - --no-keys suppress the key column - --no-ttl suppress the TTL column - --no-values suppress the value column + -b, --base64 view binary data as base64 + -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -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-ttl suppress the TTL column + --no-values suppress the value column + -v, --value strings Filter values with regex pattern (repeatable) List the contents of a store Usage: @@ -28,13 +28,13 @@ Aliases: list, ls Flags: - -b, --base64 view binary data as base64 - -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) - -f, --full show full values without truncation - -g, --glob strings Filter keys with glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for list - --no-header suppress the header row - --no-keys suppress the key column - --no-ttl suppress the TTL column - --no-values suppress the value column + -b, --base64 view binary data as base64 + -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) + -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-ttl suppress the TTL column + --no-values suppress the value column + -v, --value strings Filter values with regex pattern (repeatable) diff --git a/testdata/help__remove__ok.ct b/testdata/help__remove__ok.ct index 19f0992..fe2bef6 100644 --- a/testdata/help__remove__ok.ct +++ b/testdata/help__remove__ok.ct @@ -9,10 +9,9 @@ Aliases: remove, rm Flags: - -g, --glob strings Delete keys matching glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for remove - -i, --interactive Prompt yes/no for each deletion + -h, --help help for remove + -i, --interactive Prompt yes/no for each deletion + -k, --key strings Delete keys matching glob pattern (repeatable) Delete one or more keys Usage: @@ -22,7 +21,6 @@ Aliases: remove, rm Flags: - -g, --glob strings Delete keys matching glob pattern (repeatable) - --glob-sep string Characters treated as separators for globbing (default '/-_.@: ') - -h, --help help for remove - -i, --interactive Prompt yes/no for each deletion + -h, --help help for remove + -i, --interactive Prompt yes/no for each deletion + -k, --key strings Delete keys matching glob pattern (repeatable) diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index 140e160..2bbadff 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -9,12 +9,11 @@ Aliases: import, restore Flags: - --drop Drop existing entries before restoring (full replace) - -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 import - -i, --interactive Prompt before overwriting existing keys + --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) Restore key/value pairs from an NDJSON dump Usage: @@ -24,9 +23,8 @@ Aliases: import, restore Flags: - --drop Drop existing entries before restoring (full replace) - -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 import - -i, --interactive Prompt before overwriting existing keys + --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) diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct deleted file mode 100644 index d6f3564..0000000 --- a/testdata/list__glob__ok.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 --glob a* --format tsv -Key Value TTL -a1 1 no expiry -a2 2 no expiry -$ pda ls lg --glob b* --format tsv -Key Value TTL -b1 3 no expiry -$ pda ls lg --glob c* --> FAIL -FAIL cannot ls '@lg': no matches for pattern 'c*' diff --git a/testdata/list__key__ok.ct b/testdata/list__key__ok.ct new file mode 100644 index 0000000..a686456 --- /dev/null +++ b/testdata/list__key__ok.ct @@ -0,0 +1,12 @@ +$ pda set a1@lg 1 +$ pda set a2@lg 2 +$ pda set b1@lg 3 +$ pda ls lg --key "a*" --format tsv +Key Value TTL +a1 1 no expiry +a2 2 no expiry +$ pda ls lg --key "b*" --format tsv +Key Value TTL +b1 3 no expiry +$ pda ls lg --key "c*" --> FAIL +FAIL cannot ls '@lg': no matches for key pattern 'c*' diff --git a/testdata/list__key__value__ok.ct b/testdata/list__key__value__ok.ct new file mode 100644 index 0000000..9d55fcb --- /dev/null +++ b/testdata/list__key__value__ok.ct @@ -0,0 +1,11 @@ +$ pda set dburl@kv postgres://localhost:5432 +$ pda set apiurl@kv https://api.example.com +$ pda set dbpass@kv s3cret +$ pda ls kv -k "db*" -v "**localhost**" --format tsv +Key Value TTL +dburl postgres://localhost:5432 no expiry +$ pda ls kv -k "*url*" -v "**example**" --format tsv +Key Value TTL +apiurl https://api.example.com no expiry +$ 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__value__multi__ok.ct b/testdata/list__value__multi__ok.ct new file mode 100644 index 0000000..a57e2ce --- /dev/null +++ b/testdata/list__value__multi__ok.ct @@ -0,0 +1,8 @@ +$ 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 +Key Value TTL +greeting hello world (..1 more chars) no expiry +number 42 no expiry diff --git a/testdata/list__value__ok.ct b/testdata/list__value__ok.ct new file mode 100644 index 0000000..ee0ca02 --- /dev/null +++ b/testdata/list__value__ok.ct @@ -0,0 +1,15 @@ +$ 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 +Key Value TTL +greeting hello world (..1 more chars) no expiry +$ pda ls vt --value "**https**" --format tsv +Key Value TTL +url https://example.com no expiry +$ pda ls vt --value "*" --format tsv +Key Value TTL +number 42 no expiry +$ pda ls vt --value "**nomatch**" --> FAIL +FAIL cannot ls '@vt': no matches for value pattern '**nomatch**' diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct index cfd5433..afa50a7 100644 --- a/testdata/remove__dedupe__ok.ct +++ b/testdata/remove__dedupe__ok.ct @@ -1,16 +1,19 @@ $ pda set foo 1 $ pda set bar 2 $ pda ls -Key Value TTL -a echo hello (..1 more chars) no expiry -a1 1 no expiry -a2 2 no expiry -b1 3 no expiry -bar 2 no expiry -copied-key hidden-value no expiry -foo 1 no expiry -moved-key hidden-value no expiry -$ pda rm foo --glob "*" +Key Value TTL +a echo hello (..1 more chars) no expiry +a1 1 no expiry +a2 2 no expiry +b1 3 no expiry +bar 2 no expiry +copied-key hidden-value no expiry +foo 1 no expiry +greeting hello world (..1 more chars) no expiry +moved-key hidden-value no expiry +number 42 no expiry +url https://example.com no expiry +$ pda rm foo --key "*" $ pda get bar --> FAIL FAIL cannot get 'bar': no such key $ pda get foo --> FAIL diff --git a/testdata/remove__glob__mixed__ok.ct b/testdata/remove__key__mixed__ok.ct similarity index 89% rename from testdata/remove__glob__mixed__ok.ct rename to testdata/remove__key__mixed__ok.ct index 3f5fa6b..20a870b 100644 --- a/testdata/remove__glob__mixed__ok.ct +++ b/testdata/remove__key__mixed__ok.ct @@ -1,7 +1,7 @@ $ pda set foo 1 $ pda set bar1 2 $ pda set bar2 3 -$ pda rm foo --glob bar* +$ pda rm foo --key "bar*" $ pda get foo --> FAIL FAIL cannot get 'foo': no such key $ pda get bar1 --> FAIL diff --git a/testdata/remove__glob__ok.ct b/testdata/remove__key__ok.ct similarity index 90% rename from testdata/remove__glob__ok.ct rename to testdata/remove__key__ok.ct index 7ad2534..ce7fb2a 100644 --- a/testdata/remove__glob__ok.ct +++ b/testdata/remove__key__ok.ct @@ -1,7 +1,7 @@ $ pda set a1 1 $ pda set a2 2 $ pda set b1 3 -$ pda rm --glob a* +$ pda rm --key "a*" $ pda get a1 --> FAIL FAIL cannot get 'a1': no such key hint did you mean 'b1'? diff --git a/testdata/restore__glob__ok.ct b/testdata/restore__key__ok.ct similarity index 69% rename from testdata/restore__glob__ok.ct rename to testdata/restore__key__ok.ct index 9f5f5da..544d033 100644 --- a/testdata/restore__glob__ok.ct +++ b/testdata/restore__key__ok.ct @@ -3,7 +3,7 @@ $ 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 rm a1 a2 b1 -$ pda restore --glob a* --file dumpfile +$ pda restore --key "a*" --file dumpfile ok restored 2 entries into @default $ pda get a1 1 @@ -12,5 +12,5 @@ $ pda get a2 $ pda get b1 --> FAIL FAIL cannot get 'b1': no such key hint did you mean 'a1'? -$ pda restore --glob c* --file dumpfile --> FAIL -FAIL cannot restore '@default': no matches for pattern 'c*' +$ pda restore --key "c*" --file dumpfile --> FAIL +FAIL cannot restore '@default': no matches for key pattern 'c*' From d0926c2c1de472eb1d34f21f06893f3935c98ba2 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 15:27:29 +0000 Subject: [PATCH 042/107] fix: fixes a collision when -i is used with input passed via stdin, uses /dev/tty instead --- cmd/restore.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/restore.go b/cmd/restore.go index 11756fa..6830ee1 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -97,12 +97,27 @@ func restore(cmd *cobra.Command, args []string) error { recipient = identity.Recipient() } + var promptReader io.Reader + if promptOverwrite { + filePath, _ := cmd.Flags().GetString("file") + if strings.TrimSpace(filePath) == "" { + // Data comes from stdin — open /dev/tty for interactive prompts. + 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) + } + defer tty.Close() + promptReader = tty + } + } + restored, err := restoreEntries(decoder, p, restoreOpts{ matchers: matchers, promptOverwrite: promptOverwrite, drop: drop, identity: identity, recipient: recipient, + promptReader: promptReader, }) if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) @@ -137,6 +152,7 @@ type restoreOpts struct { drop bool identity *age.X25519Identity recipient *age.X25519Recipient + promptReader io.Reader } func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) { @@ -178,8 +194,15 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( if opts.promptOverwrite && idx >= 0 { promptf("overwrite '%s'? (y/n)", entry.Key) var confirm string - if err := scanln(&confirm); err != nil { - return 0, fmt.Errorf("entry %d: %v", entryNo, err) + 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 From 572e27589bc8666c554a8cf619f09dcce6c49d11 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 15:27:47 +0000 Subject: [PATCH 043/107] feat: adds --drop mention to readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 83a6125..3fce6c6 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ pda export --value "**https**"

-`pda import` to import it all back. +`pda import` to import it all back. By default, import merges into the existing store — existing keys are updated and new keys are added. ```bash # Import with an argument. pda import -f my_backup @@ -244,6 +244,9 @@ pda import < my_backup # Import only matching keys. pda import --key "a*" -f my_backup + +# Full replace — drop all existing entries before importing. +pda import --drop -f my_backup ```

From bb57b232243066b48ecd10d45f2c4413e4e91924 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 15:31:30 +0000 Subject: [PATCH 044/107] docs: erroneous escape chars --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fce6c6..b854b7d 100644 --- a/README.md +++ b/README.md @@ -566,7 +566,7 @@ pda ls Save binary data. ```bash -pda set logo < logo.png``` +pda set logo < logo.png ```

From d3b4bef531c846dc80d13b8a571eb1a45f4b9193 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 16:07:19 +0000 Subject: [PATCH 045/107] feat: adds some test cases where they were missing, and some minor readme additions --- README.md | 17 +++++++++++++++- testdata/cp__cross-store__ok.ct | 7 +++++++ testdata/cp__err__missing.ct | 3 +++ testdata/cp__ok.ct | 7 +++++++ testdata/export__ok.ct | 6 ++++++ testdata/list-stores__ok.ct | 9 +++++++++ testdata/list__all-suppressed__err.ct | 5 +++++ testdata/list__format__csv__ok.ct | 7 +++++++ testdata/list__format__markdown__ok.ct | 8 ++++++++ testdata/list__format__ndjson__ok.ct | 6 ++++++ testdata/list__no-header__ok.ct | 4 ++++ testdata/list__no-keys__ok.ct | 5 +++++ testdata/list__no-ttl__ok.ct | 5 +++++ testdata/list__no-values__ok.ct | 5 +++++ testdata/multistore__ok.ct | 10 ++++++++++ testdata/mv__cross-store__ok.ct | 7 +++++++ testdata/mv__err__missing.ct | 3 +++ testdata/mv__ok.ct | 7 +++++++ testdata/restore__merge__ok.ct | 9 +++++++++ testdata/restore__stdin__ok.ct | 9 +++++++++ testdata/template__enum__err.ct | 5 +++++ testdata/template__no-template__ok.ct | 5 +++++ testdata/template__ok.ct | 27 ++++++++++++++++++++++++++ testdata/template__require__err.ct | 5 +++++ 24 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 testdata/cp__cross-store__ok.ct create mode 100644 testdata/cp__err__missing.ct create mode 100644 testdata/cp__ok.ct create mode 100644 testdata/export__ok.ct create mode 100644 testdata/list-stores__ok.ct create mode 100644 testdata/list__all-suppressed__err.ct create mode 100644 testdata/list__format__csv__ok.ct create mode 100644 testdata/list__format__markdown__ok.ct create mode 100644 testdata/list__format__ndjson__ok.ct create mode 100644 testdata/list__no-header__ok.ct create mode 100644 testdata/list__no-keys__ok.ct create mode 100644 testdata/list__no-ttl__ok.ct create mode 100644 testdata/list__no-values__ok.ct create mode 100644 testdata/multistore__ok.ct create mode 100644 testdata/mv__cross-store__ok.ct create mode 100644 testdata/mv__err__missing.ct create mode 100644 testdata/mv__ok.ct create mode 100644 testdata/restore__merge__ok.ct create mode 100644 testdata/restore__stdin__ok.ct create mode 100644 testdata/template__enum__err.ct create mode 100644 testdata/template__no-template__ok.ct create mode 100644 testdata/template__ok.ct create mode 100644 testdata/template__require__err.ct diff --git a/README.md b/README.md index b854b7d..484efa1 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,21 @@ Additional Commands:

+Most commands have short aliases for quick access: + +| Command | Alias(es) | +|---------|-----------| +| `set` | `s` | +| `get` | `g` | +| `list` | `ls` | +| `remove` | `rm` | +| `move` | `mv` | +| `copy` | `cp` | +| `list-stores` | `ls-stores`, `lss` | +| `remove-store` | `rm-store`, `rms` | + +

+ ### Installation ```bash @@ -544,7 +559,7 @@ Locked (encrypted without an available identity) and non-UTF-8 (binary) entries # Expire after 1 hour pda set session "123" --ttl 1h -# After 52 minutes and 10 seconds +# After 54 minutes and 10 seconds pda set session2 "xyz" --ttl 54m10s ``` diff --git a/testdata/cp__cross-store__ok.ct b/testdata/cp__cross-store__ok.ct new file mode 100644 index 0000000..8e1c4ce --- /dev/null +++ b/testdata/cp__cross-store__ok.ct @@ -0,0 +1,7 @@ +# Cross-store copy +$ pda set key@src value +$ pda cp key@src key@dst +$ pda get key@src +value +$ pda get key@dst +value diff --git a/testdata/cp__err__missing.ct b/testdata/cp__err__missing.ct new file mode 100644 index 0000000..d152d13 --- /dev/null +++ b/testdata/cp__err__missing.ct @@ -0,0 +1,3 @@ +# Copy non-existent key +$ pda cp nonexistent dest --> FAIL +FAIL cannot move 'nonexistent': no such key diff --git a/testdata/cp__ok.ct b/testdata/cp__ok.ct new file mode 100644 index 0000000..8abbe7b --- /dev/null +++ b/testdata/cp__ok.ct @@ -0,0 +1,7 @@ +# Basic copy +$ pda set source@cpok value +$ pda cp source@cpok dest@cpok +$ pda get source@cpok +value +$ pda get dest@cpok +value diff --git a/testdata/export__ok.ct b/testdata/export__ok.ct new file mode 100644 index 0000000..64d6f21 --- /dev/null +++ b/testdata/export__ok.ct @@ -0,0 +1,6 @@ +# 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"} +{"key":"b","value":"2","encoding":"text"} diff --git a/testdata/list-stores__ok.ct b/testdata/list-stores__ok.ct new file mode 100644 index 0000000..7269065 --- /dev/null +++ b/testdata/list-stores__ok.ct @@ -0,0 +1,9 @@ +# Functional list-stores: verify created stores appear +$ pda set a@lsalpha 1 +$ pda set b@lsbeta 2 +$ pda ls lsalpha --format tsv +Key Value TTL +a 1 no expiry +$ pda ls lsbeta --format tsv +Key Value TTL +b 2 no expiry diff --git a/testdata/list__all-suppressed__err.ct b/testdata/list__all-suppressed__err.ct new file mode 100644 index 0000000..f96a22b --- /dev/null +++ b/testdata/list__all-suppressed__err.ct @@ -0,0 +1,5 @@ +# Error when all columns are suppressed +$ pda set a@las 1 +$ pda ls las --no-keys --no-values --no-ttl --> FAIL +FAIL cannot ls '@las': no columns selected +hint disable --no-keys, --no-values, or --no-ttl diff --git a/testdata/list__format__csv__ok.ct b/testdata/list__format__csv__ok.ct new file mode 100644 index 0000000..9869a27 --- /dev/null +++ b/testdata/list__format__csv__ok.ct @@ -0,0 +1,7 @@ +# CSV format output +$ pda set a@csv 1 +$ pda set b@csv 2 +$ pda ls csv --format csv +Key,Value,TTL +a,1,no expiry +b,2,no expiry diff --git a/testdata/list__format__markdown__ok.ct b/testdata/list__format__markdown__ok.ct new file mode 100644 index 0000000..c97165e --- /dev/null +++ b/testdata/list__format__markdown__ok.ct @@ -0,0 +1,8 @@ +# Markdown format output +$ pda set a@md 1 +$ pda set b@md 2 +$ pda ls md --format markdown +| Key | Value | TTL | +| --- | --- | --- | +| a | 1 | no expiry | +| b | 2 | no expiry | diff --git a/testdata/list__format__ndjson__ok.ct b/testdata/list__format__ndjson__ok.ct new file mode 100644 index 0000000..6740b01 --- /dev/null +++ b/testdata/list__format__ndjson__ok.ct @@ -0,0 +1,6 @@ +# 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"} +{"key":"b","value":"2","encoding":"text"} diff --git a/testdata/list__no-header__ok.ct b/testdata/list__no-header__ok.ct new file mode 100644 index 0000000..63992dc --- /dev/null +++ b/testdata/list__no-header__ok.ct @@ -0,0 +1,4 @@ +# --no-header suppresses the header row +$ pda set a@nh 1 +$ pda ls nh --format tsv --no-header +a 1 no expiry diff --git a/testdata/list__no-keys__ok.ct b/testdata/list__no-keys__ok.ct new file mode 100644 index 0000000..c364e54 --- /dev/null +++ b/testdata/list__no-keys__ok.ct @@ -0,0 +1,5 @@ +# --no-keys suppresses the key column +$ pda set a@nk 1 +$ pda ls nk --format tsv --no-keys +Value TTL +1 no expiry diff --git a/testdata/list__no-ttl__ok.ct b/testdata/list__no-ttl__ok.ct new file mode 100644 index 0000000..c9799cb --- /dev/null +++ b/testdata/list__no-ttl__ok.ct @@ -0,0 +1,5 @@ +# --no-ttl suppresses the TTL column +$ pda set a@nt 1 +$ pda ls nt --format tsv --no-ttl +Key Value +a 1 diff --git a/testdata/list__no-values__ok.ct b/testdata/list__no-values__ok.ct new file mode 100644 index 0000000..9ebb69a --- /dev/null +++ b/testdata/list__no-values__ok.ct @@ -0,0 +1,5 @@ +# --no-values suppresses the value column +$ pda set a@nv 1 +$ pda ls nv --format tsv --no-values +Key TTL +a no expiry diff --git a/testdata/multistore__ok.ct b/testdata/multistore__ok.ct new file mode 100644 index 0000000..ac0ba32 --- /dev/null +++ b/testdata/multistore__ok.ct @@ -0,0 +1,10 @@ +# 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 +Key Value TTL +x y no expiry diff --git a/testdata/mv__cross-store__ok.ct b/testdata/mv__cross-store__ok.ct new file mode 100644 index 0000000..4420a35 --- /dev/null +++ b/testdata/mv__cross-store__ok.ct @@ -0,0 +1,7 @@ +# Cross-store move +$ pda set key@src value +$ pda mv key@src 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__err__missing.ct b/testdata/mv__err__missing.ct new file mode 100644 index 0000000..6df2ff0 --- /dev/null +++ b/testdata/mv__err__missing.ct @@ -0,0 +1,3 @@ +# Move non-existent key +$ pda mv nonexistent dest --> FAIL +FAIL cannot move 'nonexistent': no such key diff --git a/testdata/mv__ok.ct b/testdata/mv__ok.ct new file mode 100644 index 0000000..0ef7801 --- /dev/null +++ b/testdata/mv__ok.ct @@ -0,0 +1,7 @@ +# Basic move +$ pda set source@mvok value +$ pda mv source@mvok dest@mvok +$ pda get dest@mvok +value +$ pda get source@mvok --> FAIL +FAIL cannot get 'source@mvok': no such key diff --git a/testdata/restore__merge__ok.ct b/testdata/restore__merge__ok.ct new file mode 100644 index 0000000..a8ac11a --- /dev/null +++ b/testdata/restore__merge__ok.ct @@ -0,0 +1,9 @@ +# 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 restore mrg --file dumpfile + ok restored 2 entries into @mrg +$ pda get existing@mrg +updated +$ pda get new@mrg +hello diff --git a/testdata/restore__stdin__ok.ct b/testdata/restore__stdin__ok.ct new file mode 100644 index 0000000..ec2552a --- /dev/null +++ b/testdata/restore__stdin__ok.ct @@ -0,0 +1,9 @@ +# Import from stdin preserves existing entries +$ pda set existing@stn keep-me +$ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} +$ pda restore stn < dumpfile + ok restored 1 entries into @stn +$ pda get existing@stn +keep-me +$ pda get new@stn +hello diff --git a/testdata/template__enum__err.ct b/testdata/template__enum__err.ct new file mode 100644 index 0000000..e2b9f1f --- /dev/null +++ b/testdata/template__enum__err.ct @@ -0,0 +1,5 @@ +# 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': template: cmd:1:3: executing "cmd" at : error calling enum: invalid value 'debug', allowed: [info warn] diff --git a/testdata/template__no-template__ok.ct b/testdata/template__no-template__ok.ct new file mode 100644 index 0000000..3615ef1 --- /dev/null +++ b/testdata/template__no-template__ok.ct @@ -0,0 +1,5 @@ +# --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__ok.ct b/testdata/template__ok.ct new file mode 100644 index 0000000..324a9fb --- /dev/null +++ b/testdata/template__ok.ct @@ -0,0 +1,27 @@ +# 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, diff --git a/testdata/template__require__err.ct b/testdata/template__require__err.ct new file mode 100644 index 0000000..88c726c --- /dev/null +++ b/testdata/template__require__err.ct @@ -0,0 +1,5 @@ +# 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': template: cmd:1:3: executing "cmd" at : error calling require: required value is missing or empty From 8ea865b2ceacbdad3f9ad58b6831f6c2822ed3c5 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 16:09:38 +0000 Subject: [PATCH 046/107] feat: removes table from readme --- README.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.md b/README.md index 484efa1..12fbf66 100644 --- a/README.md +++ b/README.md @@ -104,18 +104,7 @@ Additional Commands:

-Most commands have short aliases for quick access: - -| Command | Alias(es) | -|---------|-----------| -| `set` | `s` | -| `get` | `g` | -| `list` | `ls` | -| `remove` | `rm` | -| `move` | `mv` | -| `copy` | `cp` | -| `list-stores` | `ls-stores`, `lss` | -| `remove-store` | `rm-store`, `rms` | +Most commands have aliases and flags. `pda help [command]` to see them.

From a4d2e919dc9f6495543f8e28fc00ad2c98e1dfbd Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 16:13:49 +0000 Subject: [PATCH 047/107] feat: command rename finalising --- cmd/del-db.go | 2 +- cmd/export.go | 2 +- cmd/identity.go | 1 + cmd/list-dbs.go | 2 +- cmd/restore.go | 2 +- testdata/dump__key__ok.ct | 4 ++-- testdata/dump__value__ok.ct | 4 ++-- testdata/help__dump__ok.ct | 10 ++-------- testdata/help__list-dbs__ok.ct | 4 ++-- testdata/help__remove-store__ok.ct | 4 ++-- testdata/help__restore__ok.ct | 10 ++-------- testdata/restore__drop__ok.ct | 2 +- testdata/restore__key__ok.ct | 4 ++-- testdata/restore__merge__ok.ct | 2 +- testdata/restore__stdin__ok.ct | 2 +- 15 files changed, 22 insertions(+), 33 deletions(-) diff --git a/cmd/del-db.go b/cmd/del-db.go index 1a254da..427c8b9 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -35,7 +35,7 @@ import ( var delStoreCmd = &cobra.Command{ Use: "remove-store STORE", Short: "Delete a store", - Aliases: []string{"rm-store", "rms"}, + Aliases: []string{"rms"}, Args: cobra.ExactArgs(1), RunE: delStore, SilenceUsage: true, diff --git a/cmd/export.go b/cmd/export.go index b1a0898..35f0a02 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -29,7 +29,7 @@ import ( var exportCmd = &cobra.Command{ Use: "export [STORE]", Short: "Export store as NDJSON (alias for list --format ndjson)", - Aliases: []string{"dump"}, + Aliases: []string{}, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { listFormat = "ndjson" diff --git a/cmd/identity.go b/cmd/identity.go index a7a4ccf..4e13fcc 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -8,6 +8,7 @@ import ( var identityCmd = &cobra.Command{ Use: "identity", + Aliases: []string{"id"}, Short: "Show or create the age encryption identity", Args: cobra.NoArgs, RunE: identityRun, diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index 2ff7a22..7c7ad78 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -31,7 +31,7 @@ import ( var listStoresCmd = &cobra.Command{ Use: "list-stores", Short: "List all stores", - Aliases: []string{"ls-stores", "lss"}, + Aliases: []string{"lss"}, Args: cobra.NoArgs, RunE: listStores, SilenceUsage: true, diff --git a/cmd/restore.go b/cmd/restore.go index 6830ee1..bc678de 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -38,7 +38,7 @@ import ( var restoreCmd = &cobra.Command{ Use: "import [STORE]", Short: "Restore key/value pairs from an NDJSON dump", - Aliases: []string{"restore"}, + Aliases: []string{}, Args: cobra.MaximumNArgs(1), RunE: restore, SilenceUsage: true, diff --git a/testdata/dump__key__ok.ct b/testdata/dump__key__ok.ct index 10569d2..f00cbff 100644 --- a/testdata/dump__key__ok.ct +++ b/testdata/dump__key__ok.ct @@ -1,8 +1,8 @@ $ pda set a1 1 $ pda set a2 2 $ pda set b1 3 -$ pda dump --key "a*" +$ pda export --key "a*" {"key":"a1","value":"1","encoding":"text"} {"key":"a2","value":"2","encoding":"text"} -$ pda dump --key "c*" --> FAIL +$ pda export --key "c*" --> FAIL FAIL cannot ls '@default': no matches for key pattern 'c*' diff --git a/testdata/dump__value__ok.ct b/testdata/dump__value__ok.ct index bab0072..14bd72a 100644 --- a/testdata/dump__value__ok.ct +++ b/testdata/dump__value__ok.ct @@ -2,7 +2,7 @@ $ pda set url https://example.com $ fecho tmpval hello world $ pda set greeting < tmpval $ pda set number 42 -$ pda dump --value "**https**" +$ pda export --value "**https**" {"key":"url","value":"https://example.com","encoding":"text"} -$ pda dump --value "**world**" +$ pda export --value "**world**" {"key":"greeting","value":"hello world\n","encoding":"text"} diff --git a/testdata/help__dump__ok.ct b/testdata/help__dump__ok.ct index 626e89d..e22b81b 100644 --- a/testdata/help__dump__ok.ct +++ b/testdata/help__dump__ok.ct @@ -1,13 +1,10 @@ -$ pda help dump -$ pda dump --help +$ pda help export +$ pda export --help Export store as NDJSON (alias for list --format ndjson) Usage: pda export [STORE] [flags] -Aliases: - export, dump - Flags: -h, --help help for export -k, --key strings Filter keys with glob pattern (repeatable) @@ -17,9 +14,6 @@ Export store as NDJSON (alias for list --format ndjson) Usage: pda export [STORE] [flags] -Aliases: - export, dump - Flags: -h, --help help for export -k, --key strings Filter keys with glob pattern (repeatable) diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help__list-dbs__ok.ct index 24ba6b5..4781886 100644 --- a/testdata/help__list-dbs__ok.ct +++ b/testdata/help__list-dbs__ok.ct @@ -6,7 +6,7 @@ Usage: pda list-stores [flags] Aliases: - list-stores, ls-stores, lss + list-stores, lss Flags: -h, --help help for list-stores @@ -16,7 +16,7 @@ Usage: pda list-stores [flags] Aliases: - list-stores, ls-stores, lss + list-stores, lss Flags: -h, --help help for list-stores diff --git a/testdata/help__remove-store__ok.ct b/testdata/help__remove-store__ok.ct index 0b7b0a8..6eaf204 100644 --- a/testdata/help__remove-store__ok.ct +++ b/testdata/help__remove-store__ok.ct @@ -6,7 +6,7 @@ Usage: pda remove-store STORE [flags] Aliases: - remove-store, rm-store, rms + remove-store, rms Flags: -h, --help help for remove-store @@ -17,7 +17,7 @@ Usage: pda remove-store STORE [flags] Aliases: - remove-store, rm-store, rms + remove-store, rms Flags: -h, --help help for remove-store diff --git a/testdata/help__restore__ok.ct b/testdata/help__restore__ok.ct index 2bbadff..9fb102a 100644 --- a/testdata/help__restore__ok.ct +++ b/testdata/help__restore__ok.ct @@ -1,13 +1,10 @@ -$ pda help restore -$ pda restore --help +$ pda help import +$ pda import --help Restore key/value pairs from an NDJSON dump Usage: pda import [STORE] [flags] -Aliases: - import, restore - Flags: --drop Drop existing entries before restoring (full replace) -f, --file string Path to an NDJSON dump (defaults to stdin) @@ -19,9 +16,6 @@ Restore key/value pairs from an NDJSON dump Usage: pda import [STORE] [flags] -Aliases: - import, restore - Flags: --drop Drop existing entries before restoring (full replace) -f, --file string Path to an NDJSON dump (defaults to stdin) diff --git a/testdata/restore__drop__ok.ct b/testdata/restore__drop__ok.ct index 3ddadb2..5b52d5d 100644 --- a/testdata/restore__drop__ok.ct +++ b/testdata/restore__drop__ok.ct @@ -1,7 +1,7 @@ $ pda set existing keep-me $ pda set other also-keep $ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} -$ pda restore --drop --file dumpfile +$ pda import --drop --file dumpfile ok restored 1 entries into @default $ pda get new hello diff --git a/testdata/restore__key__ok.ct b/testdata/restore__key__ok.ct index 544d033..9fc3baf 100644 --- a/testdata/restore__key__ok.ct +++ b/testdata/restore__key__ok.ct @@ -3,7 +3,7 @@ $ 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 rm a1 a2 b1 -$ pda restore --key "a*" --file dumpfile +$ pda import --key "a*" --file dumpfile ok restored 2 entries into @default $ pda get a1 1 @@ -12,5 +12,5 @@ $ pda get a2 $ pda get b1 --> FAIL FAIL cannot get 'b1': no such key hint did you mean 'a1'? -$ pda restore --key "c*" --file dumpfile --> FAIL +$ pda import --key "c*" --file dumpfile --> FAIL FAIL cannot restore '@default': no matches for key pattern 'c*' diff --git a/testdata/restore__merge__ok.ct b/testdata/restore__merge__ok.ct index a8ac11a..4c81265 100644 --- a/testdata/restore__merge__ok.ct +++ b/testdata/restore__merge__ok.ct @@ -1,7 +1,7 @@ # 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 restore mrg --file dumpfile +$ pda import mrg --file dumpfile ok restored 2 entries into @mrg $ pda get existing@mrg updated diff --git a/testdata/restore__stdin__ok.ct b/testdata/restore__stdin__ok.ct index ec2552a..f120052 100644 --- a/testdata/restore__stdin__ok.ct +++ b/testdata/restore__stdin__ok.ct @@ -1,7 +1,7 @@ # Import from stdin preserves existing entries $ pda set existing@stn keep-me $ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} -$ pda restore stn < dumpfile +$ pda import stn < dumpfile ok restored 1 entries into @stn $ pda get existing@stn keep-me From 3c2a0129c00a8831c9898e234342008b8d4fa0c1 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 16:38:09 +0000 Subject: [PATCH 048/107] style: renames testdata so tests can actually be parsed at a glance --- ...__cross-store__ok.ct => cp-cross-store.ct} | 0 testdata/cp-encrypt.ct | 7 +++++++ ...{cp__err__missing.ct => cp-missing-err.ct} | 0 testdata/{cp__ok.ct => cp.ct} | 0 testdata/cp__encrypt__ok.ct | 7 ------- testdata/dump__key__ok.ct | 8 -------- testdata/dump__value__ok.ct | 8 -------- testdata/export-key-filter.ct | 8 ++++++++ testdata/export-value-filter.ct | 8 ++++++++ testdata/{export__ok.ct => export.ct} | 0 testdata/get-base64-run.ct | 4 ++++ testdata/get-base64.ct | 3 +++ ...invalid_db.ct => get-invalid-store-err.ct} | 0 ...h__any.ct => get-missing-all-flags-err.ct} | 0 ...et__missing__err.ct => get-missing-err.ct} | 0 testdata/get-run.ct | 6 ++++++ testdata/get.ct | 3 +++ testdata/get__ok.ct | 3 --- testdata/get__ok__with__binary.ct | 3 --- testdata/get__ok__with__binary_run.ct | 4 ---- testdata/get__ok__with__run.ct | 6 ------ .../{help__dump__ok.ct => help-export.ct} | 0 testdata/{help__get__ok.ct => help-get.ct} | 0 testdata/{help__help__ok.ct => help-help.ct} | 0 .../{help__restore__ok.ct => help-import.ct} | 0 ...p__list-dbs__ok.ct => help-list-stores.ct} | 0 testdata/{help__list__ok.ct => help-list.ct} | 0 ...move-store__ok.ct => help-remove-store.ct} | 0 .../{help__remove__ok.ct => help-remove.ct} | 0 testdata/{help__set__ok.ct => help-set.ct} | 0 testdata/{help__ok.ct => help.ct} | 0 testdata/import-drop.ct | 9 +++++++++ testdata/import-key-filter.ct | 16 +++++++++++++++ ...{restore__merge__ok.ct => import-merge.ct} | 0 ...{restore__stdin__ok.ct => import-stdin.ct} | 0 ...invalid__err.ct => invalid-command-err.ct} | 0 ...sed__err.ct => list-all-suppressed-err.ct} | 0 ..._format__csv__ok.ct => list-format-csv.ct} | 0 ...arkdown__ok.ct => list-format-markdown.ct} | 0 ...t__ndjson__ok.ct => list-format-ndjson.ct} | 0 ...nvalid_db.ct => list-invalid-store-err.ct} | 0 .../{list__key__ok.ct => list-key-filter.ct} | 0 ..._value__ok.ct => list-key-value-filter.ct} | 0 ...st__no-header__ok.ct => list-no-header.ct} | 0 .../{list__no-keys__ok.ct => list-no-keys.ct} | 0 .../{list__no-ttl__ok.ct => list-no-ttl.ct} | 0 ...st__no-values__ok.ct => list-no-values.ct} | 0 .../{list-stores__ok.ct => list-stores.ct} | 0 ...ist__value__ok.ct => list-value-filter.ct} | 0 ...ulti__ok.ct => list-value-multi-filter.ct} | 0 testdata/{multistore__ok.ct => multistore.ct} | 0 ...__cross-store__ok.ct => mv-cross-store.ct} | 0 testdata/mv-encrypt.ct | 7 +++++++ ...{mv__err__missing.ct => mv-missing-err.ct} | 0 testdata/{mv__ok.ct => mv.ct} | 0 testdata/mv__encrypt__ok.ct | 7 ------- testdata/remove-dedupe.ct | 12 +++++++++++ testdata/remove-key-glob.ct | 11 ++++++++++ testdata/remove-key-mixed.ct | 10 ++++++++++ testdata/remove-multiple.ct | 7 +++++++ ...alid_db.ct => remove-store-invalid-err.ct} | 0 testdata/remove.ct | 2 ++ testdata/remove__dedupe__ok.ct | 20 ------------------- testdata/remove__key__mixed__ok.ct | 10 ---------- testdata/remove__key__ok.ct | 11 ---------- testdata/remove__multiple__ok.ct | 8 -------- testdata/remove__ok.ct | 2 -- testdata/restore__drop__ok.ct | 9 --------- testdata/restore__key__ok.ct | 16 --------------- testdata/{root__ok.ct => root.ct} | 0 testdata/set-encrypt-ttl.ct | 4 ++++ .../{set__encrypt__ok.ct => set-encrypt.ct} | 4 ++-- ..._invalid-ttl.ct => set-invalid-ttl-err.ct} | 0 testdata/{set__stdin__ok.ct => set-stdin.ct} | 2 +- testdata/set-ttl.ct | 1 + testdata/set.ct | 1 + testdata/set__encrypt__ok__with__ttl.ct | 4 ---- testdata/set__ok.ct | 1 - testdata/set__ok__with__ttl.ct | 1 - ...ate__enum__err.ct => template-enum-err.ct} | 0 ...emplate__ok.ct => template-no-template.ct} | 0 ...equire__err.ct => template-require-err.ct} | 0 testdata/{template__ok.ct => template.ct} | 0 83 files changed, 122 insertions(+), 131 deletions(-) rename testdata/{cp__cross-store__ok.ct => cp-cross-store.ct} (100%) create mode 100644 testdata/cp-encrypt.ct rename testdata/{cp__err__missing.ct => cp-missing-err.ct} (100%) rename testdata/{cp__ok.ct => cp.ct} (100%) delete mode 100644 testdata/cp__encrypt__ok.ct delete mode 100644 testdata/dump__key__ok.ct delete mode 100644 testdata/dump__value__ok.ct create mode 100644 testdata/export-key-filter.ct create mode 100644 testdata/export-value-filter.ct rename testdata/{export__ok.ct => export.ct} (100%) create mode 100644 testdata/get-base64-run.ct create mode 100644 testdata/get-base64.ct rename testdata/{get__err__with__invalid_db.ct => get-invalid-store-err.ct} (100%) rename testdata/{get__missing__err__with__any.ct => get-missing-all-flags-err.ct} (100%) rename testdata/{get__missing__err.ct => get-missing-err.ct} (100%) create mode 100644 testdata/get-run.ct create mode 100644 testdata/get.ct delete mode 100644 testdata/get__ok.ct delete mode 100644 testdata/get__ok__with__binary.ct delete mode 100644 testdata/get__ok__with__binary_run.ct delete mode 100644 testdata/get__ok__with__run.ct rename testdata/{help__dump__ok.ct => help-export.ct} (100%) rename testdata/{help__get__ok.ct => help-get.ct} (100%) rename testdata/{help__help__ok.ct => help-help.ct} (100%) rename testdata/{help__restore__ok.ct => help-import.ct} (100%) rename testdata/{help__list-dbs__ok.ct => help-list-stores.ct} (100%) rename testdata/{help__list__ok.ct => help-list.ct} (100%) rename testdata/{help__remove-store__ok.ct => help-remove-store.ct} (100%) rename testdata/{help__remove__ok.ct => help-remove.ct} (100%) rename testdata/{help__set__ok.ct => help-set.ct} (100%) rename testdata/{help__ok.ct => help.ct} (100%) create mode 100644 testdata/import-drop.ct create mode 100644 testdata/import-key-filter.ct rename testdata/{restore__merge__ok.ct => import-merge.ct} (100%) rename testdata/{restore__stdin__ok.ct => import-stdin.ct} (100%) rename testdata/{invalid__err.ct => invalid-command-err.ct} (100%) rename testdata/{list__all-suppressed__err.ct => list-all-suppressed-err.ct} (100%) rename testdata/{list__format__csv__ok.ct => list-format-csv.ct} (100%) rename testdata/{list__format__markdown__ok.ct => list-format-markdown.ct} (100%) rename testdata/{list__format__ndjson__ok.ct => list-format-ndjson.ct} (100%) rename testdata/{list__err__with__invalid_db.ct => list-invalid-store-err.ct} (100%) rename testdata/{list__key__ok.ct => list-key-filter.ct} (100%) rename testdata/{list__key__value__ok.ct => list-key-value-filter.ct} (100%) rename testdata/{list__no-header__ok.ct => list-no-header.ct} (100%) rename testdata/{list__no-keys__ok.ct => list-no-keys.ct} (100%) rename testdata/{list__no-ttl__ok.ct => list-no-ttl.ct} (100%) rename testdata/{list__no-values__ok.ct => list-no-values.ct} (100%) rename testdata/{list-stores__ok.ct => list-stores.ct} (100%) rename testdata/{list__value__ok.ct => list-value-filter.ct} (100%) rename testdata/{list__value__multi__ok.ct => list-value-multi-filter.ct} (100%) rename testdata/{multistore__ok.ct => multistore.ct} (100%) rename testdata/{mv__cross-store__ok.ct => mv-cross-store.ct} (100%) create mode 100644 testdata/mv-encrypt.ct rename testdata/{mv__err__missing.ct => mv-missing-err.ct} (100%) rename testdata/{mv__ok.ct => mv.ct} (100%) delete mode 100644 testdata/mv__encrypt__ok.ct create mode 100644 testdata/remove-dedupe.ct create mode 100644 testdata/remove-key-glob.ct create mode 100644 testdata/remove-key-mixed.ct create mode 100644 testdata/remove-multiple.ct rename testdata/{remove-store__err__with__invalid_db.ct => remove-store-invalid-err.ct} (100%) create mode 100644 testdata/remove.ct delete mode 100644 testdata/remove__dedupe__ok.ct delete mode 100644 testdata/remove__key__mixed__ok.ct delete mode 100644 testdata/remove__key__ok.ct delete mode 100644 testdata/remove__multiple__ok.ct delete mode 100644 testdata/remove__ok.ct delete mode 100644 testdata/restore__drop__ok.ct delete mode 100644 testdata/restore__key__ok.ct rename testdata/{root__ok.ct => root.ct} (100%) create mode 100644 testdata/set-encrypt-ttl.ct rename testdata/{set__encrypt__ok.ct => set-encrypt.ct} (55%) rename testdata/{set__err__with__invalid-ttl.ct => set-invalid-ttl-err.ct} (100%) rename testdata/{set__stdin__ok.ct => set-stdin.ct} (51%) create mode 100644 testdata/set-ttl.ct create mode 100644 testdata/set.ct delete mode 100644 testdata/set__encrypt__ok__with__ttl.ct delete mode 100644 testdata/set__ok.ct delete mode 100644 testdata/set__ok__with__ttl.ct rename testdata/{template__enum__err.ct => template-enum-err.ct} (100%) rename testdata/{template__no-template__ok.ct => template-no-template.ct} (100%) rename testdata/{template__require__err.ct => template-require-err.ct} (100%) rename testdata/{template__ok.ct => template.ct} (100%) diff --git a/testdata/cp__cross-store__ok.ct b/testdata/cp-cross-store.ct similarity index 100% rename from testdata/cp__cross-store__ok.ct rename to testdata/cp-cross-store.ct diff --git a/testdata/cp-encrypt.ct b/testdata/cp-encrypt.ct new file mode 100644 index 0000000..f6c435f --- /dev/null +++ b/testdata/cp-encrypt.ct @@ -0,0 +1,7 @@ +# Copy an encrypted key; both keys should decrypt. +$ pda set --encrypt secret-key@cpe hidden-value +$ pda cp secret-key@cpe copied-key@cpe +$ pda get secret-key@cpe +hidden-value +$ pda get copied-key@cpe +hidden-value diff --git a/testdata/cp__err__missing.ct b/testdata/cp-missing-err.ct similarity index 100% rename from testdata/cp__err__missing.ct rename to testdata/cp-missing-err.ct diff --git a/testdata/cp__ok.ct b/testdata/cp.ct similarity index 100% rename from testdata/cp__ok.ct rename to testdata/cp.ct diff --git a/testdata/cp__encrypt__ok.ct b/testdata/cp__encrypt__ok.ct deleted file mode 100644 index b172e6c..0000000 --- a/testdata/cp__encrypt__ok.ct +++ /dev/null @@ -1,7 +0,0 @@ -# Copy an encrypted key; both keys should decrypt. -$ pda set --encrypt secret-key hidden-value -$ pda cp secret-key copied-key -$ pda get secret-key -hidden-value -$ pda get copied-key -hidden-value diff --git a/testdata/dump__key__ok.ct b/testdata/dump__key__ok.ct deleted file mode 100644 index f00cbff..0000000 --- a/testdata/dump__key__ok.ct +++ /dev/null @@ -1,8 +0,0 @@ -$ pda set a1 1 -$ pda set a2 2 -$ pda set b1 3 -$ pda export --key "a*" -{"key":"a1","value":"1","encoding":"text"} -{"key":"a2","value":"2","encoding":"text"} -$ pda export --key "c*" --> FAIL -FAIL cannot ls '@default': no matches for key pattern 'c*' diff --git a/testdata/dump__value__ok.ct b/testdata/dump__value__ok.ct deleted file mode 100644 index 14bd72a..0000000 --- a/testdata/dump__value__ok.ct +++ /dev/null @@ -1,8 +0,0 @@ -$ pda set url https://example.com -$ fecho tmpval hello world -$ pda set greeting < tmpval -$ pda set number 42 -$ pda export --value "**https**" -{"key":"url","value":"https://example.com","encoding":"text"} -$ pda export --value "**world**" -{"key":"greeting","value":"hello world\n","encoding":"text"} diff --git a/testdata/export-key-filter.ct b/testdata/export-key-filter.ct new file mode 100644 index 0000000..78a3452 --- /dev/null +++ b/testdata/export-key-filter.ct @@ -0,0 +1,8 @@ +$ 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"} +{"key":"a2","value":"2","encoding":"text"} +$ 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 new file mode 100644 index 0000000..7889003 --- /dev/null +++ b/testdata/export-value-filter.ct @@ -0,0 +1,8 @@ +$ 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"} +$ pda export evf --value "**world**" +{"key":"greeting","value":"hello world\n","encoding":"text"} diff --git a/testdata/export__ok.ct b/testdata/export.ct similarity index 100% rename from testdata/export__ok.ct rename to testdata/export.ct diff --git a/testdata/get-base64-run.ct b/testdata/get-base64-run.ct new file mode 100644 index 0000000..a086bb9 --- /dev/null +++ b/testdata/get-base64-run.ct @@ -0,0 +1,4 @@ +$ 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 new file mode 100644 index 0000000..fdefa8e --- /dev/null +++ b/testdata/get-base64.ct @@ -0,0 +1,3 @@ +$ pda set a@gb b +$ pda get a@gb --base64 +b diff --git a/testdata/get__err__with__invalid_db.ct b/testdata/get-invalid-store-err.ct similarity index 100% rename from testdata/get__err__with__invalid_db.ct rename to testdata/get-invalid-store-err.ct diff --git a/testdata/get__missing__err__with__any.ct b/testdata/get-missing-all-flags-err.ct similarity index 100% rename from testdata/get__missing__err__with__any.ct rename to testdata/get-missing-all-flags-err.ct diff --git a/testdata/get__missing__err.ct b/testdata/get-missing-err.ct similarity index 100% rename from testdata/get__missing__err.ct rename to testdata/get-missing-err.ct diff --git a/testdata/get-run.ct b/testdata/get-run.ct new file mode 100644 index 0000000..22b3b4b --- /dev/null +++ b/testdata/get-run.ct @@ -0,0 +1,6 @@ +$ 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 new file mode 100644 index 0000000..4ce93b0 --- /dev/null +++ b/testdata/get.ct @@ -0,0 +1,3 @@ +$ pda set foo@g bar +$ pda get foo@g +bar diff --git a/testdata/get__ok.ct b/testdata/get__ok.ct deleted file mode 100644 index 2ba3573..0000000 --- a/testdata/get__ok.ct +++ /dev/null @@ -1,3 +0,0 @@ -$ pda set foo bar -$ pda get foo -bar diff --git a/testdata/get__ok__with__binary.ct b/testdata/get__ok__with__binary.ct deleted file mode 100644 index 67be970..0000000 --- a/testdata/get__ok__with__binary.ct +++ /dev/null @@ -1,3 +0,0 @@ -$ pda set a b -$ pda get a --base64 -b diff --git a/testdata/get__ok__with__binary_run.ct b/testdata/get__ok__with__binary_run.ct deleted file mode 100644 index bcc3bc5..0000000 --- a/testdata/get__ok__with__binary_run.ct +++ /dev/null @@ -1,4 +0,0 @@ -$ fecho cmd echo hello -$ pda set foo < cmd -$ pda get foo --base64 --run -hello diff --git a/testdata/get__ok__with__run.ct b/testdata/get__ok__with__run.ct deleted file mode 100644 index e74e0c1..0000000 --- a/testdata/get__ok__with__run.ct +++ /dev/null @@ -1,6 +0,0 @@ -$ fecho cmd echo hello -$ pda set a < cmd -$ pda get a -echo hello -$ pda get a --run -hello diff --git a/testdata/help__dump__ok.ct b/testdata/help-export.ct similarity index 100% rename from testdata/help__dump__ok.ct rename to testdata/help-export.ct diff --git a/testdata/help__get__ok.ct b/testdata/help-get.ct similarity index 100% rename from testdata/help__get__ok.ct rename to testdata/help-get.ct diff --git a/testdata/help__help__ok.ct b/testdata/help-help.ct similarity index 100% rename from testdata/help__help__ok.ct rename to testdata/help-help.ct diff --git a/testdata/help__restore__ok.ct b/testdata/help-import.ct similarity index 100% rename from testdata/help__restore__ok.ct rename to testdata/help-import.ct diff --git a/testdata/help__list-dbs__ok.ct b/testdata/help-list-stores.ct similarity index 100% rename from testdata/help__list-dbs__ok.ct rename to testdata/help-list-stores.ct diff --git a/testdata/help__list__ok.ct b/testdata/help-list.ct similarity index 100% rename from testdata/help__list__ok.ct rename to testdata/help-list.ct diff --git a/testdata/help__remove-store__ok.ct b/testdata/help-remove-store.ct similarity index 100% rename from testdata/help__remove-store__ok.ct rename to testdata/help-remove-store.ct diff --git a/testdata/help__remove__ok.ct b/testdata/help-remove.ct similarity index 100% rename from testdata/help__remove__ok.ct rename to testdata/help-remove.ct diff --git a/testdata/help__set__ok.ct b/testdata/help-set.ct similarity index 100% rename from testdata/help__set__ok.ct rename to testdata/help-set.ct diff --git a/testdata/help__ok.ct b/testdata/help.ct similarity index 100% rename from testdata/help__ok.ct rename to testdata/help.ct diff --git a/testdata/import-drop.ct b/testdata/import-drop.ct new file mode 100644 index 0000000..3422eab --- /dev/null +++ b/testdata/import-drop.ct @@ -0,0 +1,9 @@ +$ 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 new file mode 100644 index 0000000..5a5cffb --- /dev/null +++ b/testdata/import-key-filter.ct @@ -0,0 +1,16 @@ +$ 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/restore__merge__ok.ct b/testdata/import-merge.ct similarity index 100% rename from testdata/restore__merge__ok.ct rename to testdata/import-merge.ct diff --git a/testdata/restore__stdin__ok.ct b/testdata/import-stdin.ct similarity index 100% rename from testdata/restore__stdin__ok.ct rename to testdata/import-stdin.ct diff --git a/testdata/invalid__err.ct b/testdata/invalid-command-err.ct similarity index 100% rename from testdata/invalid__err.ct rename to testdata/invalid-command-err.ct diff --git a/testdata/list__all-suppressed__err.ct b/testdata/list-all-suppressed-err.ct similarity index 100% rename from testdata/list__all-suppressed__err.ct rename to testdata/list-all-suppressed-err.ct diff --git a/testdata/list__format__csv__ok.ct b/testdata/list-format-csv.ct similarity index 100% rename from testdata/list__format__csv__ok.ct rename to testdata/list-format-csv.ct diff --git a/testdata/list__format__markdown__ok.ct b/testdata/list-format-markdown.ct similarity index 100% rename from testdata/list__format__markdown__ok.ct rename to testdata/list-format-markdown.ct diff --git a/testdata/list__format__ndjson__ok.ct b/testdata/list-format-ndjson.ct similarity index 100% rename from testdata/list__format__ndjson__ok.ct rename to testdata/list-format-ndjson.ct diff --git a/testdata/list__err__with__invalid_db.ct b/testdata/list-invalid-store-err.ct similarity index 100% rename from testdata/list__err__with__invalid_db.ct rename to testdata/list-invalid-store-err.ct diff --git a/testdata/list__key__ok.ct b/testdata/list-key-filter.ct similarity index 100% rename from testdata/list__key__ok.ct rename to testdata/list-key-filter.ct diff --git a/testdata/list__key__value__ok.ct b/testdata/list-key-value-filter.ct similarity index 100% rename from testdata/list__key__value__ok.ct rename to testdata/list-key-value-filter.ct diff --git a/testdata/list__no-header__ok.ct b/testdata/list-no-header.ct similarity index 100% rename from testdata/list__no-header__ok.ct rename to testdata/list-no-header.ct diff --git a/testdata/list__no-keys__ok.ct b/testdata/list-no-keys.ct similarity index 100% rename from testdata/list__no-keys__ok.ct rename to testdata/list-no-keys.ct diff --git a/testdata/list__no-ttl__ok.ct b/testdata/list-no-ttl.ct similarity index 100% rename from testdata/list__no-ttl__ok.ct rename to testdata/list-no-ttl.ct diff --git a/testdata/list__no-values__ok.ct b/testdata/list-no-values.ct similarity index 100% rename from testdata/list__no-values__ok.ct rename to testdata/list-no-values.ct diff --git a/testdata/list-stores__ok.ct b/testdata/list-stores.ct similarity index 100% rename from testdata/list-stores__ok.ct rename to testdata/list-stores.ct diff --git a/testdata/list__value__ok.ct b/testdata/list-value-filter.ct similarity index 100% rename from testdata/list__value__ok.ct rename to testdata/list-value-filter.ct diff --git a/testdata/list__value__multi__ok.ct b/testdata/list-value-multi-filter.ct similarity index 100% rename from testdata/list__value__multi__ok.ct rename to testdata/list-value-multi-filter.ct diff --git a/testdata/multistore__ok.ct b/testdata/multistore.ct similarity index 100% rename from testdata/multistore__ok.ct rename to testdata/multistore.ct diff --git a/testdata/mv__cross-store__ok.ct b/testdata/mv-cross-store.ct similarity index 100% rename from testdata/mv__cross-store__ok.ct rename to testdata/mv-cross-store.ct diff --git a/testdata/mv-encrypt.ct b/testdata/mv-encrypt.ct new file mode 100644 index 0000000..10a7feb --- /dev/null +++ b/testdata/mv-encrypt.ct @@ -0,0 +1,7 @@ +# 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 +$ 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__err__missing.ct b/testdata/mv-missing-err.ct similarity index 100% rename from testdata/mv__err__missing.ct rename to testdata/mv-missing-err.ct diff --git a/testdata/mv__ok.ct b/testdata/mv.ct similarity index 100% rename from testdata/mv__ok.ct rename to testdata/mv.ct diff --git a/testdata/mv__encrypt__ok.ct b/testdata/mv__encrypt__ok.ct deleted file mode 100644 index a0b641f..0000000 --- a/testdata/mv__encrypt__ok.ct +++ /dev/null @@ -1,7 +0,0 @@ -# Move an encrypted key; the new key should still decrypt. -$ pda set --encrypt secret-key hidden-value -$ pda mv secret-key moved-key -$ pda get moved-key -hidden-value -$ pda get secret-key --> FAIL -FAIL cannot get 'secret-key': no such key diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct new file mode 100644 index 0000000..c24ec34 --- /dev/null +++ b/testdata/remove-dedupe.ct @@ -0,0 +1,12 @@ +# Remove deduplicates positional args and glob matches +$ pda set foo@rdd 1 +$ pda set bar@rdd 2 +$ pda ls rdd --format tsv +Key Value TTL +bar 2 no expiry +foo 1 no expiry +$ pda rm foo@rdd --key "*@rdd" +$ 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 new file mode 100644 index 0000000..be5b2cf --- /dev/null +++ b/testdata/remove-key-glob.ct @@ -0,0 +1,11 @@ +$ pda set a1@rkg 1 +$ pda set a2@rkg 2 +$ pda set b1@rkg 3 +$ pda rm --key "a*@rkg" +$ 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 new file mode 100644 index 0000000..9bfa2c6 --- /dev/null +++ b/testdata/remove-key-mixed.ct @@ -0,0 +1,10 @@ +$ pda set foo@rkm 1 +$ pda set bar1@rkm 2 +$ pda set bar2@rkm 3 +$ pda rm foo@rkm --key "bar*@rkm" +$ 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 new file mode 100644 index 0000000..e54d533 --- /dev/null +++ b/testdata/remove-multiple.ct @@ -0,0 +1,7 @@ +$ 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-store__err__with__invalid_db.ct b/testdata/remove-store-invalid-err.ct similarity index 100% rename from testdata/remove-store__err__with__invalid_db.ct rename to testdata/remove-store-invalid-err.ct diff --git a/testdata/remove.ct b/testdata/remove.ct new file mode 100644 index 0000000..1f1eecc --- /dev/null +++ b/testdata/remove.ct @@ -0,0 +1,2 @@ +$ pda set a@rm b +$ pda rm a@rm diff --git a/testdata/remove__dedupe__ok.ct b/testdata/remove__dedupe__ok.ct deleted file mode 100644 index afa50a7..0000000 --- a/testdata/remove__dedupe__ok.ct +++ /dev/null @@ -1,20 +0,0 @@ -$ pda set foo 1 -$ pda set bar 2 -$ pda ls -Key Value TTL -a echo hello (..1 more chars) no expiry -a1 1 no expiry -a2 2 no expiry -b1 3 no expiry -bar 2 no expiry -copied-key hidden-value no expiry -foo 1 no expiry -greeting hello world (..1 more chars) no expiry -moved-key hidden-value no expiry -number 42 no expiry -url https://example.com no expiry -$ pda rm foo --key "*" -$ pda get bar --> FAIL -FAIL cannot get 'bar': no such key -$ pda get foo --> FAIL -FAIL cannot get 'foo': no such key diff --git a/testdata/remove__key__mixed__ok.ct b/testdata/remove__key__mixed__ok.ct deleted file mode 100644 index 20a870b..0000000 --- a/testdata/remove__key__mixed__ok.ct +++ /dev/null @@ -1,10 +0,0 @@ -$ pda set foo 1 -$ pda set bar1 2 -$ pda set bar2 3 -$ pda rm foo --key "bar*" -$ pda get foo --> FAIL -FAIL cannot get 'foo': no such key -$ pda get bar1 --> FAIL -FAIL cannot get 'bar1': no such key -$ pda get bar2 --> FAIL -FAIL cannot get 'bar2': no such key diff --git a/testdata/remove__key__ok.ct b/testdata/remove__key__ok.ct deleted file mode 100644 index ce7fb2a..0000000 --- a/testdata/remove__key__ok.ct +++ /dev/null @@ -1,11 +0,0 @@ -$ pda set a1 1 -$ pda set a2 2 -$ pda set b1 3 -$ pda rm --key "a*" -$ pda get a1 --> FAIL -FAIL cannot get 'a1': no such key -hint did you mean 'b1'? -$ pda get a2 --> FAIL -FAIL cannot get 'a2': no such key -$ pda get b1 -3 diff --git a/testdata/remove__multiple__ok.ct b/testdata/remove__multiple__ok.ct deleted file mode 100644 index a46876a..0000000 --- a/testdata/remove__multiple__ok.ct +++ /dev/null @@ -1,8 +0,0 @@ -$ pda set a 1 -$ pda set b 2 -$ pda rm a b -$ pda get a --> FAIL -FAIL cannot get 'a': no such key -$ pda get b --> FAIL -FAIL cannot get 'b': no such key -hint did you mean 'b1'? diff --git a/testdata/remove__ok.ct b/testdata/remove__ok.ct deleted file mode 100644 index fa22746..0000000 --- a/testdata/remove__ok.ct +++ /dev/null @@ -1,2 +0,0 @@ -$ pda set a b -$ pda rm a diff --git a/testdata/restore__drop__ok.ct b/testdata/restore__drop__ok.ct deleted file mode 100644 index 5b52d5d..0000000 --- a/testdata/restore__drop__ok.ct +++ /dev/null @@ -1,9 +0,0 @@ -$ pda set existing keep-me -$ pda set other also-keep -$ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} -$ pda import --drop --file dumpfile - ok restored 1 entries into @default -$ pda get new -hello -$ pda get existing --> FAIL -FAIL cannot get 'existing': no such key diff --git a/testdata/restore__key__ok.ct b/testdata/restore__key__ok.ct deleted file mode 100644 index 9fc3baf..0000000 --- a/testdata/restore__key__ok.ct +++ /dev/null @@ -1,16 +0,0 @@ -$ 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 rm a1 a2 b1 -$ pda import --key "a*" --file dumpfile - ok restored 2 entries into @default -$ pda get a1 -1 -$ pda get a2 -2 -$ pda get b1 --> FAIL -FAIL cannot get 'b1': no such key -hint did you mean 'a1'? -$ pda import --key "c*" --file dumpfile --> FAIL -FAIL cannot restore '@default': no matches for key pattern 'c*' diff --git a/testdata/root__ok.ct b/testdata/root.ct similarity index 100% rename from testdata/root__ok.ct rename to testdata/root.ct diff --git a/testdata/set-encrypt-ttl.ct b/testdata/set-encrypt-ttl.ct new file mode 100644 index 0000000..18fc9a8 --- /dev/null +++ b/testdata/set-encrypt-ttl.ct @@ -0,0 +1,4 @@ +# 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__ok.ct b/testdata/set-encrypt.ct similarity index 55% rename from testdata/set__encrypt__ok.ct rename to testdata/set-encrypt.ct index 1796daf..0ece430 100644 --- a/testdata/set__encrypt__ok.ct +++ b/testdata/set-encrypt.ct @@ -1,4 +1,4 @@ # Set an encrypted key, then retrieve it (transparent decryption). -$ pda set --encrypt api-key sk-test-123 -$ pda get api-key +$ pda set --encrypt api-key@se sk-test-123 +$ pda get api-key@se sk-test-123 diff --git a/testdata/set__err__with__invalid-ttl.ct b/testdata/set-invalid-ttl-err.ct similarity index 100% rename from testdata/set__err__with__invalid-ttl.ct rename to testdata/set-invalid-ttl-err.ct diff --git a/testdata/set__stdin__ok.ct b/testdata/set-stdin.ct similarity index 51% rename from testdata/set__stdin__ok.ct rename to testdata/set-stdin.ct index 86d11ba..3e9c843 100644 --- a/testdata/set__stdin__ok.ct +++ b/testdata/set-stdin.ct @@ -1,2 +1,2 @@ $ fecho cmd hello world -$ pda set foo < cmd +$ pda set foo@ss < cmd diff --git a/testdata/set-ttl.ct b/testdata/set-ttl.ct new file mode 100644 index 0000000..0533744 --- /dev/null +++ b/testdata/set-ttl.ct @@ -0,0 +1 @@ +$ pda set a@st b --ttl 30m diff --git a/testdata/set.ct b/testdata/set.ct new file mode 100644 index 0000000..8e6bdd7 --- /dev/null +++ b/testdata/set.ct @@ -0,0 +1 @@ +$ pda set a@s b diff --git a/testdata/set__encrypt__ok__with__ttl.ct b/testdata/set__encrypt__ok__with__ttl.ct deleted file mode 100644 index c1af7f1..0000000 --- a/testdata/set__encrypt__ok__with__ttl.ct +++ /dev/null @@ -1,4 +0,0 @@ -# Set an encrypted key with TTL, then retrieve it. -$ pda set --encrypt --ttl 1h api-key sk-ttl-test -$ pda get api-key -sk-ttl-test diff --git a/testdata/set__ok.ct b/testdata/set__ok.ct deleted file mode 100644 index d42cee6..0000000 --- a/testdata/set__ok.ct +++ /dev/null @@ -1 +0,0 @@ -$ pda set a b diff --git a/testdata/set__ok__with__ttl.ct b/testdata/set__ok__with__ttl.ct deleted file mode 100644 index ba410d8..0000000 --- a/testdata/set__ok__with__ttl.ct +++ /dev/null @@ -1 +0,0 @@ -$ pda set a b --ttl 30m diff --git a/testdata/template__enum__err.ct b/testdata/template-enum-err.ct similarity index 100% rename from testdata/template__enum__err.ct rename to testdata/template-enum-err.ct diff --git a/testdata/template__no-template__ok.ct b/testdata/template-no-template.ct similarity index 100% rename from testdata/template__no-template__ok.ct rename to testdata/template-no-template.ct diff --git a/testdata/template__require__err.ct b/testdata/template-require-err.ct similarity index 100% rename from testdata/template__require__err.ct rename to testdata/template-require-err.ct diff --git a/testdata/template__ok.ct b/testdata/template.ct similarity index 100% rename from testdata/template__ok.ct rename to testdata/template.ct From e04bcfb3063174b9e219348e652becd9d7ada6cc Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:24:32 +0000 Subject: [PATCH 049/107] fix: artifacts in comments from copying the commands over initially --- cmd/del.go | 2 +- cmd/list-dbs.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/del.go b/cmd/del.go index 62d23ee..a3f0da3 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -30,7 +30,7 @@ import ( "github.com/spf13/cobra" ) -// delCmd represents the set command +// delCmd represents the remove command var delCmd = &cobra.Command{ Use: "remove KEY[@STORE] [KEY[@STORE] ...]", Short: "Delete one or more keys", diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index 7c7ad78..9e897b8 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" ) -// delCmd represents the set command +// listStoresCmd represents the list-stores command var listStoresCmd = &cobra.Command{ Use: "list-stores", Short: "List all stores", From ad98a1e6c4bf9685790746dbd7250af231e536fb Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:24:55 +0000 Subject: [PATCH 050/107] feat(list): adds --count flag for returning a count of matches --- README.md | 6 ++++++ cmd/list.go | 7 +++++++ testdata/help-list.ct | 2 ++ testdata/list-count.ct | 9 +++++++++ 4 files changed, 24 insertions(+) create mode 100644 testdata/list-count.ct diff --git a/README.md b/README.md index 12fbf66..6a2df64 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,12 @@ pda ls --format csv # dogs,four legged mammals,no expiry # Or TSV, or Markdown, or HTML. + +# Just the count of entries. +pda ls --count +# 2 +pda ls --count --key "d*" +# 1 ```

diff --git a/cmd/list.go b/cmd/list.go index 348879d..0cebe64 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -58,6 +58,7 @@ func (e *formatEnum) Type() string { return "format" } var ( listBase64 bool + listCount bool listNoKeys bool listNoValues bool listNoTTL bool @@ -161,6 +162,11 @@ func list(cmd *cobra.Command, args []string) error { } } + if listCount { + fmt.Fprintln(cmd.OutOrStdout(), len(filtered)) + return nil + } + if (len(matchers) > 0 || len(valueMatchers) > 0) && len(filtered) == 0 { switch { case len(matchers) > 0 && len(valueMatchers) > 0: @@ -473,6 +479,7 @@ func renderTable(tw table.Writer) { func init() { listCmd.Flags().BoolVarP(&listBase64, "base64", "b", false, "view binary data as base64") + listCmd.Flags().BoolVarP(&listCount, "count", "c", false, "print only the count of matching entries") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVar(&listNoTTL, "no-ttl", false, "suppress the TTL column") diff --git a/testdata/help-list.ct b/testdata/help-list.ct index 5e817c7..ce888b0 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -10,6 +10,7 @@ Aliases: Flags: -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) (default table) -f, --full show full values without truncation -h, --help help for list @@ -29,6 +30,7 @@ Aliases: Flags: -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) (default table) -f, --full show full values without truncation -h, --help help for list diff --git a/testdata/list-count.ct b/testdata/list-count.ct new file mode 100644 index 0000000..988bdd9 --- /dev/null +++ b/testdata/list-count.ct @@ -0,0 +1,9 @@ +$ 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 From 6e1af5ba2825a1528b67167e3d145d85a9396e05 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:36:49 +0000 Subject: [PATCH 051/107] feat(get): adds --exists flag for checking existence of a key --- README.md | 3 +++ cmd/get.go | 10 ++++++++++ testdata/get-exists.ct | 3 +++ testdata/help-get.ct | 2 ++ 4 files changed, 18 insertions(+) create mode 100644 testdata/get-exists.ct diff --git a/README.md b/README.md index 6a2df64..1a1ed3e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ pda get name # Or run it directly. pda run name # same as: pda get name --run + +# Check if a key exists (exit 0 if found, exit 1 if not). +pda get name --exists ```

diff --git a/cmd/get.go b/cmd/get.go index 54c3b7c..116404f 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -87,6 +87,15 @@ func get(cmd *cobra.Command, args []string) error { 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 { @@ -238,6 +247,7 @@ 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().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") diff --git a/testdata/get-exists.ct b/testdata/get-exists.ct new file mode 100644 index 0000000..a975b42 --- /dev/null +++ b/testdata/get-exists.ct @@ -0,0 +1,3 @@ +$ pda set found@ge "hello" +$ pda get found@ge --exists +$ pda get missing@ge --exists --> FAIL diff --git a/testdata/help-get.ct b/testdata/help-get.ct index c429da8..3fa513d 100644 --- a/testdata/help-get.ct +++ b/testdata/help-get.ct @@ -17,6 +17,7 @@ Aliases: 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 @@ -37,6 +38,7 @@ Aliases: 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 From ac847f34ca4f14faa32a37044ec9d474b492fd2b Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:38:36 +0000 Subject: [PATCH 052/107] feat(set): adds --safe flag for preventing accidental overwrites --- README.md | 6 ++++++ cmd/set.go | 9 +++++++++ testdata/help-set.ct | 2 ++ testdata/set-safe.ct | 9 +++++++++ 4 files changed, 26 insertions(+) create mode 100644 testdata/set-safe.ct diff --git a/README.md b/README.md index 1a1ed3e..7042449 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,12 @@ pda set name "Alice" echo "Alice" | pda set name cat dogs.txt | pda set dogs pda set kitty < cat.png + +# --safe to skip if the key already exists. +pda set name "Alice" --safe +pda set name "Bob" --safe +pda get name +# Alice ```

diff --git a/cmd/set.go b/cmd/set.go index 0684328..2fe7558 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -62,6 +62,10 @@ 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") @@ -116,6 +120,10 @@ func set(cmd *cobra.Command, args []string) error { idx := findEntry(entries, spec.Key) + if safe && idx >= 0 { + 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()) @@ -160,4 +168,5 @@ func init() { 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") } diff --git a/testdata/help-set.ct b/testdata/help-set.ct index 34cd7d6..2f6c4f3 100644 --- a/testdata/help-set.ct +++ b/testdata/help-set.ct @@ -24,6 +24,7 @@ Flags: -e, --encrypt Encrypt the value at rest using age -h, --help help for set -i, --interactive Prompt before overwriting an existing key + --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. @@ -49,4 +50,5 @@ Flags: -e, --encrypt Encrypt the value at rest using age -h, --help help for set -i, --interactive Prompt before overwriting an existing key + --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/set-safe.ct b/testdata/set-safe.ct new file mode 100644 index 0000000..7b641e5 --- /dev/null +++ b/testdata/set-safe.ct @@ -0,0 +1,9 @@ +$ pda set key@ss "original" --safe +$ pda get key@ss +"original" +$ pda set key@ss "overwritten" --safe +$ pda get key@ss +"original" +$ pda set newkey@ss "fresh" --safe +$ pda get newkey@ss +"fresh" From cf7dbf5bee89af1b755f9b930dc8183fe26d07cf Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:49:02 +0000 Subject: [PATCH 053/107] feat(sync): adds --message flag for manual commit message --- README.md | 3 +++ cmd/sync.go | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7042449..8eabc32 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,9 @@ If you're ahead of your Git repo, syncing will add your changes, commit them, an ```bash # Sync with Git pda sync + +# With a custom commit message. +pda sync -m "added production credentials" ``` `pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. diff --git a/cmd/sync.go b/cmd/sync.go index 510a5dd..de0c233 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -34,15 +34,17 @@ var syncCmd = &cobra.Command{ Short: "Manually sync your stores with Git", SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return sync(true) + msg, _ := cmd.Flags().GetString("message") + return sync(true, msg) }, } func init() { + syncCmd.Flags().StringP("message", "m", "", "Custom commit message (defaults to timestamp)") rootCmd.AddCommand(syncCmd) } -func sync(manual bool) error { +func sync(manual bool, customMsg string) error { repoDir, err := ensureVCSInitialized() if err != nil { return err @@ -62,7 +64,13 @@ func sync(manual bool) error { return err } if changed { - msg := fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) + msg := customMsg + if msg == "" { + msg = fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) + if manual { + printHint("use -m to set a custom commit message") + } + } if err := runGit(repoDir, "commit", "-m", msg); err != nil { return err } @@ -109,6 +117,9 @@ func sync(manual bool) error { } } + if manual { + okf("in sync!") + } return nil } @@ -119,5 +130,5 @@ func autoSync() error { if _, err := ensureVCSInitialized(); err != nil { return nil } - return sync(false) + return sync(false, "") } From 9130c09e560d32b27f605d028a9b1221c9887304 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 17:57:05 +0000 Subject: [PATCH 054/107] feat(rm): adds --yes flag to auto-accept all prompts --- README.md | 3 +++ cmd/del.go | 7 ++++++- testdata/help-remove.ct | 2 ++ testdata/remove-yes.ct | 8 ++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 testdata/remove-yes.ct diff --git a/README.md b/README.md index 8eabc32..a55fac9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,9 @@ pda rm kitty --key "?og" pda rm kitty -i # ??? remove 'kitty'? (y/n) # ==> y + +# --yes/-y to auto-accept all confirmation prompts. +pda rm kitty -y ```

diff --git a/cmd/del.go b/cmd/del.go index a3f0da3..b7ffefc 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -47,6 +47,10 @@ func del(cmd *cobra.Command, args []string) error { if err != nil { return err } + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + return err + } keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { return err @@ -72,7 +76,7 @@ func del(cmd *cobra.Command, args []string) error { byStore := make(map[string]*storeTargets) var storeOrder []string for _, target := range targets { - if interactive || config.Key.AlwaysPromptDelete { + if !yes && (interactive || config.Key.AlwaysPromptDelete) { var confirm string promptf("remove '%s'? (y/n)", target.display) if err := scanln(&confirm); err != nil { @@ -120,6 +124,7 @@ func del(cmd *cobra.Command, args []string) error { func init() { delCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") + delCmd.Flags().BoolP("yes", "y", false, "Skip all confirmation prompts") delCmd.Flags().StringSliceP("key", "k", nil, "Delete keys matching glob pattern (repeatable)") rootCmd.AddCommand(delCmd) } diff --git a/testdata/help-remove.ct b/testdata/help-remove.ct index fe2bef6..2a28005 100644 --- a/testdata/help-remove.ct +++ b/testdata/help-remove.ct @@ -12,6 +12,7 @@ Flags: -h, --help help for remove -i, --interactive Prompt yes/no for each deletion -k, --key strings Delete keys matching glob pattern (repeatable) + -y, --yes Skip all confirmation prompts Delete one or more keys Usage: @@ -24,3 +25,4 @@ Flags: -h, --help help for remove -i, --interactive Prompt yes/no for each deletion -k, --key strings Delete keys matching glob pattern (repeatable) + -y, --yes Skip all confirmation prompts diff --git a/testdata/remove-yes.ct b/testdata/remove-yes.ct new file mode 100644 index 0000000..7d2b8e0 --- /dev/null +++ b/testdata/remove-yes.ct @@ -0,0 +1,8 @@ +$ 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" From 59cb09a8e7d6a1bb75ebe200ca8c5e3d0316ef0b Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 18:16:37 +0000 Subject: [PATCH 055/107] feat(version): adds --short flag to only show release information --- README.md | 15 +++++++++++++++ cmd/version.go | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a55fac9..c1390a9 100644 --- a/README.md +++ b/README.md @@ -749,3 +749,18 @@ pda run script ```

+ +### Version + +`pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in config. + +```bash +# Display the full version output. +pda version + +# Or just the release. +pda version --short +# pda! 2025.47 release +``` + +

diff --git a/cmd/version.go b/cmd/version.go index 0a6baf9..710737a 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -36,7 +36,8 @@ var versionCmd = &cobra.Command{ Use: "version", Short: "Display pda! version", Run: func(cmd *cobra.Command, args []string) { - if config.DisplayAsciiArt { + short, _ := cmd.Flags().GetBool("short") + if !short && config.DisplayAsciiArt { fmt.Print(asciiArt + "\n ") } fmt.Printf("%s\n", version) @@ -44,5 +45,6 @@ var versionCmd = &cobra.Command{ } func init() { + versionCmd.Flags().Bool("short", false, "Print only the version string") rootCmd.AddCommand(versionCmd) } From b89db8dc4831f56ec9361cf7059625d4c53bf702 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 18:27:22 +0000 Subject: [PATCH 056/107] feat(set): adds --file flag to input from a file path --- README.md | 4 ++++ cmd/set.go | 19 +++++++++++++++++-- testdata/help-set.ct | 2 ++ testdata/set-file-conflict-err.ct | 3 +++ testdata/set-file.ct | 4 ++++ 5 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 testdata/set-file-conflict-err.ct create mode 100644 testdata/set-file.ct diff --git a/README.md b/README.md index c1390a9..c2cd43a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ echo "Alice" | pda set name cat dogs.txt | pda set dogs pda set kitty < cat.png +# From a file +pda set dogs --file dogs.txt +pda set kitty -f cat.png + # --safe to skip if the key already exists. pda set name "Alice" --safe pda set name "Bob" --safe diff --git a/cmd/set.go b/cmd/set.go index 2fe7558..43d95f9 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -25,6 +25,7 @@ package cmd import ( "fmt" "io" + "os" "strings" "time" @@ -78,10 +79,23 @@ func set(cmd *cobra.Command, args []string) error { 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 - if len(args) == 2 { + 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: value = []byte(args[1]) - } else { + default: bytes, err := io.ReadAll(cmd.InOrStdin()) if err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) @@ -169,4 +183,5 @@ func init() { setCmd.Flags().BoolP("interactive", "i", false, "Prompt before overwriting an existing key") setCmd.Flags().BoolP("encrypt", "e", false, "Encrypt the value at rest using age") setCmd.Flags().Bool("safe", false, "Do not overwrite if the key already exists") + setCmd.Flags().StringP("file", "f", "", "Read value from a file") } diff --git a/testdata/help-set.ct b/testdata/help-set.ct index 2f6c4f3..330ea71 100644 --- a/testdata/help-set.ct +++ b/testdata/help-set.ct @@ -22,6 +22,7 @@ Aliases: Flags: -e, --encrypt Encrypt the value at rest using age + -f, --file string Read value from a file -h, --help help for set -i, --interactive Prompt before overwriting an existing key --safe Do not overwrite if the key already exists @@ -48,6 +49,7 @@ Aliases: Flags: -e, --encrypt Encrypt the value at rest using age + -f, --file string Read value from a file -h, --help help for set -i, --interactive Prompt before overwriting an existing key --safe Do not overwrite if the key already exists diff --git a/testdata/set-file-conflict-err.ct b/testdata/set-file-conflict-err.ct new file mode 100644 index 0000000..19ecd17 --- /dev/null +++ b/testdata/set-file-conflict-err.ct @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..d6b913e --- /dev/null +++ b/testdata/set-file.ct @@ -0,0 +1,4 @@ +$ fecho myfile hello from file +$ pda set key@sf --file myfile +$ pda get key@sf +hello from file From 4e5064d07a0b25147263b886b610e458a63e9f27 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 18:53:55 +0000 Subject: [PATCH 057/107] feat(stores): adds mvs, and flags to bring store commands on par with key commands --- README.md | 27 ++++++- cmd/config.go | 10 ++- cmd/del-db.go | 7 +- cmd/msg.go | 6 ++ cmd/mv-db.go | 129 +++++++++++++++++++++++++++++++ cmd/mv.go | 24 +++++- cmd/root.go | 1 + cmd/set.go | 1 + testdata/cp-cross-store.ct | 1 + testdata/cp-encrypt.ct | 1 + testdata/cp-safe.ct | 6 ++ testdata/cp.ct | 1 + testdata/help-remove-store.ct | 2 + testdata/help.ct | 2 + testdata/mv-cross-store.ct | 1 + testdata/mv-encrypt.ct | 1 + testdata/mv-safe.ct | 8 ++ testdata/mv-store-copy.ct | 7 ++ testdata/mv-store-missing-err.ct | 2 + testdata/mv-store-safe.ct | 8 ++ testdata/mv-store-same-err.ct | 3 + testdata/mv-store.ct | 5 ++ testdata/mv.ct | 1 + testdata/root.ct | 1 + testdata/set-safe.ct | 1 + 25 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 cmd/mv-db.go create mode 100644 testdata/cp-safe.ct create mode 100644 testdata/mv-safe.ct create mode 100644 testdata/mv-store-copy.ct create mode 100644 testdata/mv-store-missing-err.ct create mode 100644 testdata/mv-store-safe.ct create mode 100644 testdata/mv-store-same-err.ct create mode 100644 testdata/mv-store.ct diff --git a/README.md b/README.md index c2cd43a..cb10747 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ 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: @@ -171,7 +172,14 @@ pda get name --exists `pda mv` to move it. ```bash pda mv name name2 -# renamed name to name2 +# ok renamed name to name2 + +# --safe to skip if the destination already exists. +pda mv name name2 --safe +# info skipped 'name2': already exists + +# --yes/-y to skip confirmation prompts. +pda mv name name2 -y ``` `pda cp` to make a copy. @@ -277,7 +285,7 @@ pda import --drop -f my_backup

-You can have as many stores as you want. +You can have as many stores as you want. All the store commands have shorthands, like `mv` to move a key, or `mvs` to move a store. ```bash # Save to a specific store. pda set alice@birthdays 11/11/1998 @@ -298,8 +306,20 @@ pda export birthdays > friends_birthdays # Import it. pda import birthdays < friends_birthdays +# Rename it. +pda move-store birthdays bdays + +# Or copy it. +pda move-store birthdays bdays --copy + +# --safe to skip if the destination already exists. +pda move-store birthdays bdays --safe + # Delete it. -pda rm-store birthdays +pda remove-store birthdays + +# --yes/-y to skip confirmation prompts on delete or overwrite. +pda remove-store birthdays -y ```

@@ -714,6 +734,7 @@ always_prompt_overwrite = false [store] default_store_name = "default" always_prompt_delete = true +always_prompt_overwrite = true [git] auto_fetch = false diff --git a/cmd/config.go b/cmd/config.go index b55b885..9d53c74 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -44,8 +44,9 @@ type KeyConfig struct { } type StoreConfig struct { - DefaultStoreName string `toml:"default_store_name"` - AlwaysPromptDelete bool `toml:"always_prompt_delete"` + DefaultStoreName string `toml:"default_store_name"` + AlwaysPromptDelete bool `toml:"always_prompt_delete"` + AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` } type GitConfig struct { @@ -80,8 +81,9 @@ func defaultConfig() Config { AlwaysPromptOverwrite: false, }, Store: StoreConfig{ - DefaultStoreName: "default", - AlwaysPromptDelete: true, + DefaultStoreName: "default", + AlwaysPromptDelete: true, + AlwaysPromptOverwrite: true, }, Git: GitConfig{ AutoFetch: false, diff --git a/cmd/del-db.go b/cmd/del-db.go index 427c8b9..de9040b 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -60,8 +60,12 @@ func delStore(cmd *cobra.Command, args []string) error { 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) + } - if interactive || config.Store.AlwaysPromptDelete { + if !yes && (interactive || config.Store.AlwaysPromptDelete) { promptf("delete store '%s'? (y/n)", args[0]) var confirm string @@ -87,5 +91,6 @@ func executeDeletion(path string) error { 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) } diff --git a/cmd/msg.go b/cmd/msg.go index 21a44ed..c850b81 100644 --- a/cmd/msg.go +++ b/cmd/msg.go @@ -35,6 +35,7 @@ func stdoutIsTerminal() bool { // FAIL red (stderr) // hint dim (stderr) // WARN yellow (stderr) +// info blue (stderr) // ok green (stderr) // ? cyan (stdout) // > dim (stdout) @@ -60,6 +61,11 @@ func warnf(format string, args ...any) { 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) diff --git a/cmd/mv-db.go b/cmd/mv-db.go new file mode 100644 index 0000000..76bc21c --- /dev/null +++ b/cmd/mv-db.go @@ -0,0 +1,129 @@ +/* +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), + 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") + 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) + } 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) + } + return autoSync() +} + +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 d9d5069..1900013 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -64,7 +64,15 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { if err != nil { return err } - promptOverwrite := interactive || config.Key.AlwaysPromptOverwrite + safe, err := cmd.Flags().GetBool("safe") + if err != nil { + return err + } + yes, err := cmd.Flags().GetBool("yes") + if err != nil { + return err + } + promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite) identity, _ := loadIdentity() var recipient *age.X25519Recipient @@ -114,6 +122,11 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { dstIdx := findEntry(dstEntries, toSpec.Key) + if safe && dstIdx >= 0 { + infof("skipped '%s': already exists", toSpec.Display()) + return nil + } + if promptOverwrite && dstIdx >= 0 { var confirm string promptf("overwrite '%s'? (y/n)", toSpec.Display()) @@ -169,13 +182,22 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } } + if keepSource { + okf("copied %s to %s", fromSpec.Display(), toSpec.Display()) + } else { + okf("renamed %s to %s", fromSpec.Display(), toSpec.Display()) + } return autoSync() } 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") 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") rootCmd.AddCommand(cpCmd) } diff --git a/cmd/root.go b/cmd/root.go index b7a03bf..4f4a99f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,6 +65,7 @@ func init() { listStoresCmd.GroupID = "stores" delStoreCmd.GroupID = "stores" + mvStoreCmd.GroupID = "stores" exportCmd.GroupID = "stores" restoreCmd.GroupID = "stores" diff --git a/cmd/set.go b/cmd/set.go index 43d95f9..cb7c0e4 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -135,6 +135,7 @@ func set(cmd *cobra.Command, args []string) error { idx := findEntry(entries, spec.Key) if safe && idx >= 0 { + infof("skipped '%s': already exists", spec.Display()) return nil } diff --git a/testdata/cp-cross-store.ct b/testdata/cp-cross-store.ct index 8e1c4ce..1d94f54 100644 --- a/testdata/cp-cross-store.ct +++ b/testdata/cp-cross-store.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/cp-encrypt.ct b/testdata/cp-encrypt.ct index f6c435f..7512f52 100644 --- a/testdata/cp-encrypt.ct +++ b/testdata/cp-encrypt.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/cp-safe.ct b/testdata/cp-safe.ct new file mode 100644 index 0000000..0a46ca8 --- /dev/null +++ b/testdata/cp-safe.ct @@ -0,0 +1,6 @@ +$ 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 index 8abbe7b..0a7096c 100644 --- a/testdata/cp.ct +++ b/testdata/cp.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/help-remove-store.ct b/testdata/help-remove-store.ct index 6eaf204..f60225c 100644 --- a/testdata/help-remove-store.ct +++ b/testdata/help-remove-store.ct @@ -11,6 +11,7 @@ Aliases: 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: @@ -22,3 +23,4 @@ Aliases: 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.ct b/testdata/help.ct index 86a7c34..c511e94 100644 --- a/testdata/help.ct +++ b/testdata/help.ct @@ -26,6 +26,7 @@ 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: @@ -68,6 +69,7 @@ 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: diff --git a/testdata/mv-cross-store.ct b/testdata/mv-cross-store.ct index 4420a35..a604178 100644 --- a/testdata/mv-cross-store.ct +++ b/testdata/mv-cross-store.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/mv-encrypt.ct b/testdata/mv-encrypt.ct index 10a7feb..df03f91 100644 --- a/testdata/mv-encrypt.ct +++ b/testdata/mv-encrypt.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/mv-safe.ct b/testdata/mv-safe.ct new file mode 100644 index 0000000..0213ada --- /dev/null +++ b/testdata/mv-safe.ct @@ -0,0 +1,8 @@ +$ 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 new file mode 100644 index 0000000..5ef049a --- /dev/null +++ b/testdata/mv-store-copy.ct @@ -0,0 +1,7 @@ +$ 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 new file mode 100644 index 0000000..b42fbe3 --- /dev/null +++ b/testdata/mv-store-missing-err.ct @@ -0,0 +1,2 @@ +$ 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 new file mode 100644 index 0000000..20e5e3e --- /dev/null +++ b/testdata/mv-store-safe.ct @@ -0,0 +1,8 @@ +$ 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 new file mode 100644 index 0000000..11013b2 --- /dev/null +++ b/testdata/mv-store-same-err.ct @@ -0,0 +1,3 @@ +$ 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 new file mode 100644 index 0000000..c3b1cc0 --- /dev/null +++ b/testdata/mv-store.ct @@ -0,0 +1,5 @@ +$ 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 index 0ef7801..d2036e0 100644 --- a/testdata/mv.ct +++ b/testdata/mv.ct @@ -1,6 +1,7 @@ # 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 diff --git a/testdata/root.ct b/testdata/root.ct index 29c2cc7..3b6911d 100644 --- a/testdata/root.ct +++ b/testdata/root.ct @@ -25,6 +25,7 @@ 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: diff --git a/testdata/set-safe.ct b/testdata/set-safe.ct index 7b641e5..8755d20 100644 --- a/testdata/set-safe.ct +++ b/testdata/set-safe.ct @@ -2,6 +2,7 @@ $ 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 From 15c1d6733c9109a714e2b9e7f5ea44019aa79a53 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 19:29:14 +0000 Subject: [PATCH 058/107] feat(lss): adds --no-header and --short flags, and lowercases all flag descriptions --- README.md | 8 +++- cmd/del-db.go | 4 +- cmd/del.go | 6 +-- cmd/export.go | 4 +- cmd/identity.go | 4 +- cmd/init.go | 2 +- cmd/list-dbs.go | 69 ++++++++++++++++++++++++++++++++++- cmd/list.go | 4 +- cmd/mv-db.go | 8 ++-- cmd/mv.go | 14 +++---- cmd/restore.go | 8 ++-- cmd/set.go | 10 ++--- cmd/shared.go | 20 +++++----- cmd/sync.go | 2 +- cmd/version.go | 2 +- testdata/help-export.ct | 8 ++-- testdata/help-import.ct | 16 ++++---- testdata/help-list-stores.ct | 8 +++- testdata/help-list.ct | 8 ++-- testdata/help-remove-store.ct | 8 ++-- testdata/help-remove.ct | 12 +++--- testdata/help-set.ct | 20 +++++----- 22 files changed, 161 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index cb10747..528cbd3 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,14 @@ pda set alice@birthdays 11/11/1998 # See which stores have contents. pda list-stores -# @default +# Keys Size Store +# 2 1.8k @birthdays +# 12 4.2k @default + +# Just the names. +pda list-stores --short # @birthdays +# @default # Check out a specific store. pda ls @birthdays --no-header --no-ttl diff --git a/cmd/del-db.go b/cmd/del-db.go index de9040b..b1ccd2b 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -90,7 +90,7 @@ func executeDeletion(path string) error { } func init() { - delStoreCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") - delStoreCmd.Flags().BoolP("yes", "y", false, "Skip all confirmation prompts") + 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) } diff --git a/cmd/del.go b/cmd/del.go index b7ffefc..641e5e0 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -123,9 +123,9 @@ func del(cmd *cobra.Command, args []string) error { } func init() { - delCmd.Flags().BoolP("interactive", "i", false, "Prompt yes/no for each deletion") - delCmd.Flags().BoolP("yes", "y", false, "Skip all confirmation prompts") - delCmd.Flags().StringSliceP("key", "k", nil, "Delete keys matching glob pattern (repeatable)") + delCmd.Flags().BoolP("interactive", "i", false, "prompt yes/no for each deletion") + delCmd.Flags().BoolP("yes", "y", false, "skip all confirmation prompts") + delCmd.Flags().StringSliceP("key", "k", nil, "delete keys matching glob pattern (repeatable)") rootCmd.AddCommand(delCmd) } diff --git a/cmd/export.go b/cmd/export.go index 35f0a02..ce90a82 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -39,7 +39,7 @@ var exportCmd = &cobra.Command{ } func init() { - exportCmd.Flags().StringSliceP("key", "k", nil, "Filter keys with glob pattern (repeatable)") - exportCmd.Flags().StringSliceP("value", "v", nil, "Filter values with regex pattern (repeatable)") + exportCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)") + exportCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(exportCmd) } diff --git a/cmd/identity.go b/cmd/identity.go index 4e13fcc..89e81a9 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -70,8 +70,8 @@ func identityRun(cmd *cobra.Command, args []string) error { } 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().Bool("new", false, "generate a new identity (errors if one already exists)") + identityCmd.Flags().Bool("path", false, "print only the identity file path") identityCmd.MarkFlagsMutuallyExclusive("new", "path") rootCmd.AddCommand(identityCmd) } diff --git a/cmd/init.go b/cmd/init.go index bec7472..3b37815 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -40,7 +40,7 @@ var initCmd = &cobra.Command{ } func init() { - initCmd.Flags().Bool("clean", false, "Remove .git from stores directory before initialising") + initCmd.Flags().Bool("clean", false, "remove .git from stores directory before initialising") rootCmd.AddCommand(initCmd) } diff --git a/cmd/list-dbs.go b/cmd/list-dbs.go index 9e897b8..15b48cd 100644 --- a/cmd/list-dbs.go +++ b/cmd/list-dbs.go @@ -24,6 +24,8 @@ package cmd import ( "fmt" + "os" + "github.com/spf13/cobra" ) @@ -43,12 +45,77 @@ func listStores(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("cannot list stores: %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 { - fmt.Println("@" + db) + 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) } 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) } diff --git a/cmd/list.go b/cmd/list.go index 0cebe64..8c4539d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -486,7 +486,7 @@ func init() { 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)") - listCmd.Flags().StringSliceP("key", "k", nil, "Filter keys with glob pattern (repeatable)") - listCmd.Flags().StringSliceP("value", "v", nil, "Filter values with regex pattern (repeatable)") + listCmd.Flags().StringSliceP("key", "k", nil, "filter keys with glob pattern (repeatable)") + listCmd.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(listCmd) } diff --git a/cmd/mv-db.go b/cmd/mv-db.go index 76bc21c..22c3cdc 100644 --- a/cmd/mv-db.go +++ b/cmd/mv-db.go @@ -121,9 +121,9 @@ func mvStore(cmd *cobra.Command, args []string) error { } 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") + 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 1900013..1d549db 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -191,13 +191,13 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } 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("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") 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().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") rootCmd.AddCommand(cpCmd) } diff --git a/cmd/restore.go b/cmd/restore.go index bc678de..aaec428 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -226,9 +226,9 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( } 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().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("key", "k", nil, "restore keys matching glob pattern (repeatable)") + restoreCmd.Flags().BoolP("interactive", "i", false, "prompt before overwriting existing keys") + restoreCmd.Flags().Bool("drop", false, "drop existing entries before restoring (full replace)") rootCmd.AddCommand(restoreCmd) } diff --git a/cmd/set.go b/cmd/set.go index cb7c0e4..2f1a7de 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -180,9 +180,9 @@ func set(cmd *cobra.Command, args []string) error { 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().StringP("file", "f", "", "Read value from a file") + 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().StringP("file", "f", "", "read value from a file") } diff --git a/cmd/shared.go b/cmd/shared.go index 517162b..4e986cc 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -86,19 +86,19 @@ func (s *Store) formatBytes(base64Flag bool, v []byte) string { func formatSize(n int) string { const ( - kb = 1024 - mb = 1024 * kb - gb = 1024 * mb + ki = 1024 + mi = 1024 * ki + gi = 1024 * mi ) switch { - case n < kb: - return fmt.Sprintf("%d B", n) - case n < mb: - return fmt.Sprintf("%.1f KB", float64(n)/float64(kb)) - case n < gb: - return fmt.Sprintf("%.1f MB", float64(n)/float64(mb)) + 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("%.1f GB", float64(n)/float64(gb)) + return fmt.Sprintf("%.1fG", float64(n)/float64(gi)) } } diff --git a/cmd/sync.go b/cmd/sync.go index de0c233..b775d4a 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -40,7 +40,7 @@ var syncCmd = &cobra.Command{ } func init() { - syncCmd.Flags().StringP("message", "m", "", "Custom commit message (defaults to timestamp)") + syncCmd.Flags().StringP("message", "m", "", "custom commit message (defaults to timestamp)") rootCmd.AddCommand(syncCmd) } diff --git a/cmd/version.go b/cmd/version.go index 710737a..8c46579 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -45,6 +45,6 @@ var versionCmd = &cobra.Command{ } func init() { - versionCmd.Flags().Bool("short", false, "Print only the version string") + versionCmd.Flags().Bool("short", false, "print only the version string") rootCmd.AddCommand(versionCmd) } diff --git a/testdata/help-export.ct b/testdata/help-export.ct index e22b81b..4bbbf8e 100644 --- a/testdata/help-export.ct +++ b/testdata/help-export.ct @@ -7,8 +7,8 @@ Usage: Flags: -h, --help help for export - -k, --key strings Filter keys with glob pattern (repeatable) - -v, --value strings Filter values with regex pattern (repeatable) + -k, --key strings filter keys with glob pattern (repeatable) + -v, --value strings filter values with glob pattern (repeatable) Export store as NDJSON (alias for list --format ndjson) Usage: @@ -16,5 +16,5 @@ Usage: Flags: -h, --help help for export - -k, --key strings Filter keys with glob pattern (repeatable) - -v, --value strings Filter values with regex pattern (repeatable) + -k, --key strings filter keys with glob pattern (repeatable) + -v, --value strings filter values with glob pattern (repeatable) diff --git a/testdata/help-import.ct b/testdata/help-import.ct index 9fb102a..2baf780 100644 --- a/testdata/help-import.ct +++ b/testdata/help-import.ct @@ -6,19 +6,19 @@ 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) + --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) + -i, --interactive prompt before overwriting existing keys + -k, --key strings restore keys 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) + --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) + -i, --interactive prompt before overwriting existing keys + -k, --key strings restore keys matching glob pattern (repeatable) diff --git a/testdata/help-list-stores.ct b/testdata/help-list-stores.ct index 4781886..5e57786 100644 --- a/testdata/help-list-stores.ct +++ b/testdata/help-list-stores.ct @@ -9,7 +9,9 @@ Aliases: list-stores, lss Flags: - -h, --help help for list-stores + -h, --help help for list-stores + --no-header suppress the header row + --short only print store names List all stores Usage: @@ -19,4 +21,6 @@ Aliases: list-stores, lss Flags: - -h, --help help for list-stores + -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 index ce888b0..80e64f5 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -14,12 +14,12 @@ Flags: -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -f, --full show full values without truncation -h, --help help for list - -k, --key strings Filter keys with glob pattern (repeatable) + -k, --key strings filter keys with glob pattern (repeatable) --no-header suppress the header row --no-keys suppress the key column --no-ttl suppress the TTL column --no-values suppress the value column - -v, --value strings Filter values with regex pattern (repeatable) + -v, --value strings filter values with glob pattern (repeatable) List the contents of a store Usage: @@ -34,9 +34,9 @@ Flags: -o, --format format output format (table|tsv|csv|markdown|html|ndjson) (default table) -f, --full show full values without truncation -h, --help help for list - -k, --key strings Filter keys with glob pattern (repeatable) + -k, --key strings filter keys with glob pattern (repeatable) --no-header suppress the header row --no-keys suppress the key column --no-ttl suppress the TTL column --no-values suppress the value column - -v, --value strings Filter values with regex 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 index f60225c..d367770 100644 --- a/testdata/help-remove-store.ct +++ b/testdata/help-remove-store.ct @@ -10,8 +10,8 @@ Aliases: Flags: -h, --help help for remove-store - -i, --interactive Prompt yes/no for each deletion - -y, --yes Skip all confirmation prompts + -i, --interactive prompt yes/no for each deletion + -y, --yes skip all confirmation prompts Delete a store Usage: @@ -22,5 +22,5 @@ Aliases: Flags: -h, --help help for remove-store - -i, --interactive Prompt yes/no for each deletion - -y, --yes Skip all confirmation prompts + -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 index 2a28005..124c491 100644 --- a/testdata/help-remove.ct +++ b/testdata/help-remove.ct @@ -10,9 +10,9 @@ Aliases: Flags: -h, --help help for remove - -i, --interactive Prompt yes/no for each deletion - -k, --key strings Delete keys matching glob pattern (repeatable) - -y, --yes Skip all confirmation prompts + -i, --interactive prompt yes/no for each deletion + -k, --key strings delete keys matching glob pattern (repeatable) + -y, --yes skip all confirmation prompts Delete one or more keys Usage: @@ -23,6 +23,6 @@ Aliases: Flags: -h, --help help for remove - -i, --interactive Prompt yes/no for each deletion - -k, --key strings Delete keys matching glob pattern (repeatable) - -y, --yes Skip all confirmation prompts + -i, --interactive prompt yes/no for each deletion + -k, --key strings delete keys matching glob pattern (repeatable) + -y, --yes skip all confirmation prompts diff --git a/testdata/help-set.ct b/testdata/help-set.ct index 330ea71..0d8ac57 100644 --- a/testdata/help-set.ct +++ b/testdata/help-set.ct @@ -21,12 +21,12 @@ Aliases: set, s Flags: - -e, --encrypt Encrypt the value at rest using age - -f, --file string Read value from a file + -e, --encrypt encrypt the value at rest using age + -f, --file string read value from a file -h, --help help for set - -i, --interactive Prompt before overwriting an existing key - --safe Do not overwrite if the key already exists - -t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m) + -i, --interactive prompt before overwriting an existing key + --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 @@ -48,9 +48,9 @@ Aliases: set, s Flags: - -e, --encrypt Encrypt the value at rest using age - -f, --file string Read value from a file + -e, --encrypt encrypt the value at rest using age + -f, --file string read value from a file -h, --help help for set - -i, --interactive Prompt before overwriting an existing key - --safe Do not overwrite if the key already exists - -t, --ttl duration Expire the key after the provided duration (e.g. 24h, 30m) + -i, --interactive prompt before overwriting an existing key + --safe do not overwrite if the key already exists + -t, --ttl duration expire the key after the provided duration (e.g. 24h, 30m) From ce7336324f6b9a6932a6100c929428bc1c005863 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 19:34:29 +0000 Subject: [PATCH 059/107] feat(list): adds plain json formatting --- README.md | 8 ++++++-- cmd/list.go | 24 +++++++++++++++++++++--- testdata/help-list.ct | 4 ++-- testdata/list-format-json.ct | 5 +++++ 4 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 testdata/list-format-json.ct diff --git a/README.md b/README.md index 528cbd3..2031ea4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. The `list` command 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_element#Tables) tables, and raw NDJSON. Because every store is in plaintext, Git versioning is pretty easy: auto-committing, pushing, and fetching can be enabled in the config to automatically version changes, or just `pda sync` regularly. +`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. The `list` command 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_element#Tables) tables, JSON, and raw NDJSON. Because every store is in plaintext, Git versioning is pretty easy: auto-committing, pushing, and fetching can be enabled in the config to automatically version changes, or just `pda sync` regularly.

@@ -229,7 +229,11 @@ pda ls --format csv # name,Alice,no expiry # dogs,four legged mammals,no expiry -# Or TSV, or Markdown, or HTML. +# Or as a JSON array. +pda ls --format json +# [{"key":"name","value":"Alice","encoding":"text"},{"key":"dogs","value":"four legged mammals","encoding":"text"}] + +# Or TSV, Markdown, HTML, NDJSON. # Just the count of entries. pda ls --count diff --git a/cmd/list.go b/cmd/list.go index 8c4539d..b8c0a98 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -46,11 +46,11 @@ func (e *formatEnum) String() string { return string(*e) } func (e *formatEnum) Set(v string) error { switch v { - case "table", "tsv", "csv", "html", "markdown", "ndjson": + case "table", "tsv", "csv", "html", "markdown", "ndjson", "json": *e = formatEnum(v) return nil default: - return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', or 'ndjson'") + return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', 'ndjson', or 'json'") } } @@ -196,6 +196,24 @@ func list(cmd *cobra.Command, args []string) error { return nil } + // JSON format: emit a single JSON array + if listFormat.String() == "json" { + var entries []jsonEntry + for _, e := range filtered { + je, err := encodeJsonEntry(e, recipient) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + entries = append(entries, je) + } + data, err := json.Marshal(entries) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + fmt.Fprintln(output, string(data)) + return nil + } + // Table-based formats showValues := !listNoValues tw := table.NewWriter() @@ -485,7 +503,7 @@ func init() { 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)") + 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("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(listCmd) diff --git a/testdata/help-list.ct b/testdata/help-list.ct index 80e64f5..f513a64 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -11,7 +11,7 @@ Aliases: Flags: -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) (default table) + -o, --format format output format (table|tsv|csv|markdown|html|ndjson|json) (default table) -f, --full show full values without truncation -h, --help help for list -k, --key strings filter keys with glob pattern (repeatable) @@ -31,7 +31,7 @@ Aliases: Flags: -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) (default table) + -o, --format format output format (table|tsv|csv|markdown|html|ndjson|json) (default table) -f, --full show full values without truncation -h, --help help for list -k, --key strings filter keys with glob pattern (repeatable) diff --git a/testdata/list-format-json.ct b/testdata/list-format-json.ct new file mode 100644 index 0000000..6c2cd85 --- /dev/null +++ b/testdata/list-format-json.ct @@ -0,0 +1,5 @@ +# 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"},{"key":"b","value":"2","encoding":"text"}] From 0c5b73154de7fc61b7e707ff7f2ca62107fd392b Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 20:10:35 +0000 Subject: [PATCH 060/107] feat(doctor): initial doctor command --- cmd/doctor.go | 128 +++++++++++++++++++++++++++++++++++++++++++++ testdata/doctor.ct | 7 +++ testdata/help.ct | 2 + testdata/root.ct | 1 + 4 files changed, 138 insertions(+) create mode 100644 cmd/doctor.go create mode 100644 testdata/doctor.ct diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..1e99748 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +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 { + tty := stdoutIsTerminal() + hasError := false + + emit := func(level, msg string) { + var code string + switch level { + case "ok": + code = "32" + case "info": + code = "34" + case "FAIL": + code = "31" + hasError = true + } + fmt.Fprintf(os.Stdout, "%s %s\n", keyword(code, level, tty), msg) + } + + // Config + cfgPath, err := configPath() + if err != nil { + emit("FAIL", fmt.Sprintf("Cannot determine config path: %v", err)) + } else if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + emit("info", "Using default configuration") + } else if err != nil { + emit("FAIL", fmt.Sprintf("Cannot access config: %v", err)) + } else { + emit("ok", "Configuration loaded") + } + + // Data directory + store := &Store{} + dataDir, err := store.path() + if err != nil { + emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err)) + } else { + emit("ok", "Data directory accessible") + } + + // Identity + 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("info", "No identity file. One will be created on first encrypt.") + } else if err != nil { + emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err)) + } else { + emit("ok", "Identity file present") + } + + // Git + gitInitialised := false + if dataDir != "" { + gitDir := filepath.Join(dataDir, ".git") + if _, err := os.Stat(gitDir); os.IsNotExist(err) { + emit("info", "Git not initialised") + } else if err != nil { + emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err)) + } else { + gitInitialised = true + emit("ok", "Git initialised") + } + } + + // Git remote (only when git is initialised) + if gitInitialised { + 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("info", "No git remote configured") + } + } + + // Stores + stores, err := store.AllStores() + if err != nil { + emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err)) + } else if len(stores) == 0 { + emit("info", "No stores") + } else { + var parseErrors int + for _, name := range stores { + p, pErr := store.storePath(name) + if pErr != nil { + parseErrors++ + continue + } + if _, rErr := readStoreFile(p, nil); rErr != nil { + parseErrors++ + } + } + if parseErrors > 0 { + emit("FAIL", fmt.Sprintf("%d store(s), %d with errors", len(stores), parseErrors)) + } else { + emit("ok", fmt.Sprintf("%d store(s)", len(stores))) + } + } + + if hasError { + os.Exit(1) + } + return nil +} diff --git a/testdata/doctor.ct b/testdata/doctor.ct new file mode 100644 index 0000000..0495da5 --- /dev/null +++ b/testdata/doctor.ct @@ -0,0 +1,7 @@ +# Doctor reports environment health +$ pda doctor +info Using default configuration + ok Data directory accessible + ok Identity file present +info Git not initialised + ok 5 store(s) diff --git a/testdata/help.ct b/testdata/help.ct index c511e94..5df93df 100644 --- a/testdata/help.ct +++ b/testdata/help.ct @@ -36,6 +36,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + doctor Check environment health help Help about any command version Display pda! version @@ -79,6 +80,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + doctor Check environment health help Help about any command version Display pda! version diff --git a/testdata/root.ct b/testdata/root.ct index 3b6911d..6e049e0 100644 --- a/testdata/root.ct +++ b/testdata/root.ct @@ -35,6 +35,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + doctor Check environment health help Help about any command version Display pda! version From 11276fcf25cbdab89886529f71cb2a8deaa78141 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 21:44:35 +0000 Subject: [PATCH 061/107] feat(doctor): full implementation of doctor health checks --- README.md | 33 ++++++ cmd/doctor.go | 288 ++++++++++++++++++++++++++++++++++++++++----- cmd/doctor_test.go | 118 +++++++++++++++++++ testdata/doctor.ct | 7 -- 4 files changed, 410 insertions(+), 36 deletions(-) create mode 100644 cmd/doctor_test.go delete mode 100644 testdata/doctor.ct diff --git a/README.md b/README.md index 2031ea4..2f4a491 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - plaintext exports in multiple formats, - support for all [binary data](https://github.com/Llywelwyn/pda#binary), - [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, +- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor), and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). @@ -56,6 +57,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [TTL](https://github.com/Llywelwyn/pda#ttl) - [Binary](https://github.com/Llywelwyn/pda#binary) - [Encryption](https://github.com/Llywelwyn/pda#encryption) +- [Doctor](https://github.com/Llywelwyn/pda#doctor) - [Environment](https://github.com/Llywelwyn/pda#environment)

@@ -99,6 +101,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + doctor Check environment health help Help about any command version Display pda! version ``` @@ -727,6 +730,36 @@ pda identity --new

+### Doctor + +`pda doctor` runs a set of health checks of your environment. + +```bash +pda doctor +# ok pda! 2025.52 Christmas release (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/stores +# 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 +# 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 mean some functionality isn't being made use of, like for example version control not having been initialised yet. + +

+ ### Environment Config is stored in your user config directory in `pda/config.toml`. diff --git a/cmd/doctor.go b/cmd/doctor.go index 1e99748..f1a58af 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -2,10 +2,15 @@ package cmd import ( "fmt" + "io" "os" + "os/exec" "path/filepath" + "runtime" + "strings" "github.com/spf13/cobra" + "golang.org/x/term" ) var doctorCmd = &cobra.Command{ @@ -20,7 +25,17 @@ func init() { } func doctor(cmd *cobra.Command, args []string) error { - tty := stdoutIsTerminal() + 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 emit := func(level, msg string) { @@ -28,101 +43,316 @@ func doctor(cmd *cobra.Command, args []string) error { switch level { case "ok": code = "32" - case "info": - code = "34" + case "WARN": + code = "33" case "FAIL": code = "31" hasError = true } - fmt.Fprintf(os.Stdout, "%s %s\n", keyword(code, level, tty), msg) + fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg) } - // Config + tree := func(items []string) { + for i, item := range items { + connector := "├── " + if i == len(items)-1 { + connector = "└── " + } + 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 if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - emit("info", "Using default configuration") - } else if err != nil { - emit("FAIL", fmt.Sprintf("Cannot access config: %v", err)) } else { - emit("ok", "Configuration loaded") + 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 unexpectedFiles(cfgDir, map[string]bool{ + "config.toml": true, + "identity.txt": 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") + } } - // Data directory + // 7. Non-default config values + if diffs := configDiffs(); len(diffs) > 0 { + emit("ok", "Non-default config:") + tree(diffs) + } + + // 8. Data directory store := &Store{} dataDir, err := store.path() if err != nil { emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err)) } else { - emit("ok", "Data directory accessible") + 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)) + } } - // Identity + // 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("info", "No identity file. One will be created on first encrypt.") + emit("WARN", "No identity file found") } else if err != nil { emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err)) } else { - emit("ok", "Identity file present") + 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)) + } } - // Git + // 10. Git initialised gitInitialised := false if dataDir != "" { gitDir := filepath.Join(dataDir, ".git") if _, err := os.Stat(gitDir); os.IsNotExist(err) { - emit("info", "Git not initialised") + emit("WARN", "Git not initialised") } else if err != nil { emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err)) } else { gitInitialised = true - emit("ok", "Git initialised") + branch, _ := currentBranch(dataDir) + if branch == "" { + branch = "unknown" + } + emit("ok", fmt.Sprintf("Git initialised on %s", branch)) } } - // Git remote (only when git is initialised) + // 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 { - hasOrigin, err := repoHasRemote(dataDir, "origin") + 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("info", "No git remote configured") + emit("WARN", "No git remote configured") } } - // Stores + // 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("info", "No stores") + emit("WARN", "No stores found") } else { - var parseErrors int + var totalKeys, totalSecrets, parseErrors int + var totalSize int64 for _, name := range stores { p, pErr := store.storePath(name) if pErr != nil { parseErrors++ continue } - if _, rErr := readStoreFile(p, nil); rErr != nil { + 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 errors", len(stores), parseErrors)) + emit("FAIL", fmt.Sprintf("%d store(s), %d with parse errors", len(stores), parseErrors)) } else { - emit("ok", fmt.Sprintf("%d store(s)", len(stores))) + emit("ok", fmt.Sprintf("%d store(s), %d key(s), %d secret(s), %s total", + len(stores), totalKeys, totalSecrets, formatSize(int(totalSize)))) } } if hasError { - os.Exit(1) + emit("FAIL", "1 or more issues found") + } else { + emit("ok", "No issues found") } - return nil + + return hasError +} + +func configDiffs() []string { + def := defaultConfig() + var diffs []string + if config.DisplayAsciiArt != def.DisplayAsciiArt { + diffs = append(diffs, fmt.Sprintf("display_ascii_art: %v", config.DisplayAsciiArt)) + } + if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete { + diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete)) + } + if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite { + diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite)) + } + if config.Store.DefaultStoreName != def.Store.DefaultStoreName { + diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName)) + } + if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete { + diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete)) + } + if config.Store.AlwaysPromptOverwrite != def.Store.AlwaysPromptOverwrite { + diffs = append(diffs, fmt.Sprintf("store.always_prompt_overwrite: %v", config.Store.AlwaysPromptOverwrite)) + } + if config.Git.AutoFetch != def.Git.AutoFetch { + diffs = append(diffs, fmt.Sprintf("git.auto_fetch: %v", config.Git.AutoFetch)) + } + if config.Git.AutoCommit != def.Git.AutoCommit { + diffs = append(diffs, fmt.Sprintf("git.auto_commit: %v", config.Git.AutoCommit)) + } + if config.Git.AutoPush != def.Git.AutoPush { + diffs = append(diffs, fmt.Sprintf("git.auto_push: %v", config.Git.AutoPush)) + } + 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" || filepath.Ext(name) == ".ndjson") { + continue + } + return true + } + return false } diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..22780d9 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,118 @@ +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) + + 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) + + 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(configDir, "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 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/testdata/doctor.ct b/testdata/doctor.ct deleted file mode 100644 index 0495da5..0000000 --- a/testdata/doctor.ct +++ /dev/null @@ -1,7 +0,0 @@ -# Doctor reports environment health -$ pda doctor -info Using default configuration - ok Data directory accessible - ok Identity file present -info Git not initialised - ok 5 store(s) From b6248e409f9dd61c764c3b6f5aa1c87a31f3d971 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 21:51:58 +0000 Subject: [PATCH 062/107] refactor(home)!: moves home to PDA_HOME, out of PDA_HOME/stores/ --- README.md | 10 +++++----- cmd/git.go | 5 ++--- cmd/shared.go | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2f4a491..c04af7e 100644 --- a/README.md +++ b/README.md @@ -745,7 +745,7 @@ pda doctor # ok Non-default config: # ├── display_ascii_art: false # └── git.auto_commit: true -# ok Data: /home/user/.local/share/pda/stores +# ok Data: /home/user/.local/share/pda # ok Identity: /home/user/.config/pda/identity.txt # ok Git initialised on main # ok Git remote configured @@ -792,12 +792,12 @@ PDA_CONFIG=/tmp/config/ pda set key value

-Data is stored in your user data directory under `pda/stores/`. +Data is stored in your user data directory under `pda/`. Usually: -- linux: `~/.local/share/pda/stores/` -- macOS: `~/Library/Application Support/pda/stores/` -- windows: `%LOCALAPPDATA%/pda/stores/` +- linux: `~/.local/share/pda/` +- macOS: `~/Library/Application Support/pda/` +- windows: `%LOCALAPPDATA%/pda/` `PDA_DATA` overrides the default storage location. ```bash diff --git a/cmd/git.go b/cmd/git.go index 4254d4d..6deca82 100644 --- a/cmd/git.go +++ b/cmd/git.go @@ -34,9 +34,8 @@ var gitCmd = &cobra.Command{ Short: "Run any arbitrary command. Use with caution.", Long: `Run any arbitrary command. Use with caution. -The Git repository lives directly in the stores directory -("PDA_DATA/pda/stores"). Store files (*.ndjson) are tracked -by Git as-is. +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. diff --git a/cmd/shared.go b/cmd/shared.go index 4e986cc..40e2410 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -190,7 +190,7 @@ func (s *Store) path() (string, error) { } return override, nil } - scope := gap.NewVendorScope(gap.User, "pda", "stores") + scope := gap.NewScope(gap.User, "pda") dir, err := scope.DataPath("") if err != nil { return "", err From 55b2e7f6cb4877949c7a4440da7c28aec5e2d32d Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:04:14 +0000 Subject: [PATCH 063/107] feat: makes ls list all stores by default, with config option to disable. adds --store glob support --- README.md | 44 +++++--- cmd/config.go | 12 ++- cmd/del.go | 87 +++++++++++----- cmd/doctor.go | 6 ++ cmd/export.go | 1 + cmd/list.go | 150 +++++++++++++++++++++------ cmd/ndjson.go | 2 + cmd/restore.go | 151 ++++++++++++++++++++++------ testdata/export-key-filter.ct | 4 +- testdata/export-value-filter.ct | 4 +- testdata/export.ct | 4 +- testdata/help-export.ct | 2 + testdata/help-import.ct | 22 ++-- testdata/help-list.ct | 26 ++++- testdata/help-remove.ct | 20 ++-- testdata/help.ct | 4 +- testdata/list-all.ct | 29 ++++++ testdata/list-format-csv.ct | 6 +- testdata/list-format-json.ct | 2 +- testdata/list-format-markdown.ct | 8 +- testdata/list-format-ndjson.ct | 4 +- testdata/list-key-filter.ct | 10 +- testdata/list-key-value-filter.ct | 8 +- testdata/list-no-header.ct | 2 +- testdata/list-no-keys.ct | 4 +- testdata/list-no-ttl.ct | 4 +- testdata/list-no-values.ct | 4 +- testdata/list-stores.ct | 8 +- testdata/list-value-filter.ct | 12 +-- testdata/list-value-multi-filter.ct | 6 +- testdata/multistore.ct | 4 +- testdata/remove-dedupe.ct | 8 +- testdata/remove-key-glob.ct | 2 +- testdata/remove-key-mixed.ct | 2 +- testdata/root.ct | 2 +- 35 files changed, 487 insertions(+), 177 deletions(-) create mode 100644 testdata/list-all.ct diff --git a/README.md b/README.md index c04af7e..4164295 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Key commands: copy Make a copy of a key get Get the value of a key identity Show or create the age encryption identity - list List the contents of a store + list List the contents of all stores move Move a key remove Delete one or more keys run Get the value of a key and execute it @@ -219,22 +219,28 @@ pda rm kitty -y

-`pda ls` to see what you've got stored. +`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `store.list_all_stores` can be set to false to list `store.default_store_name` by default. ```bash pda ls -# Key Value TTL -# name Alice no expiry -# dogs four legged mammals no expiry +# Key Store Value TTL +# dogs default four legged mammals no expiry +# name default Alice no expiry + +# Narrow to a single store. +pda ls @default + +# Or filter stores by glob pattern. +pda ls --store "prod*" # Or as CSV. pda ls --format csv -# Key,Value,TTL -# name,Alice,no expiry -# dogs,four legged mammals,no expiry +# Key,Store,Value,TTL +# dogs,default,four legged mammals,no expiry +# name,default,Alice,no expiry # Or as a JSON array. pda ls --format json -# [{"key":"name","value":"Alice","encoding":"text"},{"key":"dogs","value":"four legged mammals","encoding":"text"}] +# [{"key":"dogs","value":"four legged mammals","encoding":"text","store":"default"},{"key":"name","value":"Alice","encoding":"text","store":"default"}] # Or TSV, Markdown, HTML, NDJSON. @@ -273,19 +279,25 @@ pda export --value "**https**"

-`pda import` to import it all back. By default, import merges into the existing store — existing keys are updated and new keys are added. +`pda import` to import it all back. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to the default store. Pass a store name as a positional argument to force all entries into one store. Existing keys are updated and new keys are added. ```bash -# Import with an argument. +# Entries are routed to their original stores. pda import -f my_backup -# ok restored 2 entries into @default +# ok restored 5 entries + +# Force all entries into a specific store by passing a store name. +pda import mystore -f my_backup +# ok restored 5 entries into @mystore # Or from stdin. pda import < my_backup -# ok restored 2 entries into @default # Import only matching keys. pda import --key "a*" -f my_backup +# Import only entries from matching stores. +pda import --store "prod*" -f my_backup + # Full replace — drop all existing entries before importing. pda import --drop -f my_backup ``` @@ -476,9 +488,9 @@ pda get hello --no-template ### Filtering -`--key`/`-k` and `--value`/`-v` can be used as filters with glob support. `gobwas/glob` is used for matching. Both flags are repeatable, with results matching one-or-more of the keys and one-or-more of the values passed. If a `--key` and `--value` are passed, results must match both of them. If multiple are passed, results must match at least one `--key` and `--value` pattern. +`--key`/`-k`, `--value`/`-v`, and `--store`/`-s` can be used as filters with glob support. `gobwas/glob` is used for matching. All three flags are repeatable, with results matching one-or-more of the patterns passed per flag. When multiple flags are combined, results must satisfy all of them (AND across flags, OR within the same flag). -`--key` and `--value` filters work with `list`, `remove`, `export`, and `import` commands. +`--key`, `--value`, and `--store` filters work with `list`, `export`, `import`, and `remove`. `--value` is not available on `import` or `remove`.

@@ -772,10 +784,12 @@ display_ascii_art = true [key] always_prompt_delete = false +always_prompt_glob_delete = true always_prompt_overwrite = false [store] default_store_name = "default" +list_all_stores = true always_prompt_delete = true always_prompt_overwrite = true diff --git a/cmd/config.go b/cmd/config.go index 9d53c74..aecc107 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -39,12 +39,14 @@ type Config struct { } type KeyConfig struct { - AlwaysPromptDelete bool `toml:"always_prompt_delete"` - AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` + AlwaysPromptDelete bool `toml:"always_prompt_delete"` + AlwaysPromptGlobDelete bool `toml:"always_prompt_glob_delete"` + AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` } type StoreConfig struct { DefaultStoreName string `toml:"default_store_name"` + ListAllStores bool `toml:"list_all_stores"` AlwaysPromptDelete bool `toml:"always_prompt_delete"` AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` } @@ -77,11 +79,13 @@ func defaultConfig() Config { return Config{ DisplayAsciiArt: true, Key: KeyConfig{ - AlwaysPromptDelete: false, - AlwaysPromptOverwrite: false, + AlwaysPromptDelete: false, + AlwaysPromptGlobDelete: true, + AlwaysPromptOverwrite: false, }, Store: StoreConfig{ DefaultStoreName: "default", + ListAllStores: true, AlwaysPromptDelete: true, AlwaysPromptOverwrite: true, }, diff --git a/cmd/del.go b/cmd/del.go index 641e5e0..e91d392 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -55,12 +55,21 @@ func del(cmd *cobra.Command, args []string) error { if err != nil { return err } + valuePatterns, err := cmd.Flags().GetStringSlice("value") + if err != nil { + return err + } + storePatterns, err := cmd.Flags().GetStringSlice("store") + if err != nil { + return err + } - if len(args) == 0 && len(keyPatterns) == 0 { + hasFilters := len(keyPatterns) > 0 || len(valuePatterns) > 0 || len(storePatterns) > 0 + if len(args) == 0 && !hasFilters { return fmt.Errorf("cannot remove: no keys provided") } - targets, err := resolveDeleteTargets(store, args, keyPatterns) + targets, err := resolveDeleteTargets(store, args, keyPatterns, valuePatterns, storePatterns) if err != nil { return err } @@ -75,8 +84,9 @@ func del(cmd *cobra.Command, args []string) error { } byStore := make(map[string]*storeTargets) var storeOrder []string + promptGlob := hasFilters && config.Key.AlwaysPromptGlobDelete for _, target := range targets { - if !yes && (interactive || config.Key.AlwaysPromptDelete) { + if !yes && (interactive || config.Key.AlwaysPromptDelete || promptGlob) { var confirm string promptf("remove '%s'? (y/n)", target.display) if err := scanln(&confirm); err != nil { @@ -126,6 +136,8 @@ 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().StringSliceP("key", "k", nil, "delete keys matching glob pattern (repeatable)") + delCmd.Flags().StringSliceP("store", "s", nil, "target stores matching glob pattern (repeatable)") + delCmd.Flags().StringSliceP("value", "v", nil, "delete entries matching value glob pattern (repeatable)") rootCmd.AddCommand(delCmd) } @@ -152,7 +164,7 @@ func keyExists(store *Store, arg string) (bool, error) { return findEntry(entries, spec.Key) >= 0, nil } -func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string) ([]resolvedTarget, error) { +func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, valuePatterns []string, storePatterns []string) ([]resolvedTarget, error) { targetSet := make(map[string]struct{}) var targets []resolvedTarget @@ -185,16 +197,32 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin addTarget(spec) } - if len(globPatterns) == 0 { + if len(globPatterns) == 0 && len(valuePatterns) == 0 && len(storePatterns) == 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) @@ -206,37 +234,50 @@ func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []strin if err != nil { return nil, fmt.Errorf("cannot remove '%s': %v", raw, err) } - compiled = append(compiled, compiledPattern{ - rawArg: raw, - db: spec.DB, - matcher: m, - }) + 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}) + } } - keysByDB := make(map[string][]string) - getKeys := func(db string) ([]string, error) { - if keys, ok := keysByDB[db]; ok { - return keys, nil + entriesByDB := make(map[string][]Entry) + getEntries := func(db string) ([]Entry, error) { + if entries, ok := entriesByDB[db]; ok { + return entries, nil } - keys, err := store.Keys(db) + p, err := store.storePath(db) if err != nil { return nil, err } - keysByDB[db] = keys - return keys, nil + entries, err := readStoreFile(p, nil) + if err != nil { + return nil, err + } + entriesByDB[db] = entries + return entries, nil } for _, p := range compiled { - keys, err := getKeys(p.db) + entries, err := getEntries(p.db) if err != nil { return nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err) } - for _, k := range keys { - if p.matcher.Match(k) { + for _, e := range entries { + if p.matcher.Match(e.Key) && valueMatch(valueMatchers, e) { addTarget(KeySpec{ - Raw: k, - RawKey: k, - Key: k, + Raw: e.Key, + RawKey: e.Key, + Key: e.Key, DB: p.db, }) } diff --git a/cmd/doctor.go b/cmd/doctor.go index f1a58af..6ac28df 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -302,12 +302,18 @@ func configDiffs() []string { if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete { diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete)) } + if config.Key.AlwaysPromptGlobDelete != def.Key.AlwaysPromptGlobDelete { + diffs = append(diffs, fmt.Sprintf("key.always_prompt_glob_delete: %v", config.Key.AlwaysPromptGlobDelete)) + } if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite { diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite)) } if config.Store.DefaultStoreName != def.Store.DefaultStoreName { diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName)) } + if config.Store.ListAllStores != def.Store.ListAllStores { + diffs = append(diffs, fmt.Sprintf("store.list_all_stores: %v", config.Store.ListAllStores)) + } if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete { diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete)) } diff --git a/cmd/export.go b/cmd/export.go index ce90a82..94a22eb 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -40,6 +40,7 @@ var exportCmd = &cobra.Command{ 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.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(exportCmd) } diff --git a/cmd/list.go b/cmd/list.go index b8c0a98..a4d1768 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -28,6 +28,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" "unicode/utf8" @@ -63,6 +64,7 @@ var ( listNoValues bool listNoTTL bool listFull bool + listAll bool listNoHeader bool listFormat formatEnum = "table" @@ -75,11 +77,22 @@ const ( columnKey columnKind = iota columnValue columnTTL + columnStore ) var listCmd = &cobra.Command{ - Use: "list [STORE]", - Short: "List the contents of a store", + 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. + +The Store column is always shown so entries can be distinguished across +stores. Use --key/-k and --value/-v to filter by key or value glob, and +--store/-s to filter by store name. All filters are repeatable and OR'd +within the same flag.`, Aliases: []string{"ls"}, Args: cobra.MaximumNArgs(1), RunE: list, @@ -88,8 +101,22 @@ var listCmd = &cobra.Command{ func list(cmd *cobra.Command, args []string) error { store := &Store{} - targetDB := "@" + config.Store.DefaultStoreName - if len(args) == 1 { + + 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.Store.ListAllStores || listAll) + var targetDB string + if allStores { + targetDB = "all" + } else if len(args) == 0 { + targetDB = "@" + config.Store.DefaultStoreName + } else { rawArg := args[0] dbName, err := store.parseDB(rawArg, false) if err != nil { @@ -113,6 +140,7 @@ func list(cmd *cobra.Command, args []string) error { if !listNoKeys { columns = append(columns, columnKey) } + columns = append(columns, columnStore) if !listNoValues { columns = append(columns, columnValue) } @@ -138,26 +166,62 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } + storeMatchers, err := compileGlobMatchers(storePatterns) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + identity, _ := loadIdentity() var recipient *age.X25519Recipient if identity != nil { recipient = identity.Recipient() } - 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) + 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 and value regex + // Filter by key glob, value regex, and store glob var filtered []Entry for _, e := range entries { - if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) { + if globMatch(matchers, e.Key) && valueMatch(valueMatchers, e) && globMatch(storeMatchers, e.StoreName) { filtered = append(filtered, e) } } @@ -167,15 +231,19 @@ func list(cmd *cobra.Command, args []string) error { return nil } - if (len(matchers) > 0 || len(valueMatchers) > 0) && len(filtered) == 0 { - switch { - case len(matchers) > 0 && len(valueMatchers) > 0: - return fmt.Errorf("cannot ls '%s': no matches for key pattern %s and value pattern %s", targetDB, formatGlobPatterns(keyPatterns), formatValuePatterns(valuePatterns)) - case len(valueMatchers) > 0: - return fmt.Errorf("cannot ls '%s': no matches for value pattern %s", targetDB, formatValuePatterns(valuePatterns)) - default: - return fmt.Errorf("cannot ls '%s': no matches for key pattern %s", targetDB, formatGlobPatterns(keyPatterns)) + 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() @@ -187,6 +255,7 @@ func list(cmd *cobra.Command, args []string) error { 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) @@ -198,15 +267,16 @@ func list(cmd *cobra.Command, args []string) error { // JSON format: emit a single JSON array if listFormat.String() == "json" { - var entries []jsonEntry + var jsonEntries []jsonEntry for _, e := range filtered { je, err := encodeJsonEntry(e, recipient) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } - entries = append(entries, je) + je.Store = e.StoreName + jsonEntries = append(jsonEntries, je) } - data, err := json.Marshal(entries) + data, err := json.Marshal(jsonEntries) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -267,6 +337,12 @@ func list(cmd *cobra.Command, args []string) error { } else { row = append(row, valueStr) } + case columnStore: + if tty { + row = append(row, dimStyle.Sprint(e.StoreName)) + } else { + row = append(row, e.StoreName) + } case columnTTL: ttlStr := formatExpiry(e.ExpiresAt) if tty && e.ExpiresAt == 0 { @@ -359,6 +435,8 @@ func headerRow(columns []columnKind, tty bool) table.Row { switch col { case columnKey: row = append(row, h("Key")) + case columnStore: + row = append(row, h("Store")) case columnValue: row = append(row, h("Value")) case columnTTL: @@ -369,13 +447,14 @@ func headerRow(columns []columnKind, tty bool) table.Row { } const ( - keyColumnWidthCap = 30 - ttlColumnWidthCap = 20 + keyColumnWidthCap = 30 + storeColumnWidthCap = 20 + ttlColumnWidthCap = 20 ) // columnLayout holds the resolved max widths for each column kind. type columnLayout struct { - key, value, ttl int + key, store, value, ttl int } // computeLayout derives column widths from the terminal size and actual @@ -385,11 +464,14 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL var lay columnLayout termWidth := detectTerminalWidth(out) - // Scan entries for actual max key/TTL content widths. + // Scan entries for actual max key/store/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(formatExpiry(e.ExpiresAt)); w > lay.ttl { lay.ttl = w } @@ -397,6 +479,9 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL if lay.key > keyColumnWidthCap { lay.key = keyColumnWidthCap } + if lay.store > storeColumnWidthCap { + lay.store = storeColumnWidthCap + } if lay.ttl > ttlColumnWidthCap { lay.ttl = ttlColumnWidthCap } @@ -417,6 +502,8 @@ func computeLayout(columns []columnKind, out io.Writer, entries []Entry) columnL switch col { case columnKey: lay.value -= lay.key + case columnStore: + lay.value -= lay.store case columnTTL: lay.value -= lay.ttl } @@ -442,6 +529,9 @@ func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer, lay case columnKey: maxW = lay.key enforcer = text.Trim + case columnStore: + maxW = lay.store + enforcer = text.Trim case columnValue: maxW = lay.value if full { @@ -496,6 +586,7 @@ func renderTable(tw table.Writer) { } 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") @@ -505,6 +596,7 @@ func init() { 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.Flags().StringSliceP("value", "v", nil, "filter values with glob pattern (repeatable)") rootCmd.AddCommand(listCmd) } diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 09e35d2..3908232 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -43,6 +43,7 @@ type Entry struct { ExpiresAt uint64 // Unix timestamp; 0 = never expires Secret bool // encrypted on disk Locked bool // secret but no identity available to decrypt + StoreName string // populated by list --all } // jsonEntry is the NDJSON on-disk format. @@ -51,6 +52,7 @@ type jsonEntry struct { Value string `json:"value"` Encoding string `json:"encoding,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` + Store string `json:"store,omitempty"` } // readStoreFile reads all non-expired entries from an NDJSON file. diff --git a/cmd/restore.go b/cmd/restore.go index aaec428..90d7e3a 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -46,15 +46,16 @@ var restoreCmd = &cobra.Command{ func restore(cmd *cobra.Command, args []string) error { store := &Store{} - dbName := config.Store.DefaultStoreName - if len(args) == 1 { + explicitStore := len(args) == 1 + targetDB := config.Store.DefaultStoreName + if explicitStore { parsed, err := store.parseDB(args[0], false) if err != nil { return fmt.Errorf("cannot restore '%s': %v", args[0], err) } - dbName = parsed + targetDB = parsed } - displayTarget := "@" + dbName + displayTarget := "@" + targetDB keyPatterns, err := cmd.Flags().GetStringSlice("key") if err != nil { @@ -65,6 +66,15 @@ func restore(cmd *cobra.Command, args []string) error { 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) + if err != nil { + return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) + } + reader, closer, err := restoreInput(cmd) if err != nil { return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) @@ -73,11 +83,6 @@ func restore(cmd *cobra.Command, args []string) error { defer closer.Close() } - p, err := store.storePath(dbName) - if err != nil { - return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) - } - decoder := json.NewDecoder(bufio.NewReaderSize(reader, 8*1024*1024)) interactive, err := cmd.Flags().GetBool("interactive") @@ -101,7 +106,6 @@ func restore(cmd *cobra.Command, args []string) error { if promptOverwrite { filePath, _ := cmd.Flags().GetString("file") if strings.TrimSpace(filePath) == "" { - // Data comes from stdin — open /dev/tty for interactive prompts. 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) @@ -111,26 +115,60 @@ func restore(cmd *cobra.Command, args []string) error { } } - restored, err := restoreEntries(decoder, p, restoreOpts{ + opts := restoreOpts{ matchers: matchers, + storeMatchers: storeMatchers, promptOverwrite: promptOverwrite, drop: drop, identity: identity, recipient: recipient, promptReader: promptReader, - }) - if err != nil { - return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } - if len(matchers) > 0 && restored == 0 { - return fmt.Errorf("cannot restore '%s': no matches for key pattern %s", displayTarget, formatGlobPatterns(keyPatterns)) + // When a specific store is given, all entries go there (original behaviour). + // Otherwise, route entries to their original store via the "store" field. + if explicitStore { + p, err := store.storePath(targetDB) + if err != nil { + return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) + } + restored, err := restoreEntries(decoder, map[string]string{targetDB: p}, targetDB, opts) + if err != nil { + return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) + } + if err := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil { + return err + } + okf("restored %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 := reportRestoreFilters(displayTarget, restored, matchers, keyPatterns, storeMatchers, storePatterns); err != nil { + return err + } + okf("restored %d entries", restored) } - okf("restored %d entries into @%s", restored, dbName) return autoSync() } +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 ")) + } + return nil +} + func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) { filePath, err := cmd.Flags().GetString("file") if err != nil { @@ -148,6 +186,7 @@ func restoreInput(cmd *cobra.Command) (io.Reader, io.Closer, error) { type restoreOpts struct { matchers []glob.Glob + storeMatchers []glob.Glob promptOverwrite bool drop bool identity *age.X25519Identity @@ -155,14 +194,49 @@ type restoreOpts struct { promptReader io.Reader } -func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) (int, error) { - var existing []Entry - if !opts.drop { - var err error - existing, err = readStoreFile(storePath, opts.identity) - if err != nil { - return 0, err +// 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 @@ -183,13 +257,27 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( 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) if err != nil { return 0, fmt.Errorf("entry %d: %w", entryNo, err) } - idx := findEntry(existing, entry.Key) + 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) @@ -210,16 +298,18 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( } if idx >= 0 { - existing[idx] = entry + acc.entries[idx] = entry } else { - existing = append(existing, entry) + acc.entries = append(acc.entries, entry) } restored++ } - if restored > 0 || opts.drop { - if err := writeStoreFile(storePath, existing, opts.recipient); err != nil { - return 0, err + for _, acc := range stores { + if restored > 0 || opts.drop { + if err := writeStoreFile(acc.path, acc.entries, opts.recipient); err != nil { + return 0, err + } } } return restored, nil @@ -228,6 +318,7 @@ func restoreEntries(decoder *json.Decoder, storePath string, opts restoreOpts) ( 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.Flags().BoolP("interactive", "i", false, "prompt before overwriting existing keys") restoreCmd.Flags().Bool("drop", false, "drop existing entries before restoring (full replace)") rootCmd.AddCommand(restoreCmd) diff --git a/testdata/export-key-filter.ct b/testdata/export-key-filter.ct index 78a3452..d9e3cdf 100644 --- a/testdata/export-key-filter.ct +++ b/testdata/export-key-filter.ct @@ -2,7 +2,7 @@ $ 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"} -{"key":"a2","value":"2","encoding":"text"} +{"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 index 7889003..7a87b6c 100644 --- a/testdata/export-value-filter.ct +++ b/testdata/export-value-filter.ct @@ -3,6 +3,6 @@ $ 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"} +{"key":"url","value":"https://example.com","encoding":"text","store":"evf"} $ pda export evf --value "**world**" -{"key":"greeting","value":"hello world\n","encoding":"text"} +{"key":"greeting","value":"hello world\n","encoding":"text","store":"evf"} diff --git a/testdata/export.ct b/testdata/export.ct index 64d6f21..d5c9792 100644 --- a/testdata/export.ct +++ b/testdata/export.ct @@ -2,5 +2,5 @@ $ pda set a@exp 1 $ pda set b@exp 2 $ pda export exp -{"key":"a","value":"1","encoding":"text"} -{"key":"b","value":"2","encoding":"text"} +{"key":"a","value":"1","encoding":"text","store":"exp"} +{"key":"b","value":"2","encoding":"text","store":"exp"} diff --git a/testdata/help-export.ct b/testdata/help-export.ct index 4bbbf8e..9a8d8e5 100644 --- a/testdata/help-export.ct +++ b/testdata/help-export.ct @@ -8,6 +8,7 @@ Usage: 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) @@ -17,4 +18,5 @@ Usage: 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-import.ct b/testdata/help-import.ct index 2baf780..c3c70f8 100644 --- a/testdata/help-import.ct +++ b/testdata/help-import.ct @@ -6,19 +6,21 @@ 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) + --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) + --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.ct b/testdata/help-list.ct index f513a64..e6aec50 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -1,6 +1,15 @@ $ pda help list $ pda list --help -List the contents of a store +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. + +The Store column is always shown so entries can be distinguished across +stores. Use --key/-k and --value/-v to filter by key or value glob, and +--store/-s to filter by store name. All filters are repeatable and OR'd +within the same flag. Usage: pda list [STORE] [flags] @@ -9,6 +18,7 @@ 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) (default table) @@ -19,8 +29,18 @@ Flags: --no-keys suppress the key 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 a store +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. + +The Store column is always shown so entries can be distinguished across +stores. Use --key/-k and --value/-v to filter by key or value glob, and +--store/-s to filter by store name. All filters are repeatable and OR'd +within the same flag. Usage: pda list [STORE] [flags] @@ -29,6 +49,7 @@ 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) (default table) @@ -39,4 +60,5 @@ Flags: --no-keys suppress the key 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.ct b/testdata/help-remove.ct index 124c491..6e93d94 100644 --- a/testdata/help-remove.ct +++ b/testdata/help-remove.ct @@ -9,10 +9,12 @@ Aliases: remove, rm Flags: - -h, --help help for remove - -i, --interactive prompt yes/no for each deletion - -k, --key strings delete keys matching glob pattern (repeatable) - -y, --yes skip all confirmation prompts + -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: @@ -22,7 +24,9 @@ Aliases: remove, rm Flags: - -h, --help help for remove - -i, --interactive prompt yes/no for each deletion - -k, --key strings delete keys matching glob pattern (repeatable) - -y, --yes skip all confirmation prompts + -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.ct b/testdata/help.ct index 5df93df..530fc3d 100644 --- a/testdata/help.ct +++ b/testdata/help.ct @@ -16,7 +16,7 @@ Key commands: copy Make a copy of a key get Get the value of a key identity Show or create the age encryption identity - list List the contents of a store + list List the contents of all stores move Move a key remove Delete one or more keys run Get the value of a key and execute it @@ -60,7 +60,7 @@ Key commands: copy Make a copy of a key get Get the value of a key identity Show or create the age encryption identity - list List the contents of a store + list List the contents of all stores move Move a key remove Delete one or more keys run Get the value of a key and execute it diff --git a/testdata/list-all.ct b/testdata/list-all.ct new file mode 100644 index 0000000..2adf6f3 --- /dev/null +++ b/testdata/list-all.ct @@ -0,0 +1,29 @@ +# List defaults to all stores +$ 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 +$ pda ls --key "lax" --count +2 +$ pda ls --key "lax" --format json +[{"key":"lax","value":"1","encoding":"text","store":"laa"},{"key":"lax","value":"2","encoding":"text","store":"lab"}] +# Positional arg narrows to one store +$ pda ls laa --key "lax" --format tsv +Key Store Value TTL +lax laa 1 no expiry +# --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 +$ pda ls --store "laa" --key "lax" --format tsv +Key Store Value TTL +lax laa 1 no expiry +# --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-format-csv.ct b/testdata/list-format-csv.ct index 9869a27..6d5fd73 100644 --- a/testdata/list-format-csv.ct +++ b/testdata/list-format-csv.ct @@ -2,6 +2,6 @@ $ pda set a@csv 1 $ pda set b@csv 2 $ pda ls csv --format csv -Key,Value,TTL -a,1,no expiry -b,2,no expiry +Key,Store,Value,TTL +a,csv,1,no expiry +b,csv,2,no expiry diff --git a/testdata/list-format-json.ct b/testdata/list-format-json.ct index 6c2cd85..2db4304 100644 --- a/testdata/list-format-json.ct +++ b/testdata/list-format-json.ct @@ -2,4 +2,4 @@ $ pda set a@jf 1 $ pda set b@jf 2 $ pda ls jf --format json -[{"key":"a","value":"1","encoding":"text"},{"key":"b","value":"2","encoding":"text"}] +[{"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 index c97165e..c27f045 100644 --- a/testdata/list-format-markdown.ct +++ b/testdata/list-format-markdown.ct @@ -2,7 +2,7 @@ $ pda set a@md 1 $ pda set b@md 2 $ pda ls md --format markdown -| Key | Value | TTL | -| --- | --- | --- | -| a | 1 | no expiry | -| b | 2 | no expiry | +| Key | Store | Value | TTL | +| --- | --- | --- | --- | +| a | md | 1 | no expiry | +| b | md | 2 | no expiry | diff --git a/testdata/list-format-ndjson.ct b/testdata/list-format-ndjson.ct index 6740b01..cd949e5 100644 --- a/testdata/list-format-ndjson.ct +++ b/testdata/list-format-ndjson.ct @@ -2,5 +2,5 @@ $ pda set a@nj 1 $ pda set b@nj 2 $ pda ls nj --format ndjson -{"key":"a","value":"1","encoding":"text"} -{"key":"b","value":"2","encoding":"text"} +{"key":"a","value":"1","encoding":"text","store":"nj"} +{"key":"b","value":"2","encoding":"text","store":"nj"} diff --git a/testdata/list-key-filter.ct b/testdata/list-key-filter.ct index a686456..7f8e8b2 100644 --- a/testdata/list-key-filter.ct +++ b/testdata/list-key-filter.ct @@ -2,11 +2,11 @@ $ pda set a1@lg 1 $ pda set a2@lg 2 $ pda set b1@lg 3 $ pda ls lg --key "a*" --format tsv -Key Value TTL -a1 1 no expiry -a2 2 no expiry +Key Store Value TTL +a1 lg 1 no expiry +a2 lg 2 no expiry $ pda ls lg --key "b*" --format tsv -Key Value TTL -b1 3 no expiry +Key Store Value TTL +b1 lg 3 no expiry $ 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 9d55fcb..2ffdbc4 100644 --- a/testdata/list-key-value-filter.ct +++ b/testdata/list-key-value-filter.ct @@ -2,10 +2,10 @@ $ pda set dburl@kv postgres://localhost:5432 $ pda set apiurl@kv https://api.example.com $ pda set dbpass@kv s3cret $ pda ls kv -k "db*" -v "**localhost**" --format tsv -Key Value TTL -dburl postgres://localhost:5432 no expiry +Key Store Value TTL +dburl kv postgres://localhost:5432 no expiry $ pda ls kv -k "*url*" -v "**example**" --format tsv -Key Value TTL -apiurl https://api.example.com no expiry +Key Store Value TTL +apiurl kv https://api.example.com no expiry $ 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 63992dc..6b80e52 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 1 no expiry +a nh 1 no expiry diff --git a/testdata/list-no-keys.ct b/testdata/list-no-keys.ct index c364e54..c1ffe62 100644 --- a/testdata/list-no-keys.ct +++ b/testdata/list-no-keys.ct @@ -1,5 +1,5 @@ # --no-keys suppresses the key column $ pda set a@nk 1 $ pda ls nk --format tsv --no-keys -Value TTL -1 no expiry +Store Value TTL +nk 1 no expiry diff --git a/testdata/list-no-ttl.ct b/testdata/list-no-ttl.ct index c9799cb..6f9107c 100644 --- a/testdata/list-no-ttl.ct +++ b/testdata/list-no-ttl.ct @@ -1,5 +1,5 @@ # --no-ttl suppresses the TTL column $ pda set a@nt 1 $ pda ls nt --format tsv --no-ttl -Key Value -a 1 +Key Store Value +a nt 1 diff --git a/testdata/list-no-values.ct b/testdata/list-no-values.ct index 9ebb69a..b76c081 100644 --- a/testdata/list-no-values.ct +++ b/testdata/list-no-values.ct @@ -1,5 +1,5 @@ # --no-values suppresses the value column $ pda set a@nv 1 $ pda ls nv --format tsv --no-values -Key TTL -a no expiry +Key Store TTL +a nv no expiry diff --git a/testdata/list-stores.ct b/testdata/list-stores.ct index 7269065..e5c7412 100644 --- a/testdata/list-stores.ct +++ b/testdata/list-stores.ct @@ -2,8 +2,8 @@ $ pda set a@lsalpha 1 $ pda set b@lsbeta 2 $ pda ls lsalpha --format tsv -Key Value TTL -a 1 no expiry +Key Store Value TTL +a lsalpha 1 no expiry $ pda ls lsbeta --format tsv -Key Value TTL -b 2 no expiry +Key Store Value TTL +b lsbeta 2 no expiry diff --git a/testdata/list-value-filter.ct b/testdata/list-value-filter.ct index ee0ca02..dae1dbd 100644 --- a/testdata/list-value-filter.ct +++ b/testdata/list-value-filter.ct @@ -3,13 +3,13 @@ $ fecho tmpval hello world $ pda set greeting@vt < tmpval $ pda set number@vt 42 $ pda ls vt --value "**world**" --format tsv -Key Value TTL -greeting hello world (..1 more chars) no expiry +Key Store Value TTL +greeting vt hello world (..1 more chars) no expiry $ pda ls vt --value "**https**" --format tsv -Key Value TTL -url https://example.com no expiry +Key Store Value TTL +url vt https://example.com no expiry $ pda ls vt --value "*" --format tsv -Key Value TTL -number 42 no expiry +Key Store Value TTL +number vt 42 no expiry $ 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 a57e2ce..df1fe0b 100644 --- a/testdata/list-value-multi-filter.ct +++ b/testdata/list-value-multi-filter.ct @@ -3,6 +3,6 @@ $ fecho tmpval hello world $ pda set greeting@vm < tmpval $ pda set number@vm 42 $ pda ls vm --value "**world**" --value "42" --format tsv -Key Value TTL -greeting hello world (..1 more chars) no expiry -number 42 no expiry +Key Store Value TTL +greeting vm hello world (..1 more chars) no expiry +number vm 42 no expiry diff --git a/testdata/multistore.ct b/testdata/multistore.ct index ac0ba32..cde1df3 100644 --- a/testdata/multistore.ct +++ b/testdata/multistore.ct @@ -6,5 +6,5 @@ bar $ pda get x@ms2 y $ pda ls ms2 --format tsv -Key Value TTL -x y no expiry +Key Store Value TTL +x ms2 y no expiry diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct index c24ec34..d7c1245 100644 --- a/testdata/remove-dedupe.ct +++ b/testdata/remove-dedupe.ct @@ -2,10 +2,10 @@ $ pda set foo@rdd 1 $ pda set bar@rdd 2 $ pda ls rdd --format tsv -Key Value TTL -bar 2 no expiry -foo 1 no expiry -$ pda rm foo@rdd --key "*@rdd" +Key Store Value TTL +bar rdd 2 no expiry +foo rdd 1 no expiry +$ 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 diff --git a/testdata/remove-key-glob.ct b/testdata/remove-key-glob.ct index be5b2cf..fed822d 100644 --- a/testdata/remove-key-glob.ct +++ b/testdata/remove-key-glob.ct @@ -1,7 +1,7 @@ $ pda set a1@rkg 1 $ pda set a2@rkg 2 $ pda set b1@rkg 3 -$ pda rm --key "a*@rkg" +$ pda rm --key "a*@rkg" -y $ pda get a1@rkg --> FAIL FAIL cannot get 'a1@rkg': no such key hint did you mean 'b1'? diff --git a/testdata/remove-key-mixed.ct b/testdata/remove-key-mixed.ct index 9bfa2c6..49b7299 100644 --- a/testdata/remove-key-mixed.ct +++ b/testdata/remove-key-mixed.ct @@ -1,7 +1,7 @@ $ pda set foo@rkm 1 $ pda set bar1@rkm 2 $ pda set bar2@rkm 3 -$ pda rm foo@rkm --key "bar*@rkm" +$ 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 diff --git a/testdata/root.ct b/testdata/root.ct index 6e049e0..fe8496e 100644 --- a/testdata/root.ct +++ b/testdata/root.ct @@ -15,7 +15,7 @@ Key commands: copy Make a copy of a key get Get the value of a key identity Show or create the age encryption identity - list List the contents of a store + list List the contents of all stores move Move a key remove Delete one or more keys run Get the value of a key and execute it From 3f6ddfbcd4cf78eaacad317c88401a8042d92ca8 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:29:54 +0000 Subject: [PATCH 064/107] feat(config): add reflection-based configFields framework --- cmd/config_fields.go | 55 +++++++++++++++ cmd/config_fields_test.go | 138 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 cmd/config_fields.go create mode 100644 cmd/config_fields_test.go diff --git a/cmd/config_fields.go b/cmd/config_fields.go new file mode 100644 index 0000000..6627cec --- /dev/null +++ b/cmd/config_fields.go @@ -0,0 +1,55 @@ +package cmd + +import "reflect" + +// 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(), + }) + } +} diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go new file mode 100644 index 0000000..1f994d6 --- /dev/null +++ b/cmd/config_fields_test.go @@ -0,0 +1,138 @@ +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, + "store.default_store_name": true, + "store.list_all_stores": true, + "store.always_prompt_delete": true, + "store.always_prompt_overwrite": true, + "git.auto_fetch": true, + "git.auto_commit": true, + "git.auto_push": 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 != "default" { + t.Errorf("store.default_store_name Value = %v, want 'default'", f.Value) + } + return + } + } + t.Error("store.default_store_name not found in fields") +} From e4a5e7f71501103daf095f91e69d01b144772a36 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:34:20 +0000 Subject: [PATCH 065/107] feat(config): add config parent command and path subcommand --- cmd/config_cmd.go | 32 ++++++++++++++++++++++++++++++++ testdata/help.ct | 2 ++ testdata/root.ct | 1 + 3 files changed, 35 insertions(+) create mode 100644 cmd/config_cmd.go diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go new file mode 100644 index 0000000..802e815 --- /dev/null +++ b/cmd/config_cmd.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "View and modify configuration", +} + +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 + }, +} + +func init() { + configCmd.AddCommand(configPathCmd) + rootCmd.AddCommand(configCmd) +} diff --git a/testdata/help.ct b/testdata/help.ct index 530fc3d..cc7e94b 100644 --- a/testdata/help.ct +++ b/testdata/help.ct @@ -36,6 +36,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + config View and modify configuration doctor Check environment health help Help about any command version Display pda! version @@ -80,6 +81,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + config View and modify configuration doctor Check environment health help Help about any command version Display pda! version diff --git a/testdata/root.ct b/testdata/root.ct index fe8496e..1370ab8 100644 --- a/testdata/root.ct +++ b/testdata/root.ct @@ -35,6 +35,7 @@ Git commands: Additional Commands: completion Generate the autocompletion script for the specified shell + config View and modify configuration doctor Check environment health help Help about any command version Display pda! version From cc19ee5c0f172c433348a4d8c0f34250c52d0341 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:35:51 +0000 Subject: [PATCH 066/107] feat(config): add config list subcommand --- cmd/config_cmd.go | 17 +++++++++++++++++ testdata/config-list.ct | 12 ++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 testdata/config-list.ct diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 802e815..2e1423c 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -11,6 +11,22 @@ var configCmd = &cobra.Command{ 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 configPathCmd = &cobra.Command{ Use: "path", Short: "Print config file path", @@ -27,6 +43,7 @@ var configPathCmd = &cobra.Command{ } func init() { + configCmd.AddCommand(configListCmd) configCmd.AddCommand(configPathCmd) rootCmd.AddCommand(configCmd) } diff --git a/testdata/config-list.ct b/testdata/config-list.ct new file mode 100644 index 0000000..3df2e13 --- /dev/null +++ b/testdata/config-list.ct @@ -0,0 +1,12 @@ +$ pda config list +display_ascii_art = true +key.always_prompt_delete = false +key.always_prompt_glob_delete = true +key.always_prompt_overwrite = false +store.default_store_name = default +store.list_all_stores = true +store.always_prompt_delete = true +store.always_prompt_overwrite = true +git.auto_fetch = false +git.auto_commit = false +git.auto_push = false From 6bba227654a9b756d128c576a9cbaf0e474032be Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:37:52 +0000 Subject: [PATCH 067/107] feat(config): add config get subcommand with suggestions --- cmd/config_cmd.go | 23 +++++++++++++++++++++++ cmd/config_fields.go | 31 ++++++++++++++++++++++++++++++- testdata/config-get.ct | 13 +++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 testdata/config-get.ct diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 2e1423c..5ab24d8 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" ) @@ -27,6 +28,27 @@ var configListCmd = &cobra.Command{ }, } +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", @@ -43,6 +65,7 @@ var configPathCmd = &cobra.Command{ } func init() { + configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configListCmd) configCmd.AddCommand(configPathCmd) rootCmd.AddCommand(configCmd) diff --git a/cmd/config_fields.go b/cmd/config_fields.go index 6627cec..5b94f79 100644 --- a/cmd/config_fields.go +++ b/cmd/config_fields.go @@ -1,6 +1,10 @@ package cmd -import "reflect" +import ( + "reflect" + + "github.com/agnivade/levenshtein" +) // ConfigField represents a single leaf field in the Config struct, // mapped to its dotted TOML key path. @@ -53,3 +57,28 @@ func walk(cv, dv reflect.Value, prefix string, out *[]ConfigField) { }) } } + +// 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 Levenshtein-based suggestions for a mistyped config key. +func suggestConfigKey(fields []ConfigField, target string) []string { + minThreshold := 1 + maxThreshold := 4 + threshold := min(max(len(target)/3, minThreshold), maxThreshold) + var suggestions []string + for _, f := range fields { + if levenshtein.ComputeDistance(target, f.Key) <= threshold { + suggestions = append(suggestions, f.Key) + } + } + return suggestions +} diff --git a/testdata/config-get.ct b/testdata/config-get.ct new file mode 100644 index 0000000..a4e7f4e --- /dev/null +++ b/testdata/config-get.ct @@ -0,0 +1,13 @@ +$ pda config get display_ascii_art +true + +$ pda config get store.default_store_name +default + +$ pda config get git.auto_commit +false + +# Unknown key with suggestion +$ pda config get git.auto_comit --> FAIL +FAIL unknown config key 'git.auto_comit' +hint did you mean 'git.auto_commit'? From db607ac69647a2fc7a2e36b3d6012fd5cf298e4a Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:43:21 +0000 Subject: [PATCH 068/107] feat(config): add config init subcommand with --new flag --- cmd/config_cmd.go | 45 +++++++++++++++++++++++++++++++++++++++++ testdata/config-init.ct | 10 +++++++++ 2 files changed, 55 insertions(+) create mode 100644 testdata/config-init.ct diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 5ab24d8..21361cc 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -2,8 +2,11 @@ package cmd import ( "fmt" + "os" + "path/filepath" "strings" + "github.com/BurntSushi/toml" "github.com/spf13/cobra" ) @@ -64,8 +67,50 @@ var configPathCmd = &cobra.Command{ }, } +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") + if !newFlag { + if _, err := os.Stat(p); err == nil { + return withHint( + fmt.Errorf("config file already exists"), + "use 'pda config edit' or 'pda config init --new'", + ) + } + } + return writeConfigFile(defaultConfig()) + }, +} + func init() { + configInitCmd.Flags().Bool("new", false, "overwrite existing config file") configCmd.AddCommand(configGetCmd) + configCmd.AddCommand(configInitCmd) configCmd.AddCommand(configListCmd) configCmd.AddCommand(configPathCmd) rootCmd.AddCommand(configCmd) diff --git a/testdata/config-init.ct b/testdata/config-init.ct new file mode 100644 index 0000000..2b36536 --- /dev/null +++ b/testdata/config-init.ct @@ -0,0 +1,10 @@ +# Init creates a config file +$ pda config init + +# Second init fails +$ pda config init --> FAIL +FAIL config file already exists +hint use 'pda config edit' or 'pda config init --new' + +# Init --new overwrites +$ pda config init --new From c9b448d5089194aa8b5b024d7e78ef7443f24f77 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:47:59 +0000 Subject: [PATCH 069/107] refactor(msg): single space between keyword and message, improve config suggestions Tightens keyword formatting (ok/FAIL/hint/etc.) from two spaces to one. Makes config key suggestions more generous: normalises spaces to underscores, matches against leaf segments, and uses substring matching. Updates all golden files. --- README.md | 70 +++++++++++++-------------- cmd/config_fields.go | 31 ++++++++++-- cmd/doctor.go | 4 +- cmd/msg.go | 16 +++--- testdata/config-get.ct | 11 +++-- testdata/config-init.ct | 4 +- testdata/cp-cross-store.ct | 2 +- testdata/cp-encrypt.ct | 2 +- testdata/cp-missing-err.ct | 2 +- testdata/cp-safe.ct | 2 +- testdata/cp.ct | 2 +- testdata/export-key-filter.ct | 2 +- testdata/get-invalid-store-err.ct | 2 +- testdata/get-missing-all-flags-err.ct | 14 +++--- testdata/get-missing-err.ct | 2 +- testdata/import-drop.ct | 4 +- testdata/import-key-filter.ct | 8 +-- testdata/import-merge.ct | 2 +- testdata/import-stdin.ct | 2 +- testdata/invalid-command-err.ct | 2 +- testdata/list-all-suppressed-err.ct | 4 +- testdata/list-all.ct | 4 +- testdata/list-invalid-store-err.ct | 2 +- testdata/list-key-filter.ct | 2 +- testdata/list-key-value-filter.ct | 2 +- testdata/list-value-filter.ct | 2 +- testdata/mv-cross-store.ct | 4 +- testdata/mv-encrypt.ct | 4 +- testdata/mv-missing-err.ct | 2 +- testdata/mv-safe.ct | 2 +- testdata/mv-store-copy.ct | 2 +- testdata/mv-store-missing-err.ct | 2 +- testdata/mv-store-safe.ct | 2 +- testdata/mv-store-same-err.ct | 2 +- testdata/mv-store.ct | 2 +- testdata/mv.ct | 4 +- testdata/remove-dedupe.ct | 4 +- testdata/remove-key-glob.ct | 6 +-- testdata/remove-key-mixed.ct | 6 +-- testdata/remove-multiple.ct | 4 +- testdata/remove-store-invalid-err.ct | 2 +- testdata/remove-yes.ct | 4 +- testdata/set-file-conflict-err.ct | 2 +- testdata/set-invalid-ttl-err.ct | 2 +- testdata/set-safe.ct | 2 +- testdata/template-enum-err.ct | 2 +- testdata/template-require-err.ct | 2 +- 47 files changed, 144 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 4164295..6ec65af 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,11 @@ pda get name --exists `pda mv` to move it. ```bash pda mv name name2 -# ok renamed name to name2 +# ok renamed name to name2 # --safe to skip if the destination already exists. pda mv name name2 --safe -# info skipped 'name2': already exists +# info skipped 'name2': already exists # --yes/-y to skip confirmation prompts. pda mv name name2 -y @@ -210,8 +210,8 @@ pda rm kitty --key "?og" # Opt in to a confirmation prompt with --interactive/-i (or always_prompt_delete in config). pda rm kitty -i -# ??? remove 'kitty'? (y/n) -# ==> y +# ??? remove 'kitty'? (y/n) +# ==> y # --yes/-y to auto-accept all confirmation prompts. pda rm kitty -y @@ -283,11 +283,11 @@ pda export --value "**https**" ```bash # Entries are routed to their original stores. pda import -f my_backup -# ok restored 5 entries +# ok restored 5 entries # Force all entries into a specific store by passing a store name. pda import mystore -f my_backup -# ok restored 5 entries into @mystore +# ok restored 5 entries into @mystore # Or from stdin. pda import < my_backup @@ -322,8 +322,8 @@ pda list-stores --short # Check out a specific store. pda ls @birthdays --no-header --no-ttl -# alice 11/11/1998 -# bob 05/12/1980 +# alice 11/11/1998 +# bob 05/12/1980 # Export it. pda export birthdays > friends_birthdays @@ -427,7 +427,7 @@ pda get greeting NAME="Bob" ```bash pda set file "{{ require .FILE }}" pda get file -# FAIL cannot get 'file': ...required value is missing or empty +# FAIL cannot get 'file': ...required value is missing or empty ```

@@ -447,7 +447,7 @@ 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] +# FAIL cannot get 'level': ...invalid value 'debug', allowed: [info warn error] ```

@@ -601,9 +601,9 @@ pda ls --value "**world**" --value "42" Globs can be arbitrarily complex, and `--key` can be combined with exact positional args on `rm`. ```bash pda rm cat --key "{mouse,[cd]og}**" -# ??? remove 'cat'? (y/n) -# ==> y -# ??? remove 'mouse trap'? (y/n) +# ??? remove 'cat'? (y/n) +# ==> y +# ??? remove 'mouse trap'? (y/n) # ... ``` @@ -679,7 +679,7 @@ pda export ```bash pda set --encrypt api-key "sk-live-abc123" -# ok created identity at ~/.config/pda/identity.txt +# ok created identity at ~/.config/pda/identity.txt pda set --encrypt token "ghp_xxxx" ``` @@ -708,8 +708,8 @@ pda cp api-key api-key-backup # still encrypted pda set api-key "oops" -# WARN overwriting encrypted key 'api-key' as plaintext -# hint pass --encrypt to keep it encrypted +# WARN overwriting encrypted key 'api-key' as plaintext +# hint pass --encrypt to keep it encrypted ```

@@ -721,7 +721,7 @@ pda ls # api-key locked (identity file missing) no expiry pda get api-key -# FAIL cannot get 'api-key': secret is locked (identity file missing) +# FAIL cannot get 'api-key': secret is locked (identity file missing) ```

@@ -729,8 +729,8 @@ pda get api-key `pda identity` to see your public key and identity file path. ```bash pda identity -# ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -# ok identity ~/.config/pda/identity.txt +# ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +# ok identity ~/.config/pda/identity.txt # Just the path. pda identity --path @@ -748,22 +748,22 @@ pda identity --new ```bash pda doctor -# ok pda! 2025.52 Christmas release (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 -# ok No issues found +# ok pda! 2025.52 Christmas release (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 ```

diff --git a/cmd/config_fields.go b/cmd/config_fields.go index 5b94f79..f65f122 100644 --- a/cmd/config_fields.go +++ b/cmd/config_fields.go @@ -2,6 +2,7 @@ package cmd import ( "reflect" + "strings" "github.com/agnivade/levenshtein" ) @@ -69,16 +70,36 @@ func findConfigField(fields []ConfigField, key string) *ConfigField { return nil } -// suggestConfigKey returns Levenshtein-based suggestions for a mistyped config key. +// 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 { - minThreshold := 1 - maxThreshold := 4 - threshold := min(max(len(target)/3, minThreshold), maxThreshold) + normalized := strings.ReplaceAll(target, " ", "_") var suggestions []string for _, f := range fields { - if levenshtein.ComputeDistance(target, f.Key) <= threshold { + 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/doctor.go b/cmd/doctor.go index 6ac28df..56dfdc3 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -49,7 +49,7 @@ func runDoctor(w io.Writer) bool { code = "31" hasError = true } - fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg) + fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg) } tree := func(items []string) { @@ -279,7 +279,7 @@ func runDoctor(w io.Writer) bool { 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", + emit("ok", fmt.Sprintf("%d store(s), %d key(s), %d secret(s), %s total size", len(stores), totalKeys, totalSecrets, formatSize(int(totalSize)))) } } diff --git a/cmd/msg.go b/cmd/msg.go index c850b81..ea1d7d6 100644 --- a/cmd/msg.go +++ b/cmd/msg.go @@ -48,41 +48,41 @@ func keyword(code, word string, tty bool) string { } func printError(err error) { - fmt.Fprintf(os.Stderr, "%s %s\n", keyword("31", "FAIL", stderrIsTerminal()), err) + fmt.Fprintf(os.Stderr, "%s %s\n", keyword("31", "FAIL", stderrIsTerminal()), err) } func printHint(format string, args ...any) { msg := fmt.Sprintf(format, args...) - fmt.Fprintf(os.Stderr, "%s %s\n", keyword("2", "hint", stderrIsTerminal()), msg) + 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) + 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) + 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) + 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) + 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) + fmt.Fprintf(os.Stdout, "%s %s\n", keyword("2", ">", stdoutIsTerminal()), msg) } func scanln(dest *string) error { - fmt.Fprintf(os.Stdout, "%s ", keyword("2", "==>", stdoutIsTerminal())) + fmt.Fprintf(os.Stdout, "%s ", keyword("2", "==>", stdoutIsTerminal())) _, err := fmt.Scanln(dest) return err } diff --git a/testdata/config-get.ct b/testdata/config-get.ct index a4e7f4e..1ca3e65 100644 --- a/testdata/config-get.ct +++ b/testdata/config-get.ct @@ -7,7 +7,12 @@ default $ pda config get git.auto_commit false -# Unknown key with suggestion +# 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'? +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 index 2b36536..20539f3 100644 --- a/testdata/config-init.ct +++ b/testdata/config-init.ct @@ -3,8 +3,8 @@ $ pda config init # Second init fails $ pda config init --> FAIL -FAIL config file already exists -hint use 'pda config edit' or 'pda config init --new' +FAIL config file already exists +hint use 'pda config edit' or 'pda config init --new' # Init --new overwrites $ pda config init --new diff --git a/testdata/cp-cross-store.ct b/testdata/cp-cross-store.ct index 1d94f54..ee2d8ea 100644 --- a/testdata/cp-cross-store.ct +++ b/testdata/cp-cross-store.ct @@ -1,7 +1,7 @@ # Cross-store copy $ pda set key@src value $ pda cp key@src key@dst - ok copied key@src to key@dst + ok copied key@src to key@dst $ pda get key@src value $ pda get key@dst diff --git a/testdata/cp-encrypt.ct b/testdata/cp-encrypt.ct index 7512f52..7686f8e 100644 --- a/testdata/cp-encrypt.ct +++ b/testdata/cp-encrypt.ct @@ -1,7 +1,7 @@ # 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 + ok copied secret-key@cpe to copied-key@cpe $ pda get secret-key@cpe hidden-value $ pda get copied-key@cpe diff --git a/testdata/cp-missing-err.ct b/testdata/cp-missing-err.ct index d152d13..73e403e 100644 --- a/testdata/cp-missing-err.ct +++ b/testdata/cp-missing-err.ct @@ -1,3 +1,3 @@ # Copy non-existent key $ pda cp nonexistent dest --> FAIL -FAIL cannot move 'nonexistent': no such key +FAIL cannot move 'nonexistent': no such key diff --git a/testdata/cp-safe.ct b/testdata/cp-safe.ct index 0a46ca8..b4b057f 100644 --- a/testdata/cp-safe.ct +++ b/testdata/cp-safe.ct @@ -1,6 +1,6 @@ $ pda set src@csf hello $ pda set dst@csf existing $ pda cp src@csf dst@csf --safe -info skipped 'dst@csf': already exists +info skipped 'dst@csf': already exists $ pda get dst@csf existing diff --git a/testdata/cp.ct b/testdata/cp.ct index 0a7096c..92d18b4 100644 --- a/testdata/cp.ct +++ b/testdata/cp.ct @@ -1,7 +1,7 @@ # Basic copy $ pda set source@cpok value $ pda cp source@cpok dest@cpok - ok copied source@cpok to dest@cpok + ok copied source@cpok to dest@cpok $ pda get source@cpok value $ pda get dest@cpok diff --git a/testdata/export-key-filter.ct b/testdata/export-key-filter.ct index d9e3cdf..4bc3759 100644 --- a/testdata/export-key-filter.ct +++ b/testdata/export-key-filter.ct @@ -5,4 +5,4 @@ $ 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*' +FAIL cannot ls '@ekf': no matches for key pattern 'c*' diff --git a/testdata/get-invalid-store-err.ct b/testdata/get-invalid-store-err.ct index 973d83b..7c93ff2 100644 --- a/testdata/get-invalid-store-err.ct +++ b/testdata/get-invalid-store-err.ct @@ -1,2 +1,2 @@ $ pda get key@foo/bar --> FAIL -FAIL cannot get 'key@foo/bar': bad store format, use STORE or @STORE +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 index b4fe45a..55891a5 100644 --- a/testdata/get-missing-all-flags-err.ct +++ b/testdata/get-missing-all-flags-err.ct @@ -5,10 +5,10 @@ $ 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 +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 index b528954..20b7acf 100644 --- a/testdata/get-missing-err.ct +++ b/testdata/get-missing-err.ct @@ -1,2 +1,2 @@ $ pda get foobar --> FAIL -FAIL cannot get 'foobar': no such key +FAIL cannot get 'foobar': no such key diff --git a/testdata/import-drop.ct b/testdata/import-drop.ct index 3422eab..4466ad1 100644 --- a/testdata/import-drop.ct +++ b/testdata/import-drop.ct @@ -2,8 +2,8 @@ $ 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 + ok restored 1 entries into @idr $ pda get new@idr hello $ pda get existing@idr --> FAIL -FAIL cannot get 'existing@idr': no such key +FAIL cannot get 'existing@idr': no such key diff --git a/testdata/import-key-filter.ct b/testdata/import-key-filter.ct index 5a5cffb..98258df 100644 --- a/testdata/import-key-filter.ct +++ b/testdata/import-key-filter.ct @@ -4,13 +4,13 @@ $ 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 + 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'? +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*' +FAIL cannot restore '@ikf': no matches for key pattern 'c*' diff --git a/testdata/import-merge.ct b/testdata/import-merge.ct index 4c81265..7a66e5f 100644 --- a/testdata/import-merge.ct +++ b/testdata/import-merge.ct @@ -2,7 +2,7 @@ $ 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 + ok restored 2 entries into @mrg $ pda get existing@mrg updated $ pda get new@mrg diff --git a/testdata/import-stdin.ct b/testdata/import-stdin.ct index f120052..18383c5 100644 --- a/testdata/import-stdin.ct +++ b/testdata/import-stdin.ct @@ -2,7 +2,7 @@ $ pda set existing@stn keep-me $ fecho dumpfile {"key":"new","value":"hello","encoding":"text"} $ pda import stn < dumpfile - ok restored 1 entries into @stn + ok restored 1 entries into @stn $ pda get existing@stn keep-me $ pda get new@stn diff --git a/testdata/invalid-command-err.ct b/testdata/invalid-command-err.ct index 93359ea..d39ba9c 100644 --- a/testdata/invalid-command-err.ct +++ b/testdata/invalid-command-err.ct @@ -1,2 +1,2 @@ $ pda invalidcmd --> FAIL -FAIL unknown command "invalidcmd" for "pda" +FAIL unknown command "invalidcmd" for "pda" diff --git a/testdata/list-all-suppressed-err.ct b/testdata/list-all-suppressed-err.ct index f96a22b..432e144 100644 --- a/testdata/list-all-suppressed-err.ct +++ b/testdata/list-all-suppressed-err.ct @@ -1,5 +1,5 @@ # Error when all columns are suppressed $ pda set a@las 1 $ pda ls las --no-keys --no-values --no-ttl --> FAIL -FAIL cannot ls '@las': no columns selected -hint disable --no-keys, --no-values, or --no-ttl +FAIL cannot ls '@las': no columns selected +hint disable --no-keys, --no-values, or --no-ttl diff --git a/testdata/list-all.ct b/testdata/list-all.ct index 2adf6f3..1694b4f 100644 --- a/testdata/list-all.ct +++ b/testdata/list-all.ct @@ -23,7 +23,7 @@ Key Store Value TTL lax laa 1 no expiry # --store cannot be combined with positional arg $ pda ls --store "laa" laa --> FAIL -FAIL cannot use --store with a store argument +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' +FAIL cannot ls 'all': no matches for key pattern 'lax' and store pattern 'nonexistent' diff --git a/testdata/list-invalid-store-err.ct b/testdata/list-invalid-store-err.ct index 0a90ecb..eaf1a60 100644 --- a/testdata/list-invalid-store-err.ct +++ b/testdata/list-invalid-store-err.ct @@ -1,2 +1,2 @@ $ pda ls foo/bar --> FAIL -FAIL cannot ls 'foo/bar': cannot parse store: bad store format, use STORE or @STORE +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 index 7f8e8b2..d10e229 100644 --- a/testdata/list-key-filter.ct +++ b/testdata/list-key-filter.ct @@ -9,4 +9,4 @@ $ pda ls lg --key "b*" --format tsv Key Store Value TTL b1 lg 3 no expiry $ pda ls lg --key "c*" --> FAIL -FAIL cannot ls '@lg': no matches for key pattern 'c*' +FAIL cannot ls '@lg': no matches for key pattern 'c*' diff --git a/testdata/list-key-value-filter.ct b/testdata/list-key-value-filter.ct index 2ffdbc4..5fde71a 100644 --- a/testdata/list-key-value-filter.ct +++ b/testdata/list-key-value-filter.ct @@ -8,4 +8,4 @@ $ pda ls kv -k "*url*" -v "**example**" --format tsv Key Store Value TTL apiurl kv https://api.example.com no expiry $ pda ls kv -k "db*" -v "**nomatch**" --> FAIL -FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**' +FAIL cannot ls '@kv': no matches for key pattern 'db*' and value pattern '**nomatch**' diff --git a/testdata/list-value-filter.ct b/testdata/list-value-filter.ct index dae1dbd..0e29856 100644 --- a/testdata/list-value-filter.ct +++ b/testdata/list-value-filter.ct @@ -12,4 +12,4 @@ $ pda ls vt --value "*" --format tsv Key Store Value TTL number vt 42 no expiry $ pda ls vt --value "**nomatch**" --> FAIL -FAIL cannot ls '@vt': no matches for value pattern '**nomatch**' +FAIL cannot ls '@vt': no matches for value pattern '**nomatch**' diff --git a/testdata/mv-cross-store.ct b/testdata/mv-cross-store.ct index a604178..2e43987 100644 --- a/testdata/mv-cross-store.ct +++ b/testdata/mv-cross-store.ct @@ -1,8 +1,8 @@ # Cross-store move $ pda set key@src value $ pda mv key@src key@dst - ok renamed key@src to 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 +FAIL cannot get 'key@src': no such key diff --git a/testdata/mv-encrypt.ct b/testdata/mv-encrypt.ct index df03f91..99ae9aa 100644 --- a/testdata/mv-encrypt.ct +++ b/testdata/mv-encrypt.ct @@ -1,8 +1,8 @@ # 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 + 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 +FAIL cannot get 'secret-key@mve': no such key diff --git a/testdata/mv-missing-err.ct b/testdata/mv-missing-err.ct index 6df2ff0..9267bc0 100644 --- a/testdata/mv-missing-err.ct +++ b/testdata/mv-missing-err.ct @@ -1,3 +1,3 @@ # Move non-existent key $ pda mv nonexistent dest --> FAIL -FAIL cannot move 'nonexistent': no such key +FAIL cannot move 'nonexistent': no such key diff --git a/testdata/mv-safe.ct b/testdata/mv-safe.ct index 0213ada..98cf125 100644 --- a/testdata/mv-safe.ct +++ b/testdata/mv-safe.ct @@ -1,7 +1,7 @@ $ pda set src@msf hello $ pda set dst@msf existing $ pda mv src@msf dst@msf --safe -info skipped 'dst@msf': already exists +info skipped 'dst@msf': already exists $ pda get src@msf hello $ pda get dst@msf diff --git a/testdata/mv-store-copy.ct b/testdata/mv-store-copy.ct index 5ef049a..618396a 100644 --- a/testdata/mv-store-copy.ct +++ b/testdata/mv-store-copy.ct @@ -1,6 +1,6 @@ $ pda set key@msc1 value $ pda move-store msc1 msc2 --copy - ok copied @msc1 to @msc2 + ok copied @msc1 to @msc2 $ pda get key@msc1 value $ pda get key@msc2 diff --git a/testdata/mv-store-missing-err.ct b/testdata/mv-store-missing-err.ct index b42fbe3..cb4baa0 100644 --- a/testdata/mv-store-missing-err.ct +++ b/testdata/mv-store-missing-err.ct @@ -1,2 +1,2 @@ $ pda move-store nonexistent dest --> FAIL -FAIL cannot rename store 'nonexistent': no such store +FAIL cannot rename store 'nonexistent': no such store diff --git a/testdata/mv-store-safe.ct b/testdata/mv-store-safe.ct index 20e5e3e..3415aba 100644 --- a/testdata/mv-store-safe.ct +++ b/testdata/mv-store-safe.ct @@ -1,7 +1,7 @@ $ pda set a@mssf1 1 $ pda set b@mssf2 2 $ pda move-store mssf1 mssf2 --safe -info skipped '@mssf2': already exists +info skipped '@mssf2': already exists $ pda get a@mssf1 1 $ pda get b@mssf2 diff --git a/testdata/mv-store-same-err.ct b/testdata/mv-store-same-err.ct index 11013b2..d7f0c6b 100644 --- a/testdata/mv-store-same-err.ct +++ b/testdata/mv-store-same-err.ct @@ -1,3 +1,3 @@ $ pda set a@mss same $ pda move-store mss mss --> FAIL -FAIL cannot rename store 'mss': source and destination are the same +FAIL cannot rename store 'mss': source and destination are the same diff --git a/testdata/mv-store.ct b/testdata/mv-store.ct index c3b1cc0..7ae0855 100644 --- a/testdata/mv-store.ct +++ b/testdata/mv-store.ct @@ -1,5 +1,5 @@ $ pda set key@mvs1 value $ pda move-store mvs1 mvs2 - ok renamed @mvs1 to @mvs2 + ok renamed @mvs1 to @mvs2 $ pda get key@mvs2 value diff --git a/testdata/mv.ct b/testdata/mv.ct index d2036e0..3679ffd 100644 --- a/testdata/mv.ct +++ b/testdata/mv.ct @@ -1,8 +1,8 @@ # Basic move $ pda set source@mvok value $ pda mv source@mvok dest@mvok - ok renamed source@mvok to 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 +FAIL cannot get 'source@mvok': no such key diff --git a/testdata/remove-dedupe.ct b/testdata/remove-dedupe.ct index d7c1245..6f8fe32 100644 --- a/testdata/remove-dedupe.ct +++ b/testdata/remove-dedupe.ct @@ -7,6 +7,6 @@ bar rdd 2 no expiry foo rdd 1 no expiry $ pda rm foo@rdd --key "*@rdd" -y $ pda get bar@rdd --> FAIL -FAIL cannot get 'bar@rdd': no such key +FAIL cannot get 'bar@rdd': no such key $ pda get foo@rdd --> FAIL -FAIL cannot get 'foo@rdd': no such key +FAIL cannot get 'foo@rdd': no such key diff --git a/testdata/remove-key-glob.ct b/testdata/remove-key-glob.ct index fed822d..84b90d0 100644 --- a/testdata/remove-key-glob.ct +++ b/testdata/remove-key-glob.ct @@ -3,9 +3,9 @@ $ 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'? +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 +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 index 49b7299..638e136 100644 --- a/testdata/remove-key-mixed.ct +++ b/testdata/remove-key-mixed.ct @@ -3,8 +3,8 @@ $ 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 +FAIL cannot get 'foo@rkm': no such key $ pda get bar1@rkm --> FAIL -FAIL cannot get 'bar1@rkm': no such key +FAIL cannot get 'bar1@rkm': no such key $ pda get bar2@rkm --> FAIL -FAIL cannot get 'bar2@rkm': no such key +FAIL cannot get 'bar2@rkm': no such key diff --git a/testdata/remove-multiple.ct b/testdata/remove-multiple.ct index e54d533..2c2fa89 100644 --- a/testdata/remove-multiple.ct +++ b/testdata/remove-multiple.ct @@ -2,6 +2,6 @@ $ 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 +FAIL cannot get 'a@rmm': no such key $ pda get b@rmm --> FAIL -FAIL cannot get 'b@rmm': no such key +FAIL cannot get 'b@rmm': no such key diff --git a/testdata/remove-store-invalid-err.ct b/testdata/remove-store-invalid-err.ct index 6750010..fcdda4b 100644 --- a/testdata/remove-store-invalid-err.ct +++ b/testdata/remove-store-invalid-err.ct @@ -1,2 +1,2 @@ $ pda rms foo/bar --> FAIL -FAIL cannot delete store 'foo/bar': cannot parse store: bad store format, use STORE or @STORE +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 index 7d2b8e0..10e3650 100644 --- a/testdata/remove-yes.ct +++ b/testdata/remove-yes.ct @@ -2,7 +2,7 @@ $ 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'? +FAIL cannot get 'a@ry': no such key +hint did you mean 'b'? $ pda get b@ry "2" diff --git a/testdata/set-file-conflict-err.ct b/testdata/set-file-conflict-err.ct index 19ecd17..f8a971e 100644 --- a/testdata/set-file-conflict-err.ct +++ b/testdata/set-file-conflict-err.ct @@ -1,3 +1,3 @@ $ fecho myfile contents $ pda set key@sfc value --file myfile --> FAIL -FAIL cannot set 'key@sfc': --file and VALUE argument are mutually exclusive +FAIL cannot set 'key@sfc': --file and VALUE argument are mutually exclusive diff --git a/testdata/set-invalid-ttl-err.ct b/testdata/set-invalid-ttl-err.ct index 9a33eef..781623b 100644 --- a/testdata/set-invalid-ttl-err.ct +++ b/testdata/set-invalid-ttl-err.ct @@ -1,2 +1,2 @@ $ pda set a b --ttl 3343r --> FAIL -FAIL invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r" +FAIL invalid argument "3343r" for "-t, --ttl" flag: time: unknown unit "r" in duration "3343r" diff --git a/testdata/set-safe.ct b/testdata/set-safe.ct index 8755d20..9a08cb7 100644 --- a/testdata/set-safe.ct +++ b/testdata/set-safe.ct @@ -2,7 +2,7 @@ $ pda set key@ss "original" --safe $ pda get key@ss "original" $ pda set key@ss "overwritten" --safe -info skipped 'key@ss': already exists +info skipped 'key@ss': already exists $ pda get key@ss "original" $ pda set newkey@ss "fresh" --safe diff --git a/testdata/template-enum-err.ct b/testdata/template-enum-err.ct index e2b9f1f..f3a5e79 100644 --- a/testdata/template-enum-err.ct +++ b/testdata/template-enum-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ enum .LEVEL "info" "warn" }} $ pda set level@tple < tpl $ pda get level@tple LEVEL=debug --> FAIL -FAIL cannot get 'level@tple': template: cmd:1:3: executing "cmd" at : error calling enum: invalid value 'debug', allowed: [info warn] +FAIL cannot get 'level@tple': template: cmd:1:3: executing "cmd" at : error calling enum: invalid value 'debug', allowed: [info warn] diff --git a/testdata/template-require-err.ct b/testdata/template-require-err.ct index 88c726c..55a686b 100644 --- a/testdata/template-require-err.ct +++ b/testdata/template-require-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ require .FILE }} $ pda set tmpl@tplr < tpl $ pda get tmpl@tplr --> FAIL -FAIL cannot get 'tmpl@tplr': template: cmd:1:3: executing "cmd" at : error calling require: required value is missing or empty +FAIL cannot get 'tmpl@tplr': template: cmd:1:3: executing "cmd" at : error calling require: required value is missing or empty From 4afc0fd8ce5585b89c0bf3d00cd3828b1508db4d Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:49:44 +0000 Subject: [PATCH 070/107] feat(config): add config set subcommand with type validation --- cmd/config_cmd.go | 49 ++++++++++++++++++++++++++++++++++++++++++ testdata/config-set.ct | 23 ++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 testdata/config-set.ct diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 21361cc..cbee910 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "github.com/BurntSushi/toml" @@ -107,11 +108,59 @@ var configInitCmd = &cobra.Command{ }, } +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] + + // Work on a copy of the current config so we can write it back. + 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 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 := writeConfigFile(cfg); err != nil { + return err + } + + // Reload so subsequent commands in the same process see the change. + config = cfg + return nil + }, +} + func init() { configInitCmd.Flags().Bool("new", false, "overwrite existing config file") configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configInitCmd) configCmd.AddCommand(configListCmd) configCmd.AddCommand(configPathCmd) + configCmd.AddCommand(configSetCmd) rootCmd.AddCommand(configCmd) } diff --git a/testdata/config-set.ct b/testdata/config-set.ct new file mode 100644 index 0000000..38cb572 --- /dev/null +++ b/testdata/config-set.ct @@ -0,0 +1,23 @@ +# Set a bool value and verify with get +$ pda config set git.auto_commit true +$ pda config get git.auto_commit +true + +# Set a string value +$ pda config set store.default_store_name mystore +$ pda config get store.default_store_name +mystore + +# Set back to original +$ pda config set git.auto_commit false +$ pda config get git.auto_commit +false + +# Bad type +$ pda config set git.auto_commit yes --> FAIL +FAIL cannot set 'git.auto_commit': expected bool (true/false), got 'yes' + +# Unknown key +$ pda config set git.auto_comit true --> FAIL +FAIL unknown config key 'git.auto_comit' +hint did you mean 'git.auto_commit'? From abf0c86ab0c59e497b6f2452a68a5b46229f4c21 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:51:29 +0000 Subject: [PATCH 071/107] feat(config): add config edit subcommand --- cmd/config_cmd.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index cbee910..672784d 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "os/exec" "path/filepath" "reflect" "strings" @@ -68,6 +69,37 @@ var configPathCmd = &cobra.Command{ }, } +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 + return c.Run() + }, +} + func writeConfigFile(cfg Config) error { p, err := configPath() if err != nil { @@ -157,6 +189,7 @@ var configSetCmd = &cobra.Command{ func init() { configInitCmd.Flags().Bool("new", false, "overwrite existing config file") + configCmd.AddCommand(configEditCmd) configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configInitCmd) configCmd.AddCommand(configListCmd) From bc9d95e8d50a1c64e75aa17bddde9610390027e0 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:52:44 +0000 Subject: [PATCH 072/107] fix(config): accept case-insensitive booleans in config set --- cmd/config_cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index 672784d..c4e3faa 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -163,7 +163,7 @@ var configSetCmd = &cobra.Command{ switch f.Kind { case reflect.Bool: - switch raw { + switch strings.ToLower(raw) { case "true": f.Field.SetBool(true) case "false": From ed1a562c2c2126287d3523c4328e096c073e620d Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:53:33 +0000 Subject: [PATCH 073/107] refactor(doctor): replace hand-maintained configDiffs with configFields --- cmd/doctor.go | 42 +++++++----------------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 56dfdc3..394e43f 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -135,7 +135,8 @@ func runDoctor(w io.Writer) bool { } // 7. Non-default config values - if diffs := configDiffs(); len(diffs) > 0 { + defaults := defaultConfig() + if diffs := configDiffStrings(configFields(&config, &defaults)); len(diffs) > 0 { emit("ok", "Non-default config:") tree(diffs) } @@ -293,41 +294,12 @@ func runDoctor(w io.Writer) bool { return hasError } -func configDiffs() []string { - def := defaultConfig() +func configDiffStrings(fields []ConfigField) []string { var diffs []string - if config.DisplayAsciiArt != def.DisplayAsciiArt { - diffs = append(diffs, fmt.Sprintf("display_ascii_art: %v", config.DisplayAsciiArt)) - } - if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete { - diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete)) - } - if config.Key.AlwaysPromptGlobDelete != def.Key.AlwaysPromptGlobDelete { - diffs = append(diffs, fmt.Sprintf("key.always_prompt_glob_delete: %v", config.Key.AlwaysPromptGlobDelete)) - } - if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite { - diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite)) - } - if config.Store.DefaultStoreName != def.Store.DefaultStoreName { - diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName)) - } - if config.Store.ListAllStores != def.Store.ListAllStores { - diffs = append(diffs, fmt.Sprintf("store.list_all_stores: %v", config.Store.ListAllStores)) - } - if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete { - diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete)) - } - if config.Store.AlwaysPromptOverwrite != def.Store.AlwaysPromptOverwrite { - diffs = append(diffs, fmt.Sprintf("store.always_prompt_overwrite: %v", config.Store.AlwaysPromptOverwrite)) - } - if config.Git.AutoFetch != def.Git.AutoFetch { - diffs = append(diffs, fmt.Sprintf("git.auto_fetch: %v", config.Git.AutoFetch)) - } - if config.Git.AutoCommit != def.Git.AutoCommit { - diffs = append(diffs, fmt.Sprintf("git.auto_commit: %v", config.Git.AutoCommit)) - } - if config.Git.AutoPush != def.Git.AutoPush { - diffs = append(diffs, fmt.Sprintf("git.auto_push: %v", config.Git.AutoPush)) + for _, f := range fields { + if !f.IsDefault { + diffs = append(diffs, fmt.Sprintf("%s: %v", f.Key, f.Value)) + } } return diffs } From 4d61a6913c031e00d5339dfbe882f8ed0a02f5da Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 11 Feb 2026 23:57:55 +0000 Subject: [PATCH 074/107] feat: exempt config/doctor from config errors, run doctor on failure When the config file is malformed, config and doctor commands now proceed with defaults (showing a warning). All other commands print the parse error and automatically run doctor to aid diagnosis. --- cmd/root.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 4f4a99f..cf75c2d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,8 +39,14 @@ var rootCmd = &cobra.Command{ func Execute() { if configErr != nil { - printError(fmt.Errorf("cannot load config: %v", configErr)) - os.Exit(1) + cmd, _, _ := rootCmd.Find(os.Args[1:]) + if configSafeCmd(cmd) { + warnf("config error: %v (using defaults)", configErr) + } else { + printError(fmt.Errorf("cannot load config: %v", configErr)) + runDoctor(os.Stderr) + os.Exit(1) + } } err := rootCmd.Execute() if err != nil { @@ -49,6 +55,16 @@ func Execute() { } } +// configSafeCmd reports whether cmd can run with a broken config. +func configSafeCmd(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c == configCmd || c == doctorCmd { + return true + } + } + return false +} + func init() { rootCmd.AddGroup(&cobra.Group{ID: "keys", Title: "Key commands:"}) From b4c89e7d904394962ff7766895e6b31143fb59d1 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:03:51 +0000 Subject: [PATCH 075/107] fix: restrict config-safe commands, add doctor header on config failure Only config edit, config init, config path, and doctor run with a broken config. Destructive commands like config set (which would overwrite a partially-valid file with defaults) are now blocked. Suppresses the warning on safe commands. Adds "Running pda! doctor" header before diagnostic output. --- cmd/root.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index cf75c2d..c6de8f8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,10 +40,10 @@ var rootCmd = &cobra.Command{ func Execute() { if configErr != nil { cmd, _, _ := rootCmd.Find(os.Args[1:]) - if configSafeCmd(cmd) { - warnf("config error: %v (using defaults)", configErr) - } else { + if !configSafeCmd(cmd) { printError(fmt.Errorf("cannot load config: %v", configErr)) + fmt.Fprintln(os.Stderr) + infof("Running pda! doctor") runDoctor(os.Stderr) os.Exit(1) } @@ -56,13 +56,10 @@ func Execute() { } // 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 { - for c := cmd; c != nil; c = c.Parent() { - if c == configCmd || c == doctorCmd { - return true - } - } - return false + return cmd == configEditCmd || cmd == configInitCmd || + cmd == configPathCmd || cmd == doctorCmd } func init() { From 6ad6876051cf69714f035f31be9736334d92bf38 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:07:14 +0000 Subject: [PATCH 076/107] fix(doctor): report config parse errors, remove redundant error in Execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor now checks configErr and emits a FAIL with the parse error and fix hint. Execute() no longer prints a separate error before running doctor — the doctor output is self-contained. --- cmd/doctor.go | 16 +++++++++++----- cmd/root.go | 3 --- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 394e43f..f14373b 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -115,6 +115,10 @@ func runDoctor(w io.Writer) bool { 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, "Fix with 'pda config edit' or 'pda config init --new'") + } if unexpectedFiles(cfgDir, map[string]bool{ "config.toml": true, "identity.txt": true, @@ -134,11 +138,13 @@ func runDoctor(w io.Writer) bool { } } - // 7. Non-default config values - defaults := defaultConfig() - if diffs := configDiffStrings(configFields(&config, &defaults)); len(diffs) > 0 { - emit("ok", "Non-default config:") - tree(diffs) + // 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) + } } // 8. Data directory diff --git a/cmd/root.go b/cmd/root.go index c6de8f8..e718397 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,7 +23,6 @@ THE SOFTWARE. package cmd import ( - "fmt" "os" "github.com/spf13/cobra" @@ -41,8 +40,6 @@ func Execute() { if configErr != nil { cmd, _, _ := rootCmd.Find(os.Args[1:]) if !configSafeCmd(cmd) { - printError(fmt.Errorf("cannot load config: %v", configErr)) - fmt.Fprintln(os.Stderr) infof("Running pda! doctor") runDoctor(os.Stderr) os.Exit(1) From d992074c9c7c806d54f7d96d5b291da6d75a5d34 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:17:33 +0000 Subject: [PATCH 077/107] feat: improved error messaging, and automatic doctor runs on fatal failure --- cmd/doctor.go | 18 ++++++++++++++++-- cmd/doctor_test.go | 6 ++++++ cmd/msg.go | 21 +++++++++++++++------ cmd/root.go | 3 ++- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index f14373b..cb1f2f9 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -38,18 +38,27 @@ func runDoctor(w io.Writer) bool { } 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) } - fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg) } tree := func(items []string) { @@ -58,7 +67,11 @@ func runDoctor(w io.Writer) bool { if i == len(items)-1 { connector = "└── " } - fmt.Fprintf(w, " %s%s\n", connector, item) + if lastFail && tty { + fmt.Fprintf(w, "\033[1m %s%s\033[0m\n", connector, item) + } else { + fmt.Fprintf(w, " %s%s\n", connector, item) + } } } @@ -117,6 +130,7 @@ func runDoctor(w io.Writer) bool { } 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{ diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 22780d9..4516bc7 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -14,6 +14,9 @@ func TestDoctorCleanEnv(t *testing.T) { 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) @@ -40,6 +43,9 @@ func TestDoctorWithStores(t *testing.T) { 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" diff --git a/cmd/msg.go b/cmd/msg.go index ea1d7d6..20dff20 100644 --- a/cmd/msg.go +++ b/cmd/msg.go @@ -31,24 +31,33 @@ func stdoutIsTerminal() bool { } // keyword returns a right-aligned, colored keyword (color only on TTY). +// All keywords are bold except dim (code "2"). // -// FAIL red (stderr) +// FAIL bold red (stderr) // hint dim (stderr) -// WARN yellow (stderr) -// info blue (stderr) -// ok green (stderr) -// ? cyan (stdout) +// 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) { - fmt.Fprintf(os.Stderr, "%s %s\n", keyword("31", "FAIL", stderrIsTerminal()), err) + 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) { diff --git a/cmd/root.go b/cmd/root.go index e718397..5c01bf8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ THE SOFTWARE. package cmd import ( + "fmt" "os" "github.com/spf13/cobra" @@ -40,7 +41,7 @@ func Execute() { if configErr != nil { cmd, _, _ := rootCmd.Find(os.Args[1:]) if !configSafeCmd(cmd) { - infof("Running pda! doctor") + printError(fmt.Errorf("fatal problem: running pda! doctor automatically")) runDoctor(os.Stderr) os.Exit(1) } From df70be2c4f4735600b43cac0652741c5400ee567 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:32:07 +0000 Subject: [PATCH 078/107] refactor(config)!: moved store.list_all_stores to list.list_all_stores --- README.md | 7 +++++-- cmd/config.go | 19 +++++++++++++++++-- cmd/config_fields_test.go | 3 ++- cmd/list.go | 17 ++++++++++++++--- testdata/config-list.ct | 3 ++- testdata/help-list.ct | 4 ++-- 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6ec65af..76a27eb 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ pda rm kitty -y

-`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `store.list_all_stores` can be set to false to list `store.default_store_name` by default. +`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.list_all_stores` can be set to false to list `store.default_store_name` by default. ```bash pda ls # Key Store Value TTL @@ -789,10 +789,13 @@ always_prompt_overwrite = false [store] default_store_name = "default" -list_all_stores = true always_prompt_delete = true always_prompt_overwrite = true +[list] +list_all_stores = true +default_list_format = "table" + [git] auto_fetch = false auto_commit = true diff --git a/cmd/config.go b/cmd/config.go index aecc107..db252c4 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -35,6 +35,7 @@ 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"` } @@ -46,11 +47,15 @@ type KeyConfig struct { type StoreConfig struct { DefaultStoreName string `toml:"default_store_name"` - ListAllStores bool `toml:"list_all_stores"` AlwaysPromptDelete bool `toml:"always_prompt_delete"` AlwaysPromptOverwrite bool `toml:"always_prompt_overwrite"` } +type ListConfig struct { + ListAllStores bool `toml:"list_all_stores"` + DefaultListFormat string `toml:"default_list_format"` +} + type GitConfig struct { AutoFetch bool `toml:"auto_fetch"` AutoCommit bool `toml:"auto_commit"` @@ -85,10 +90,13 @@ func defaultConfig() Config { }, Store: StoreConfig{ DefaultStoreName: "default", - ListAllStores: true, AlwaysPromptDelete: true, AlwaysPromptOverwrite: true, }, + List: ListConfig{ + ListAllStores: true, + DefaultListFormat: "table", + }, Git: GitConfig{ AutoFetch: false, AutoCommit: false, @@ -121,6 +129,13 @@ func loadConfig() (Config, error) { cfg.Store.DefaultStoreName = defaultConfig().Store.DefaultStoreName } + if cfg.List.DefaultListFormat == "" { + cfg.List.DefaultListFormat = defaultConfig().List.DefaultListFormat + } + if err := validListFormat(cfg.List.DefaultListFormat); err != nil { + return cfg, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) + } + return cfg, nil } diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go index 1f994d6..dac76e4 100644 --- a/cmd/config_fields_test.go +++ b/cmd/config_fields_test.go @@ -41,9 +41,10 @@ func TestConfigFieldsDottedKeys(t *testing.T) { "key.always_prompt_glob_delete": true, "key.always_prompt_overwrite": true, "store.default_store_name": true, - "store.list_all_stores": true, "store.always_prompt_delete": true, "store.always_prompt_overwrite": true, + "list.list_all_stores": true, + "list.default_list_format": true, "git.auto_fetch": true, "git.auto_commit": true, "git.auto_push": true, diff --git a/cmd/list.go b/cmd/list.go index a4d1768..8795d8b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -46,9 +46,16 @@ 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": - *e = formatEnum(v) return nil default: return fmt.Errorf("must be one of 'table', 'tsv', 'csv', 'html', 'markdown', 'ndjson', or 'json'") @@ -66,7 +73,7 @@ var ( listFull bool listAll bool listNoHeader bool - listFormat formatEnum = "table" + listFormat formatEnum dimStyle = text.Colors{text.Faint, text.Italic} ) @@ -100,6 +107,10 @@ within the same flag.`, } func list(cmd *cobra.Command, args []string) error { + if listFormat == "" { + listFormat = formatEnum(config.List.DefaultListFormat) + } + store := &Store{} storePatterns, err := cmd.Flags().GetStringSlice("store") @@ -110,7 +121,7 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot use --store with a store argument") } - allStores := len(args) == 0 && (config.Store.ListAllStores || listAll) + allStores := len(args) == 0 && (config.List.ListAllStores || listAll) var targetDB string if allStores { targetDB = "all" diff --git a/testdata/config-list.ct b/testdata/config-list.ct index 3df2e13..b67c388 100644 --- a/testdata/config-list.ct +++ b/testdata/config-list.ct @@ -4,9 +4,10 @@ key.always_prompt_delete = false key.always_prompt_glob_delete = true key.always_prompt_overwrite = false store.default_store_name = default -store.list_all_stores = true store.always_prompt_delete = true store.always_prompt_overwrite = true +list.list_all_stores = true +list.default_list_format = table git.auto_fetch = false git.auto_commit = false git.auto_push = false diff --git a/testdata/help-list.ct b/testdata/help-list.ct index e6aec50..30815c9 100644 --- a/testdata/help-list.ct +++ b/testdata/help-list.ct @@ -21,7 +21,7 @@ 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) (default table) + -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) @@ -52,7 +52,7 @@ 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) (default table) + -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) From 4bd45e7d3c0ed17a7b8b36a0f45f93de058331be Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:35:28 +0000 Subject: [PATCH 079/107] feat(doctor): detects undecoded config keys --- cmd/config.go | 26 ++++++++++++++++---------- cmd/doctor.go | 6 ++++++ cmd/doctor_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index db252c4..94e6ac0 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -63,7 +63,8 @@ type GitConfig struct { } var ( - config Config + config Config + configUndecodedKeys []string asciiArt string = ` ▄▄ ██ ██▄███▄ ▄███▄██ ▄█████▄ @@ -77,7 +78,7 @@ var ( ) func init() { - config, configErr = loadConfig() + config, configUndecodedKeys, configErr = loadConfig() } func defaultConfig() Config { @@ -105,24 +106,29 @@ func defaultConfig() Config { } } -func loadConfig() (Config, error) { +func loadConfig() (Config, []string, error) { cfg := defaultConfig() path, err := configPath() if err != nil { - return cfg, err + return cfg, nil, err } if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { - return cfg, nil + return cfg, nil, nil } - return cfg, err + return cfg, nil, err } - _, err = toml.DecodeFile(path, &cfg) + meta, err := toml.DecodeFile(path, &cfg) if err != nil { - return cfg, fmt.Errorf("parse %s: %w", path, err) + return cfg, nil, fmt.Errorf("parse %s: %w", path, err) + } + + var undecoded []string + for _, key := range meta.Undecoded() { + undecoded = append(undecoded, key.String()) } if cfg.Store.DefaultStoreName == "" { @@ -133,10 +139,10 @@ func loadConfig() (Config, error) { cfg.List.DefaultListFormat = defaultConfig().List.DefaultListFormat } if err := validListFormat(cfg.List.DefaultListFormat); err != nil { - return cfg, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) + return cfg, undecoded, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) } - return cfg, nil + return cfg, undecoded, nil } func configPath() (string, error) { diff --git a/cmd/doctor.go b/cmd/doctor.go index cb1f2f9..dcb1b26 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -161,6 +161,12 @@ func runDoctor(w io.Writer) bool { } } + // 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() diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 4516bc7..3cbf7ae 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -88,6 +88,36 @@ func TestDoctorIdentityPermissions(t *testing.T) { } } +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() From 32459b420bf5cf490c01ad0ce87e36c70c70049d Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:39:41 +0000 Subject: [PATCH 080/107] feat(config): validation on set, refusal to set incorrect values. warns when manually editing with incorrect values --- cmd/config.go | 5 +++++ cmd/config_cmd.go | 23 ++++++++++++++++++++++- testdata/config-set.ct | 9 +++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cmd/config.go b/cmd/config.go index 94e6ac0..7015555 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -145,6 +145,11 @@ func loadConfig() (Config, []string, error) { return cfg, undecoded, nil } +// validateConfig checks invariants on a Config value before it is persisted. +func validateConfig(cfg Config) error { + return validListFormat(cfg.List.DefaultListFormat) +} + func configPath() (string, error) { if override := os.Getenv("PDA_CONFIG"); override != "" { return filepath.Join(override, "config.toml"), nil diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index c4e3faa..acfe3fe 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -96,7 +96,23 @@ var configEditCmd = &cobra.Command{ c.Stdin = os.Stdin c.Stdout = os.Stdout c.Stderr = os.Stderr - return c.Run() + if err := c.Run(); err != nil { + return err + } + + cfg, undecoded, err := loadConfig() + 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 + return nil }, } @@ -177,12 +193,17 @@ var configSetCmd = &cobra.Command{ 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 } // Reload so subsequent commands in the same process see the change. config = cfg + configUndecodedKeys = nil return nil }, } diff --git a/testdata/config-set.ct b/testdata/config-set.ct index 38cb572..f99bebb 100644 --- a/testdata/config-set.ct +++ b/testdata/config-set.ct @@ -17,6 +17,15 @@ false $ 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 +json + # Unknown key $ pda config set git.auto_comit true --> FAIL FAIL unknown config key 'git.auto_comit' From f3b18c6b084edc09f92e3186f7dab8e2703abee3 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 00:46:18 +0000 Subject: [PATCH 081/107] feat: new environment commands group, and updates to README --- README.md | 74 ++++++++++++++++++++++++++++++++++++------------ cmd/root.go | 5 ++++ testdata/help.ct | 12 +++++--- testdata/root.ct | 6 ++-- 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 76a27eb..b58eff0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr - [Binary](https://github.com/Llywelwyn/pda#binary) - [Encryption](https://github.com/Llywelwyn/pda#encryption) - [Doctor](https://github.com/Llywelwyn/pda#doctor) +- [Config](https://github.com/Llywelwyn/pda#config) - [Environment](https://github.com/Llywelwyn/pda#environment)

@@ -99,9 +100,12 @@ Git commands: 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 - doctor Check environment health help Help about any command version Display pda! version ``` @@ -772,14 +776,48 @@ Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red

-### Environment +### Config -Config is stored in your user config directory in `pda/config.toml`. +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. -Usually: `~/.config/pda/config.toml` +

+`pda config` manages configuration without editing files by hand. +```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 ``` -# ~/.config/pda/config.toml + +

+ +`pda doctor` will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit. + +

+ +#### Example config.toml + +All values below are the defaults. A missing config file or missing keys will use these values. + +```toml display_ascii_art = true [key] @@ -793,43 +831,43 @@ always_prompt_delete = true always_prompt_overwrite = true [list] +# List all stores when 'pda ls' is run with no arguments. list_all_stores = true +# Output format for 'pda ls' (table|tsv|csv|markdown|html|ndjson|json). default_list_format = "table" [git] auto_fetch = false -auto_commit = true +auto_commit = false auto_push = false ``` -`PDA_CONFIG` overrides the default config location. pda! will look for a config.toml file in that directory. +

+ +### Environment + +`PDA_CONFIG` overrides the config directory. pda! will look for `config.toml` in this directory. ```bash PDA_CONFIG=/tmp/config/ pda set key value ```

-Data is stored in your user data directory under `pda/`. +`PDA_DATA` overrides the data storage directory. -Usually: -- linux: `~/.local/share/pda/` +Default locations: +- Linux: `~/.local/share/pda/` - macOS: `~/Library/Application Support/pda/` -- windows: `%LOCALAPPDATA%/pda/` +- Windows: `%LOCALAPPDATA%/pda/` -`PDA_DATA` overrides the default storage location. ```bash PDA_DATA=/tmp/stores pda set key value ```

-`pda run` (or `pda get --run`) uses `SHELL` for command execution. +`SHELL` is used by `pda run` (or `pda get --run`) for command execution. Falls back to `/bin/sh` if unset. ```bash -# SHELL is usually your current shell. -pda run script - -# An empty SHELL falls back to using 'sh'. -export SHELL="" pda run script ``` diff --git a/cmd/root.go b/cmd/root.go index 5c01bf8..fa5416a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -85,4 +85,9 @@ func init() { initCmd.GroupID = "git" syncCmd.GroupID = "git" gitCmd.GroupID = "git" + + rootCmd.AddGroup(&cobra.Group{ID: "env", Title: "Environment commands:"}) + + configCmd.GroupID = "env" + doctorCmd.GroupID = "env" } diff --git a/testdata/help.ct b/testdata/help.ct index cc7e94b..7cb28ba 100644 --- a/testdata/help.ct +++ b/testdata/help.ct @@ -34,10 +34,12 @@ Git commands: init Initialise pda! version control sync Manually sync your stores with Git -Additional Commands: - completion Generate the autocompletion script for the specified shell +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 @@ -79,10 +81,12 @@ Git commands: init Initialise pda! version control sync Manually sync your stores with Git -Additional Commands: - completion Generate the autocompletion script for the specified shell +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 diff --git a/testdata/root.ct b/testdata/root.ct index 1370ab8..b2c6546 100644 --- a/testdata/root.ct +++ b/testdata/root.ct @@ -33,10 +33,12 @@ Git commands: init Initialise pda! version control sync Manually sync your stores with Git -Additional Commands: - completion Generate the autocompletion script for the specified shell +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 From f7e45137dfb980c62b01a1e06afa17433a71f566 Mon Sep 17 00:00:00 2001 From: lew <82828093+Llywelwyn@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:07:19 +0000 Subject: [PATCH 082/107] Update README.md --- README.md | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/README.md b/README.md index b58eff0..78ab79a 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr ### Overview ```bash - ▄▄ - ██ -██▄███▄ ▄███▄██ ▄█████▄ -██▀ ▀██ ██▀ ▀██ ▀ ▄▄▄██ -██ ██ ██ ██ ▄██▀▀▀██ -███▄▄██▀ ▀██▄▄███ ██▄▄▄███ -██ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀▀ ▀▀ -██ (c) 2025 Lewis Wynne +pda! MIT licensed. (c) 2025 Lewis Wynne Usage: pda [command] From 629358a81b0f4ef608985ce206c460705982d3cf Mon Sep 17 00:00:00 2001 From: lew <82828093+Llywelwyn@users.noreply.github.com> Date: Thu, 12 Feb 2026 01:20:57 +0000 Subject: [PATCH 083/107] Update README.md --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 78ab79a..efe6548 100644 --- a/README.md +++ b/README.md @@ -811,27 +811,37 @@ pda config init --new All values below are the defaults. A missing config file or missing keys will use these values. ```toml -display_ascii_art = true +# 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 [store] +# store name used when none is specified default_store_name = "default" +# prompt y/n before deleting whole store always_prompt_delete = true +# prompt y/n before store overwrites always_prompt_overwrite = true [list] -# List all stores when 'pda ls' is run with no arguments. +# list all, or list only the default store when none specified list_all_stores = true -# Output format for 'pda ls' (table|tsv|csv|markdown|html|ndjson|json). +# default output, accepts: table|tsv|csv|markdown|html|ndjson|json default_list_format = "table" [git] +# auto fetch whenever a change happens auto_fetch = false +# auto commit any changes auto_commit = false +# auto push after committing auto_push = false ``` From 4e78cefd5698ade795d8308bf46933292ef11700 Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 19:31:24 +0000 Subject: [PATCH 084/107] feat(config): some additional config options, and config migration from deprecated keys --- README.md | 17 ++++- cmd/config.go | 99 +++++++++++++++++++++-------- cmd/config_cmd.go | 45 +++++++++++-- cmd/config_fields_test.go | 7 +- cmd/config_migrate.go | 92 +++++++++++++++++++++++++++ cmd/doctor_test.go | 2 +- cmd/list.go | 59 +++++++++++++---- cmd/set.go | 1 + cmd/shared.go | 2 +- cmd/sync.go | 4 +- testdata/config-init.ct | 22 ++++++- testdata/config-list.ct | 7 +- testdata/config-set.ct | 26 ++++++++ testdata/list-config-columns.ct | 11 ++++ testdata/list-config-hide-header.ct | 10 +++ testdata/set-config-encrypt.ct | 10 +++ 16 files changed, 363 insertions(+), 51 deletions(-) create mode 100644 cmd/config_migrate.go create mode 100644 testdata/list-config-columns.ct create mode 100644 testdata/list-config-hide-header.ct create mode 100644 testdata/set-config-encrypt.ct diff --git a/README.md b/README.md index efe6548..82acb99 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ pda rm kitty -y

-`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.list_all_stores` can be set to false to list `store.default_store_name` by default. +`pda ls` to see what you've got stored. By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.always_show_all_stores` can be set to false to list `store.default_store_name` by default. ```bash pda ls # Key Store Value TTL @@ -798,6 +798,9 @@ 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 ```

@@ -821,6 +824,8 @@ always_prompt_delete = false 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 @@ -832,9 +837,15 @@ always_prompt_overwrite = true [list] # list all, or list only the default store when none specified -list_all_stores = true +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: key,store,value,ttl +default_columns = "key,store,value,ttl" [git] # auto fetch whenever a change happens @@ -843,6 +854,8 @@ 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}}" ```

diff --git a/cmd/config.go b/cmd/config.go index 7015555..6a42a97 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -23,6 +23,7 @@ THE SOFTWARE. package cmd import ( + "bytes" "fmt" "os" "path/filepath" @@ -43,6 +44,7 @@ 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"` } type StoreConfig struct { @@ -52,14 +54,18 @@ type StoreConfig struct { } type ListConfig struct { - ListAllStores bool `toml:"list_all_stores"` - DefaultListFormat string `toml:"default_list_format"` + 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"` } type GitConfig struct { - AutoFetch bool `toml:"auto_fetch"` - AutoCommit bool `toml:"auto_commit"` - AutoPush bool `toml:"auto_push"` + AutoFetch bool `toml:"auto_fetch"` + AutoCommit bool `toml:"auto_commit"` + AutoPush bool `toml:"auto_push"` + DefaultCommitMessage string `toml:"default_commit_message"` } var ( @@ -78,7 +84,15 @@ var ( ) func init() { - config, configUndecodedKeys, configErr = loadConfig() + 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) + } + } } func defaultConfig() Config { @@ -95,35 +109,56 @@ func defaultConfig() Config { AlwaysPromptOverwrite: true, }, List: ListConfig{ - ListAllStores: true, - DefaultListFormat: "table", + AlwaysShowAllStores: true, + DefaultListFormat: "table", + DefaultColumns: "key,store,value,ttl", }, Git: GitConfig{ - AutoFetch: false, - AutoCommit: false, - AutoPush: false, + AutoFetch: false, + AutoCommit: false, + AutoPush: false, + DefaultCommitMessage: "sync: {{.Time}}", }, } } -func loadConfig() (Config, []string, error) { +// loadConfig returns (config, undecodedKeys, migrations, error). +// Migrations are returned but NOT printed — callers decide. +func loadConfig() (Config, []string, []migration, error) { cfg := defaultConfig() path, err := configPath() if err != nil { - return cfg, nil, err + return cfg, nil, nil, err } - if _, err := os.Stat(path); err != nil { - if os.IsNotExist(err) { - return cfg, nil, nil - } - return cfg, nil, err - } - - meta, err := toml.DecodeFile(path, &cfg) + data, err := os.ReadFile(path) if err != nil { - return cfg, nil, fmt.Errorf("parse %s: %w", path, err) + if os.IsNotExist(err) { + return cfg, nil, nil, nil + } + return cfg, nil, nil, 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) + if err != nil { + return cfg, nil, nil, fmt.Errorf("parse %s: %w", path, err) } var undecoded []string @@ -139,15 +174,29 @@ func loadConfig() (Config, []string, error) { cfg.List.DefaultListFormat = defaultConfig().List.DefaultListFormat } if err := validListFormat(cfg.List.DefaultListFormat); err != nil { - return cfg, undecoded, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) + return cfg, undecoded, warnings, fmt.Errorf("parse %s: list.default_list_format: %w", path, err) } - return cfg, undecoded, nil + 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 cfg.Git.DefaultCommitMessage == "" { + cfg.Git.DefaultCommitMessage = defaultConfig().Git.DefaultCommitMessage + } + + return cfg, undecoded, warnings, nil } // validateConfig checks invariants on a Config value before it is persisted. func validateConfig(cfg Config) error { - return validListFormat(cfg.List.DefaultListFormat) + if err := validListFormat(cfg.List.DefaultListFormat); err != nil { + return err + } + return validListColumns(cfg.List.DefaultColumns) } func configPath() (string, error) { diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index acfe3fe..a118d45 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -100,7 +100,14 @@ var configEditCmd = &cobra.Command{ return err } - cfg, undecoded, err := loadConfig() + 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") @@ -112,6 +119,7 @@ var configEditCmd = &cobra.Command{ config = cfg configUndecodedKeys = undecoded configErr = nil + okf("saved config: %s", p) return nil }, } @@ -144,14 +152,42 @@ var configInitCmd = &cobra.Command{ 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 'pda config edit' or 'pda config init --new'", + "use '--update' to update your config, or '--new' to get a fresh copy", ) } } + okf("generated config: %s", p) return writeConfigFile(defaultConfig()) }, } @@ -163,8 +199,6 @@ var configSetCmd = &cobra.Command{ SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { key, raw := args[0], args[1] - - // Work on a copy of the current config so we can write it back. cfg := config defaults := defaultConfig() fields := configFields(&cfg, &defaults) @@ -201,15 +235,16 @@ var configSetCmd = &cobra.Command{ return err } - // Reload so subsequent commands in the same process see the change. 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) diff --git a/cmd/config_fields_test.go b/cmd/config_fields_test.go index dac76e4..89b4288 100644 --- a/cmd/config_fields_test.go +++ b/cmd/config_fields_test.go @@ -40,14 +40,19 @@ func TestConfigFieldsDottedKeys(t *testing.T) { "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.list_all_stores": 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) diff --git a/cmd/config_migrate.go b/cmd/config_migrate.go new file mode 100644 index 0000000..6050152 --- /dev/null +++ b/cmd/config_migrate.go @@ -0,0 +1,92 @@ +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/doctor_test.go b/cmd/doctor_test.go index 3cbf7ae..9efd9e3 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -101,7 +101,7 @@ func TestDoctorUndecodedKeys(t *testing.T) { } savedCfg, savedUndecoded, savedErr := config, configUndecodedKeys, configErr - config, configUndecodedKeys, configErr = loadConfig() + config, configUndecodedKeys, _, configErr = loadConfig() t.Cleanup(func() { config, configUndecodedKeys, configErr = savedCfg, savedUndecoded, savedErr }) diff --git a/cmd/list.go b/cmd/list.go index 8795d8b..ee604e3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -64,6 +64,42 @@ func validListFormat(v string) error { func (e *formatEnum) Type() string { return "format" } +var columnNames = map[string]columnKind{ + "key": columnKey, + "store": columnStore, + "value": columnValue, + "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', '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 @@ -121,7 +157,7 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot use --store with a store argument") } - allStores := len(args) == 0 && (config.List.ListAllStores || listAll) + allStores := len(args) == 0 && (config.List.AlwaysShowAllStores || listAll) var targetDB string if allStores { targetDB = "all" @@ -147,16 +183,15 @@ func list(cmd *cobra.Command, args []string) error { return withHint(fmt.Errorf("cannot ls '%s': no columns selected", targetDB), "disable --no-keys, --no-values, or --no-ttl") } - var columns []columnKind - if !listNoKeys { - columns = append(columns, columnKey) + columns := parseColumns(config.List.DefaultColumns) + if listNoKeys { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnKey }) } - columns = append(columns, columnStore) - if !listNoValues { - columns = append(columns, columnValue) + if listNoValues { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnValue }) } - if !listNoTTL { - columns = append(columns, columnTTL) + if listNoTTL { + columns = slices.DeleteFunc(columns, func(c columnKind) bool { return c == columnTTL }) } keyPatterns, err := cmd.Flags().GetStringSlice("key") @@ -310,7 +345,7 @@ func list(cmd *cobra.Command, args []string) error { tty := stdoutIsTerminal() && listFormat.String() == "table" - if !listNoHeader { + if !(listNoHeader || config.List.AlwaysHideHeader) { tw.AppendHeader(headerRow(columns, tty)) tw.Style().Format.Header = text.FormatDefault } @@ -329,7 +364,7 @@ func list(cmd *cobra.Command, args []string) error { dimValue = true } } - if !listFull { + if !(listFull || config.List.AlwaysShowFullValues) { valueStr = summariseValue(valueStr, lay.value, tty) } } @@ -365,7 +400,7 @@ func list(cmd *cobra.Command, args []string) error { tw.AppendRow(row) } - applyColumnWidths(tw, columns, output, lay, listFull) + applyColumnWidths(tw, columns, output, lay, listFull || config.List.AlwaysShowFullValues) renderTable(tw) return nil } diff --git a/cmd/set.go b/cmd/set.go index 2f1a7de..4f8c88d 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -73,6 +73,7 @@ func set(cmd *cobra.Command, args []string) error { if err != nil { return err } + secret = secret || config.Key.AlwaysEncrypt spec, err := store.parseKey(args[0], true) if err != nil { diff --git a/cmd/shared.go b/cmd/shared.go index 40e2410..a5d1740 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -262,7 +262,7 @@ func validateDBName(name string) error { func formatExpiry(expiresAt uint64) string { if expiresAt == 0 { - return "no expiry" + return "none" } expiry := time.Unix(int64(expiresAt), 0).UTC() remaining := time.Until(expiry) diff --git a/cmd/sync.go b/cmd/sync.go index b775d4a..0353470 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -23,7 +23,7 @@ THE SOFTWARE. package cmd import ( - "fmt" + "strings" "time" "github.com/spf13/cobra" @@ -66,7 +66,7 @@ func sync(manual bool, customMsg string) error { if changed { msg := customMsg if msg == "" { - msg = fmt.Sprintf("sync: %s", time.Now().UTC().Format(time.RFC3339)) + msg = strings.ReplaceAll(config.Git.DefaultCommitMessage, "{{.Time}}", time.Now().UTC().Format(time.RFC3339)) if manual { printHint("use -m to set a custom commit message") } diff --git a/testdata/config-init.ct b/testdata/config-init.ct index 20539f3..5d5eb85 100644 --- a/testdata/config-init.ct +++ b/testdata/config-init.ct @@ -1,10 +1,30 @@ # Init creates a config file $ pda config init + ok generated config: /tmp/TestMain2533282848/002/config.toml # Second init fails $ pda config init --> FAIL FAIL config file already exists -hint use 'pda config edit' or 'pda config init --new' +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 + +# --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: /tmp/TestMain2533282848/002/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: /tmp/TestMain2533282848/002/config.toml diff --git a/testdata/config-list.ct b/testdata/config-list.ct index b67c388..6a909d7 100644 --- a/testdata/config-list.ct +++ b/testdata/config-list.ct @@ -3,11 +3,16 @@ 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 = default store.always_prompt_delete = true store.always_prompt_overwrite = true -list.list_all_stores = true +list.always_show_all_stores = true list.default_list_format = table +list.always_show_full_values = false +list.always_hide_header = false +list.default_columns = key,store,value,ttl git.auto_fetch = false git.auto_commit = false git.auto_push = false +git.default_commit_message = sync: {{.Time}} diff --git a/testdata/config-set.ct b/testdata/config-set.ct index f99bebb..9c8e8cf 100644 --- a/testdata/config-set.ct +++ b/testdata/config-set.ct @@ -1,16 +1,19 @@ # 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 @@ -24,9 +27,32 @@ FAIL cannot set 'list.default_list_format': must be one of 'table', 'tsv', 'csv' # 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', '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 default +$ pda config set list.default_list_format table +$ pda config set list.default_columns key,store,value,ttl + ok store.default_store_name set to 'default' + ok list.default_list_format set to 'table' + ok list.default_columns set to 'key,store,value,ttl' diff --git a/testdata/list-config-columns.ct b/testdata/list-config-columns.ct new file mode 100644 index 0000000..9b369d4 --- /dev/null +++ b/testdata/list-config-columns.ct @@ -0,0 +1,11 @@ +# 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 key,store,value,ttl + ok list.default_columns set to 'key,store,value,ttl' diff --git a/testdata/list-config-hide-header.ct b/testdata/list-config-hide-header.ct new file mode 100644 index 0000000..6f8b61e --- /dev/null +++ b/testdata/list-config-hide-header.ct @@ -0,0 +1,10 @@ +# 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' +a lchh 1 no expiry + +# Reset +$ pda config set list.always_hide_header false + ok list.always_hide_header set to 'false' diff --git a/testdata/set-config-encrypt.ct b/testdata/set-config-encrypt.ct new file mode 100644 index 0000000..75307f2 --- /dev/null +++ b/testdata/set-config-encrypt.ct @@ -0,0 +1,10 @@ +# 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' From 2ca32769d5032beef2c2a129b8f1b37e9975df8b Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 20:00:57 +0000 Subject: [PATCH 085/107] feat(commit): text templating in commit messages --- README.md | 16 +++++- cmd/commit_message.go | 48 ++++++++++++++++ cmd/commit_message_test.go | 53 ++++++++++++++++++ cmd/config.go | 2 +- cmd/del-db.go | 2 +- cmd/del.go | 4 +- cmd/get.go | 50 +---------------- cmd/mv-db.go | 5 +- cmd/mv.go | 5 +- cmd/restore.go | 5 +- cmd/set.go | 2 +- cmd/sync.go | 13 ++--- cmd/template.go | 85 +++++++++++++++++++++++++++++ main_test.go | 38 ++++++++----- testdata/config-init.ct | 8 +-- testdata/config-list.ct | 2 +- testdata/list-all.ct | 12 ++-- testdata/list-config-hide-header.ct | 2 +- testdata/list-format-csv.ct | 4 +- testdata/list-format-markdown.ct | 4 +- testdata/list-key-filter.ct | 6 +- testdata/list-key-value-filter.ct | 4 +- testdata/list-no-header.ct | 2 +- testdata/list-no-keys.ct | 2 +- testdata/list-no-values.ct | 2 +- testdata/list-stores.ct | 4 +- testdata/list-value-filter.ct | 6 +- testdata/list-value-multi-filter.ct | 4 +- testdata/multistore.ct | 2 +- testdata/remove-dedupe.ct | 4 +- 30 files changed, 281 insertions(+), 115 deletions(-) create mode 100644 cmd/commit_message.go create mode 100644 cmd/commit_message_test.go create mode 100644 cmd/template.go 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 From f9ff2c0d629889984e5bd95f902822744dbfe0eb Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 12 Feb 2026 23:28:19 +0000 Subject: [PATCH 086/107] feat(templates): adds arbitrary shell execution and pda-getting --- README.md | 43 +++++++++++++++---- cmd/get.go | 8 +++- cmd/template.go | 72 ++++++++++++++++++++++++++++++++ testdata/template-enum-err.ct | 2 +- testdata/template-pda-ref-err.ct | 5 +++ testdata/template-pda-ref.ct | 13 ++++++ testdata/template-require-err.ct | 2 +- testdata/template-shell.ct | 5 +++ 8 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 testdata/template-pda-ref-err.ct create mode 100644 testdata/template-pda-ref.ct create mode 100644 testdata/template-shell.ct diff --git a/README.md b/README.md index 550b72d..a1d6a66 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@

`pda!` is a command-line key-value store tool with: -- [templates](https://github.com/Llywelwyn/pda#templates), +- [templates](https://github.com/Llywelwyn/pda#templates) supporting arbitrary shell execution, conditionals, loops, more, - [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age), -- Git-backed [version control](https://github.com/Llywelwyn/pda#git), -- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key and/or value, -- plaintext exports in multiple formats, +- Git-backed [version control](https://github.com/Llywelwyn/pda#git) with automatic syncing, +- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key, value, or store, +- plaintext exports in 7 different formats, - support for all [binary data](https://github.com/Llywelwyn/pda#binary), -- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support, -- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor), +- expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl), +- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor) and [configuration](https://github.com/Llywelwyn/pda#config), and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb). @@ -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, `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"). +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`, arbitrary `shell` execution, and getting other `pda` keys (recursively!). 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. @@ -481,6 +481,35 @@ pda get names NAMES=Bob,Alice

+`shell` executes a command and returns stdout. +```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` gets another key. +```bash +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 + +# Cross-store references work too. +pda set host@urls "https://example.com" +pda set api '{{ pda "host@urls" }}/api' +pda get api +# https://example.com/api +``` + +

+ pass `no-template` to output literally without templating. ```bash pda set hello "{{ if .MORNING }}Good morning.{{ end }}" diff --git a/cmd/get.go b/cmd/get.go index 5a3a85d..e4b6b1c 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -148,18 +148,22 @@ func applyTemplate(tplBytes []byte, substitutions []string) ([]byte, error) { val := parts[1] vars[key] = val } + funcMap := templateFuncMap() + funcMap["pda"] = func(key string) (string, error) { + return pdaGet(key, substitutions) + } 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(templateFuncMap()). + Funcs(funcMap). Parse(string(tplBytes)) if err != nil { return nil, err } var buf bytes.Buffer if err := tpl.Execute(&buf, vars); err != nil { - return nil, err + return nil, cleanTemplateError(err) } return buf.Bytes(), nil } diff --git a/cmd/template.go b/cmd/template.go index 0bd1209..d8cef0d 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -25,6 +25,7 @@ package cmd import ( "fmt" "os" + "os/exec" "slices" "strconv" "strings" @@ -81,5 +82,76 @@ func templateFuncMap() template.FuncMap { 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/testdata/template-enum-err.ct b/testdata/template-enum-err.ct index f3a5e79..b0597cf 100644 --- a/testdata/template-enum-err.ct +++ b/testdata/template-enum-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ enum .LEVEL "info" "warn" }} $ pda set level@tple < tpl $ pda get level@tple LEVEL=debug --> FAIL -FAIL cannot get 'level@tple': template: cmd:1:3: executing "cmd" at : error calling enum: invalid value 'debug', allowed: [info warn] +FAIL cannot get 'level@tple': invalid value 'debug', allowed: [info warn] diff --git a/testdata/template-pda-ref-err.ct b/testdata/template-pda-ref-err.ct new file mode 100644 index 0000000..e779297 --- /dev/null +++ b/testdata/template-pda-ref-err.ct @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..eccba79 --- /dev/null +++ b/testdata/template-pda-ref.ct @@ -0,0 +1,13 @@ +# 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 index 55a686b..255cf57 100644 --- a/testdata/template-require-err.ct +++ b/testdata/template-require-err.ct @@ -2,4 +2,4 @@ $ fecho tpl {{ require .FILE }} $ pda set tmpl@tplr < tpl $ pda get tmpl@tplr --> FAIL -FAIL cannot get 'tmpl@tplr': template: cmd:1:3: executing "cmd" at : error calling require: required value is missing or empty +FAIL cannot get 'tmpl@tplr': required value is missing or empty diff --git a/testdata/template-shell.ct b/testdata/template-shell.ct new file mode 100644 index 0000000..ad5c933 --- /dev/null +++ b/testdata/template-shell.ct @@ -0,0 +1,5 @@ +# Shell function executes a command and returns stdout +$ fecho tpl1 {{ shell "echo hello" }} +$ pda set shelltest@tpls < tpl1 +$ pda get shelltest@tpls +hello From 579e6a1eee2006efe660a3ff17dac1d477300651 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:12:22 +0000 Subject: [PATCH 087/107] feat(identity): added --add-recipient and --remove-recipient flags for multi-recipient keys --- README.md | 20 ++++ cmd/doctor.go | 5 +- cmd/doctor_test.go | 2 +- cmd/identity.go | 139 ++++++++++++++++++++++++- cmd/list.go | 11 +- cmd/mv.go | 13 ++- cmd/ndjson.go | 12 +-- cmd/restore.go | 13 +-- cmd/secret.go | 150 +++++++++++++++++++++++++-- cmd/secret_test.go | 251 +++++++++++++++++++++++++++++++++++++++++++-- cmd/set.go | 8 +- main_test.go | 2 +- 12 files changed, 575 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index a1d6a66..7c5e3b3 100644 --- a/README.md +++ b/README.md @@ -777,6 +777,26 @@ pda identity --new

+By default, secrets are encrypted only for your own identity. To encrypt for additional recipients (e.g. a teammate or another device), use `--add-recipient` with their age public key. All existing secrets are automatically re-encrypted for every recipient. +```bash +# Add a recipient. All secrets are re-encrypted for both keys. +pda identity --add-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p +# ok re-encrypted api-key +# ok added recipient age1ql3z... +# ok re-encrypted 1 secret(s) + +# Remove a recipient. Secrets are re-encrypted without their key. +pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + +# Additional recipients are shown in the default identity display. +pda identity +# ok pubkey age1abc... +# ok identity ~/.local/share/pda/identity.txt +# ok recipient age1ql3z... +``` + +

+ ### Doctor `pda doctor` runs a set of health checks of your environment. diff --git a/cmd/doctor.go b/cmd/doctor.go index dcb1b26..8e6fd7f 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -134,8 +134,7 @@ func runDoctor(w io.Writer) bool { issues = append(issues, "Fix with 'pda config edit' or 'pda config init --new'") } if unexpectedFiles(cfgDir, map[string]bool{ - "config.toml": true, - "identity.txt": true, + "config.toml": true, }) { issues = append(issues, "Unexpected file(s) in directory") } @@ -353,7 +352,7 @@ func unexpectedDataFiles(dir string) bool { if e.IsDir() && name == ".git" { continue } - if !e.IsDir() && (name == ".gitignore" || filepath.Ext(name) == ".ndjson") { + if !e.IsDir() && (name == ".gitignore" || name == "identity.txt" || name == "recipients.txt" || filepath.Ext(name) == ".ndjson") { continue } return true diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 9efd9e3..3bb5f8c 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -71,7 +71,7 @@ func TestDoctorIdentityPermissions(t *testing.T) { t.Setenv("PDA_DATA", dataDir) t.Setenv("PDA_CONFIG", configDir) - idPath := filepath.Join(configDir, "identity.txt") + idPath := filepath.Join(dataDir, "identity.txt") if err := os.WriteFile(idPath, []byte("placeholder\n"), 0o644); err != nil { t.Fatal(err) } diff --git a/cmd/identity.go b/cmd/identity.go index 89e81a9..27a71c8 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + "filippo.io/age" "github.com/spf13/cobra" ) @@ -24,6 +25,14 @@ func identityRun(cmd *cobra.Command, args []string) error { 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() @@ -45,6 +54,14 @@ func identityRun(cmd *cobra.Command, args []string) error { return nil } + if addRecipient != "" { + return identityAddRecipient(addRecipient) + } + + if removeRecipient != "" { + return identityRemoveRecipient(removeRecipient) + } + if showPath { path, err := identityPath() if err != nil { @@ -66,12 +83,132 @@ func identityRun(cmd *cobra.Command, args []string) error { 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.MarkFlagsMutuallyExclusive("new", "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/list.go b/cmd/list.go index ee604e3..336f18d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -33,7 +33,6 @@ import ( "strings" "unicode/utf8" - "filippo.io/age" "github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" @@ -218,9 +217,9 @@ func list(cmd *cobra.Command, args []string) error { } identity, _ := loadIdentity() - var recipient *age.X25519Recipient - if identity != nil { - recipient = identity.Recipient() + recipients, err := allRecipients(identity) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } var entries []Entry @@ -297,7 +296,7 @@ func list(cmd *cobra.Command, args []string) error { // NDJSON format: emit JSON lines directly (encrypted form for secrets) if listFormat.String() == "ndjson" { for _, e := range filtered { - je, err := encodeJsonEntry(e, recipient) + je, err := encodeJsonEntry(e, recipients) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } @@ -315,7 +314,7 @@ func list(cmd *cobra.Command, args []string) error { if listFormat.String() == "json" { var jsonEntries []jsonEntry for _, e := range filtered { - je, err := encodeJsonEntry(e, recipient) + je, err := encodeJsonEntry(e, recipients) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } diff --git a/cmd/mv.go b/cmd/mv.go index ecc5e92..2e1bf7e 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -26,7 +26,6 @@ import ( "fmt" "strings" - "filippo.io/age" "github.com/spf13/cobra" ) @@ -75,9 +74,9 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { promptOverwrite := !yes && (interactive || config.Key.AlwaysPromptOverwrite) identity, _ := loadIdentity() - var recipient *age.X25519Recipient - if identity != nil { - recipient = identity.Recipient() + recipients, err := allRecipients(identity) + if err != nil { + return err } fromSpec, err := store.parseKey(args[0], true) @@ -161,7 +160,7 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { dstEntries = append(dstEntries[:idx], dstEntries[idx+1:]...) } } - if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil { + if err := writeStoreFile(dstPath, dstEntries, recipients); err != nil { return err } } else { @@ -171,12 +170,12 @@ func mvImpl(cmd *cobra.Command, args []string, keepSource bool) error { } else { dstEntries = append(dstEntries, newEntry) } - if err := writeStoreFile(dstPath, dstEntries, recipient); err != nil { + if err := writeStoreFile(dstPath, dstEntries, recipients); err != nil { return err } if !keepSource { srcEntries = append(srcEntries[:srcIdx], srcEntries[srcIdx+1:]...) - if err := writeStoreFile(srcPath, srcEntries, recipient); err != nil { + if err := writeStoreFile(srcPath, srcEntries, recipients); err != nil { return err } } diff --git a/cmd/ndjson.go b/cmd/ndjson.go index 3908232..2e7f855 100644 --- a/cmd/ndjson.go +++ b/cmd/ndjson.go @@ -98,8 +98,8 @@ func readStoreFile(path string, identity *age.X25519Identity) ([]Entry, error) { // writeStoreFile atomically writes entries to an NDJSON file, sorted by key. // Expired entries are excluded. Empty entry list writes an empty file. -// If recipient is nil, secret entries are written as-is (locked passthrough). -func writeStoreFile(path string, entries []Entry, recipient *age.X25519Recipient) error { +// 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) @@ -121,7 +121,7 @@ func writeStoreFile(path string, entries []Entry, recipient *age.X25519Recipient if e.ExpiresAt > 0 && e.ExpiresAt <= now { continue } - je, err := encodeJsonEntry(e, recipient) + je, err := encodeJsonEntry(e, recipients) if err != nil { return fmt.Errorf("key '%s': %w", e.Key, err) } @@ -182,7 +182,7 @@ func decodeJsonEntry(je jsonEntry, identity *age.X25519Identity) (Entry, error) return Entry{Key: je.Key, Value: value, ExpiresAt: expiresAt}, nil } -func encodeJsonEntry(e Entry, recipient *age.X25519Recipient) (jsonEntry, error) { +func encodeJsonEntry(e Entry, recipients []age.Recipient) (jsonEntry, error) { je := jsonEntry{Key: e.Key} if e.ExpiresAt > 0 { ts := int64(e.ExpiresAt) @@ -196,10 +196,10 @@ func encodeJsonEntry(e Entry, recipient *age.X25519Recipient) (jsonEntry, error) return je, nil } if e.Secret { - if recipient == nil { + if len(recipients) == 0 { return je, fmt.Errorf("no recipient available to encrypt") } - ciphertext, err := encrypt(e.Value, recipient) + ciphertext, err := encrypt(e.Value, recipients...) if err != nil { return je, fmt.Errorf("encrypt: %w", err) } diff --git a/cmd/restore.go b/cmd/restore.go index ba4a577..70948ba 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -31,6 +31,7 @@ import ( "strings" "filippo.io/age" + "github.com/gobwas/glob" "github.com/spf13/cobra" ) @@ -97,9 +98,9 @@ func restore(cmd *cobra.Command, args []string) error { } identity, _ := loadIdentity() - var recipient *age.X25519Recipient - if identity != nil { - recipient = identity.Recipient() + recipients, err := allRecipients(identity) + if err != nil { + return fmt.Errorf("cannot restore '%s': %v", displayTarget, err) } var promptReader io.Reader @@ -121,7 +122,7 @@ func restore(cmd *cobra.Command, args []string) error { promptOverwrite: promptOverwrite, drop: drop, identity: identity, - recipient: recipient, + recipients: recipients, promptReader: promptReader, } @@ -193,7 +194,7 @@ type restoreOpts struct { promptOverwrite bool drop bool identity *age.X25519Identity - recipient *age.X25519Recipient + recipients []age.Recipient promptReader io.Reader } @@ -310,7 +311,7 @@ func restoreEntries(decoder *json.Decoder, storePaths map[string]string, default for _, acc := range stores { if restored > 0 || opts.drop { - if err := writeStoreFile(acc.path, acc.entries, opts.recipient); err != nil { + if err := writeStoreFile(acc.path, acc.entries, opts.recipients); err != nil { return 0, err } } diff --git a/cmd/secret.go b/cmd/secret.go index b71f272..52eb848 100644 --- a/cmd/secret.go +++ b/cmd/secret.go @@ -1,24 +1,26 @@ 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_CONFIG the same way configPath() does. +// respecting PDA_DATA the same way Store.path() does. func identityPath() (string, error) { - if override := os.Getenv("PDA_CONFIG"); override != "" { + if override := os.Getenv("PDA_DATA"); override != "" { return filepath.Join(override, "identity.txt"), nil } scope := gap.NewScope(gap.User, "pda") - dir, err := scope.ConfigPath("") + dir, err := scope.DataPath("") if err != nil { return "", err } @@ -77,10 +79,100 @@ func ensureIdentity() (*age.X25519Identity, error) { return id, nil } -// encrypt encrypts plaintext for the given recipient using age. -func encrypt(plaintext []byte, recipient *age.X25519Recipient) ([]byte, error) { +// 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 - w, err := age.Encrypt(&buf, recipient) + 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 } @@ -93,6 +185,52 @@ func encrypt(plaintext []byte, recipient *age.X25519Recipient) ([]byte, error) { 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) diff --git a/cmd/secret_test.go b/cmd/secret_test.go index 6db1bb1..fb209a1 100644 --- a/cmd/secret_test.go +++ b/cmd/secret_test.go @@ -46,7 +46,7 @@ func TestEncryptDecryptRoundtrip(t *testing.T) { } func TestLoadIdentityMissing(t *testing.T) { - t.Setenv("PDA_CONFIG", t.TempDir()) + t.Setenv("PDA_DATA", t.TempDir()) id, err := loadIdentity() if err != nil { t.Fatal(err) @@ -58,7 +58,7 @@ func TestLoadIdentityMissing(t *testing.T) { func TestEnsureIdentityCreatesFile(t *testing.T) { dir := t.TempDir() - t.Setenv("PDA_CONFIG", dir) + t.Setenv("PDA_DATA", dir) id, err := ensureIdentity() if err != nil { @@ -89,7 +89,7 @@ func TestEnsureIdentityCreatesFile(t *testing.T) { func TestEnsureIdentityIdempotent(t *testing.T) { dir := t.TempDir() - t.Setenv("PDA_CONFIG", dir) + t.Setenv("PDA_DATA", dir) id1, err := ensureIdentity() if err != nil { @@ -109,7 +109,7 @@ func TestSecretEntryRoundtrip(t *testing.T) { if err != nil { t.Fatal(err) } - recipient := id.Recipient() + recipients := []age.Recipient{id.Recipient()} dir := t.TempDir() path := filepath.Join(dir, "test.ndjson") @@ -118,7 +118,7 @@ func TestSecretEntryRoundtrip(t *testing.T) { {Key: "encrypted", Value: []byte("secret-value"), Secret: true}, } - if err := writeStoreFile(path, entries, recipient); err != nil { + if err := writeStoreFile(path, entries, recipients); err != nil { t.Fatal(err) } @@ -153,14 +153,14 @@ func TestSecretEntryLockedWithoutIdentity(t *testing.T) { if err != nil { t.Fatal(err) } - recipient := id.Recipient() + 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, recipient); err != nil { + if err := writeStoreFile(path, entries, recipients); err != nil { t.Fatal(err) } @@ -185,7 +185,7 @@ func TestLockedPassthrough(t *testing.T) { if err != nil { t.Fatal(err) } - recipient := id.Recipient() + recipients := []age.Recipient{id.Recipient()} dir := t.TempDir() path := filepath.Join(dir, "test.ndjson") @@ -193,7 +193,7 @@ func TestLockedPassthrough(t *testing.T) { entries := []Entry{ {Key: "encrypted", Value: []byte("secret-value"), Secret: true}, } - if err := writeStoreFile(path, entries, recipient); err != nil { + if err := writeStoreFile(path, entries, recipients); err != nil { t.Fatal(err) } @@ -224,9 +224,240 @@ func TestLockedPassthrough(t *testing.T) { } } +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_CONFIG", dir) + t.Setenv("PDA_DATA", dir) return ensureIdentity() } diff --git a/cmd/set.go b/cmd/set.go index e39586f..7ba38e8 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -119,9 +119,9 @@ func set(cmd *cobra.Command, args []string) error { } else { identity, _ = loadIdentity() } - var recipient *age.X25519Recipient - if identity != nil { - recipient = identity.Recipient() + recipients, err := allRecipients(identity) + if err != nil { + return fmt.Errorf("cannot set '%s': %v", args[0], err) } p, err := store.storePath(spec.DB) @@ -172,7 +172,7 @@ func set(cmd *cobra.Command, args []string) error { entries = append(entries, entry) } - if err := writeStoreFile(p, entries, recipient); err != nil { + if err := writeStoreFile(p, entries, recipients); err != nil { return fmt.Errorf("cannot set '%s': %v", args[0], err) } diff --git a/main_test.go b/main_test.go index 0eee94b..60d648b 100644 --- a/main_test.go +++ b/main_test.go @@ -66,7 +66,7 @@ func TestMain(t *testing.T) { if err != nil { return err } - return os.WriteFile(filepath.Join(configDir, "identity.txt"), []byte(id.String()+"\n"), 0o600) + return os.WriteFile(filepath.Join(dataDir, "identity.txt"), []byte(id.String()+"\n"), 0o600) } ts.Run(t, *update) From a382e8dc79eec86d67ebb1b4c0b8b0b118bd6096 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:14:05 +0000 Subject: [PATCH 088/107] feat(shared): add parseTTLString helper for duration/"never" parsing --- cmd/shared.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cmd/shared.go b/cmd/shared.go index a5d1740..87a8cfe 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -272,6 +272,23 @@ func formatExpiry(expiresAt uint64) string { return fmt.Sprintf("in %s", 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 are returned in lowercase to mirror stored key format. func (s *Store) Keys(dbName string) ([]string, error) { From 618842b2854f8b0cff9b4a6eabd09468d5f8f4e8 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:15:26 +0000 Subject: [PATCH 089/107] feat(meta): add meta command for viewing/modifying key metadata --- cmd/meta.go | 124 +++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + testdata/meta-decrypt.ct | 9 +++ testdata/meta-encrypt.ct | 10 ++++ testdata/meta-err.ct | 21 +++++++ testdata/meta-ttl.ct | 11 ++++ testdata/meta.ct | 13 ++++ 7 files changed, 189 insertions(+) create mode 100644 cmd/meta.go create mode 100644 testdata/meta-decrypt.ct create mode 100644 testdata/meta-encrypt.ct create mode 100644 testdata/meta-err.ct create mode 100644 testdata/meta-ttl.ct create mode 100644 testdata/meta.ct diff --git a/cmd/meta.go b/cmd/meta.go new file mode 100644 index 0000000..0309649 --- /dev/null +++ b/cmd/meta.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "fmt" + + "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) for a key without changing its value. + +With no flags, displays the key's current metadata. Use flags to modify: + --ttl DURATION Set expiry (e.g. 30m, 2h) + --ttl never Remove expiry + --encrypt Encrypt the value at rest + --decrypt Decrypt the value (store as plaintext)`, + Args: cobra.ExactArgs(1), + 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") + + if encryptFlag && decryptFlag { + return fmt.Errorf("cannot meta '%s': --encrypt and --decrypt are mutually exclusive", args[0]) + } + + // View mode: no flags set + if ttlStr == "" && !encryptFlag && !decryptFlag { + 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(), " expires: %s\n", expiresStr) + return nil + } + + // 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) + } + + if ttlStr != "" { + expiresAt, err := parseTTLString(ttlStr) + if err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + entry.ExpiresAt = expiresAt + } + + 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 + } + + 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 + } + + if err := writeStoreFile(p, entries, recipients); err != nil { + return fmt.Errorf("cannot meta '%s': %v", args[0], err) + } + + return autoSync("meta " + 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)") + rootCmd.AddCommand(metaCmd) +} diff --git a/cmd/root.go b/cmd/root.go index fa5416a..ba78a53 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,6 +70,7 @@ func init() { cpCmd.GroupID = "keys" delCmd.GroupID = "keys" listCmd.GroupID = "keys" + metaCmd.GroupID = "keys" identityCmd.GroupID = "keys" rootCmd.AddGroup(&cobra.Group{ID: "stores", Title: "Store commands:"}) diff --git a/testdata/meta-decrypt.ct b/testdata/meta-decrypt.ct new file mode 100644 index 0000000..6b9741c --- /dev/null +++ b/testdata/meta-decrypt.ct @@ -0,0 +1,9 @@ +# Decrypt an existing encrypted key +$ pda set --encrypt hello@md world +$ pda meta hello@md --decrypt +$ pda meta hello@md + key: hello@md + secret: false + expires: never +$ pda get hello@md +world diff --git a/testdata/meta-encrypt.ct b/testdata/meta-encrypt.ct new file mode 100644 index 0000000..f6898c3 --- /dev/null +++ b/testdata/meta-encrypt.ct @@ -0,0 +1,10 @@ +# Encrypt an existing plaintext key +$ pda set hello@me world +$ pda meta hello@me --encrypt +$ pda meta hello@me + key: hello@me + secret: true + expires: never +# Value should still be retrievable +$ pda get hello@me +world diff --git a/testdata/meta-err.ct b/testdata/meta-err.ct new file mode 100644 index 0000000..7f5cfba --- /dev/null +++ b/testdata/meta-err.ct @@ -0,0 +1,21 @@ +# 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-ttl.ct b/testdata/meta-ttl.ct new file mode 100644 index 0000000..51fd761 --- /dev/null +++ b/testdata/meta-ttl.ct @@ -0,0 +1,11 @@ +# 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 + +# Clear TTL with --ttl never +$ pda set --ttl 1h expiring@mt val +$ pda meta expiring@mt --ttl never +$ pda meta expiring@mt + key: expiring@mt + secret: false + expires: never diff --git a/testdata/meta.ct b/testdata/meta.ct new file mode 100644 index 0000000..e13fcf9 --- /dev/null +++ b/testdata/meta.ct @@ -0,0 +1,13 @@ +# View metadata for a plaintext key +$ pda set hello@m world +$ pda meta hello@m + key: hello@m + secret: false + expires: never + +# View metadata for an encrypted key +$ pda set --encrypt secret@m hunter2 +$ pda meta secret@m + key: secret@m + secret: true + expires: never From 637c7e0b5688c517f06813c84128f1b9d5908819 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:21:49 +0000 Subject: [PATCH 090/107] feat(edit): add edit command to open key values in $EDITOR --- cmd/edit.go | 223 +++++++++++++++++++++++++++++++++ cmd/edit_test.go | 113 +++++++++++++++++ cmd/root.go | 1 + main_test.go | 1 + testdata/edit-no-editor-err.ct | 5 + 5 files changed, 343 insertions(+) create mode 100644 cmd/edit.go create mode 100644 cmd/edit_test.go create mode 100644 testdata/edit-no-editor-err.ct diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..b385457 --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,223 @@ +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), + 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") + + if encryptFlag && decryptFlag { + return fmt.Errorf("cannot edit '%s': --encrypt and --decrypt 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.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 + 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, + } + 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 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") + rootCmd.AddCommand(editCmd) +} diff --git a/cmd/edit_test.go b/cmd/edit_test.go new file mode 100644 index 0000000..a1cbcd8 --- /dev/null +++ b/cmd/edit_test.go @@ -0,0 +1,113 @@ +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/root.go b/cmd/root.go index ba78a53..0fa6b76 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -70,6 +70,7 @@ func init() { cpCmd.GroupID = "keys" delCmd.GroupID = "keys" listCmd.GroupID = "keys" + editCmd.GroupID = "keys" metaCmd.GroupID = "keys" identityCmd.GroupID = "keys" diff --git a/main_test.go b/main_test.go index 60d648b..9ecb6ed 100644 --- a/main_test.go +++ b/main_test.go @@ -59,6 +59,7 @@ func TestMain(t *testing.T) { } 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. diff --git a/testdata/edit-no-editor-err.ct b/testdata/edit-no-editor-err.ct new file mode 100644 index 0000000..5b7a2ed --- /dev/null +++ b/testdata/edit-no-editor-err.ct @@ -0,0 +1,5 @@ +# 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 From eaaafbc040689df9de0245f09de895c1d8dc3e06 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:29:52 +0000 Subject: [PATCH 091/107] docs: add edit and meta commands to README --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/README.md b/README.md index 7c5e3b3..1951587 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ Usage: 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 @@ -169,6 +171,24 @@ pda get name --exists

+`pda edit` to open a key in your `$EDITOR`. +```bash +# Edit an existing key. +pda edit name + +# Edit a key that doesn't exist yet — saving non-empty content creates it. +pda edit newkey + +# Edit and modify metadata in the same operation. +pda edit name --ttl 1h --encrypt + +# Trailing newlines added by the editor are stripped by default. +# Pass --preserve-newline to keep them. +pda edit name --preserve-newline +``` + +

+ `pda mv` to move it. ```bash pda mv name name2 @@ -671,6 +691,29 @@ pda ls

+`pda meta` views or modifies metadata (TTL, encryption) without changing a key's value. +```bash +# View metadata for a key. +pda meta session +# key: session@default +# secret: false +# expires: in 59m30s + +# Set or change TTL. +pda meta session --ttl 2h + +# Clear TTL. +pda meta session --ttl never + +# Encrypt an existing plaintext key. +pda meta api-key --encrypt + +# Decrypt an encrypted key. +pda meta api-key --decrypt +``` + +

+ ### Binary Save binary data. @@ -941,6 +984,13 @@ PDA_DATA=/tmp/stores pda set key value

+`EDITOR` is used by `pda edit` and `pda config edit` to open values in a text editor. Must be set for these commands to work. +```bash +EDITOR=nvim pda edit mykey +``` + +

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+`pda meta` to view or modify metadata for a key. +```bash +pda meta session +# key: session@store +# secret: false +# writable: true +# pinned: false +# expires: 59m30s +``` +Metadata flags like `--ttl`, `--encrypt`, `--readonly`, and `--pin` are covered in their dedicated sections below. + +

+ `pda mv` to move it. ```bash pda mv name name2 @@ -715,80 +729,33 @@ pda ls

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

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

@@ -803,6 +770,12 @@ pda set api-url "new value" --force pda rm api-url --force pda mv api-url new-name --force + +# Modifying a read-only key's metadata also requires --force. +pda meta api-url --ttl 1h +# FAIL cannot meta 'api-url': key is read-only +pda meta api-url --ttl 1h --force +# ok set ttl to 1h api-url ```

@@ -811,7 +784,23 @@ pda mv api-url new-name --force

-Pinned entries sort to the top of `list` output, preserving alphabetical order within the pinned and unpinned groups. +### Pinned + +Pinned keys sort to the top of `list` output, preserving alphabetical order within the pinned and unpinned groups. + +```bash +# Pin a key at creation time. +pda set important "remember this" --pin + +# Or toggle with meta. +pda meta todo --pin +# ok pinned todo +pda meta todo --unpin +# ok unpinned todo +``` + +

+ ```bash pda ls # Meta Key Value @@ -872,6 +861,17 @@ pda set --encrypt token "ghp_xxxx"

+`meta --encrypt` and `meta --decrypt` to toggle encryption on an existing key. +```bash +pda meta api-key --encrypt +# ok encrypted api-key + +pda meta api-key --decrypt +# ok decrypted api-key +``` + +

+ `get` decrypts automatically. ```bash pda get api-key From 9914f51140aadcb4cdeaa5db14ea8ada7474b6f0 Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 14 Feb 2026 01:41:40 +0000 Subject: [PATCH 095/107] fix(get): prevents templating invalid utf8 values --- cmd/get.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/get.go b/cmd/get.go index e4b6b1c..ff1f5a8 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -29,6 +29,7 @@ import ( "os/exec" "strings" "text/template" + "unicode/utf8" "github.com/spf13/cobra" ) @@ -117,7 +118,7 @@ func get(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot get '%s': %v", args[0], err) } - if !noTemplate { + if !noTemplate && utf8.Valid(v) { var substitutions []string if len(args) > 1 { substitutions = args[1:] From 80d252738fc8291e07cd364df07266ec06e6aa5a Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 14 Feb 2026 01:41:53 +0000 Subject: [PATCH 096/107] chore(docs update): --- README.md | 2457 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 1684 insertions(+), 773 deletions(-) diff --git a/README.md b/README.md index 3344e63..65eef93 100644 --- a/README.md +++ b/README.md @@ -19,56 +19,135 @@

`pda!` is a command-line key-value store tool with: -- [templates](https://github.com/Llywelwyn/pda#templates) supporting arbitrary shell execution, conditionals, loops, more, -- [encryption](https://github.com/Llywelwyn/pda#encryption) at rest using [age](https://github.com/FiloSottile/age), -- Git-backed [version control](https://github.com/Llywelwyn/pda#git) with automatic syncing, -- [search and filtering](https://github.com/Llywelwyn/pda#filtering) by key, value, or store, +- [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](https://github.com/Llywelwyn/pda#binary), -- expiring keys with a [time-to-live](https://github.com/Llywelwyn/pda#ttl), -- [read-only](https://github.com/Llywelwyn/pda#read-only) keys and [pinned](https://github.com/Llywelwyn/pda#pinned) entries, -- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor) and [configuration](https://github.com/Llywelwyn/pda#config), +- 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), 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. The `list` command 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_element#Tables) tables, JSON, and raw NDJSON. Because every store is in plaintext, Git versioning is pretty easy: auto-committing, pushing, and fetching can be enabled in the config to automatically version changes, or just `pda sync` regularly. +`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. Every store is plaintext, portable, and yours. There's no daemon, no cloud service, and no proprietary format. Keys are just lines in a JSON file; stores are just files in a directory. If you can `cat` a file, you can read your data without `pda!` installed. + +Git versioning is built in. Enable auto-committing, pushing, and fetching in the [config](#config) to automatically version every change, or just run [`pda sync`](#sync) when you want to. Because the storage format is line-oriented plaintext, diffs are meaningful and merges are clean. + +Go's [`text/template`](https://pkg.go.dev/text/template) engine is available on every value at retrieval time, turning simple key-value pairs into dynamic snippets with variables, environment lookups, shell execution, cross-references, and more.

-### Contents +### Installation -- [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) -- [Filtering](https://github.com/Llywelwyn/pda#filtering) -- [TTL](https://github.com/Llywelwyn/pda#ttl) -- [Read-only](https://github.com/Llywelwyn/pda#read-only) -- [Pinned](https://github.com/Llywelwyn/pda#pinned) -- [Binary](https://github.com/Llywelwyn/pda#binary) -- [Encryption](https://github.com/Llywelwyn/pda#encryption) -- [Doctor](https://github.com/Llywelwyn/pda#doctor) -- [Config](https://github.com/Llywelwyn/pda#config) -- [Environment](https://github.com/Llywelwyn/pda#environment) +

+ + + 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 +go install github.com/llywelwyn/pda@latest + +# Or from a spceific commit. +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 +## Overview - ```bash +
+ Setting · + Getting · + Running · + Listing · + Editing · + Moving & Copying · + Removing · + Metadata · + TTL · + Encryption · + Read-Only · + Pinned · + Stores · + Import & Export · + Templates · + Filtering · + Binary Data + Git · + Identity · + Config · + Environment · + Doctor · + Help & Version +
+ +

+ +```bash pda! MIT licensed. (c) 2025 Lewis Wynne Usage: @@ -110,747 +189,593 @@ Additional Commands:

-Most commands have aliases and flags. `pda help [command]` to see them. +Most commands have aliases and flags. Run `pda help [command]` to see them. -

+### Key commands -### Installation +

+ + · + pda set, + pda get, + pda run, + pda list, + pda edit, + pda move, + pda remove + +

+ +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`](#setting) (alias: [`s`](#setting)) creates a key-value pair. Values can come from arguments, stdin, or a file. ```bash -# Get the latest release from the AUR -yay -S pda +Usage: + pda set KEY[@STORE] [VALUE] [flags] -# Or use pda-git for the latest commit -yay -S pda-git +Aliases: + set, s -# Go install -go install github.com/llywelwyn/pda@latest - -# Or -git clone https://github.com/llywelwyn/pda -cd pda -go install +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 set`](#setting) requires a key and a value as inputs. The first argument given will always be used to determine the key. -### Get Started - -`pda set` to save a key. ```bash -# From arguments +# create a key-value pair pda set name "Alice" -# From stdin -echo "Alice" | pda set name -cat dogs.txt | pda set dogs -pda set kitty < cat.png +# create a key-value pair with piped input +echo "Bob" | pda set name -# From a file -pda set dogs --file dogs.txt -pda set kitty -f cat.png +# create a key-value pair with redirection +pda set example < silmarillion.txt -# --safe to skip if the key already exists. -pda set name "Alice" --safe +# create a pinned key-value pair from a file +pda set --pin example --file example.md + +# create a key-value pair in the "Favourites" store +pda set movie@favourites "The Road" + +# create an encrypted key-value pair, expiring in one day +pda set secret "Secret data." --encrypt --ttl 24h +``` + +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. + +```bash +# prevent ever overwriting an existing key pda set name "Bob" --safe -pda get name -# Alice -# --readonly to protect a key from modification. -pda set api-url "https://prod.example.com" --readonly +# guarantee a prompt when overwriting an existing key +pda set name "Joe" --interactive ``` -

+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 get` to retrieve it. ```bash -pda get name -# Alice +# create a readonly key-value pair +pda set repo "https://github.com/llywelwyn/pda" --readonly -# Or run it directly. -pda run name -# same as: pda get name --run - -# Check if a key exists (exit 0 if found, exit 1 if not). -pda get name --exists +# force-overwrite a readonly key-value pair +pda set dog "A four-legged mammal that isn't a cat." --force ``` -

+#### Getting + +[`pda get`](#getting) (alias: [`g`](#getting)) retrieves a key's value. [Templates](#templates) are evaluated at retrieval time. -`pda edit` to open a key in your `$EDITOR`. ```bash -# Edit an existing key. +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 get`] takes one argument: the desired key. The value is output to stdout. + +```bash +# get the value of a key +❯ pda get name +Alice +``` + +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. + +```bash +# get the information of a non-UTF8 key +❯ pda get cat_gif +(size: 101.2k, image/gif) + +# get the raw bytes of a non-UTF8 key via pipe +pda get cat_gif | xdg-open + +# get the raw bytes of a non-UTF8 key via redirect +pda get cat_gif > cat.gif + +# get the base64 representation of a non-UTF8 key +❯ pda get cat_gif --base64 +R0lGODlhXANYAvf/MQAAAAEBAQICAgMDAwQEBAUFBQYGBggI... +``` + +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. + +```bash +# check if an existent key exists +❯ pda get name --exists +exit code 0 + +# check if a non-existent key exists +❯ pda get nlammfd --exists +exit code 1 +``` + +Running [`pda get`](#getting) will resolve templates in the stored key at run-time. This can be prevented with the `no-template` flag. + +```bash +# set key "user" to a template of the USER environment variable +❯ pda set user "{{ env "USER" }}" + +# get a templated key +❯ pda get user +lew + +# get a templated key without resolving the template +❯ pda get user +{{ env "USER" }} +``` + +An alternative to [templates](#templates) is the `run` flag. For detailed information, see [`pda run`](#running), an alias for `pda get --run`. + +```bash +# create a key containg a script +❯ pda set my_script "echo Hello, world." + +# get and run a key using $SHELL +❯ pda get my_script --run +Hello, world. +``` + +#### Running + +[`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`. Templates are functional when running a key directly. + +```bash +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 +``` + +Running takes one argument: the key. + +```bash +# create a key containing a script, and a template +❯ pda set greet 'echo "Hello, {{ default "Jane Doe" .NAME }}"' + +# run the key directly in $SHELL +❯ pda run greet +Hello, Jane Doe + +# run the key, setting NAME to "Alice" +❯ pda run greet NAME="Alice" +Hello, Alice +``` + +#### Listing + +[`pda list`](#listing) (alias: [`ls`](#listing)) shows what you've got stored. 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. + +```bash +❯ pda ls +Meta Size TTL Store Key Value +-w-p 5 - store todo don't forget this +---- 23 - store url https://prod.example.com +-w-- 5 - store name Alice +``` + +By default, [`pda list`](#listing) shows entries from every store. Pass a store name to narrow to a single store: + +```bash +pda ls @store +``` + +Use [`--store`](#filtering) / `-s` to filter stores by [glob pattern](#filtering): + +```bash +pda ls --store "prod*" +``` + +Filter by key or value with [`--key`](#filtering) / `-k` and [`--value`](#filtering) / `-v`: + +```bash +pda ls --key "db*" --value "**localhost**" +``` + +Columns can be toggled with `--no-X` flags. `--no-X` suppresses a column; `--no-X=false` adds it even if it's not in the default config: + +```bash +# hide the meta and size columns +pda ls --no-meta --no-size +``` + +Long values are truncated to fit the terminal. [`--full`](#listing) / `-f` shows the complete value: + +```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 +``` + +[`--count`](#listing) / `-c` prints only the count of matching entries: + +```bash +❯ pda ls --count +3 + +❯ pda ls --count --key "d*" +1 +``` + +[`--format`](#listing) / `-o` selects the output format. Available formats: `table` (default), `csv`, `tsv`, `json`, `ndjson`, `markdown`, `html`: + +```bash +❯ pda ls --format csv +Meta,Size,TTL,Store,Key,Value +-w--,5,-,store,name,Alice + +❯ pda ls --format json +[{"key":"name","value":"Alice","encoding":"text","store":"store"}] +``` + +[`--all`](#listing) / `-a` lists across all stores (default when `list.always_show_all_stores` is true). + +[`--base64`](#listing) / `-b` shows binary data as base64. + +[`--no-header`](#listing) suppresses the header row. + +[Pinned](#pinned) entries sort to the top, preserving alphabetical order within the pinned and unpinned groups. + +

+ + See also: + pda help list + +

+ +#### Editing + +

+ + · + pda edit, + pda set, + pda meta + +

+ +[`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 — saving non-empty content creates it. + +```bash +# edit an existing key pda edit name -# Edit a key that doesn't exist yet — saving non-empty content creates it. +# edit a new key — saving non-empty content creates it pda edit newkey +``` -# Edit and modify metadata in the same operation. +Metadata flags can be passed alongside the edit to modify metadata in the same operation: + +```bash pda edit name --ttl 1h --encrypt +``` -# Trailing newlines added by the editor are stripped by default. -# Pass --preserve-newline to keep them. +Trailing newlines added by the editor are stripped by default. [`--preserve-newline`](#editing) keeps them: + +```bash pda edit name --preserve-newline ``` -

+[`--encrypt`](#editing) / `-e` encrypts the value. [`--decrypt`](#editing) / `-d` decrypts it. [`--readonly`](#editing) and [`--writable`](#editing) toggle protection. [`--pin`](#editing) and [`--unpin`](#editing) toggle pinning. [`--ttl`](#editing) sets or clears expiry (e.g. `30m`, `2h`, or `never`). + +Binary values are presented as base64 for editing and decoded back on save. + +[Read-only](#read-only) keys require [`--force`](#editing) to edit. + +

+ + See also: + pda help edit + +

+ +#### Moving & Copying + +

+ + · + pda move, + pda copy + +

+ +[`pda move`](#moving--copying) (alias: [`mv`](#moving--copying)) moves a key to a new name or store. All metadata is preserved. -`pda meta` to view or modify metadata for a key. ```bash -pda meta session -# key: session@store -# secret: false -# writable: true -# pinned: false -# expires: 59m30s +❯ pda mv name name2 + ok renamed name to name2 ``` -Metadata flags like `--ttl`, `--encrypt`, `--readonly`, and `--pin` are covered in their dedicated sections below. -

+[`pda copy`](#moving--copying) (alias: [`cp`](#moving--copying)) makes a copy. The source is kept and all metadata is preserved. -`pda mv` to move it. ```bash -pda mv name name2 -# ok renamed name to name2 +pda cp name name2 +``` -# --safe to skip if the destination already exists. +[`mv --copy`](#moving--copying) and [`cp`](#moving--copying) are equivalent: + +```bash +pda mv name name2 --copy +``` + +Move or copy across stores: + +```bash +pda mv name@store name@archive +pda cp config@dev config@prod +``` + +[`--safe`](#moving--copying) skips if the destination already exists: + +```bash pda mv name name2 --safe # info skipped 'name2': already exists +``` -# --yes/-y to skip confirmation prompts. +[`--yes`](#moving--copying) / `-y` skips all confirmation prompts: + +```bash pda mv name name2 -y ``` -`pda cp` to make a copy. All metadata is preserved. +[Read-only](#read-only) keys can't be moved or overwritten without [`--force`](#moving--copying): + ```bash -pda cp name name2 +❯ pda mv readonly-key newname +FAIL cannot move 'readonly-key': key is read-only -# 'mv --copy' and 'cp' are aliases. Either one works. -pda mv name name2 --copy - -# Read-only keys can't be moved or overwritten without --force. -pda mv readonly-key newname -# FAIL cannot move 'readonly-key': key is read-only pda mv readonly-key newname --force ``` -

+[`cp`](#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`](#moving--copying). + +

+ + See also: + pda help move, + pda help copy + +

+ +#### Removing + +

+ + · + pda remove, + --key + +

+ +[`pda remove`](#removing) (alias: [`rm`](#removing)) deletes one or more keys. -`pda rm` to delete one or more keys. ```bash pda rm kitty +``` -# Remove multiple keys. +Remove multiple keys at once: + +```bash pda rm kitty dog@animals +``` -# Mix exact keys with glob patterns. +Mix exact keys with [glob patterns](#filtering) using [`--key`](#removing): + +```bash pda set cog "cogs" pda set dog "doggy" pda set kitty "cat" pda rm kitty --key "?og" +``` -# Opt in to a confirmation prompt with --interactive/-i (or always_prompt_delete in config). +Filter by store with [`--store`](#removing) / `-s` and by value with [`--value`](#removing) / `-v`: + +```bash +pda rm --store "temp*" --key "session*" +``` + +[`--interactive`](#removing) / `-i` prompts before each deletion (or set `key.always_prompt_delete` in [config](#config)): + +```bash pda rm kitty -i # ??? remove 'kitty'? (y/n) # ==> y +``` -# --yes/-y to auto-accept all confirmation prompts. +Glob-matched deletions prompt by default (configurable with `key.always_prompt_glob_delete`). + +[`--yes`](#removing) / `-y` auto-accepts all confirmation prompts: + +```bash pda rm kitty -y +``` + +[Read-only](#read-only) keys can't be deleted without [`--force`](#removing): + +```bash +❯ pda rm protected-key +FAIL cannot remove 'protected-key': key is read-only -# Read-only keys can't be deleted without --force. -pda rm protected-key -# FAIL cannot remove 'protected-key': key is read-only pda rm protected-key --force ``` -

+

+ + See also: + pda help remove + +

-`pda ls` to see what you've got stored. The default columns are `meta,size,ttl,store,key,value`. Meta is a 4-char flag string showing `(e)ncrypted (w)ritable (t)tl (p)inned`, or a dash for an unset flag. Pinned entries sort to the top. +### Metadata + +

+ + · + pda meta, + TTL, + Encryption, + Read-Only, + Pinned + +

+ +[`pda meta`](#metadata) views or modifies metadata for a key without changing its value. With no flags, it displays the key's current metadata: -By default it lists the contents of all stores. Pass a store name to check only the given store. Checking a specific store is faster than checking everything, but the slowdown should be insignificant unless you have masses of different stores. `list.always_show_all_stores` can be set to false to list only the default store when none is specified. ```bash -pda ls -# Meta Size TTL Store Key Value -# -w-p 5 - store todo don't forget this -# ---- 23 - store url https://prod.example.com -# -w-- 5 - store name Alice - -# Narrow to a single store. -pda ls @store - -# Or filter stores by glob pattern. -pda ls --store "prod*" - -# Suppress or add columns with --no-X flags. -# --no-X suppresses. --no-X=false adds even if not in default config. - -# Or as CSV. -pda ls --format csv -# Meta,Size,TTL,Store,Key,Value -# -w--,5,-,store,name,Alice - -# Or as a JSON array. -pda ls --format json -# [{"key":"name","value":"Alice","encoding":"text","store":"store"}] - -# Or TSV, Markdown, HTML, NDJSON. - -# Just the count of entries. -pda ls --count -# 2 -pda ls --count --key "d*" -# 1 +❯ pda meta session + key: session@store + secret: false + writable: true + pinned: false + expires: 59m30s ``` -

+Pass flags to modify: [`--ttl`](#ttl), [`--encrypt`](#encryption) / [`--decrypt`](#encryption), [`--readonly`](#read-only) / [`--writable`](#read-only), [`--pin`](#pinned) / [`--unpin`](#pinned). + +Multiple metadata changes can be combined in one call: -Long values are truncated to fit the terminal. Use `--full`/`-f` to show the complete value. ```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 +pda meta session --ttl 2h --encrypt --pin ``` -

+Modifying a [read-only](#read-only) key's metadata requires [`--force`](#metadata) (except for toggling the read-only flag itself, and pin/unpin): -`pda export` to export everything as NDJSON. ```bash -pda export > my_backup +❯ pda meta api-url --ttl 1h +FAIL cannot meta 'api-url': key is read-only -# Export only matching keys. -pda export --key "a*" - -# Export only entries whose values contain a URL. -pda export --value "**https**" +pda meta api-url --ttl 1h --force ``` -

+

+ + See also: + pda help meta + +

-`pda import` to import it all back. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to `store.default_store_name`. Pass a store name as a positional argument to force all entries into one store. Existing keys are updated and new keys are added. -```bash -# Entries are routed to their original stores. -pda import -f my_backup -# ok restored 5 entries +#### TTL -# Force all entries into a specific store by passing a store name. -pda import mystore -f my_backup -# ok restored 5 entries into @mystore +

+ + · + pda set, + pda meta + +

-# Or from stdin. -pda import < my_backup +Keys can be given an expiration time. Expired keys are marked for garbage collection and deleted on the next access to the store. -# Import only matching keys. -pda import --key "a*" -f my_backup - -# Import only entries from matching stores. -pda import --store "prod*" -f my_backup - -# Full replace — drop all existing entries before importing. -pda import --drop -f my_backup -``` - -

- -You can have as many stores as you want. All the store commands have shorthands, like `mv` to move a key, or `mvs` to move a store. -```bash -# Save to a specific store. -pda set alice@birthdays 11/11/1998 - -# See which stores have contents. -pda list-stores -# Keys Size Store -# 2 1.8k @birthdays -# 12 4.2k @store - -# Just the names. -pda list-stores --short -# @birthdays -# @store - -# Check out a specific store. -pda ls @birthdays -# Store Key Value -# birthdays alice 11/11/1998 -# birthdays bob 05/12/1980 - -# Export it. -pda export birthdays > friends_birthdays - -# Import it. -pda import birthdays < friends_birthdays - -# Rename it. -pda move-store birthdays bdays - -# Or copy it. -pda move-store birthdays bdays --copy - -# --safe to skip if the destination already exists. -pda move-store birthdays bdays --safe - -# Delete it. -pda remove-store birthdays - -# --yes/-y to skip confirmation prompts on delete or overwrite. -pda remove-store birthdays -y -``` - -

- -### Git - -pda! supports automatic version control backed by Git, either in a local-only repository or by initialising from a remote repository. - -`pda init` will initialise the version control system. -```bash -# Initialise an empty pda! repository. -pda init - -# Or clone an existing one. -pda init https://github.com/llywelwyn/my-repository - -# --clean to replace your (existing) local repo with a new one. -pda init --clean -``` - -

- -`pda sync` conducts a best-effort syncing of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!` itself, I recommend syncing. - -If you're ahead of your Git repo, syncing will add your changes, commit them, and push to remote if a remote is set. If you use multiple devices or otherwise end up behind your Git repo, syncing will detect this and give you a prompt: either stash your local changes and pull the latest commit from version control, or abort and fix the issue manually. +Set a TTL at creation time with [`pda set --ttl`](#setting): ```bash -# Sync with Git -pda sync - -# With a custom commit message. -pda sync -m "added production credentials" -``` - -`pda!` supports some automation via its config. There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. Any of these operations will slow down `pda!` because it means versioning with every change, but it does effectively guarantee never managing to desync oneself and requiring manual fixes, and reduces the frequency with which one will need to manually run the sync command. - -Auto-commit will commit changes immediately to the local Git repository any time `pda!` data is changed. Auto-fetch will fetch before committing any changes, but incurs a significant slowdown in operations simply due to the time a fetch takes. Auto-push will automatically push committed changes to the remote repository, if one is set. - -If auto-commit is set to false, auto-fetch and auto-push will do nothing. They can be considered to be additional steps taken during the commit process. - -Running `pda sync` manually will always fetch, commit, and push - or if behind it will fetch, stash, and pull - regardless of config. - -My general recommendation would be to enable `git.auto_commit`, and to run a manual `pda sync` any time you're preparing to switch machines, or loading up a new one. - -

- -### Templates - -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, `time`, `lists`, arbitrary `shell` execution, and getting other `pda` keys (recursively!). 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. - -

- -`{{ .BASIC }}` substitution -```bash -pda set greeting "Hello, {{ .NAME }}" -pda get greeting NAME="Alice" -# Hello, Alice -``` - -

- -`default` sets a default value. -```bash -pda set greeting "Hello, {{ default "World" .NAME }}" -pda get greeting -# Hello, World -pda get greeting NAME="Bob" -# Hello, Bob -``` - -

- -`require` errors if missing. -```bash -pda set file "{{ require .FILE }}" -pda get file -# FAIL cannot get 'file': ...required value is missing or empty -``` - -

- -`env` reads from environment variables. -```bash -pda set my_name "{{ env "USER" }}" -pda get my_name -# llywelwyn -``` - -

- -`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" }}" -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` 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. -``` - -

- -`shell` executes a command and returns stdout. -```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` gets another key. -```bash -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 - -# Cross-store references work too. -pda set host@urls "https://example.com" -pda set api '{{ pda "host@urls" }}/api' -pda get api -# https://example.com/api -``` - -

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

- -### Filtering - -`--key`/`-k`, `--value`/`-v`, and `--store`/`-s` can be used as filters with glob support. `gobwas/glob` is used for matching. All three flags are repeatable, with results matching one-or-more of the patterns passed per flag. When multiple flags are combined, results must satisfy all of them (AND across flags, OR within the same flag). - -`--key`, `--value`, and `--store` filters work with `list`, `export`, `import`, and `remove`. `--value` is not available on `import` or `remove`. - -

- -`*` wildcards a word or series of characters, stopping at separator boundaries (the default separators are `/-_.@:` and space). -```bash -pda ls -# cat -# dog -# cog -# mouse hotdog -# mouse house -# foo.bar.baz - -pda ls --key "*" -# cat -# dog -# cog - -pda ls --key "* *" -# mouse hotdog -# mouse house - -pda ls --key "foo.*.baz" -# foo.bar.baz -``` - -

- -`**` super-wildcards ignore word boundaries. -```bash -pda ls --key "foo**" -# foo.bar.baz - -pda ls --key "**g" -# dog -# cog -# mouse hotdog -``` - -

- -`?` wildcards a single letter. -```bash -pda ls --key "?og" -# dog -# cog -# frog --> fail -# dogs --> fail -``` - -

- -`[abc]` must match one of the characters in the brackets. -```bash -pda ls --key "[dc]og" -# dog -# cog -# bog --> fail - -# Can be negated with '!' -pda ls --key "[!dc]og" -# dog --> fail -# cog --> fail -# bog -``` - -

- -`[a-c]` must fall within the range given in the brackets. -```bash -pda ls --key "[a-g]ag" -# bag -# gag -# wag --> fail - -# Can be negated with '!' -pda ls --key "[!a-g]ag" -# bag --> fail -# gag --> fail -# wag - -pda ls --key "19[90-99]" -# 1991 -# 1992 -# 2001 --> fail -# 1988 --> fail -``` - -

- -`--value` filters by value content using the same glob syntax. -```bash -pda ls --value "**localhost**" -# Key Value -# db-url postgres://localhost:5432 - -# Combine key and value filters. -pda ls --key "db*" --value "**localhost**" -# Key Value -# db-url postgres://localhost:5432 - -# Multiple --value patterns are OR'd. -pda ls --value "**world**" --value "42" -# Key Value -# greeting hello world -# number 42 -``` - -

- -Globs can be arbitrarily complex, and `--key` can be combined with exact positional args on `rm`. -```bash -pda rm cat --key "{mouse,[cd]og}**" -# ??? remove 'cat'? (y/n) -# ==> y -# ??? remove 'mouse trap'? (y/n) -# ... -``` - -Locked (encrypted without an available identity) and non-UTF-8 (binary) entries are silently excluded from `--value` matching. - -

- -### 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 +# expire after 1 hour pda set session "123" --ttl 1h -# After 54 minutes and 10 seconds +# expire after 54 minutes and 10 seconds pda set session2 "xyz" --ttl 54m10s ``` -

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

- -`meta --ttl` to change or clear the TTL on an existing key. -```bash -pda meta session --ttl 2h -# ok set ttl to 2h session - -pda meta session --ttl never -# ok cleared ttl session -``` - -

- -### Read-only - -Keys marked read-only are protected from accidental modification. You can modify a read-only key again by making it `--writable` or by explicitly forcing it with the `--force` flag when trying to make an edit. +[`pda list`](#listing) shows expiration in the TTL column: ```bash -# Set a key as read-only at creation time. -pda set api-url "https://prod.example.com" --readonly - -# Or toggle with meta. -pda meta api-url --readonly -# ok made readonly api-url -pda meta api-url --writable -# ok made writable api-url - -# Or alongside an edit. -pda edit notes --readonly +❯ pda ls + TTL Key Value +59m30s session 123 +51m40s session2 xyz ``` -

- -Read-only keys are protected from `set`, `rm`, `mv`, and `edit`. Use `--force` to bypass. -```bash -pda set api-url "new value" -# FAIL cannot set 'api-url': key is read-only - -pda set api-url "new value" --force -# overwrites despite read-only - -pda rm api-url --force -pda mv api-url new-name --force - -# Modifying a read-only key's metadata also requires --force. -pda meta api-url --ttl 1h -# FAIL cannot meta 'api-url': key is read-only -pda meta api-url --ttl 1h --force -# ok set ttl to 1h api-url -``` - -

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

- -### Pinned - -Pinned keys sort to the top of `list` output, preserving alphabetical order within the pinned and unpinned groups. +Change or clear the TTL on an existing key with [`pda meta --ttl`](#metadata): ```bash -# Pin a key at creation time. -pda set important "remember this" --pin +❯ pda meta session --ttl 2h + ok set ttl to 2h session -# Or toggle with meta. -pda meta todo --pin -# ok pinned todo -pda meta todo --unpin -# ok unpinned todo +❯ pda meta session --ttl never + ok cleared ttl session ``` -

+The [`edit`](#editing) command also accepts `--ttl`: ```bash -pda ls -# Meta Key Value -# -w-p important remember this -# -w-- name Alice -# -w-- other foo +pda edit session --ttl 30m ``` -

+[`export`](#import--export) and [`import`](#import--export) preserve the expiry date. Expirations are stored as a timestamp, not a timer — they continue ticking down regardless of whether the key is in an active store or sitting in a backup file. -### Binary +

+ + See also: + pda help set, + pda help meta + +

-Save binary data. -```bash -pda set logo < logo.png -``` +#### Encryption -

+

+ + · + pda set, + pda meta, + pda identity + +

-And `get` it like normal. -```bash -pda get logo > output.png -``` - -

- -`list` and `get` will show a summary for binary data on a TTY. If it's being piped somewhere or ran outside of a TTY, it'll output the raw bytes. - -`--base64`/`-b` to view binary data as base64 on a TTY. -```bash -pda get logo -# (binary: 4.2 KB, image/png) - -pda get logo --base64 -# iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... -``` - -

- -`export` encodes binary data as base64. -```bash -pda export -# {"key":"logo","value":"89504E470D0A1A0A0000000D4948445200000001000000010802000000","encoding":"base64"} -``` - -

- -### Encryption - -`pda set --encrypt` encrypts values at rest using [age](https://github.com/FiloSottile/age). Values are stored on disk as age ciphertext and decrypted automatically by commands like `get` and `list` when the correct identity file is present. An X25519 identity is generated on first use and saved at `~/.config/pda/identity.txt`. +[`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 by commands like [`get`](#getting) and [`list`](#listing) when the correct identity file is present. An X25519 identity is generated on first use. ```bash pda set --encrypt api-key "sk-live-abc123" @@ -859,172 +784,1097 @@ pda set --encrypt api-key "sk-live-abc123" pda set --encrypt token "ghp_xxxx" ``` -

+[`pda get`](#getting) decrypts automatically: -`meta --encrypt` and `meta --decrypt` to toggle encryption on an existing key. ```bash -pda meta api-key --encrypt -# ok encrypted api-key - -pda meta api-key --decrypt -# ok decrypted api-key +❯ pda get api-key +sk-live-abc123 ``` -

+Toggle encryption on an existing key with [`pda meta`](#metadata): -`get` decrypts automatically. ```bash -pda get api-key -# sk-live-abc123 +❯ pda meta api-key --encrypt + ok encrypted api-key + +❯ pda meta api-key --decrypt + ok decrypted api-key ``` -

+The on-disk value is ciphertext, so encrypted entries are safe to commit and push with [Git](#git): -The on-disk value is ciphertext, so encrypted entries are safe to commit and push with Git. ```bash -pda export -# {"key":"api-key","value":"YWdlLWVuY3J5cHRpb24u...","encoding":"secret"} +❯ 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 without `--encrypt` will warn you: -`mv`, `cp`, and `import` all preserve encryption, read-only, and pinned flags. Overwriting an encrypted key without `--encrypt` will warn you. ```bash pda cp api-key api-key-backup # still encrypted -pda set api-key "oops" -# WARN overwriting encrypted key 'api-key' as plaintext -# hint pass --encrypt to keep it encrypted +❯ pda set api-key "oops" +WARN overwriting encrypted key 'api-key' as plaintext +hint pass --encrypt to keep it encrypted ``` -

+If the identity file is missing, encrypted values are inaccessible but not lost. Keys remain visible, and the ciphertext is preserved through reads and writes: -If the identity file is missing, encrypted values are inaccessible but not lost. Keys are still visible, and the ciphertext is preserved through reads and writes. ```bash -pda ls -# Meta Key Value -# ew-- api-key locked (identity file missing) +❯ 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) +❯ pda get api-key +FAIL cannot get 'api-key': secret is locked (identity file missing) ``` -

+All encryption operations can be set as default with `key.always_encrypt` in [config](#config), so every [`pda set`](#setting) automatically encrypts. + +

+ + See also: + pda help set, + pda help meta, + pda help identity + +

+ +#### Read-Only + +

+ + · + pda set, + pda meta, + pda edit + +

+ +Keys marked read-only are protected from accidental modification. You can modify a read-only key again by making it [`--writable`](#metadata) or by explicitly bypassing with [`--force`](#metadata). + +Set a key as read-only at creation time: -`pda identity` to see your public key and identity file path. ```bash -pda identity -# ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -# ok identity ~/.config/pda/identity.txt +pda set api-url "https://prod.example.com" --readonly +``` -# Just the path. -pda identity --path -# ~/.config/pda/identity.txt +Toggle with [`pda meta`](#metadata): -# Generate a new identity. Errors if one already exists. +```bash +❯ pda meta api-url --readonly + ok made readonly api-url + +❯ pda meta api-url --writable + ok made writable api-url +``` + +Or alongside an edit: + +```bash +pda edit notes --readonly +``` + +Read-only keys are protected from [`set`](#setting), [`rm`](#removing), [`mv`](#moving--copying), and [`edit`](#editing). Use `--force` to bypass: + +```bash +❯ pda set api-url "new value" +FAIL cannot set 'api-url': key is read-only + +pda set api-url "new value" --force +pda rm api-url --force +pda mv api-url new-name --force +``` + +Modifying a read-only key's metadata also requires `--force` (except for toggling the read-only flag itself, and pin/unpin): + +```bash +❯ pda meta api-url --ttl 1h +FAIL cannot meta 'api-url': key is read-only + +pda meta api-url --ttl 1h --force +``` + +[`cp`](#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`. + +

+ + See also: + pda help set, + pda help meta, + pda help edit + +

+ +#### Pinned + +

+ + · + pda set, + pda meta, + pda list + +

+ +Pinned keys sort to the top of [`pda list`](#listing) output, preserving alphabetical order within the pinned and unpinned groups. + +Pin a key at creation time: + +```bash +pda set important "remember this" --pin +``` + +Toggle with [`pda meta`](#metadata): + +```bash +❯ pda meta todo --pin + ok pinned todo + +❯ pda meta todo --unpin + ok unpinned todo +``` + +```bash +❯ pda ls +Meta Key Value +-w-p important remember this +-w-- name Alice +-w-- other foo +``` + +

+ + See also: + pda help set, + pda help meta + +

+ +### Stores + +

+ + · + pda list-stores, + pda move-store, + pda remove-store + +

+ +You can have as many stores as you want. Stores are created implicitly when you set a key with a `@STORE` suffix. Each store is a separate NDJSON file on disk. + +[`pda list-stores`](#stores) (alias: [`lss`](#stores)) shows all stores with key counts and file sizes: + +```bash +❯ pda list-stores +Keys Size Store + 2 1.8k @birthdays + 12 4.2k @store +``` + +[`--short`](#stores) prints only the names: + +```bash +❯ pda list-stores --short +@birthdays +@store +``` + +Save to a specific store with the `@STORE` syntax: + +```bash +pda set alice@birthdays "11/11/1998" +``` + +List a specific store: + +```bash +❯ pda ls @birthdays + Store Key Value +birthdays alice 11/11/1998 +birthdays bob 05/12/1980 +``` + +[`pda move-store`](#stores) (alias: [`mvs`](#stores)) renames a store: + +```bash +pda move-store birthdays bdays +``` + +Copy a store with `--copy`: + +```bash +pda move-store birthdays bdays --copy +``` + +[`--safe`](#stores) skips if the destination already exists: + +```bash +pda move-store birthdays bdays --safe +``` + +[`pda remove-store`](#stores) (alias: [`rms`](#stores)) deletes a store: + +```bash +pda remove-store birthdays +``` + +[`--yes`](#stores) / `-y` skips confirmation prompts: + +```bash +pda remove-store birthdays -y +``` + +

+ + See also: + pda help list-stores, + pda help move-store, + pda help remove-store + +

+ +#### Import & Export + +

+ + · + pda export, + pda import + +

+ +[`pda export`](#import--export) exports everything as NDJSON (it's an alias for `list --format ndjson`): + +```bash +pda export > my_backup +``` + +Filter exports with [`--key`](#filtering), [`--value`](#filtering), and [`--store`](#filtering): + +```bash +# 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. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to `store.default_store_name`. + +```bash +# entries are routed to their original stores +pda import -f my_backup +# ok restored 5 entries +``` + +Pass a store name as a positional argument to force all entries into one store: + +```bash +pda import mystore -f my_backup +# ok restored 5 entries into @mystore +``` + +Read from stdin: + +```bash +pda import < my_backup +``` + +Filter imports with [`--key`](#filtering) and [`--store`](#filtering): + +```bash +# import only matching keys +pda import --key "a*" -f my_backup + +# import only entries from matching stores +pda import --store "prod*" -f my_backup +``` + +[`--drop`](#import--export) does a full replace — drops all existing entries before importing: + +```bash +pda import --drop -f my_backup +``` + +[`--interactive`](#import--export) / `-i` prompts before overwriting existing keys. + +[`export`](#import--export) encodes [binary data](#binary-data) as base64. [Encryption](#encryption), [read-only](#read-only), [pinned](#pinned) flags, and [TTL](#ttl) are all preserved through export and import. + +

+ + See also: + pda help export, + pda help 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 fit `text/template` into a CLI key-value tool, `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, along with `summary` which returns the action that triggered the commit (e.g. "set foo", "removed bar"). + +#### Basic Substitution + +

+ + · + Templates, + pda get + +

+ +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 for loops and arithmetic: + +```bash +pda set number "{{ int .N }}" + +❯ pda get number N=3 +3 +``` + +Use it in a range loop: + +```bash +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: + +```bash +pda set rev '{{ shell "git rev-parse --short HEAD" }}' + +❯ pda get rev +a1b2c3d +``` + +```bash +pda set today '{{ shell "date +%Y-%m-%d" }}' + +❯ pda get today +2025-06-15 +``` + +#### `pda` (Recursive) + +

+ + · + Templates + +

+ +`pda` gets another key's value, enabling recursive composition: + +```bash +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 +``` + +Cross-store references work too: + +```bash +pda set host@urls "https://example.com" +pda set api '{{ pda "host@urls" }}/api' + +❯ pda get api +https://example.com/api +``` + +#### `no-template` + +

+ + · + pda get + +

+ +Pass [`--no-template`](#getting) to [`pda get`](#getting) to output the raw value without evaluating templates: + +```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 }} +``` + +

+ + See also: + pda help get, + pda help set + +

+ +### Filtering + +

+ + · + pda list, + pda remove, + pda export, + pda import + +

+ +[`--key`](#filtering) / `-k`, [`--value`](#filtering) / `-v`, and [`--store`](#filtering) / `-s` filter entries with glob support. 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 [`list`](#listing), [`export`](#import--export), [`import`](#import--export), and [`remove`](#removing). [`--value`](#filtering) is not available on [`import`](#import--export) or [`remove`](#removing). + +[`gobwas/glob`](https://github.com/gobwas/glob) is used for matching. The default separators are `/-_.@:` and space. + +#### Glob Patterns + +

+ + · + Filtering + +

+ +`*` wildcards a word or series of characters, stopping at separator boundaries: + +```bash +❯ pda ls +cat +dog +cog +mouse hotdog +mouse house +foo.bar.baz + +pda ls --key "*" +# cat, dog, cog (single-segment keys only) + +pda ls --key "* *" +# mouse hotdog, mouse house + +pda ls --key "foo.*.baz" +# foo.bar.baz +``` + +`**` super-wildcards ignore word boundaries: + +```bash +pda ls --key "foo**" +# foo.bar.baz + +pda ls --key "**g" +# dog, cog, mouse hotdog +``` + +`?` matches a single character: + +```bash +pda ls --key "?og" +# dog, cog +``` + +`[abc]` matches one of the characters in the brackets: + +```bash +pda ls --key "[dc]og" +# dog, cog + +# negate with '!' +pda ls --key "[!dc]og" +# bog (if it exists) +``` + +`[a-c]` matches a range: + +```bash +pda ls --key "[a-g]ag" +# bag, gag + +pda ls --key "[!a-g]ag" +# wag +``` + +#### Filtering by Key + +

+ + · + Filtering, + pda list + +

+ +[`--key`](#filtering) / `-k` filters entries by key name: + +```bash +pda ls --key "db*" +pda ls --key "session*" --key "token*" +``` + +Multiple `--key` patterns are OR'd — an entry matches if it matches any of them. + +#### Filtering by Value + +

+ + · + Filtering, + pda list + +

+ +[`--value`](#filtering) / `-v` filters by value content using the same glob syntax: + +```bash +❯ pda ls --value "**localhost**" +Key Value +db-url postgres://localhost:5432 +``` + +Multiple `--value` patterns are OR'd: + +```bash +❯ pda ls --value "**world**" --value "42" +Key Value +greeting hello world +number 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) / `-s` filters by store name: + +```bash +pda ls --store "prod*" +pda export --store "dev*" +``` + +#### Combining Filters + +

+ + · + Filtering + +

+ +Combine key, value, and store filters. Results must match all flags (AND), with OR within each flag: + +```bash +pda ls --key "db*" --value "**localhost**" +``` + +Globs can be arbitrarily complex, and [`--key`](#filtering) can be combined with exact positional args on [`rm`](#removing): + +```bash +pda rm cat --key "{mouse,[cd]og}**" +# ??? remove 'cat'? (y/n) +# ==> y +# ??? remove 'mouse trap'? (y/n) +# ... +``` + +

+ + See also: + pda help list, + pda help remove + +

+ +### Binary Data + +

+ + · + pda set, + pda get, + pda list + +

+ +`pda!` supports all binary data. Save it with [`pda set`](#setting): + +```bash +pda set logo < logo.png +pda set logo -f logo.png +``` + +And retrieve it with [`pda get`](#getting): + +```bash +pda get logo > output.png +``` + +On a TTY, [`get`](#getting) and [`list`](#listing) show a summary for binary data. If piped or run outside of a TTY, raw bytes are output: + +```bash +❯ pda get logo +(binary: 4.2 KB, image/png) +``` + +[`--base64`](#getting) / `-b` views binary data as base64: + +```bash +❯ pda get logo --base64 +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... +``` + +[`pda export`](#import--export) encodes binary data as base64 in the NDJSON: + +```bash +❯ pda export +{"key":"logo","value":"89504E470D0A1A0A0000000D4948445200000001000000010802000000","encoding":"base64"} +``` + +[`pda edit`](#editing) presents binary values as base64 for editing and decodes them back on save. + +

+ + See also: + pda help set, + pda help get + +

+ +### Git + +

+ + · + pda init, + pda sync, + Config + +

+ +`pda!` supports automatic version control backed by Git, either in a local-only repository or by initialising from a remote. + +#### Init + +

+ + · + Git, + pda sync + +

+ +[`pda init`](#git) initialises version control: + +```bash +# initialise an empty repository +pda init + +# or clone an existing one +pda init https://github.com/llywelwyn/my-repository +``` + +[`--clean`](#git) removes the existing `.git` directory first, useful for reinitialising or switching remotes: + +```bash +pda init --clean +pda init https://github.com/llywelwyn/my-repository --clean +``` + +

+ + See also: + pda help init + +

+ +#### Sync + +

+ + · + Git + +

+ +[`pda sync`](#sync) conducts a best-effort sync of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!`, syncing is recommended. + +If you're ahead, syncing will commit and push. If you're behind, syncing will detect this and prompt you: either stash local changes and pull, or abort and fix manually. + +```bash +# sync with Git +pda sync + +# with a custom commit message +pda sync -m "added production credentials" +``` + +Running [`pda sync`](#sync) manually will always fetch, commit, and push — or stash and pull if behind — regardless of config. + +

+ + See also: + pda help sync + +

+ +#### Auto-Commit & Auto-Push + +

+ + · + Git, + Config + +

+ +`pda!` supports automation via its [config](#config). There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. + +**`git.auto_commit`** commits changes immediately to the local Git repository any time data is changed. + +**`git.auto_fetch`** fetches before committing any changes. This incurs a noticeable slowdown due to network round-trips. + +**`git.auto_push`** automatically pushes committed changes to the remote repository, if one is configured. + +If `auto_commit` is false, `auto_fetch` and `auto_push` have no effect. They are additional steps in the commit process. + +A recommended setup is to enable `git.auto_commit` and run [`pda sync`](#sync) manually when switching machines. + +### Identity + +

+ + · + pda identity, + Encryption + +

+ +[`pda identity`](#identity) (alias: [`id`](#identity)) manages the age encryption identity used for [encryption](#encryption). + +#### Viewing Identity + +

+ + · + pda identity + +

+ +With no flags, [`pda identity`](#identity) shows your public key, identity file path, and any additional recipients: + +```bash +❯ pda identity + ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + ok identity ~/.config/pda/identity.txt +``` + +[`--path`](#identity) prints only the identity file path: + +```bash +❯ pda identity --path +~/.config/pda/identity.txt +``` + +#### Creating an Identity + +

+ + · + pda identity + +

+ +An identity is generated automatically the first time you use [`--encrypt`](#encryption). To create one manually: + +```bash pda identity --new ``` -

+[`--new`](#identity) errors if an identity already exists. Delete the file manually to replace it. + +#### Recipients + +

+ + · + pda identity, + Encryption + +

+ +By default, secrets are encrypted only for your own identity. To encrypt for additional recipients (e.g. a teammate or another device), use [`--add-recipient`](#identity) with their age public key. All existing secrets are automatically re-encrypted for every recipient: -By default, secrets are encrypted only for your own identity. To encrypt for additional recipients (e.g. a teammate or another device), use `--add-recipient` with their age public key. All existing secrets are automatically re-encrypted for every recipient. ```bash -# Add a recipient. All secrets are re-encrypted for both keys. -pda identity --add-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -# ok re-encrypted api-key -# ok added recipient age1ql3z... -# ok re-encrypted 1 secret(s) +❯ pda identity --add-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + ok re-encrypted api-key + ok added recipient age1ql3z... + ok re-encrypted 1 secret(s) +``` -# Remove a recipient. Secrets are re-encrypted without their key. +Remove a recipient with [`--remove-recipient`](#identity). Secrets are re-encrypted without their key: + +```bash pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p - -# Additional recipients are shown in the default identity display. -pda identity -# ok pubkey age1abc... -# ok identity ~/.local/share/pda/identity.txt -# ok recipient age1ql3z... ``` -

- -### Doctor - -`pda doctor` runs a set of health checks of your environment. +Additional recipients are shown in the default identity display: ```bash -pda doctor -# ok pda! 2025.52 Christmas release (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 +❯ pda identity + ok pubkey age1abc... + ok identity ~/.local/share/pda/identity.txt + ok recipient age1ql3z... ``` -

- -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 mean some functionality isn't being made use of, like for example version control not having been initialised yet. - -

+

+ + See also: + pda help identity + +

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

+#### Config Commands + +

+ + · + pda config + +

+ +[`pda config`](#config) manages configuration without editing files by hand: -`pda config` manages configuration without editing files by hand. ```bash -# List all config values and their current settings. +# list all config values and their current settings pda config list -# Get a single value. -pda config get git.auto_commit -# false +# get a single value +❯ pda config get git.auto_commit +false -# Set a value. Validated before saving. +# set a value (validated before saving) pda config set git.auto_commit true -# Open in $EDITOR. Validated on save. +# open in $EDITOR (validated on save) pda config edit -# Print the config file path. +# print the config file path pda config path -# Generate a fresh default config file. +# generate a fresh default config file pda config init -# Overwrite an existing config with defaults. +# overwrite an existing config with defaults pda config init --new -# Update config: migrate deprecated keys and fill missing defaults. +# update config: migrate deprecated keys and fill missing defaults pda config init --update ``` -

+[`pda doctor`](#doctor) will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit. -`pda doctor` will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit. - -

+

+ + See also: + pda help config + +

#### Example config.toml +

+ + · + 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 +display_ascii_art = true [key] # prompt y/n before deleting keys @@ -1068,55 +1918,116 @@ auto_push = false default_commit_message = "{{ summary }} {{ time }}" ``` -

- ### Environment -`PDA_CONFIG` overrides the config directory. pda! will look for `config.toml` in this directory. +

+ + · + Config, + pda doctor + +

+ +`PDA_CONFIG` overrides the config directory. `pda!` will look for `config.toml` in this directory: + ```bash PDA_CONFIG=/tmp/config/ pda set key value ``` -

- -`PDA_DATA` overrides the data storage directory. - -Default locations: -- Linux: `~/.local/share/pda/` -- macOS: `~/Library/Application Support/pda/` -- Windows: `%LOCALAPPDATA%/pda/` +`PDA_DATA` overrides the data storage directory: ```bash PDA_DATA=/tmp/stores pda set key value ``` -

+Default data locations: +- Linux: `~/.local/share/pda/` +- macOS: `~/Library/Application Support/pda/` +- Windows: `%LOCALAPPDATA%/pda/` + +`EDITOR` is used by [`pda edit`](#editing) and [`pda config edit`](#config) to open values in a text editor. Must be set for these commands to work: -`EDITOR` is used by `pda edit` and `pda config edit` to open values in a text editor. Must be set for these commands to work. ```bash EDITOR=nvim pda edit mykey ``` -

+`SHELL` is used by [`pda run`](#running) (or [`pda get --run`](#getting)) for command execution. Falls back to `/bin/sh` if unset: -`SHELL` is used by `pda run` (or `pda get --run`) for command execution. Falls back to `/bin/sh` if unset. ```bash pda run script ``` -

+### Doctor -### Version +

+ + · + Config, + Environment + +

-`pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in config. +[`pda doctor`](#doctor) runs a set of health checks of your environment: ```bash -# Display the full version output. -pda version - -# Or just the release. -pda version --short -# pda! 2025.47 release +❯ pda doctor + ok pda! 2025.52 Christmas release (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 mean some functionality isn't being made use of, like version control not having been initialised yet. + +

+ + See also: + pda help doctor + +

+ +### Help & Version + +

+ + + +

+ +```bash +# help for any command +pda help set +pda help list +pda help config + +# display the full version output +pda version + +# or just the release +❯ pda version --short +pda! 2025.52 Christmas release +``` + +`pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in [config](#config). + +### License + +

+ + + +

+ +MIT — see [LICENSE](LICENSE). From 940c3d694def830d43a86850ac6f65670a62cf8d Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 14 Feb 2026 04:17:05 +0000 Subject: [PATCH 097/107] docs: progressively updating README.md --- README.md | 1233 +++++++++++++++++++++++++++++++++++------------------ 1 file changed, 821 insertions(+), 412 deletions(-) diff --git a/README.md b/README.md index 65eef93..f95caa9 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`pda!` stores key-value pairs natively as [newline-delimited JSON](https://en.wikipedia.org/wiki/JSON_streaming#Newline-delimited_JSON) files. Every store is plaintext, portable, and yours. There's no daemon, no cloud service, and no proprietary format. Keys are just lines in a JSON file; stores are just files in a directory. If you can `cat` a file, you can read your data without `pda!` installed. +`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](), [Markdown]() and [HTML]() tables, [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 is built in. Enable auto-committing, pushing, and fetching in the [config](#config) to automatically version every change, or just run [`pda sync`](#sync) when you want to. Because the storage format is line-oriented plaintext, diffs are meaningful and merges are clean. +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. -Go's [`text/template`](https://pkg.go.dev/text/template) engine is available on every value at retrieval time, turning simple key-value pairs into dynamic snippets with variables, environment lookups, shell execution, cross-references, and more. +[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.

@@ -136,59 +136,18 @@ Powershell users will need to manually add the above command to their profile; t Import & Export · Templates · Filtering · - Binary Data + Binary Data · Git · Identity · Config · Environment · Doctor · - Help & Version + Version · + Help

-```bash -pda! MIT licensed. (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 -``` - -

- Most commands have aliases and flags. Run `pda help [command]` to see them. ### Key commands @@ -196,13 +155,13 @@ Most commands have aliases and flags. Run `pda help [command]` to see them.

· - pda set, - pda get, - pda run, - pda list, - pda edit, - pda move, - pda remove + Setting, + Getting, + Running, + Listing, + Editing, + Moving and Copying, + Removing

@@ -214,33 +173,22 @@ Advanced usage of `pda` revolves around [templates](#templates) and [`pda run`]( #### Setting -[`pda set`](#setting) (alias: [`s`](#setting)) creates a key-value pair. Values can come from arguments, stdin, or a file. +

+ + · + pda set + +

-```bash -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 set`](#setting) requires a key and a value as inputs. The first argument given will always be used to determine the key. +[`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. ```bash # create a key-value pair 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 @@ -250,9 +198,6 @@ pda set example < silmarillion.txt # create a pinned key-value pair from a file pda set --pin example --file example.md -# create a key-value pair in the "Favourites" store -pda set movie@favourites "The Road" - # create an encrypted key-value pair, expiring in one day pda set secret "Secret data." --encrypt --ttl 24h ``` @@ -279,24 +224,15 @@ pda set dog "A four-legged mammal that isn't a cat." --force #### Getting -[`pda get`](#getting) (alias: [`g`](#getting)) retrieves a key's value. [Templates](#templates) are evaluated at retrieval time. +

+ + · + Templates · + pda get + +

-```bash -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 get`] takes one argument: the desired key. The value is output to stdout. +[`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. ```bash # get the value of a key @@ -347,11 +283,23 @@ Running [`pda get`](#getting) will resolve templates in the stored key at run-ti lew # get a templated key without resolving the template -❯ pda get user +❯ pda get user --no-template {{ env "USER" }} ``` -An alternative to [templates](#templates) is the `run` flag. For detailed information, see [`pda run`](#running), an alias for `pda get --run`. +#### 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. ```bash # create a key containg a script @@ -360,23 +308,12 @@ An alternative to [templates](#templates) is the `run` flag. For detailed inform # get and run a key using $SHELL ❯ pda get my_script --run Hello, world. + +❯ pda run my_script +Hello, world. ``` -#### Running - -[`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`. Templates are functional when running a key directly. - -```bash -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 -``` - -Running takes one argument: the key. +[Templates](#templates) are fully resolved before any shell execution happens. ```bash # create a key containing a script, and a template @@ -393,42 +330,74 @@ Hello, Alice #### Listing -[`pda list`](#listing) (alias: [`ls`](#listing)) shows what you've got stored. 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. +

+ + · + 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 -❯ pda ls +# list all store contents +❯ pda list Meta Size TTL Store Key Value --w-p 5 - store todo don't forget this ----- 23 - store url https://prod.example.com --w-- 5 - store name Alice +-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 ``` -By default, [`pda list`](#listing) shows entries from every store. Pass a store name to narrow to a single store: +When [listing](#listing), [glob patterns](#filtering) can be used to further filter by `key`, `store`, or `value`. ```bash -pda ls @store +# 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**" ``` -Use [`--store`](#filtering) / `-s` to filter stores by [glob pattern](#filtering): +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 -pda ls --store "prod*" +# 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"}] ``` -Filter by key or value with [`--key`](#filtering) / `-k` and [`--value`](#filtering) / `-v`: - -```bash -pda ls --key "db*" --value "**localhost**" -``` - -Columns can be toggled with `--no-X` flags. `--no-X` suppresses a column; `--no-X=false` adds it even if it's not in the default config: - -```bash -# hide the meta and size columns -pda ls --no-meta --no-size -``` - -Long values are truncated to fit the terminal. [`--full`](#listing) / `-f` shows the complete value: +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 @@ -440,163 +409,95 @@ Key Value note this is a very long value that keeps on going and going ``` -[`--count`](#listing) / `-c` prints only the count of matching entries: - -```bash -❯ pda ls --count -3 - -❯ pda ls --count --key "d*" -1 -``` - -[`--format`](#listing) / `-o` selects the output format. Available formats: `table` (default), `csv`, `tsv`, `json`, `ndjson`, `markdown`, `html`: - -```bash -❯ pda ls --format csv -Meta,Size,TTL,Store,Key,Value --w--,5,-,store,name,Alice - -❯ pda ls --format json -[{"key":"name","value":"Alice","encoding":"text","store":"store"}] -``` - -[`--all`](#listing) / `-a` lists across all stores (default when `list.always_show_all_stores` is true). - -[`--base64`](#listing) / `-b` shows binary data as base64. - -[`--no-header`](#listing) suppresses the header row. - -[Pinned](#pinned) entries sort to the top, preserving alphabetical order within the pinned and unpinned groups. - -

- - See also: - pda help list - -

+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 set, - pda meta + 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 — saving non-empty content creates it. +[`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 +# edit an existing key or create a new one pda edit name -# edit a new key — saving non-empty content creates it -pda edit newkey +# 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 ``` -Metadata flags can be passed alongside the edit to modify metadata in the same operation: +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 -pda edit name --ttl 1h --encrypt +# edit a key and preserve the automatic $EDITOR newline +pda edit example --preserve-newline ``` -Trailing newlines added by the editor are stripped by default. [`--preserve-newline`](#editing) keeps them: - -```bash -pda edit name --preserve-newline -``` - -[`--encrypt`](#editing) / `-e` encrypts the value. [`--decrypt`](#editing) / `-d` decrypts it. [`--readonly`](#editing) and [`--writable`](#editing) toggle protection. [`--pin`](#editing) and [`--unpin`](#editing) toggle pinning. [`--ttl`](#editing) sets or clears expiry (e.g. `30m`, `2h`, or `never`). - -Binary values are presented as base64 for editing and decoded back on save. - -[Read-only](#read-only) keys require [`--force`](#editing) to edit. - -

- - See also: - pda help edit - -

+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 + pda move, + pda copy

-[`pda move`](#moving--copying) (alias: [`mv`](#moving--copying)) moves a key to a new name or store. All metadata is preserved. +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 -❯ pda mv name name2 +# 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 ``` -[`pda copy`](#moving--copying) (alias: [`cp`](#moving--copying)) makes a copy. The source is kept and all metadata is preserved. - -```bash -pda cp name name2 -``` - -[`mv --copy`](#moving--copying) and [`cp`](#moving--copying) are equivalent: - -```bash -pda mv name name2 --copy -``` - -Move or copy across stores: - -```bash -pda mv name@store name@archive -pda cp config@dev config@prod -``` - -[`--safe`](#moving--copying) skips if the destination already exists: - -```bash -pda mv name name2 --safe -# info skipped 'name2': already exists -``` - -[`--yes`](#moving--copying) / `-y` skips all confirmation prompts: - -```bash -pda mv name name2 -y -``` - -[Read-only](#read-only) keys can't be moved or overwritten without [`--force`](#moving--copying): - -```bash -❯ pda mv readonly-key newname -FAIL cannot move 'readonly-key': key is read-only - -pda mv readonly-key newname --force -``` - -[`cp`](#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`](#moving--copying). - -

- - See also: - pda help move, - pda help copy - -

- #### Removing

· - pda remove, - --key + pda help remove

@@ -652,13 +553,6 @@ FAIL cannot remove 'protected-key': key is read-only pda rm protected-key --force ``` -

- - See also: - pda help remove - -

- ### Metadata

@@ -700,20 +594,13 @@ FAIL cannot meta 'api-url': key is read-only pda meta api-url --ttl 1h --force ``` -

- - See also: - pda help meta - -

- #### TTL

· - pda set, - pda meta + pda help set, + pda help meta

@@ -756,22 +643,14 @@ pda edit session --ttl 30m [`export`](#import--export) and [`import`](#import--export) preserve the expiry date. Expirations are stored as a timestamp, not a timer — they continue ticking down regardless of whether the key is in an active store or sitting in a backup file. -

- - See also: - pda help set, - pda help meta - -

- #### Encryption

· - pda set, - pda meta, - pda identity + pda help set, + pda help meta, + pda help identity

@@ -832,23 +711,13 @@ FAIL cannot get 'api-key': secret is locked (identity file missing) All encryption operations can be set as default with `key.always_encrypt` in [config](#config), so every [`pda set`](#setting) automatically encrypts. -

- - See also: - pda help set, - pda help meta, - pda help identity - -

- #### Read-Only

· - pda set, - pda meta, - pda edit + pda help set, + pda help meta

@@ -898,23 +767,13 @@ pda meta api-url --ttl 1h --force [`cp`](#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`. -

- - See also: - pda help set, - pda help meta, - pda help edit - -

- #### Pinned

· - pda set, - pda meta, - pda list + pda help set, + pda help meta

@@ -944,14 +803,6 @@ Meta Key Value -w-- other foo ``` -

- - See also: - pda help set, - pda help meta - -

- ### Stores

@@ -1027,22 +878,13 @@ pda remove-store birthdays pda remove-store birthdays -y ``` -

- - See also: - pda help list-stores, - pda help move-store, - pda help remove-store - -

- #### Import & Export

· - pda export, - pda import + pda help export, + pda help import

@@ -1103,14 +945,6 @@ pda import --drop -f my_backup [`export`](#import--export) encodes [binary data](#binary-data) as base64. [Encryption](#encryption), [read-only](#read-only), [pinned](#pinned) flags, and [TTL](#ttl) are all preserved through export and import. -

- - See also: - pda help export, - pda help import - -

- ### Templates

@@ -1132,8 +966,7 @@ These same functions are also available in `git.default_commit_message` template

· - Templates, - pda get + Templates

@@ -1346,7 +1179,7 @@ https://example.com/api

· - pda get + pda help get

@@ -1362,14 +1195,6 @@ Good morning. {{ if .MORNING }}Good morning.{{ end }} ``` -

- - See also: - pda help get, - pda help set - -

- ### Filtering

@@ -1546,14 +1371,6 @@ pda rm cat --key "{mouse,[cd]og}**" # ... ``` -

- - See also: - pda help list, - pda help remove - -

- ### Binary Data

@@ -1601,14 +1418,6 @@ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... [`pda edit`](#editing) presents binary values as base64 for editing and decodes them back on save. -

- - See also: - pda help set, - pda help get - -

- ### Git

@@ -1627,8 +1436,7 @@ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12...

· - Git, - pda sync + pda help init

@@ -1649,19 +1457,12 @@ pda init --clean pda init https://github.com/llywelwyn/my-repository --clean ``` -

- - See also: - pda help init - -

- #### Sync

· - Git + pda help sync

@@ -1679,20 +1480,12 @@ pda sync -m "added production credentials" Running [`pda sync`](#sync) manually will always fetch, commit, and push — or stash and pull if behind — regardless of config. -

- - See also: - pda help sync - -

- #### Auto-Commit & Auto-Push

· - Git, - Config + pda help config

@@ -1725,7 +1518,7 @@ A recommended setup is to enable `git.auto_commit` and run [`pda sync`](#sync) m

· - pda identity + pda help identity

@@ -1749,7 +1542,7 @@ With no flags, [`pda identity`](#identity) shows your public key, identity file

· - pda identity + pda help identity

@@ -1766,8 +1559,7 @@ pda identity --new

· - pda identity, - Encryption + pda help identity

@@ -1795,13 +1587,6 @@ Additional recipients are shown in the default identity display: ok recipient age1ql3z... ``` -

- - See also: - pda help identity - -

- ### Config

@@ -1819,7 +1604,7 @@ Config is stored at `~/.config/pda/config.toml` (Linux/macOS) or `%LOCALAPPDATA%

· - pda config + pda help config

@@ -1854,19 +1639,13 @@ pda config init --update [`pda doctor`](#doctor) will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit. -

- - See also: - pda help config - -

- #### Example config.toml

· - Config + Config, + pda help config

@@ -1991,27 +1770,16 @@ pda run script 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 mean some functionality isn't being made use of, like version control not having been initialised yet. -

- - See also: - pda help doctor - -

- -### Help & Version +### Version

- + · + pda help version

```bash -# help for any command -pda help set -pda help list -pda help config - # display the full version output pda version @@ -2022,6 +1790,647 @@ pda! 2025.52 Christmas release `pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in [config](#config). +### Help + +

+ + + +

+ +
+ set · + get · + run · + list · + edit · + move · + copy · + remove · + meta · + identity · + export · + import · + list-stores · + move-store · + remove-store · + init · + sync · + git · + config · + doctor · + version +
+ +#### `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

From 3923d20ae9740815de9961d3d6b587ce94a1bbb1 Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 14 Feb 2026 05:38:55 +0000 Subject: [PATCH 098/107] docs: a majority of the README has been renewed --- README.md | 515 +++++++++++++++++++----------------------------------- 1 file changed, 182 insertions(+), 333 deletions(-) diff --git a/README.md b/README.md index f95caa9..10068ba 100644 --- a/README.md +++ b/README.md @@ -497,60 +497,57 @@ Additionally, `interactive` being passed or `key.always_prompt_overwrite` being

· - pda help remove + pda remove

-[`pda remove`](#removing) (alias: [`rm`](#removing)) deletes one or more keys. +[`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 -pda rm kitty +# delete a single key +pda remove kitty + +# delete multiple keys at once +pda remove kitty doggy + +# delete across stores +pda remove kitty secret@private ``` -Remove multiple keys at once: +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 -pda rm kitty dog@animals +# 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*" ``` -Mix exact keys with [glob patterns](#filtering) using [`--key`](#removing): +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 -pda set cog "cogs" -pda set dog "doggy" -pda set kitty "cat" -pda rm kitty --key "?og" +# prompt before each deletion +❯ pda rm kitty -i + ??? remove 'kitty'? (y/n) + ==> y + +# auto-accept all prompts +❯ pda rm kitty -y ``` -Filter by store with [`--store`](#removing) / `-s` and by value with [`--value`](#removing) / `-v`: - -```bash -pda rm --store "temp*" --key "session*" -``` - -[`--interactive`](#removing) / `-i` prompts before each deletion (or set `key.always_prompt_delete` in [config](#config)): - -```bash -pda rm kitty -i -# ??? remove 'kitty'? (y/n) -# ==> y -``` - -Glob-matched deletions prompt by default (configurable with `key.always_prompt_glob_delete`). - -[`--yes`](#removing) / `-y` auto-accepts all confirmation prompts: - -```bash -pda rm kitty -y -``` - -[Read-only](#read-only) keys can't be deleted without [`--force`](#removing): +[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 -pda rm protected-key --force +# force-remove a read-only key +❯ pda rm protected-key --force ``` ### Metadata @@ -558,7 +555,7 @@ pda rm protected-key --force

· - pda meta, + pda meta, TTL, Encryption, Read-Only, @@ -566,47 +563,41 @@ pda rm protected-key --force

-[`pda meta`](#metadata) views or modifies metadata for a key without changing its value. With no flags, it displays the key's current metadata: +[`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 ``` -Pass flags to modify: [`--ttl`](#ttl), [`--encrypt`](#encryption) / [`--decrypt`](#encryption), [`--readonly`](#read-only) / [`--writable`](#read-only), [`--pin`](#pinned) / [`--unpin`](#pinned). - -Multiple metadata changes can be combined in one call: - -```bash -pda meta session --ttl 2h --encrypt --pin -``` - -Modifying a [read-only](#read-only) key's metadata requires [`--force`](#metadata) (except for toggling the read-only flag itself, and pin/unpin): - -```bash -❯ pda meta api-url --ttl 1h -FAIL cannot meta 'api-url': key is read-only - -pda meta api-url --ttl 1h --force -``` +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 help set, - pda help meta + 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. - -Set a TTL at creation time with [`pda set --ttl`](#setting): +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 @@ -614,91 +605,70 @@ 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 ``` -[`pda list`](#listing) shows expiration in the TTL column: +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 ``` -Change or clear the TTL on an existing key with [`pda meta --ttl`](#metadata): - -```bash -❯ pda meta session --ttl 2h - ok set ttl to 2h session - -❯ pda meta session --ttl never - ok cleared ttl session -``` - -The [`edit`](#editing) command also accepts `--ttl`: - -```bash -pda edit session --ttl 30m -``` - -[`export`](#import--export) and [`import`](#import--export) preserve the expiry date. Expirations are stored as a timestamp, not a timer — they continue ticking down regardless of whether the key is in an active store or sitting in a backup file. +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 help set, - pda help meta, - pda help identity + 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 by commands like [`get`](#getting) and [`list`](#listing) when the correct identity file is present. An X25519 identity is generated on first use. +[`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 -pda set --encrypt api-key "sk-live-abc123" -# ok created identity at ~/.config/pda/identity.txt +# 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 -pda set --encrypt token "ghp_xxxx" +# encrypt a key after editing in $EDITOR +❯ pda edit --encrypt api-key + +# decrypt a key via meta +❯ pda meta --decrypt api-key ``` -[`pda get`](#getting) decrypts automatically: - -```bash -❯ pda get api-key -sk-live-abc123 -``` - -Toggle encryption on an existing key with [`pda meta`](#metadata): - -```bash -❯ pda meta api-key --encrypt - ok encrypted api-key - -❯ pda meta api-key --decrypt - ok decrypted api-key -``` - -The on-disk value is ciphertext, so encrypted entries are safe to commit and push with [Git](#git): +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 without `--encrypt` will warn you: +[`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 -pda cp api-key api-key-backup -# still encrypted - +# 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 the identity file is missing, encrypted values are inaccessible but not lost. Keys remain visible, and the ciphertext is preserved through reads and writes: +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 @@ -709,93 +679,76 @@ ew-- api-key locked (identity file missing) FAIL cannot get 'api-key': secret is locked (identity file missing) ``` -All encryption operations can be set as default with `key.always_encrypt` in [config](#config), so every [`pda set`](#setting) automatically encrypts. +Encrypted keys can be made the default by enabling `key.always_encrypt` in the [config](#config). #### Read-Only

· - pda help set, - pda help meta + pda set, + pda meta

-Keys marked read-only are protected from accidental modification. You can modify a read-only key again by making it [`--writable`](#metadata) or by explicitly bypassing with [`--force`](#metadata). - -Set a key as read-only at creation time: +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 -``` -Toggle with [`pda meta`](#metadata): - -```bash +# 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 ``` -Or alongside an edit: - -```bash -pda edit notes --readonly -``` - -Read-only keys are protected from [`set`](#setting), [`rm`](#removing), [`mv`](#moving--copying), and [`edit`](#editing). Use `--force` to bypass: +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 -pda set api-url "new value" --force -pda rm api-url --force -pda mv api-url new-name --force +# 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 ``` -Modifying a read-only key's metadata also requires `--force` (except for toggling the read-only flag itself, and pin/unpin): - -```bash -❯ pda meta api-url --ttl 1h -FAIL cannot meta 'api-url': key is read-only - -pda meta api-url --ttl 1h --force -``` - -[`cp`](#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`. +[`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 help set, - pda help meta + pda set, + pda meta

-Pinned keys sort to the top of [`pda list`](#listing) output, preserving alphabetical order within the pinned and unpinned groups. - -Pin a key at creation time: +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 -``` -Toggle with [`pda meta`](#metadata): - -```bash +# pin a key with meta ❯ pda meta todo --pin ok pinned todo +# unpin a key with meta ❯ pda meta todo --unpin ok unpinned todo -``` -```bash +# view pinned keys in list output, at the top ❯ pda ls Meta Key Value -w-p important remember this @@ -808,95 +761,66 @@ Meta Key Value

· - pda list-stores, - pda move-store, - pda remove-store + pda list-stores, + pda move-store, + pda remove-store

-You can have as many stores as you want. Stores are created implicitly when you set a key with a `@STORE` suffix. Each store is a separate NDJSON file on disk. +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 key counts and file sizes: +[`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 -``` -[`--short`](#stores) prints only the names: - -```bash +# list all store names ❯ pda list-stores --short @birthdays @store ``` -Save to a specific store with the `@STORE` syntax: - -```bash -pda set alice@birthdays "11/11/1998" -``` - -List a specific store: - -```bash -❯ pda ls @birthdays - Store Key Value -birthdays alice 11/11/1998 -birthdays bob 05/12/1980 -``` - -[`pda move-store`](#stores) (alias: [`mvs`](#stores)) renames a 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 with `--copy`: - -```bash +# copy a store pda move-store birthdays bdays --copy ``` -[`--safe`](#stores) skips if the destination already exists: - -```bash -pda move-store birthdays bdays --safe -``` - -[`pda remove-store`](#stores) (alias: [`rms`](#stores)) deletes a store: +[`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 ``` -[`--yes`](#stores) / `-y` skips confirmation prompts: +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. -```bash -pda remove-store birthdays -y -``` +Inversely, `yes` can be passed to bypass any confirmation prompts. #### Import & Export

· - pda help export, - pda help import + pda export, + pda import

-[`pda export`](#import--export) exports everything as NDJSON (it's an alias for `list --format ndjson`): +[`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 -``` -Filter exports with [`--key`](#filtering), [`--value`](#filtering), and [`--store`](#filtering): - -```bash # export only matching keys pda export --key "a*" @@ -904,46 +828,28 @@ pda export --key "a*" pda export --value "**https**" ``` -[`pda import`](#import--export) restores entries from an NDJSON dump. By default, each entry is routed to the store it came from (via the `"store"` field in the NDJSON). If no `"store"` field is present, entries go to `store.default_store_name`. +[`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 -``` -Pass a store name as a positional argument to force all entries into one store: - -```bash +# force all entries into a single store pda import mystore -f my_backup # ok restored 5 entries into @mystore -``` -Read from stdin: - -```bash +# read from stdin pda import < my_backup ``` -Filter imports with [`--key`](#filtering) and [`--store`](#filtering): +`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. -```bash -# import only matching keys -pda import --key "a*" -f my_backup - -# import only entries from matching stores -pda import --store "prod*" -f my_backup -``` - -[`--drop`](#import--export) does a full replace — drops all existing entries before importing: - -```bash -pda import --drop -f my_backup -``` - -[`--interactive`](#import--export) / `-i` prompts before overwriting existing keys. - -[`export`](#import--export) encodes [binary data](#binary-data) as base64. [Encryption](#encryption), [read-only](#read-only), [pinned](#pinned) flags, and [TTL](#ttl) are all preserved through export and import. +[`pda export`](#import--export) encodes [binary data](#binary-data) as base64. All [metadata](#metadata) is preserved through export and import. ### Templates @@ -957,9 +863,11 @@ pda import --drop -f my_backup 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 fit `text/template` into a CLI key-value tool, `pda!` adds a small set of built-in functions on top of the standard library. +`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, along with `summary` which returns the action that triggered the commit (e.g. "set foo", "removed bar"). +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 @@ -1084,20 +992,15 @@ FAIL cannot get 'level': ...invalid value 'debug', allowed: [info warn error]

-`int` parses a variable as an integer, useful for loops and arithmetic: +`int` parses a variable as an integer. Useful mostly for loops or arithmetic. ```bash -pda set number "{{ int .N }}" - +❯ pda set number "{{ int .N }}" ❯ pda get number N=3 3 -``` - -Use it in a range loop: - -```bash -pda set meows "{{ range int .COUNT }}meow! {{ end }}" +# using "int" in a loop +❯ pda set meows "{{ range int .COUNT }}meow! {{ end }}" ❯ pda get meows COUNT=4 meow! meow! meow! meow! ``` @@ -1129,23 +1032,19 @@ Hi Bob. Hi Alice.

-`shell` executes a command and returns its stdout: +`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 set rev '{{ shell "git rev-parse --short HEAD" }}' ❯ pda get rev a1b2c3d -``` - -```bash -pda set today '{{ shell "date +%Y-%m-%d" }}' +❯ pda set today '{{ shell "date +%Y-%m-%d" }}' ❯ pda get today 2025-06-15 ``` -#### `pda` (Recursive) +#### `pda`

@@ -1154,47 +1053,22 @@ pda set today '{{ shell "date +%Y-%m-%d" }}'

-`pda` gets another key's value, enabling recursive composition: +`pda` returns the output of [`pda get`](#getting) on a key. ```bash -pda set base_url "https://api.example.com" -pda set endpoint '{{ pda "base_url" }}/users/{{ require .ID }}' - +# 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 -``` - -Cross-store references work too: - -```bash -pda set host@urls "https://example.com" -pda set api '{{ pda "host@urls" }}/api' +# 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 ``` -#### `no-template` - -

- - · - pda help get - -

- -Pass [`--no-template`](#getting) to [`pda get`](#getting) to output the raw value without evaluating templates: - -```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 }} -``` - ### Filtering

@@ -1382,49 +1256,43 @@ pda rm cat --key "{mouse,[cd]og}**"

-`pda!` supports all binary data. Save it with [`pda set`](#setting): +`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 -``` -And retrieve it with [`pda get`](#getting): - -```bash +# retrieve binary data pda get logo > output.png ``` -On a TTY, [`get`](#getting) and [`list`](#listing) show a summary for binary data. If piped or run outside of a TTY, raw bytes are output: +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`](#getting) / `-b` views binary data as base64: - -```bash +# base64 view ❯ pda get logo --base64 iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... ``` -[`pda export`](#import--export) encodes binary data as base64 in the NDJSON: +[`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"} ``` -[`pda edit`](#editing) presents binary values as base64 for editing and decodes them back on save. - ### Git

· - pda init, - pda sync, + pda init, + pda sync, Config

@@ -1436,21 +1304,21 @@ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12...

· - pda help init + pda init

-[`pda init`](#git) initialises version control: +[`pda init`](#git) initialises version control. With no arguments it creates a local-only repository in the data directory. Pass a remote URL to clone from an existing repository instead. ```bash -# initialise an empty repository +# initialise an empty local repository pda init -# or clone an existing one +# or clone from an existing remote pda init https://github.com/llywelwyn/my-repository ``` -[`--clean`](#git) removes the existing `.git` directory first, useful for reinitialising or switching remotes: +Passing `clean` removes any existing `.git` directory first, useful for reinitialising or switching remotes. ```bash pda init --clean @@ -1462,7 +1330,7 @@ pda init https://github.com/llywelwyn/my-repository --clean

· - pda help sync + pda sync

@@ -1485,7 +1353,7 @@ Running [`pda sync`](#sync) manually will always fetch, commit, and push — or

· - pda help config + pda config

@@ -1506,7 +1374,7 @@ A recommended setup is to enable `git.auto_commit` and run [`pda sync`](#sync) m

· - pda identity, + pda identity, Encryption

@@ -1518,21 +1386,17 @@ A recommended setup is to enable `git.auto_commit` and run [`pda sync`](#sync) m

· - pda help identity + pda identity

-With no flags, [`pda identity`](#identity) shows your public key, identity file path, and any additional recipients: +With no flags, [`pda identity`](#identity) shows your public key, identity file path, and any additional recipients. Passing `path` prints only the identity file path, useful for scripting. ```bash ❯ pda identity ok pubkey age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p ok identity ~/.config/pda/identity.txt -``` -[`--path`](#identity) prints only the identity file path: - -```bash ❯ pda identity --path ~/.config/pda/identity.txt ``` @@ -1542,7 +1406,7 @@ With no flags, [`pda identity`](#identity) shows your public key, identity file

· - pda help identity + pda identity

@@ -1559,7 +1423,7 @@ pda identity --new

· - pda help identity + pda identity

@@ -1572,15 +1436,11 @@ By default, secrets are encrypted only for your own identity. To encrypt for add ok re-encrypted 1 secret(s) ``` -Remove a recipient with [`--remove-recipient`](#identity). Secrets are re-encrypted without their key: +Removing a recipient with `--remove-recipient` re-encrypts all secrets without their key. Additional recipients are shown in the default identity display. ```bash pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -``` -Additional recipients are shown in the default identity display: - -```bash ❯ pda identity ok pubkey age1abc... ok identity ~/.local/share/pda/identity.txt @@ -1592,8 +1452,8 @@ Additional recipients are shown in the default identity display:

· - pda config, - pda doctor + pda config, + pda doctor

@@ -1604,7 +1464,7 @@ Config is stored at `~/.config/pda/config.toml` (Linux/macOS) or `%LOCALAPPDATA%

· - pda help config + pda config

@@ -1645,7 +1505,7 @@ pda config init --update · Config, - pda help config + pda config

@@ -1703,39 +1563,28 @@ default_commit_message = "{{ summary }} {{ time }}" · Config, - pda doctor + pda doctor

-`PDA_CONFIG` overrides the config directory. `pda!` will look for `config.toml` in this directory: +`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 — `pda!` will 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. ```bash +# use an alternative config directory PDA_CONFIG=/tmp/config/ pda set key value -``` -`PDA_DATA` overrides the data storage directory: - -```bash +# use an alternative data directory PDA_DATA=/tmp/stores pda set key value ``` -Default data locations: -- Linux: `~/.local/share/pda/` -- macOS: `~/Library/Application Support/pda/` -- Windows: `%LOCALAPPDATA%/pda/` - -`EDITOR` is used by [`pda edit`](#editing) and [`pda config edit`](#config) to open values in a text editor. Must be set for these commands to work: +`EDITOR` is used by [`pda edit`](#editing) and [`pda config edit`](#config) to open values in a text editor. 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. ```bash EDITOR=nvim pda edit mykey ``` -`SHELL` is used by [`pda run`](#running) (or [`pda get --run`](#getting)) for command execution. Falls back to `/bin/sh` if unset: - -```bash -pda run script -``` - ### Doctor

@@ -1746,7 +1595,7 @@ pda run script

-[`pda doctor`](#doctor) runs a set of health checks of your environment: +[`pda doctor`](#doctor) runs a set of health checks against your environment, covering installed tools, config validity, store integrity, and Git status. ```bash ❯ pda doctor @@ -1775,10 +1624,12 @@ Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red

· - pda help version + pda version

+[`pda version`](#version) displays the current version. Passing `short` prints just the release string without ASCII art, useful for scripting. `pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in [config](#config). + ```bash # display the full version output pda version @@ -1788,8 +1639,6 @@ pda version pda! 2025.52 Christmas release ``` -`pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in [config](#config). - ### Help

From 16b07df33e3f8ba512931965eff02fa3e730c2ac Mon Sep 17 00:00:00 2001 From: lew Date: Sat, 14 Feb 2026 05:52:49 +0000 Subject: [PATCH 099/107] feat: README.md rewrite --- README.md | 89 ++++++++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 10068ba..fb6c1bd 100644 --- a/README.md +++ b/README.md @@ -1081,11 +1081,11 @@ https://example.com/api

-[`--key`](#filtering) / `-k`, [`--value`](#filtering) / `-v`, and [`--store`](#filtering) / `-s` filter entries with glob support. 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). +[`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 [`list`](#listing), [`export`](#import--export), [`import`](#import--export), and [`remove`](#removing). [`--value`](#filtering) is not available on [`import`](#import--export) or [`remove`](#removing). +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. +[`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 @@ -1096,63 +1096,69 @@ These filters work with [`list`](#listing), [`export`](#import--export), [`impor

-`*` wildcards a word or series of characters, stopping at separator boundaries: +`*` wildcards a word or series of characters, stopping at separator boundaries. ```bash +# list all store contents ❯ pda ls cat -dog -cog -mouse hotdog mouse house foo.bar.baz -pda ls --key "*" -# cat, dog, cog (single-segment keys only) +# match any single-word key +❯ pda ls --key "*" +cat -pda ls --key "* *" -# mouse hotdog, mouse house +# match any two-word key +❯ pda ls --key "* *" +mouse house -pda ls --key "foo.*.baz" -# foo.bar.baz +# 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: +`**` super-wildcards ignore word boundaries. ```bash -pda ls --key "foo**" -# foo.bar.baz - -pda ls --key "**g" -# dog, cog, mouse hotdog +# match anything beginning with "foo" +❯ pda ls --key "foo**" +foo.bar.baz ``` `?` matches a single character: ```bash -pda ls --key "?og" -# dog, cog +# 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: +`[abc]` matches one of the characters in the brackets. ```bash -pda ls --key "[dc]og" -# dog, cog +# 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 (if it exists) +❯ pda ls --key "[!dc]og" +bog ``` `[a-c]` matches a range: ```bash -pda ls --key "[a-g]ag" -# bag, gag +# match anything beginning with "a" to "g", and ending with "ag" +❯ pda ls --key "[a-g]ag" +bag +gag -pda ls --key "[!a-g]ag" -# wag +# negate with '!' +❯ pda ls --key "[!a-g]ag" +wag ``` #### Filtering by Key @@ -1165,14 +1171,13 @@ pda ls --key "[!a-g]ag"

-[`--key`](#filtering) / `-k` filters entries by key name: +[`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*" ``` -Multiple `--key` patterns are OR'd — an entry matches if it matches any of them. #### Filtering by Value @@ -1184,24 +1189,14 @@ Multiple `--key` patterns are OR'd — an entry matches if it matches any of the

-[`--value`](#filtering) / `-v` filters by value content using the same glob syntax: +[`value`](#filtering) filters by value. Multiple `value` patterns are OR'd. ```bash ❯ pda ls --value "**localhost**" -Key Value -db-url postgres://localhost:5432 -``` - -Multiple `--value` patterns are OR'd: - -```bash ❯ pda ls --value "**world**" --value "42" -Key Value -greeting hello world -number 42 ``` -Locked (encrypted without an available identity) and non-UTF-8 (binary) entries are silently excluded from `--value` matching. +Locked (encrypted without an available identity) and non-UTF-8 (binary) entries are silently excluded from `value` matching. #### Filtering by Store @@ -1213,7 +1208,7 @@ Locked (encrypted without an available identity) and non-UTF-8 (binary) entries

-[`--store`](#filtering) / `-s` filters by store name: +[`store`](#filtering) filters by store name. Multiple `store` patterns are OR'd. ```bash pda ls --store "prod*" @@ -1229,13 +1224,13 @@ pda export --store "dev*"

-Combine key, value, and store filters. Results must match all flags (AND), with OR within each flag: +`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 arbitrarily complex, and [`--key`](#filtering) can be combined with exact positional args on [`rm`](#removing): +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}**" From c8f91e8d023351973982952dfa24762e3e154517 Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 16 Mar 2026 12:45:27 +0000 Subject: [PATCH 100/107] docs: added redlinks --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fb6c1bd..cfac665 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr

-`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](), [Markdown]() and [HTML]() tables, [JSON](), and raw NDJSON. Everything is in plaintext to make version control easy, and to avoid tying anybody to using this tool forever. +`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. From cb135b7caafd7173b2a3fe40eb00e74575b587d3 Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 16 Mar 2026 16:27:37 +0000 Subject: [PATCH 101/107] feat(completions): add key and store completion helpers --- cmd/completions.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 cmd/completions.go diff --git a/cmd/completions.go b/cmd/completions.go new file mode 100644 index 0000000..0f8b856 --- /dev/null +++ b/cmd/completions.go @@ -0,0 +1,83 @@ +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) +} From 84b1c67c72623dd8045bc7b7eff24888dfdd55da Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 16 Mar 2026 16:31:37 +0000 Subject: [PATCH 102/107] feat(completions): wire up store completions and --store flag completions --- cmd/del-db.go | 13 +++++++------ cmd/del.go | 8 +++++--- cmd/export.go | 2 ++ cmd/list.go | 10 ++++++---- cmd/mv-db.go | 13 +++++++------ cmd/restore.go | 14 ++++++++------ 6 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/del-db.go b/cmd/del-db.go index 31fe227..e094370 100644 --- a/cmd/del-db.go +++ b/cmd/del-db.go @@ -33,12 +33,13 @@ import ( // delStoreCmd represents the set command var delStoreCmd = &cobra.Command{ - Use: "remove-store STORE", - Short: "Delete a store", - Aliases: []string{"rms"}, - Args: cobra.ExactArgs(1), - RunE: delStore, - SilenceUsage: true, + Use: "remove-store STORE", + Short: "Delete a store", + Aliases: []string{"rms"}, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeStores, + RunE: delStore, + SilenceUsage: true, } func delStore(cmd *cobra.Command, args []string) error { diff --git a/cmd/del.go b/cmd/del.go index 3dfda52..fba342c 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -34,9 +34,10 @@ import ( var delCmd = &cobra.Command{ Use: "remove KEY[@STORE] [KEY[@STORE] ...]", Short: "Delete one or more keys", - Aliases: []string{"rm"}, - Args: cobra.ArbitraryArgs, - RunE: del, + Aliases: []string{"rm"}, + Args: cobra.ArbitraryArgs, + ValidArgsFunction: completeKeys, + RunE: del, SilenceUsage: true, } @@ -145,6 +146,7 @@ func init() { 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)") rootCmd.AddCommand(delCmd) } diff --git a/cmd/export.go b/cmd/export.go index 94a22eb..80dade8 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -31,6 +31,7 @@ var exportCmd = &cobra.Command{ 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) @@ -41,6 +42,7 @@ var exportCmd = &cobra.Command{ 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/list.go b/cmd/list.go index 86259f1..50e319b 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -141,10 +141,11 @@ 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), - RunE: list, - SilenceUsage: true, + Aliases: []string{"ls"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeStores, + RunE: list, + SilenceUsage: true, } func list(cmd *cobra.Command, args []string) error { @@ -785,6 +786,7 @@ func init() { 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)") rootCmd.AddCommand(listCmd) } diff --git a/cmd/mv-db.go b/cmd/mv-db.go index f3a360e..1e1db1e 100644 --- a/cmd/mv-db.go +++ b/cmd/mv-db.go @@ -33,12 +33,13 @@ import ( // 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), - RunE: mvStore, - SilenceUsage: true, + 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 { diff --git a/cmd/restore.go b/cmd/restore.go index 70948ba..03d1d30 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -37,12 +37,13 @@ import ( ) var restoreCmd = &cobra.Command{ - Use: "import [STORE]", - Short: "Restore key/value pairs from an NDJSON dump", - Aliases: []string{}, - Args: cobra.MaximumNArgs(1), - RunE: restore, - SilenceUsage: true, + Use: "import [STORE]", + Short: "Restore key/value pairs from an NDJSON dump", + Aliases: []string{}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completeStores, + RunE: restore, + SilenceUsage: true, } func restore(cmd *cobra.Command, args []string) error { @@ -323,6 +324,7 @@ 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)") rootCmd.AddCommand(restoreCmd) From bc0b98c7f925509f22000fd141cd9bd685988fd3 Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 16 Mar 2026 16:31:57 +0000 Subject: [PATCH 103/107] feat(completions): wire up key completions for key commands --- cmd/edit.go | 7 ++++--- cmd/get.go | 12 +++++++----- cmd/meta.go | 5 +++-- cmd/mv.go | 26 ++++++++++++++------------ cmd/set.go | 7 ++++--- 5 files changed, 32 insertions(+), 25 deletions(-) diff --git a/cmd/edit.go b/cmd/edit.go index 96c31ad..a5cbe03 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -22,9 +22,10 @@ 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), - RunE: edit, + Aliases: []string{"e"}, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeKeys, + RunE: edit, SilenceUsage: true, } diff --git a/cmd/get.go b/cmd/get.go index ff1f5a8..d4d9116 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -46,9 +46,10 @@ 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), - RunE: get, + Aliases: []string{"g"}, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeKeys, + RunE: get, SilenceUsage: true, } @@ -63,8 +64,9 @@ additional argument after the initial KEY being fetched. For example: pda set greeting 'Hello, {{ .NAME }}!' pda run greeting NAME=World`, - Args: cobra.MinimumNArgs(1), - RunE: run, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completeKeys, + RunE: run, SilenceUsage: true, } diff --git a/cmd/meta.go b/cmd/meta.go index 62b862a..91efc26 100644 --- a/cmd/meta.go +++ b/cmd/meta.go @@ -14,8 +14,9 @@ var metaCmd = &cobra.Command{ without changing its value. With no flags, displays the key's current metadata. Pass flags to modify.`, - Args: cobra.ExactArgs(1), - RunE: meta, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeKeys, + RunE: meta, SilenceUsage: true, } diff --git a/cmd/mv.go b/cmd/mv.go index fa7b9d4..1594962 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -30,21 +30,23 @@ import ( ) var cpCmd = &cobra.Command{ - Use: "copy FROM[@STORE] TO[@STORE]", - Aliases: []string{"cp"}, - Short: "Make a copy of a key", - Args: cobra.ExactArgs(2), - RunE: cp, - SilenceUsage: true, + 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, } var mvCmd = &cobra.Command{ - Use: "move FROM[@STORE] TO[@STORE]", - Aliases: []string{"mv"}, - Short: "Move a key", - Args: cobra.ExactArgs(2), - RunE: mv, - SilenceUsage: true, + Use: "move FROM[@STORE] TO[@STORE]", + Aliases: []string{"mv"}, + Short: "Move a key", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeKeys, + RunE: mv, + SilenceUsage: true, } func cp(cmd *cobra.Command, args []string) error { diff --git a/cmd/set.go b/cmd/set.go index d81f41b..9435c94 100644 --- a/cmd/set.go +++ b/cmd/set.go @@ -50,9 +50,10 @@ 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), - RunE: set, + Aliases: []string{"s"}, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: completeKeys, + RunE: set, SilenceUsage: true, } From d6e71cde12e84407d4d83964b9bc3a0f600c19d7 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 1 Apr 2026 14:33:41 +0100 Subject: [PATCH 104/107] docs: quick pass over the Git section --- README.md | 51 ++++++++++++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cfac665..b0e0c19 100644 --- a/README.md +++ b/README.md @@ -1288,22 +1288,12 @@ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12... · pda init, pda sync, + pda git, Config

-`pda!` supports automatic version control backed by Git, either in a local-only repository or by initialising from a remote. - -#### Init - -

- - · - pda init - -

- -[`pda init`](#git) initialises version control. With no arguments it creates a local-only repository in the data directory. Pass a remote URL to clone from an existing repository instead. +[`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). ```bash # initialise an empty local repository @@ -1313,7 +1303,7 @@ pda init pda init https://github.com/llywelwyn/my-repository ``` -Passing `clean` removes any existing `.git` directory first, useful for reinitialising or switching remotes. +Passing `clean` removes any existing `.git` directory first. This is primarily useful for reinitialising a broken repository or switching to a different remote. ```bash pda init --clean @@ -1329,21 +1319,19 @@ pda init https://github.com/llywelwyn/my-repository --clean

-[`pda sync`](#sync) conducts a best-effort sync of your local data with your Git repository. Any time you swap machine or know you've made changes outside of `pda!`, syncing is recommended. +[`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 you're ahead, syncing will commit and push. If you're behind, syncing will detect this and prompt you: either stash local changes and pull, or abort and fix manually. +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). ```bash # sync with Git pda sync # with a custom commit message -pda sync -m "added production credentials" +pda sync -m "new bookmarks" ``` -Running [`pda sync`](#sync) manually will always fetch, commit, and push — or stash and pull if behind — regardless of config. - -#### Auto-Commit & Auto-Push +#### Auto-Commit, Push, and Fetch

@@ -1352,17 +1340,30 @@ Running [`pda sync`](#sync) manually will always fetch, commit, and push — or

-`pda!` supports automation via its [config](#config). There are options for `git.auto_commit`, `git.auto_fetch`, and `git.auto_push`. +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 immediately to the local Git repository any time data is changed. +`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. -**`git.auto_fetch`** fetches before committing any changes. This incurs a noticeable slowdown due to network round-trips. +`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. -**`git.auto_push`** automatically pushes committed changes to the remote repository, if one is configured. +#### Passthrough -If `auto_commit` is false, `auto_fetch` and `auto_push` have no effect. They are additional steps in the commit process. +

+ + · + pda git + +

-A recommended setup is to enable `git.auto_commit` and run [`pda sync`](#sync) manually when switching machines. +[`pda git`](#git) passes any arguments directly to `git`, run from within the data directory. This is an escape hatch for anything that [`pda sync`](#sync) doesn't cover, like checking the log or resetting a bad commit. Manually modifying tracked files without using the built-in commands can desync your repository, so [`pda sync`](#sync) is generally preferred. + +```bash +# check the git log +pda git log --oneline + +# run any arbitrary git command in the data directory +pda git status +``` ### Identity From d71f00357e04f57ed5800c9cc41724da0fdba6df Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 1 Apr 2026 14:35:10 +0100 Subject: [PATCH 105/107] revert: passthrough sucks --- README.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/README.md b/README.md index b0e0c19..8799928 100644 --- a/README.md +++ b/README.md @@ -1346,24 +1346,6 @@ The amount of Git automation can be configured via `git.auto_commit`, `git.auto_ `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. -#### Passthrough - -

- - · - pda git - -

- -[`pda git`](#git) passes any arguments directly to `git`, run from within the data directory. This is an escape hatch for anything that [`pda sync`](#sync) doesn't cover, like checking the log or resetting a bad commit. Manually modifying tracked files without using the built-in commands can desync your repository, so [`pda sync`](#sync) is generally preferred. - -```bash -# check the git log -pda git log --oneline - -# run any arbitrary git command in the data directory -pda git status -``` ### Identity From 2f87f0fd98911260d1000f9bd997ce11d091da30 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 1 Apr 2026 15:13:32 +0100 Subject: [PATCH 106/107] docs: some further readme updates and tweaks --- README.md | 66 ++++++++++++++----------------------------------------- 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 8799928..8efafca 100644 --- a/README.md +++ b/README.md @@ -1251,7 +1251,7 @@ pda rm cat --key "{mouse,[cd]og}**"

-`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. +`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 @@ -1357,18 +1357,14 @@ The amount of Git automation can be configured via `git.auto_commit`, `git.auto_

-[`pda identity`](#identity) (alias: [`id`](#identity)) manages the age encryption identity used for [encryption](#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. -#### Viewing Identity +```bash +# create a new identity manually +pda identity --new +``` -

- - · - pda identity - -

- -With no flags, [`pda identity`](#identity) shows your public key, identity file path, and any additional recipients. Passing `path` prints only the identity file path, useful for scripting. +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. ```bash ❯ pda identity @@ -1379,23 +1375,6 @@ With no flags, [`pda identity`](#identity) shows your public key, identity file ~/.config/pda/identity.txt ``` -#### Creating an Identity - -

- - · - pda identity - -

- -An identity is generated automatically the first time you use [`--encrypt`](#encryption). To create one manually: - -```bash -pda identity --new -``` - -[`--new`](#identity) errors if an identity already exists. Delete the file manually to replace it. - #### Recipients

@@ -1405,7 +1384,7 @@ pda identity --new

-By default, secrets are encrypted only for your own identity. To encrypt for additional recipients (e.g. a teammate or another device), use [`--add-recipient`](#identity) with their age public key. All existing secrets are automatically re-encrypted for every recipient: +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. ```bash ❯ pda identity --add-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p @@ -1414,7 +1393,7 @@ By default, secrets are encrypted only for your own identity. To encrypt for add ok re-encrypted 1 secret(s) ``` -Removing a recipient with `--remove-recipient` re-encrypts all secrets without their key. Additional recipients are shown in the default identity display. +Removing a recipient with `--remove-recipient` re-encrypts all secrets without their key. Additional recipients are shown in the default [`pda identity`](#identity) output. ```bash pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p @@ -1435,18 +1414,7 @@ pda identity --remove-recipient age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2k

-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. - -#### Config Commands - -

- - · - pda config - -

- -[`pda config`](#config) manages configuration without editing files by hand: +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. ```bash # list all config values and their current settings @@ -1475,9 +1443,7 @@ pda config init --new pda config init --update ``` -[`pda doctor`](#doctor) will warn about unrecognised keys (typos, removed options) and show any non-default values, so it doubles as a config audit. - -#### Example config.toml +#### Default Config

@@ -1547,7 +1513,7 @@ default_commit_message = "{{ summary }} {{ time }}" `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 — `pda!` will 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. +`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. ```bash # use an alternative config directory @@ -1557,7 +1523,7 @@ PDA_CONFIG=/tmp/config/ pda set key value 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. 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. +`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. ```bash EDITOR=nvim pda edit mykey @@ -1573,7 +1539,7 @@ EDITOR=nvim pda edit mykey

-[`pda doctor`](#doctor) runs a set of health checks against your environment, covering installed tools, config validity, store integrity, and Git status. +[`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 @@ -1595,7 +1561,7 @@ EDITOR=nvim pda edit mykey 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 mean some functionality isn't being made use of, like version control not having been initialised yet. +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 @@ -1606,7 +1572,7 @@ Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red

-[`pda version`](#version) displays the current version. Passing `short` prints just the release string without ASCII art, useful for scripting. `pda!` uses calendar versioning: `YYYY.WW`. ASCII art can be permanently disabled with `display_ascii_art = false` in [config](#config). +[`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 From b614c97f4218dc1e722d3f915f753840301fbb39 Mon Sep 17 00:00:00 2001 From: lew Date: Wed, 1 Apr 2026 15:15:21 +0100 Subject: [PATCH 107/107] chore: bump version to 2026.14 --- README.md | 4 ++-- cmd/version.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8efafca..097a7b6 100644 --- a/README.md +++ b/README.md @@ -1543,7 +1543,7 @@ EDITOR=nvim pda edit mykey ```bash ❯ pda doctor - ok pda! 2025.52 Christmas release (linux/amd64) + ok pda! 2026.14 (linux/amd64) ok OS: Linux 6.18.7-arch1-1 ok Go: go1.23.0 ok Git: 2.45.0 @@ -1580,7 +1580,7 @@ pda version # or just the release ❯ pda version --short -pda! 2025.52 Christmas release +pda! 2026.14 ``` ### Help diff --git a/cmd/version.go b/cmd/version.go index 8c46579..5e27c90 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -28,7 +28,7 @@ import ( ) var ( - version = "pda! 2025.52 Christmas release" + version = "pda! 2026.14" ) // versionCmd represents the version command