Skip to content

Commit 9ec2589

Browse files
committed
feat(show): allow repeated -k and -K with -k
Also support repeated `-k`/`--key` in `clip` and `pick`.
1 parent 60f5e7c commit 9ec2589

File tree

4 files changed

+127
-53
lines changed

4 files changed

+127
-53
lines changed

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,9 @@ pago show services/my-api-custom-default
283283
```
284284

285285
You can retrieve other values from the TOML entry using the `-k`/`--key` option with `show`, `clip`, and `pick`.
286+
The option can be repeated to access nested keys.
286287
To see all available keys (sorted), use the `-K`/`--keys` option with `show`.
288+
You can combine this with `-k`/`--key` to list keys within a nested table.
287289

288290
```shell
289291
# List all keys in the entry.
@@ -294,13 +296,21 @@ pago show --keys services/my-api
294296
# => url
295297
# => user
296298

299+
# List all keys in a nested table.
300+
pago show --keys -k table entry-with-table
301+
# => key
302+
297303
# You can also pick an entry to list keys from.
298304
pago show -K -p
299305

300306
# Show the user from the TOML entry.
301307
pago show -k user services/my-api
302308
# => jdoe
303309

310+
# Show a nested key.
311+
pago show entry-with-table -k table -k key
312+
# => value
313+
304314
# Show an array.
305315
pago show -k numbers services/my-api
306316
# => [1, 1, 2, 3, 5]
@@ -311,7 +321,7 @@ pago clip -k key services/my-api
311321

312322
When an entry is parsed as TOML, pago can retrieve scalar values (strings, numbers, booleans) and arrays of scalars.
313323
Arrays and scalars other than strings are encoded as TOML for output.
314-
pago cannot retrieve tables.
324+
pago cannot retrieve tables directly, but it can traverse them to access nested values.
315325

316326
### TOTP
317327

