From 22f481e41a729cc1d85b7ca8fc2e438f21d3e656 Mon Sep 17 00:00:00 2001 From: Tom Whitwell Date: Wed, 24 May 2023 15:49:12 +0100 Subject: [PATCH] Add a command to generate completion scripts Rather than directing people to download a file, which could end up out of sync if the upstream version changes, instead bundle the scripts into the binary and add a command to output them. `//go:embed` requires the files to be in a descendant directory of the file containing the directive, so move them into a subdirectory, and symlink them back to the original locations so that people can still use the legacy method --- USAGE.md | 10 +- cli/completion-scripts/aws-vault.bash | 17 +++ cli/completion-scripts/aws-vault.fish | 24 +++++ cli/completion-scripts/aws-vault.zsh | 24 +++++ cli/completion.go | 72 +++++++++++++ cli/completion_test.go | 138 ++++++++++++++++++++++++ contrib/completions/bash/aws-vault.bash | 18 +--- contrib/completions/fish/aws-vault.fish | 25 +---- contrib/completions/zsh/aws-vault.zsh | 25 +---- main.go | 1 + 10 files changed, 283 insertions(+), 71 deletions(-) create mode 100644 cli/completion-scripts/aws-vault.bash create mode 100644 cli/completion-scripts/aws-vault.fish create mode 100644 cli/completion-scripts/aws-vault.zsh create mode 100644 cli/completion.go create mode 100644 cli/completion_test.go mode change 100644 => 120000 contrib/completions/bash/aws-vault.bash mode change 100644 => 120000 contrib/completions/fish/aws-vault.fish mode change 100644 => 120000 contrib/completions/zsh/aws-vault.zsh diff --git a/USAGE.md b/USAGE.md index e6fe5e9de..9b1bc586e 100644 --- a/USAGE.md +++ b/USAGE.md @@ -683,12 +683,10 @@ Further config: ## Shell completion -You can generate shell completions for - - bash: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/bash/aws-vault.bash)"` - - zsh: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/zsh/aws-vault.zsh)"` - - fish: `eval "$(curl -fs https://raw.githubusercontent.com/99designs/aws-vault/master/contrib/completions/fish/aws-vault.fish)"` - -Find the completion scripts at [contrib/completions](contrib/completions). +You can generate shell completions for `bash`, `zsh` and `fish`: + - bash: `eval "$(aws-vault completion bash)"` + - zsh: `eval "$(aws-vault completion zsh)"` + - fish: `eval "$(aws-vault completion fish)"` ## Desktop apps diff --git a/cli/completion-scripts/aws-vault.bash b/cli/completion-scripts/aws-vault.bash new file mode 100644 index 000000000..ee5617908 --- /dev/null +++ b/cli/completion-scripts/aws-vault.bash @@ -0,0 +1,17 @@ +_aws-vault_bash_autocomplete() { + local i cur prev opts base + + for (( i=1; i < COMP_CWORD; i++ )); do + if [[ ${COMP_WORDS[i]} == -- ]]; then + _command_offset $i+1 + return + fi + done + + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts=$( ${COMP_WORDS[0]} --completion-bash "${COMP_WORDS[@]:1:$COMP_CWORD}" ) + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} +complete -F _aws-vault_bash_autocomplete -o default aws-vault diff --git a/cli/completion-scripts/aws-vault.fish b/cli/completion-scripts/aws-vault.fish new file mode 100644 index 000000000..1c6423d30 --- /dev/null +++ b/cli/completion-scripts/aws-vault.fish @@ -0,0 +1,24 @@ +if status --is-interactive + complete -ec aws-vault + + # switch based on seeing a `--` + complete -c aws-vault -n 'not __fish_aws_vault_is_commandline' -xa '(__fish_aws_vault_complete_arg)' + complete -c aws-vault -n '__fish_aws_vault_is_commandline' -xa '(__fish_aws_vault_complete_commandline)' + + function __fish_aws_vault_is_commandline + string match -q -r '^--$' -- (commandline -opc) + end + + function __fish_aws_vault_complete_arg + set -l parts (commandline -opc) + set -e parts[1] + + aws-vault --completion-bash $parts + end + + function __fish_aws_vault_complete_commandline + set -l parts (string split --max 1 '--' -- (commandline -pc)) + + complete "-C$parts[2]" + end +end diff --git a/cli/completion-scripts/aws-vault.zsh b/cli/completion-scripts/aws-vault.zsh new file mode 100644 index 000000000..3cbbdba75 --- /dev/null +++ b/cli/completion-scripts/aws-vault.zsh @@ -0,0 +1,24 @@ +#compdef aws-vault + +_aws-vault() { + local i + for (( i=2; i < CURRENT; i++ )); do + if [[ ${words[i]} == -- ]]; then + shift $i words + (( CURRENT -= i )) + _normal + return + fi + done + + local matches=($(${words[1]} --completion-bash ${(@)words[2,$CURRENT]})) + compadd -a matches + + if [[ $compstate[nmatches] -eq 0 && $words[$CURRENT] != -* ]]; then + _files + fi +} + +if [[ "$(basename -- ${(%):-%x})" != "_aws-vault" ]]; then + compdef _aws-vault aws-vault +fi diff --git a/cli/completion.go b/cli/completion.go new file mode 100644 index 000000000..1686c4e75 --- /dev/null +++ b/cli/completion.go @@ -0,0 +1,72 @@ +package cli + +import ( + "embed" + "fmt" + "io" + "os" + "path" + "strings" + + "github.com/alecthomas/kingpin/v2" +) + +//go:embed completion-scripts/aws-vault.* +var completionScripts embed.FS + +type CompletionCommandInput struct { + Shell string +} + +var completionScriptPrinter io.Writer = os.Stdout + +func ConfigureCompletionCommand(app *kingpin.Application) { + var input CompletionCommandInput + + supportedShells, err := completionSupportedShells() + if err != nil { + panic(err) + } + + cmd := app.Command( + "completion", + "Output shell completion script. To be used with `eval $(aws-vault completion SHELL)`.", + ) + + shellArgHelp := fmt.Sprintf("Shell to get completion script for [ %s ]", strings.Join(supportedShells, " ")) + cmd.Arg("shell", shellArgHelp). + Required(). + Envar("SHELL"). + HintOptions(supportedShells...). + StringVar(&input.Shell) + + cmd.Action(func(c *kingpin.ParseContext) error { + shell := path.Base(input.Shell) // strip any path (useful for $SHELL, doesn't hurt for other cases) + + completionScript, err := completionScripts.ReadFile(fmt.Sprintf("completion-scripts/aws-vault.%s", shell)) + if err != nil { + return fmt.Errorf("unknown shell: %s", input.Shell) + } + + _, err = fmt.Fprint(completionScriptPrinter, string(completionScript)) + if err != nil { + return fmt.Errorf("failed to print completion script: %w", err) + } + + return nil + }) +} + +// completionSupportedShells returns a list of shells with available completions. +// The list is generated from the embedded completion scripts. +func completionSupportedShells() ([]string, error) { + scripts, err := completionScripts.ReadDir("completion-scripts") + if err != nil { + return nil, fmt.Errorf("failed to read completion scripts: %w", err) + } + var shells []string + for _, f := range scripts { + shells = append(shells, strings.Split(path.Ext(f.Name()), ".")[1]) + } + return shells, nil +} diff --git a/cli/completion_test.go b/cli/completion_test.go new file mode 100644 index 000000000..661b81336 --- /dev/null +++ b/cli/completion_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "bytes" + "fmt" + "os" + "testing" + + "github.com/alecthomas/kingpin/v2" +) + +type shellTestDataItem struct { + shellName string + shellPath string + completionScript string +} + +func TestConfigureCompletionCommand(t *testing.T) { + app := kingpin.New("test", "") + ConfigureCompletionCommand(app) + + shellsAndCompletionScripts, err := getShellsAndCompletionScripts() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(shellsAndCompletionScripts) == 0 { + t.Fatal("no shells found") + } + + invalidShellTestDataItem := shellTestDataItem{ + shellName: "invalid", + shellPath: "/bin/invalid", + } + for _, tt := range shellsAndCompletionScripts { + if tt.shellName == invalidShellTestDataItem.shellName { + t.Fatalf("invalidShellTestDataItem.shellName (%s) is actually a valid shell name", invalidShellTestDataItem.shellName) + } + } + + // Test shell argument + t.Run("arg", func(t *testing.T) { + for _, tt := range shellsAndCompletionScripts { + t.Run(tt.shellName, func(t *testing.T) { + var buf bytes.Buffer + completionScriptPrinter = &buf + + _, err := app.Parse([]string{"completion", tt.shellName}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := buf.String() + if got != tt.completionScript { + t.Errorf("got %q; want %q", got, tt.completionScript) + } + }) + } + + t.Run(invalidShellTestDataItem.shellName, func(t *testing.T) { + var buf bytes.Buffer + app.UsageWriter(&buf) + + _, err := app.Parse([]string{"completion", "invalid"}) + + if err == nil { + t.Fatal("expected error, but didn't get one") + } + + want := fmt.Sprintf("unknown shell: %s", invalidShellTestDataItem.shellName) + if err.Error() != want { + t.Errorf("got error(%q); want error(%q)", err.Error(), want) + } + }) + }) + + // Test $SHELL envar + t.Run("envar", func(t *testing.T) { + for _, tt := range shellsAndCompletionScripts { + t.Run(tt.shellName, func(t *testing.T) { + var buf bytes.Buffer + completionScriptPrinter = &buf + + os.Setenv("SHELL", tt.shellPath) + + _, err := app.Parse([]string{"completion"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got := buf.String() + if got != tt.completionScript { + t.Errorf("got %q; want %q", got, tt.completionScript) + } + }) + } + + t.Run(invalidShellTestDataItem.shellName, func(t *testing.T) { + var buf bytes.Buffer + app.UsageWriter(&buf) + + os.Setenv("SHELL", invalidShellTestDataItem.shellPath) + + _, err := app.Parse([]string{"completion"}) + if err == nil { + t.Fatal("expected error, but didn't get one") + } + + want := fmt.Sprintf("unknown shell: %s", invalidShellTestDataItem.shellPath) + if err.Error() != want { + t.Errorf("got error(%q); want error(%q)", err.Error(), want) + } + }) + }) +} + +func getShellsAndCompletionScripts() ([]shellTestDataItem, error) { + shells, err := completionSupportedShells() + if err != nil { + return nil, err + } + + var shellsAndValues []shellTestDataItem + for _, shell := range shells { + completionScript, err := completionScripts.ReadFile(fmt.Sprintf("completion-scripts/aws-vault.%s", shell)) + if err != nil { + return nil, err + } + shellsAndValues = append( + shellsAndValues, + shellTestDataItem{ + shellName: shell, + shellPath: fmt.Sprintf("/bin/%s", shell), + completionScript: string(completionScript), + }, + ) + } + return shellsAndValues, nil +} diff --git a/contrib/completions/bash/aws-vault.bash b/contrib/completions/bash/aws-vault.bash deleted file mode 100644 index ee5617908..000000000 --- a/contrib/completions/bash/aws-vault.bash +++ /dev/null @@ -1,17 +0,0 @@ -_aws-vault_bash_autocomplete() { - local i cur prev opts base - - for (( i=1; i < COMP_CWORD; i++ )); do - if [[ ${COMP_WORDS[i]} == -- ]]; then - _command_offset $i+1 - return - fi - done - - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - opts=$( ${COMP_WORDS[0]} --completion-bash "${COMP_WORDS[@]:1:$COMP_CWORD}" ) - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) - return 0 -} -complete -F _aws-vault_bash_autocomplete -o default aws-vault diff --git a/contrib/completions/bash/aws-vault.bash b/contrib/completions/bash/aws-vault.bash new file mode 120000 index 000000000..0fafe0316 --- /dev/null +++ b/contrib/completions/bash/aws-vault.bash @@ -0,0 +1 @@ +../../../cli/completion-scripts/aws-vault.bash \ No newline at end of file diff --git a/contrib/completions/fish/aws-vault.fish b/contrib/completions/fish/aws-vault.fish deleted file mode 100644 index 1c6423d30..000000000 --- a/contrib/completions/fish/aws-vault.fish +++ /dev/null @@ -1,24 +0,0 @@ -if status --is-interactive - complete -ec aws-vault - - # switch based on seeing a `--` - complete -c aws-vault -n 'not __fish_aws_vault_is_commandline' -xa '(__fish_aws_vault_complete_arg)' - complete -c aws-vault -n '__fish_aws_vault_is_commandline' -xa '(__fish_aws_vault_complete_commandline)' - - function __fish_aws_vault_is_commandline - string match -q -r '^--$' -- (commandline -opc) - end - - function __fish_aws_vault_complete_arg - set -l parts (commandline -opc) - set -e parts[1] - - aws-vault --completion-bash $parts - end - - function __fish_aws_vault_complete_commandline - set -l parts (string split --max 1 '--' -- (commandline -pc)) - - complete "-C$parts[2]" - end -end diff --git a/contrib/completions/fish/aws-vault.fish b/contrib/completions/fish/aws-vault.fish new file mode 120000 index 000000000..047ff3d7d --- /dev/null +++ b/contrib/completions/fish/aws-vault.fish @@ -0,0 +1 @@ +../../../cli/completion-scripts/aws-vault.fish \ No newline at end of file diff --git a/contrib/completions/zsh/aws-vault.zsh b/contrib/completions/zsh/aws-vault.zsh deleted file mode 100644 index 3cbbdba75..000000000 --- a/contrib/completions/zsh/aws-vault.zsh +++ /dev/null @@ -1,24 +0,0 @@ -#compdef aws-vault - -_aws-vault() { - local i - for (( i=2; i < CURRENT; i++ )); do - if [[ ${words[i]} == -- ]]; then - shift $i words - (( CURRENT -= i )) - _normal - return - fi - done - - local matches=($(${words[1]} --completion-bash ${(@)words[2,$CURRENT]})) - compadd -a matches - - if [[ $compstate[nmatches] -eq 0 && $words[$CURRENT] != -* ]]; then - _files - fi -} - -if [[ "$(basename -- ${(%):-%x})" != "_aws-vault" ]]; then - compdef _aws-vault aws-vault -fi diff --git a/contrib/completions/zsh/aws-vault.zsh b/contrib/completions/zsh/aws-vault.zsh new file mode 120000 index 000000000..4047a8a98 --- /dev/null +++ b/contrib/completions/zsh/aws-vault.zsh @@ -0,0 +1 @@ +../../../cli/completion-scripts/aws-vault.zsh \ No newline at end of file diff --git a/main.go b/main.go index fc6b1f708..c9960ca85 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ func main() { cli.ConfigureClearCommand(app, a) cli.ConfigureLoginCommand(app, a) cli.ConfigureProxyCommand(app) + cli.ConfigureCompletionCommand(app) kingpin.MustParse(app.Parse(os.Args[1:])) }