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