lockbox

password manager
Log | Files | Refs | README | LICENSE

commit eebd83ad56c51faea6cca034ab2698704e030b5e
parent f0d938d6eae13e4a0fb3b0de80502a6700440a32
Author: Sean Enck <sean@ttypty.com>
Date:   Sun,  3 Sep 2023 15:29:28 -0400

better help for completions

Diffstat:
Minternal/app/completions.go | 123+++++++++++++++++++++++++++++++------------------------------------------------
Minternal/app/completions_test.go | 10+++++-----
Minternal/app/core.go | 5+++++
Minternal/app/core_test.go | 4++--
Minternal/app/doc/details | 7+++++++
Minternal/app/doc/shell | 11-----------
Minternal/app/info.go | 12++++++++++--
Minternal/app/info_test.go | 22+++++++++++++++++++---
Minternal/config/core.go | 10++++++++++
Minternal/config/vars.go | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/config/vars_test.go | 22++++++++++++++++++++++
11 files changed, 202 insertions(+), 99 deletions(-)

diff --git a/internal/app/completions.go b/internal/app/completions.go @@ -4,7 +4,6 @@ package app import ( "bytes" "fmt" - "sort" "strings" "text/template" @@ -37,22 +36,15 @@ type ( // Profile is a completion profile Profile struct { Name string - Comment string CanClip bool CanTOTP bool CanList bool ReadOnly bool IsDefault bool + env []string } ) -const ( - askProfile = "ask" - roProfile = "readonly" - noTOTPProfile = "nototp" - noClipProfile = "noclip" -) - // Env will get the environment settable value to use this profile func (p Profile) Env() string { return fmt.Sprintf("%s=%s", config.EnvironmentCompletionKey, p.Display()) @@ -75,7 +67,7 @@ func (p Profile) Options() []string { // Display is the profile display name func (p Profile) Display() string { - return strings.Join(strings.Split(strings.ToUpper(p.Name), "-")[2:], "-") + return strings.Join(strings.Split(strings.ToUpper(p.Name), "-")[1:], "-") } // TOTPSubCommands are the list of sub commands for TOTP within the profile @@ -90,67 +82,51 @@ func (p Profile) TOTPSubCommands() []string { return totp } -func newProfile(exe string, keys []string) Profile { - p := Profile{} - p.CanClip = true - p.CanList = true - p.CanTOTP = true - p.ReadOnly = false - name := "profile-" - sort.Strings(keys) - var comments []string - for _, k := range keys { - name = fmt.Sprintf("%s%s-", name, k) - switch k { - case askProfile: - comments = append(comments, "ask key mode = on") - p.CanList = false - case noTOTPProfile: - comments = append(comments, "totp = off") - p.CanTOTP = false - case noClipProfile: - comments = append(comments, "clipboard = off") - p.CanClip = false - case roProfile: - comments = append(comments, "readonly = on") - p.ReadOnly = true +func loadProfiles(exe string) []Profile { + profiles := config.LoadCompletionProfiles() + var res []Profile + for _, p := range profiles { + name := p.Name + if p.Default { + name = "default" } - } - sort.Strings(comments) - p.Name = newCompletionName(exe, strings.TrimSuffix(name, "-")) - p.Comment = fmt.Sprintf("# - %s", strings.Join(comments, "\n# - ")) - return p + n := Profile{Name: fmt.Sprintf("_%s-%s", exe, name)} + n.CanClip = p.Clip + n.CanList = p.List + n.CanTOTP = p.TOTP + n.ReadOnly = !p.Write + n.IsDefault = p.Default + n.env = p.Env + res = append(res, n) + } + return res } -func generateProfiles(exe string, keys []string) map[string]Profile { - m := make(map[string]Profile) - if len(keys) == 0 { - return m - } - p := newProfile(exe, keys) - m[p.Name] = p - for _, cur := range keys { - var subset []string - for _, key := range keys { - if key == cur { +// GenerateCompletions handles creating shell completion outputs +func GenerateCompletions(isBash, isHelp bool, exe string) ([]string, error) { + if isHelp { + var h []string + for _, p := range loadProfiles(exe) { + if p.IsDefault { continue } - subset = append(subset, key) - } - - for _, p := range generateProfiles(exe, subset) { - m[p.Name] = p + text := fmt.Sprintf("export %s\n - filtered completions\n - useful when:\n", p.Env()) + for idx, e := range p.env { + if idx > 0 { + text = fmt.Sprintf("%s and\n", text) + } + text = fmt.Sprintf("%s %s\n", text, e) + } + h = append(h, text) } + h = append(h, strings.TrimSpace(fmt.Sprintf(` +%s is not set +unset %s +export %s=<unknown> + - default completions +`, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey))) + return h, nil } - return m -} - -func newCompletionName(exe, name string) string { - return fmt.Sprintf("_%s-%s", exe, name) -} - -// GenerateCompletions handles creating shell completion outputs -func GenerateCompletions(isBash bool, exe string) ([]string, error) { c := Completions{ Executable: exe, InsertCommand: InsertCommand, @@ -168,6 +144,7 @@ func GenerateCompletions(isBash bool, exe string) ([]string, error) { DoTOTPList: fmt.Sprintf("%s %s %s", exe, TOTPCommand, TOTPListCommand), CompletionEnv: fmt.Sprintf("$%s", config.EnvironmentCompletionKey), } + using, err := readDoc("zsh") if err != nil { return nil, err @@ -182,17 +159,13 @@ func GenerateCompletions(isBash bool, exe string) ([]string, error) { if err != nil { return nil, err } - profiles := generateProfiles(exe, []string{noClipProfile, roProfile, noTOTPProfile, askProfile}) - profileObjects := []Profile{} - for _, v := range profiles { - profileObjects = append(profileObjects, v) - } - sort.Slice(profileObjects, func(i, j int) bool { - return strings.Compare(profileObjects[i].Name, profileObjects[j].Name) < 0 - }) - c.Profiles = append(c.Profiles, profileObjects...) - c.DefaultProfile = Profile{IsDefault: true, CanClip: true, CanTOTP: true, CanList: true, ReadOnly: false, Name: newCompletionName(exe, "default")} - c.Profiles = append(c.Profiles, c.DefaultProfile) + c.Profiles = loadProfiles(exe) + for _, p := range c.Profiles { + if p.IsDefault { + c.DefaultProfile = p + break + } + } shell, err := templateScript(shellScript, c) if err != nil { return nil, err diff --git a/internal/app/completions_test.go b/internal/app/completions_test.go @@ -7,7 +7,7 @@ import ( ) func TestBashCompletion(t *testing.T) { - v, err := app.GenerateCompletions(true, "lb") + v, err := app.GenerateCompletions(true, false, "lb") if err != nil { t.Errorf("invalid error: %v", err) } @@ -17,7 +17,7 @@ func TestBashCompletion(t *testing.T) { } func TestZshCompletion(t *testing.T) { - v, err := app.GenerateCompletions(false, "lb") + v, err := app.GenerateCompletions(false, false, "lb") if err != nil { t.Errorf("invalid error: %v", err) } @@ -28,14 +28,14 @@ func TestZshCompletion(t *testing.T) { func TestProfileDisplay(t *testing.T) { p := app.Profile{Name: "_abc-test-awera-zzz"} - if p.Display() != "AWERA-ZZZ" { - t.Error("invalid display") + if p.Display() != "TEST-AWERA-ZZZ" { + t.Errorf("invalid display: %s", p.Display()) } } func TestProfileEnv(t *testing.T) { p := app.Profile{Name: "_abc-test-awera-zzz"} - if p.Env() != "LOCKBOX_COMPLETION_FUNCTION=AWERA-ZZZ" { + if p.Env() != "LOCKBOX_COMPLETION_FUNCTION=TEST-AWERA-ZZZ" { t.Error("invalid env") } } diff --git a/internal/app/core.go b/internal/app/core.go @@ -18,6 +18,7 @@ import ( ) const ( + completionHelpDesc = "display shell completion help" // TOTPCommand is the parent of totp and by defaults generates a rotating token TOTPCommand = "totp" // ConvCommand handles text conversion of the data store @@ -56,6 +57,8 @@ const ( TOTPOnceCommand = "once" // BashCommand is the command to generate bash completions BashCommand = "bash" + // CompletionHelpCommand outputs completion help information + CompletionHelpCommand = "help" // ReKeyCommand will rekey the underlying database ReKeyCommand = "rekey" // MultiLineCommand handles multi-line inserts (when not piped) @@ -176,6 +179,7 @@ func commandText(args, name, desc string) string { func Usage(verbose bool, exe string) ([]string, error) { var results []string results = append(results, command(BashCommand, "", "generate user environment bash completion")) + results = append(results, subCommand(BashCommand, CompletionHelpCommand, "", completionHelpDesc)) results = append(results, command(ClipCommand, "entry", "copy the entry's value into the clipboard")) results = append(results, command(EnvCommand, "", "display environment variable information")) results = append(results, command(HelpCommand, "", "show this usage information")) @@ -197,6 +201,7 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, subCommand(TOTPCommand, TOTPShowCommand, "entry", "show the totp entry")) results = append(results, command(VersionCommand, "", "display version information")) results = append(results, command(ZshCommand, "", "generate user environment zsh completion")) + results = append(results, subCommand(ZshCommand, CompletionHelpCommand, "", completionHelpDesc)) sort.Strings(results) usage := []string{fmt.Sprintf("%s usage:", exe)} if verbose { diff --git a/internal/app/core_test.go b/internal/app/core_test.go @@ -9,11 +9,11 @@ import ( func TestUsage(t *testing.T) { u, _ := app.Usage(false, "lb") - if len(u) != 23 { + if len(u) != 25 { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = app.Usage(true, "lb") - if len(u) != 100 { + if len(u) != 109 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/app/doc/details b/internal/app/doc/details @@ -45,6 +45,13 @@ settings normally used when running `{{ $.Executable }}`. Note that is an advanced feature and should be used with caution/backups/etc. +[completions] +Completions are available for certain shells and, by default, assume all +features of `{{ $.Executable }}` are enabled and available. When changing certain environment +flags it may be useful to change the completion profile to more closely match +the restricted command options, run `{{ $.Executable }} <shell> help` for information +on how best to alter completion outputs. + [environment variables] The following environment variables can alter how '{{ $.Executable }}' works. diff --git a/internal/app/doc/shell b/internal/app/doc/shell @@ -1,14 +1,3 @@ -# the default profile -# {{ $.DefaultProfile.Env }} - -{{- range $idx, $profile := $.Profiles }} -{{ if not $profile.IsDefault }} -# settings: -{{ $profile.Comment }} -# {{ $profile.Env }} -{{- end}} -{{- end}} - _{{ $.Executable }}() { case "{{ $.CompletionEnv }}" in {{- range $idx, $profile := $.Profiles }} diff --git a/internal/app/info.go b/internal/app/info.go @@ -62,14 +62,22 @@ func info(command string, args []string) ([]string, error) { } return config.Environ(), nil case BashCommand, ZshCommand: - if len(args) != 0 { + if len(args) > 1 { return nil, fmt.Errorf("invalid %s command", command) } + isHelp := false + if len(args) == 1 { + if args[0] == CompletionHelpCommand { + isHelp = true + } else { + return nil, fmt.Errorf("invalid %s subcommand", command) + } + } exe, err := exeName() if err != nil { return nil, err } - return GenerateCompletions(command == BashCommand, exe) + return GenerateCompletions(command == BashCommand, isHelp, exe) } return nil, nil } diff --git a/internal/app/info_test.go b/internal/app/info_test.go @@ -53,13 +53,21 @@ func TestBashInfo(t *testing.T) { if buf.String() == "" { t.Error("nothing written") } - if _, err = app.Info(&buf, "bash", []string{"defaults"}); err.Error() != "invalid bash command" { + buf = bytes.Buffer{} + ok, err = app.Info(&buf, "bash", []string{"help"}) + if !ok || err != nil { + t.Errorf("invalid error: %v", err) + } + if buf.String() == "" { + t.Error("nothing written") + } + if _, err = app.Info(&buf, "bash", []string{"defaults"}); err.Error() != "invalid bash subcommand" { t.Errorf("invalid error: %v", err) } if _, err = app.Info(&buf, "bash", []string{"test", "default"}); err.Error() != "invalid bash command" { t.Errorf("invalid error: %v", err) } - if _, err = app.Info(&buf, "bash", []string{"short"}); err.Error() != "invalid bash command" { + if _, err = app.Info(&buf, "bash", []string{"short"}); err.Error() != "invalid bash subcommand" { t.Errorf("invalid error: %v", err) } } @@ -100,7 +108,15 @@ func TestZshInfo(t *testing.T) { if buf.String() == "" { t.Error("nothing written") } - if _, err = app.Info(&buf, "zsh", []string{"defaults"}); err.Error() != "invalid zsh command" { + buf = bytes.Buffer{} + ok, err = app.Info(&buf, "zsh", []string{"help"}) + if !ok || err != nil { + t.Errorf("invalid error: %v", err) + } + if buf.String() == "" { + t.Error("nothing written") + } + if _, err = app.Info(&buf, "zsh", []string{"defaults"}); err.Error() != "invalid zsh subcommand" { t.Errorf("invalid error: %v", err) } if _, err = app.Info(&buf, "zsh", []string{"test", "default"}); err.Error() != "invalid zsh command" { diff --git a/internal/config/core.go b/internal/config/core.go @@ -92,6 +92,16 @@ type ( Start int End int } + // CompletionProfile are shell completion definitions with backing environment information + CompletionProfile struct { + Clip bool + TOTP bool + List bool + Write bool + Name string + Env []string + Default bool + } ) func shlex(in string) ([]string, error) { diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -17,6 +17,10 @@ const ( fileExample = "<file>" detectedValue = "<detected>" requiredKeyOrKeyFile = "a key, a key file, or both must be set" + askProfile = "ask" + roProfile = "readonly" + noTOTPProfile = "nototp" + noClipProfile = "noclip" // ModTimeFormat is the expected modtime format ModTimeFormat = time.RFC3339 // JSONDataOutputHash means output data is hashed @@ -80,7 +84,7 @@ var ( // EnvConfig is the location of the config file to read environment variables from EnvConfig = EnvironmentString{environmentBase: environmentBase{key: prefixKey + "ENV", desc: fmt.Sprintf("Allows setting a specific file of environment variables for lockbox\nto read and use as configuration values (an '.env' file). The keyword\n'%s' will disable this functionality the keyword '%s' will search\nfor a file in the following paths in user's home directory matching\nthe first file found.\n\ndefault search paths:\n%v\n\nNote that this setting is not output as part of the environment.", noEnvironment, detectEnvironment, detectEnvironmentPaths)}, canDefault: true, defaultValue: detectEnvironment, allowed: []string{detectEnvironment, fileExample, noEnvironment}} // EnvCompletion is the completion method to use - EnvCompletion = EnvironmentString{environmentBase: environmentBase{key: EnvironmentCompletionKey, desc: "Use to select the non-default completions,\nplease review the generated shell completion files for more information.", requirement: "must be exported via a shell variable"}, canDefault: false} + EnvCompletion = EnvironmentString{environmentBase: environmentBase{key: EnvironmentCompletionKey, desc: "Use to select the non-default completions,\nplease review the shell completion help for more information.", requirement: "must be exported via a shell variable"}, canDefault: false} envKeyMode = EnvironmentString{environmentBase: environmentBase{key: prefixKey + "KEYMODE", requirement: "must be set to a valid mode when using a key", desc: fmt.Sprintf("How to retrieve the database store password.\nSet to '%s' when only using a key file\nSet to '%s' to ignore the set key value", noKeyMode, IgnoreKeyMode), whenUnset: string(DefaultKeyMode)}, allowed: []string{string(askKeyMode), string(commandKeyMode), string(IgnoreKeyMode), string(noKeyMode), string(plainKeyMode)}, canDefault: true, defaultValue: ""} envKey = EnvironmentString{environmentBase: environmentBase{requirement: requiredKeyOrKeyFile, key: prefixKey + "KEY", desc: fmt.Sprintf("The database key ('%s' mode) or command to run ('%s' mode)\nto retrieve the database password.", plainKeyMode, commandKeyMode)}, allowed: []string{commandArgsExample, "password"}, canDefault: false} envConfigExpands = EnvironmentInt{environmentBase: environmentBase{key: EnvConfig.key + "_EXPANDS", desc: "The maximum number of times to expand the input env to resolve variables,\nset to 0 to disable expansion. This value can NOT be an expansion itself\nif set in the env config file."}, shortDesc: "max expands", allowZero: true, defaultValue: 20} @@ -185,3 +189,72 @@ func ParseJSONOutput() (JSONOutputMode, error) { } return JSONDataOutputBlank, fmt.Errorf("invalid JSON output mode: %s", val) } + +func newProfile(keys []string) CompletionProfile { + p := CompletionProfile{} + p.Clip = true + p.List = true + p.TOTP = true + p.Write = true + name := "" + sort.Strings(keys) + var e []string + for _, k := range keys { + name = fmt.Sprintf("%s%s-", name, k) + switch k { + case askProfile: + e = append(e, envKeyMode.KeyValue(string(askKeyMode))) + p.List = false + case noTOTPProfile: + e = append(e, EnvNoTOTP.KeyValue(yes)) + p.TOTP = false + case noClipProfile: + e = append(e, EnvNoClip.KeyValue(yes)) + p.Clip = false + case roProfile: + e = append(e, EnvReadOnly.KeyValue(yes)) + p.Write = false + } + } + sort.Strings(e) + p.Env = e + p.Name = strings.TrimSuffix(name, "-") + return p +} + +func generateProfiles(keys []string) map[string]CompletionProfile { + m := make(map[string]CompletionProfile) + if len(keys) == 0 { + return m + } + p := newProfile(keys) + m[p.Name] = p + for _, cur := range keys { + var subset []string + for _, key := range keys { + if key == cur { + continue + } + subset = append(subset, key) + } + + for _, p := range generateProfiles(subset) { + m[p.Name] = p + } + } + return m +} + +// LoadCompletionProfiles will generate known completion profile with backing env information +func LoadCompletionProfiles() []CompletionProfile { + loaded := generateProfiles([]string{noClipProfile, roProfile, noTOTPProfile, askProfile}) + var profiles []CompletionProfile + for _, v := range loaded { + profiles = append(profiles, v) + } + sort.Slice(profiles, func(i, j int) bool { + return strings.Compare(profiles[i].Name, profiles[j].Name) < 0 + }) + profiles = append(profiles, CompletionProfile{Clip: true, Write: true, TOTP: true, List: true, Default: true}) + return profiles +} diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -281,3 +281,25 @@ func TestEnvironDefinitions(t *testing.T) { } } } + +func TestLoadCompletionProfiles(t *testing.T) { + p := config.LoadCompletionProfiles() + if len(p) != 16 { + t.Errorf("invalid completion count: %d", len(p)) + } + exp := len(p) - 1 + for idx, prof := range p { + if prof.Default { + if idx != exp { + t.Error("profile defaulted incorrectly") + } + if prof.Name != "" { + t.Error("default profile is unnamed") + } + } else { + if len(prof.Env) == 0 { + t.Error("profile has no environment information") + } + } + } +}