feat(edit): add edit command to open key values in $EDITOR
This commit is contained in:
parent
618842b285
commit
637c7e0b56
5 changed files with 343 additions and 0 deletions
223
cmd/edit.go
Normal file
223
cmd/edit.go
Normal 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
113
cmd/edit_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ func init() {
|
|||
cpCmd.GroupID = "keys"
|
||||
delCmd.GroupID = "keys"
|
||||
listCmd.GroupID = "keys"
|
||||
editCmd.GroupID = "keys"
|
||||
metaCmd.GroupID = "keys"
|
||||
identityCmd.GroupID = "keys"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
5
testdata/edit-no-editor-err.ct
vendored
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue