commit eebd83ad56c51faea6cca034ab2698704e030b5e
parent f0d938d6eae13e4a0fb3b0de80502a6700440a32
Author: Sean Enck <sean@ttypty.com>
Date: Sun, 3 Sep 2023 15:29:28 -0400
better help for completions
Diffstat:
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")
+ }
+ }
+ }
+}