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