feat(edit): add edit command to open key values in $EDITOR

This commit is contained in:
Lewis Wynne 2026-02-13 15:21:49 +00:00
parent 618842b285
commit 637c7e0b56
5 changed files with 343 additions and 0 deletions

223
cmd/edit.go Normal file
View file

@ -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)
}

113
cmd/edit_test.go Normal file
View file

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

View file

@ -70,6 +70,7 @@ func init() {
cpCmd.GroupID = "keys"
delCmd.GroupID = "keys"
listCmd.GroupID = "keys"
editCmd.GroupID = "keys"
metaCmd.GroupID = "keys"
identityCmd.GroupID = "keys"

View file

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

5
testdata/edit-no-editor-err.ct vendored Normal file
View file

@ -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