From 637c7e0b5688c517f06813c84128f1b9d5908819 Mon Sep 17 00:00:00 2001 From: lew Date: Fri, 13 Feb 2026 15:21:49 +0000 Subject: [PATCH] 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