cmd/pago/main.go

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,10 @@ func (cmd *StopCmd) Run(config *Config) error {
276276
type ClipCmd struct {
277277
Name string `arg:"" optional:"" help:"Name of the password entry"`
278278

279-
Command string `short:"c" env:"${ClipEnv}" help:"Command for copying text from stdin to clipboard (${env})"`
280-
Key string `short:"k" help:"Retrieve a key from a TOML entry"`
281-
Pick bool `short:"p" help:"Pick entry using fuzzy finder"`
282-
Timeout int `short:"t" env:"${TimeoutEnv}" default:"30" help:"Clipboard timeout (0 to disable, ${env})"`
279+
Command string `short:"c" env:"${ClipEnv}" help:"Command for copying text from stdin to clipboard (${env})"`
280+
Key []string `short:"k" help:"Retrieve a key from a TOML entry (repeatable)"`
281+
Pick bool `short:"p" help:"Pick entry using fuzzy finder"`
282+
Timeout int `short:"t" env:"${TimeoutEnv}" default:"30" help:"Clipboard timeout (0 to disable, ${env})"`
283283
}
284284

285285
// copyToClipboard executes a command to copy text to the system clipboard.
@@ -375,17 +375,30 @@ func generateOTP(otpURL string) (string, error) {
375375
return code, nil
376376
}
377377

378+
// quoteKeyPath formats a TOML key path for display in error messages.
379+
// It does so by quoting each key with %q and joining them with periods.
380+
// For example: []string{"a", "b"} becomes "a"."b"
381+
func quoteKeyPath(keys []string) string {
382+
quoted := []string{}
383+
384+
for _, key := range keys {
385+
quoted = append(quoted, fmt.Sprintf("%q", key))
386+
}
387+
388+
return strings.Join(quoted, ".")
389+
}
390+
378391
// getPassword decrypts an entry and returns its content, or a specific key's
379392
// value if it's a TOML entry.
380-
func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name, key string) (string, error) {
393+
func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name string, keys []string) (string, error) {
381394
content, err := decryptEntry(agentExecutable, agentExpire, agentMemlock, agentSocket, identities, passwordStore, name)
382395
if err != nil {
383396
return "", err
384397
}
385398

386399
if !isTOML(content) {
387-
if key != "" {
388-
return "", fmt.Errorf("%q is not a TOML entry; cannot use key", name)
400+
if len(keys) > 0 {
401+
return "", fmt.Errorf("%q is not a TOML entry; cannot use keys", name)
389402
}
390403

391404
return content, nil
@@ -396,33 +409,44 @@ func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock
396409
return "", fmt.Errorf("failed to parse entry as TOML: %w", err)
397410
}
398411

399-
if key == "" {
400-
key = "password"
412+
effectiveKeys := keys
413+
if len(effectiveKeys) == 0 {
414+
key := pago.DefaultTOMLPasswordKey
401415

402-
if defaultKey, ok := data["default"]; ok {
416+
if defaultKey, ok := data[pago.TOMLDefaultKey]; ok {
403417
if defaultKeyStr, ok := defaultKey.(string); ok {
404418
key = defaultKeyStr
405419
} else {
406-
return "", fmt.Errorf(`key "default" must have string value`)
420+
return "", fmt.Errorf("key %q must have string value", pago.TOMLDefaultKey)
407421
}
408422
}
423+
effectiveKeys = []string{key}
409424
}
410425

411-
value, ok := data[key]
412-
if !ok {
413-
return "", fmt.Errorf("key %q not found in entry %q", key, name)
426+
var value any = data
427+
for i, key := range effectiveKeys {
428+
currentMap, ok := value.(map[string]any)
429+
if !ok {
430+
return "", fmt.Errorf("value at key path %s is not a table", quoteKeyPath(effectiveKeys[:i]))
431+
}
432+
433+
v, ok := currentMap[key]
434+
if !ok {
435+
return "", fmt.Errorf("key path %s not found in entry %q", quoteKeyPath(effectiveKeys[:i+1]), name)
436+
}
437+
value = v
414438
}
415439

416440
v := reflect.ValueOf(value)
417441
switch v.Kind() {
418442

419443
case reflect.Map:
420-
return "", fmt.Errorf("key %q in entry %q is a table", key, name)
444+
return "", fmt.Errorf("key path %s in entry %q is a table", quoteKeyPath(effectiveKeys), name)
421445

422446
case reflect.String:
423447
s := v.String()
424448

425-
if key == "otp" {
449+
if len(effectiveKeys) > 0 && effectiveKeys[len(effectiveKeys)-1] == "otp" {
426450
return generateOTP(s)
427451
}
428452

@@ -794,7 +818,7 @@ func (cmd *InitCmd) Run(config *Config) error {
794818
type PickCmd struct {
795819
Name string `arg:"" optional:"" help:"Name of the password entry"`
796820

797-
Key string `short:"k" help:"Retrieve a key from a TOML entry"`
821+
Key []string `short:"k" help:"Retrieve a key from a TOML entry (repeatable)"`
798822
}
799823

800824
func (cmd *PickCmd) Run(config *Config) error {
@@ -993,7 +1017,7 @@ func (cmd *RewrapCmd) Run(config *Config) error {
9931017
}
9941018

9951019
// getTOMLKeys decrypts a TOML entry and returns a sorted list of its keys.
996-
func getTOMLKeys(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name string) ([]string, error) {
1020+
func getTOMLKeys(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name string, keyPath []string) ([]string, error) {
9971021
content, err := decryptEntry(agentExecutable, agentExpire, agentMemlock, agentSocket, identities, passwordStore, name)
9981022
if err != nil {
9991023
return nil, err
@@ -1008,8 +1032,30 @@ func getTOMLKeys(agentExecutable string, agentExpire time.Duration, agentMemlock
10081032
return nil, fmt.Errorf("failed to parse entry as TOML: %w", err)
10091033
}
10101034

1011-
keys := make([]string, 0, len(data))
1012-
for k := range data {
1035+
var value any = data
1036+
for i, key := range keyPath {
1037+
currentMap, ok := value.(map[string]any)
1038+
if !ok {
1039+
return nil, fmt.Errorf("value at key path %s is not a table", quoteKeyPath(keyPath[:i]))
1040+
}
1041+
1042+
v, ok := currentMap[key]
1043+
if !ok {
1044+
return nil, fmt.Errorf("key path %s not found in entry %q", quoteKeyPath(keyPath[:i+1]), name)
1045+
}
1046+
value = v
1047+
}
1048+
1049+
currentMap, ok := value.(map[string]any)
1050+
if !ok {
1051+
if len(keyPath) > 0 {
1052+
return nil, fmt.Errorf("value at key path %s is not a table", quoteKeyPath(keyPath))
1053+
}
1054+
return nil, fmt.Errorf("entry %q is not a TOML table", name)
1055+
}
1056+
1057+
keys := make([]string, 0, len(currentMap))
1058+
for k := range currentMap {
10131059
keys = append(keys, k)
10141060
}
10151061
sort.Strings(keys)
@@ -1020,9 +1066,9 @@ func getTOMLKeys(agentExecutable string, agentExpire time.Duration, agentMemlock
10201066
type ShowCmd struct {
10211067
Name string `arg:"" optional:"" help:"Name of the password entry"`
10221068

1023-
Key string `short:"k" help:"Retrieve a key from a TOML entry" xor:"toml"`
1024-
Keys bool `short:"K" help:"List keys in a TOML entry" xor:"toml"`
1025-
Pick bool `short:"p" help:"Pick entry using fuzzy finder"`
1069+
Key []string `short:"k" help:"Retrieve a key from a TOML entry (repeatable)"`
1070+
Keys bool `short:"K" help:"List keys in a TOML entry"`
1071+
Pick bool `short:"p" help:"Pick entry using fuzzy finder"`
10261072
}
10271073

10281074
func (cmd *ShowCmd) Run(config *Config) error {
@@ -1065,6 +1111,7 @@ func (cmd *ShowCmd) Run(config *Config) error {
10651111
config.Identities,
10661112
config.Store,
10671113
name,
1114+
cmd.Key,
10681115
)
10691116
if err != nil {
10701117
return err

config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ const (
2525
Version = "0.23.0"
2626
WaitForSocket = 3 * time.Second
2727

28+
// Configurable defaults.
2829
DefaultAgent = "pago-agent"
2930
DefaultGitEmail = "pago@localhost"
3031
DefaultGitName = "pago password manager"
3132
DefaultPasswordLength = "20"
3233
DefaultPasswordPattern = "[A-Za-z0-9]"
34+
// Not currently configurable.
35+
DefaultTOMLPasswordKey = "password"
36+
TOMLDefaultKey = "default"
3337

3438
AgentEnv = "PAGO_AGENT"
3539
ClipEnv = "PAGO_CLIP"

test/e2e_test.go

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,12 @@ bar = 5
720720
phi = 1.68
721721
# Another comment.
722722
baz = [1, 2, 3, true, false]
723-
qux = {"key" = "value"}
723+
724+
[qux]
725+
key = "value"
726+
727+
[qux.nested]
728+
deep = "secret"
724729
`)
725730
var stdout, stderr bytes.Buffer
726731
cmd.Stdout = &stdout
@@ -754,19 +759,22 @@ foo = "secret"
754759

755760
testCases := []struct {
756761
entry string
757-
key string
762+
keys []string
758763
expected string
759764
wantErr bool
760765
}{
761-
{"toml", "", "hunter2", false},
762-
{"toml", "foo", "string", false},
763-
{"toml", "bar", "5", false},
764-
{"toml", "phi", "1.68", false},
765-
{"toml", "baz", "[1, 2, 3, true, false]", false},
766-
{"toml", "qux", "", true}, // Tables cannot be retrieved.
767-
{"toml", "nonexistent", "", true},
768-
{"toml-default", "", "secret", false},
769-
{"not-toml", "foo", "", true}, // Cannot use "--key" on non-TOML entries.
766+
{"toml", nil, "hunter2", false},
767+
{"toml", []string{"foo"}, "string", false},
768+
{"toml", []string{"bar"}, "5", false},
769+
{"toml", []string{"phi"}, "1.68", false},
770+
{"toml", []string{"baz"}, "[1, 2, 3, true, false]", false},
771+
{"toml", []string{"qux"}, "", true}, // Tables cannot be retrieved.
772+
{"toml", []string{"qux", "key"}, "value", false},
773+
{"toml", []string{"qux", "nested", "deep"}, "secret", false},
774+
{"toml", []string{"qux", "nonexistent"}, "", true},
775+
{"toml", []string{"nonexistent"}, "", true},
776+
{"toml-default", nil, "secret", false},
777+
{"not-toml", []string{"foo"}, "", true}, // Cannot use "--key" on non-TOML entries.
770778
}
771779

772780
for _, tc := range testCases {
@@ -779,32 +787,33 @@ foo = "secret"
779787

780788
buf.Reset()
781789

782-
var cmd *exec.Cmd
783-
if tc.key == "" {
784-
cmd = exec.Command(commandPago, "--dir", dataDir, "--socket", "", "show", tc.entry)
785-
} else {
786-
cmd = exec.Command(commandPago, "--dir", dataDir, "--socket", "", "show", "--key", tc.key, tc.entry)
790+
args := []string{"--dir", dataDir, "--socket", "", "show"}
791+
for _, k := range tc.keys {
792+
args = append(args, "--key", k)
787793
}
794+
args = append(args, tc.entry)
795+
cmd := exec.Command(commandPago, args...)
796+
788797
cmd.Stdin = c.Tty()
789798
cmd.Stdout = &buf
790799
cmd.Stderr = c.Tty()
791800

792801
err = cmd.Start()
793802
if err != nil {
794-
return "", fmt.Errorf("failed to start command for entry %q key %q: %w", tc.entry, tc.key, err)
803+
return "", fmt.Errorf("failed to start command for entry %q keys %v: %w", tc.entry, tc.keys, err)
795804
}
796805

797806
_, _ = c.ExpectString("Enter password")
798807
_, _ = c.SendLine(password)
799808

800809
err = cmd.Wait()
801810
if (err != nil) != tc.wantErr {
802-
return "", fmt.Errorf("command failed for entry %q key %q: %w", tc.entry, tc.key, err)
811+
return "", fmt.Errorf("command failed for entry %q keys %v: %w", tc.entry, tc.keys, err)
803812
}
804813

805814
output := strings.TrimSpace(buf.String())
806815
if !tc.wantErr && output != tc.expected {
807-
return "", fmt.Errorf("for entry %q key %q, expected %q, got %q", tc.entry, tc.key, tc.expected, output)
816+
return "", fmt.Errorf("for entry %q keys %v, expected %q, got %q", tc.entry, tc.keys, tc.expected, output)
808817
}
809818
}
810819

@@ -851,14 +860,17 @@ qux = {"key" = "value"}
851860

852861
testCases := []struct {
853862
entry string
863+
args []string
854864
expected string
855-
flag string
856865
wantErr bool
857866
}{
858-
{"toml", "bar\nbaz\nfoo\npassword\nphi\nqux", "-K", false},
859-
{"toml", "bar\nbaz\nfoo\npassword\nphi\nqux", "--keys", false},
860-
{"not-toml", "", "-K", true},
861-
{"nonexistent", "", "--keys", true},
867+
{"toml", []string{"-K"}, "bar\nbaz\nfoo\npassword\nphi\nqux", false},
868+
{"toml", []string{"--keys"}, "bar\nbaz\nfoo\npassword\nphi\nqux", false},
869+
{"toml", []string{"-K", "-k", "qux"}, "key", false},
870+
{"toml", []string{"--keys", "--key", "qux"}, "key", false},
871+
{"not-toml", []string{"-K"}, "", true},
872+
{"nonexistent", []string{"--keys"}, "", true},
873+
{"", []string{"--keys"}, "", true},
862874
}
863875

864876
for _, tc := range testCases {
@@ -871,12 +883,13 @@ qux = {"key" = "value"}
871883

872884
buf.Reset()
873885

874-
var cmd *exec.Cmd
875-
if tc.entry == "" {
876-
cmd = exec.Command(commandPago, "--dir", dataDir, "--socket", "", "show", "--keys")
877-
} else {
878-
cmd = exec.Command(commandPago, "--dir", dataDir, "--socket", "", "show", "--keys", tc.entry)
886+
args := []string{"--dir", dataDir, "--socket", "", "show"}
887+
args = append(args, tc.args...)
888+
if tc.entry != "" {
889+
args = append(args, tc.entry)
879890
}
891+
cmd := exec.Command(commandPago, args...)
892+
880893
cmd.Stdin = c.Tty()
881894
cmd.Stdout = &buf
882895
cmd.Stderr = c.Tty()

0 commit comments

Comments
 (0)