mirror of https://github.com/docker/cli.git
feat: improve completion installer
Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
This commit is contained in:
parent
f6018613db
commit
c5cfb54b11
|
@ -1,52 +1,17 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
package completion
|
package completion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type shellCompletionSetup interface {
|
|
||||||
// Generate completions for the Docker CLI based on the provided shell.
|
|
||||||
DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error)
|
|
||||||
// Set up completions for the Docker CLI for the provided shell.
|
|
||||||
//
|
|
||||||
// For zsh completions, this function should also configure the user's
|
|
||||||
// .zshrc file to load the completions correctly.
|
|
||||||
// Please see https://zsh.sourceforge.io/Doc/Release/Completion-System.html
|
|
||||||
// for more information.
|
|
||||||
InstallCompletions(ctx context.Context, shell supportedCompletionShell) error
|
|
||||||
// Get the completion directory for the provided shell.
|
|
||||||
GetCompletionDir(shell supportedCompletionShell) string
|
|
||||||
// Get the manual instructions for the provided shell.
|
|
||||||
GetManualInstructions(shell supportedCompletionShell) string
|
|
||||||
}
|
|
||||||
|
|
||||||
type supportedCompletionShell string
|
|
||||||
|
|
||||||
const (
|
|
||||||
bash supportedCompletionShell = "bash"
|
|
||||||
fish supportedCompletionShell = "fish"
|
|
||||||
zsh supportedCompletionShell = "zsh"
|
|
||||||
powershell supportedCompletionShell = "powershell"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (s supportedCompletionShell) FileName() string {
|
|
||||||
switch s {
|
|
||||||
case zsh:
|
|
||||||
return "_docker"
|
|
||||||
case bash:
|
|
||||||
return "docker"
|
|
||||||
case fish:
|
|
||||||
return "docker.fish"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
zshrc = ".zshrc"
|
zshrc = ".zshrc"
|
||||||
zshCompletionDir = ".docker/completions"
|
zshCompletionDir = ".docker/completions"
|
||||||
|
@ -60,12 +25,6 @@ const (
|
||||||
// is 0755/0751.
|
// is 0755/0751.
|
||||||
const filePerm = 0755
|
const filePerm = 0755
|
||||||
|
|
||||||
type common struct {
|
|
||||||
command func(ctx context.Context, name string, arg ...string) *exec.Cmd
|
|
||||||
homeDirectory string
|
|
||||||
dockerCliBinary string
|
|
||||||
}
|
|
||||||
|
|
||||||
type unixShellSetup struct {
|
type unixShellSetup struct {
|
||||||
zshrc string
|
zshrc string
|
||||||
zshCompletionDir string
|
zshCompletionDir string
|
||||||
|
@ -75,30 +34,52 @@ type unixShellSetup struct {
|
||||||
common
|
common
|
||||||
}
|
}
|
||||||
|
|
||||||
func unixDefaultShell() (supportedCompletionShell, error) {
|
var (
|
||||||
currentShell := os.Getenv("SHELL")
|
ErrCompletionNotInstalled = errors.New("completion not installed")
|
||||||
|
ErrCompletionOutdated = errors.New("completion file is outdated")
|
||||||
|
ErrZshrcCouldNotWrite = errors.New("could not write to .zshrc file since it may not exist or have the necessary permissions")
|
||||||
|
ErrCompletionDirectoryCreate = errors.New("could not create the completions directory")
|
||||||
|
ErrCompletionFileWrite = errors.New("could not write to the completions file")
|
||||||
|
ErrCompletionGenerated = errors.New("could not generate completions")
|
||||||
|
ErrZshFpathNotFound = errors.New("completions file not found in the FPATH environment variable")
|
||||||
|
)
|
||||||
|
|
||||||
if len(currentShell) == 0 {
|
var _ ShellCompletionSetup = &unixShellSetup{}
|
||||||
return "", errors.New("SHELL environment variable not set")
|
|
||||||
|
func hasCompletionInFpath(zshrc, completionDir string) (bool, error) {
|
||||||
|
// check the FPATH environment variable first which contains a string of directories
|
||||||
|
if fpathEnv := os.Getenv("FPATH"); fpathEnv != "" && strings.Contains(fpathEnv, completionDir) {
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
t := strings.Split(currentShell, "/")
|
if _, err := os.Stat(zshrc); err != nil {
|
||||||
|
return false, fmt.Errorf("unable to edit %s since it does not exist. Setup your zsh completions manually or create the .zshrc file inside of your home directory and try again", zshrc)
|
||||||
switch t[len(t)-1] {
|
|
||||||
case "bash":
|
|
||||||
return bash, nil
|
|
||||||
case "zsh":
|
|
||||||
return zsh, nil
|
|
||||||
case "fish":
|
|
||||||
return fish, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errors.New("unsupported shell")
|
// This should error if it does not exist.
|
||||||
|
zshrcContent, err := os.ReadFile(zshrc)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("unable to edit %s. Make sure that your .zshrc file is set up correctly before continuing", zshrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ shellCompletionSetup = &unixShellSetup{}
|
fpath := fmt.Sprintf("fpath=(%s $fpath)", completionDir)
|
||||||
|
if strings.Contains(string(zshrcContent), fpath) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewShellCompletionSetup(homeDirectory string, generateCompletions generateCompletions, opts ...NewShellCompletionOptsFunc) (ShellCompletionSetup, error) {
|
||||||
|
return newUnixShellSetup(homeDirectory, generateCompletions, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnixShellSetup(homeDirectory string, generateCompletions generateCompletions, opts ...NewShellCompletionOptsFunc) (*unixShellSetup, error) {
|
||||||
|
shell, shellRawString, err := shellFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
func NewUnixShellSetup(homeDirectory string, dockerCliBinary string) shellCompletionSetup {
|
|
||||||
zshrcFile := filepath.Join(homeDirectory, zshrc)
|
zshrcFile := filepath.Join(homeDirectory, zshrc)
|
||||||
// override the default directory if ZDOTDIR is set
|
// override the default directory if ZDOTDIR is set
|
||||||
// if this is set, we assume the user has set up their own zshrc
|
// if this is set, we assume the user has set up their own zshrc
|
||||||
|
@ -116,31 +97,54 @@ func NewUnixShellSetup(homeDirectory string, dockerCliBinary string) shellComple
|
||||||
hasOhMyZsh = true
|
hasOhMyZsh = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &unixShellSetup{
|
|
||||||
|
c := &common{
|
||||||
|
homeDirectory: homeDirectory,
|
||||||
|
command: generateCompletions,
|
||||||
|
currentShell: shell,
|
||||||
|
currentShellRawString: shellRawString,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &unixShellSetup{
|
||||||
zshrc: zshrcFile,
|
zshrc: zshrcFile,
|
||||||
zshCompletionDir: zshCompletionDir,
|
zshCompletionDir: zshCompletionDir,
|
||||||
fishCompletionDir: filepath.Join(homeDirectory, fishCompletionDir),
|
fishCompletionDir: filepath.Join(homeDirectory, fishCompletionDir),
|
||||||
bashCompletionDir: filepath.Join(homeDirectory, bashCompletionDir),
|
bashCompletionDir: filepath.Join(homeDirectory, bashCompletionDir),
|
||||||
hasOhMyZsh: hasOhMyZsh,
|
hasOhMyZsh: hasOhMyZsh,
|
||||||
common: common{
|
common: *c,
|
||||||
homeDirectory: homeDirectory,
|
}
|
||||||
dockerCliBinary: dockerCliBinary,
|
|
||||||
command: exec.CommandContext,
|
return u, nil
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
func (u *unixShellSetup) GetCompletionScript(ctx context.Context) ([]byte, error) {
|
||||||
|
var err error
|
||||||
|
var buff bytes.Buffer
|
||||||
|
|
||||||
|
switch u.currentShell {
|
||||||
|
case zsh:
|
||||||
|
err = u.command.GenZshCompletion(&buff)
|
||||||
|
case bash:
|
||||||
|
err = u.command.GenBashCompletionV2(&buff, true)
|
||||||
|
case fish:
|
||||||
|
err = u.command.GenFishCompletion(&buff, true)
|
||||||
|
default:
|
||||||
|
return nil, ErrShellUnsupported
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *unixShellSetup) DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error) {
|
|
||||||
dockerCmd := u.command(ctx, u.dockerCliBinary, "completion", string(shell))
|
|
||||||
out, err := dockerCmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
|
||||||
|
return buff.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *unixShellSetup) GetCompletionDir(shell supportedCompletionShell) string {
|
func (u *unixShellSetup) GetCompletionDir(ctx context.Context) string {
|
||||||
switch shell {
|
switch u.currentShell {
|
||||||
case zsh:
|
case zsh:
|
||||||
return u.zshCompletionDir
|
return u.zshCompletionDir
|
||||||
case fish:
|
case fish:
|
||||||
|
@ -151,13 +155,13 @@ func (u *unixShellSetup) GetCompletionDir(shell supportedCompletionShell) string
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *unixShellSetup) GetManualInstructions(shell supportedCompletionShell) string {
|
func (u *unixShellSetup) GetManualInstructions(ctx context.Context) string {
|
||||||
completionDir := u.GetCompletionDir(shell)
|
completionDir := u.GetCompletionDir(ctx)
|
||||||
completionsFile := filepath.Join(completionDir, shell.FileName())
|
completionsFile := filepath.Join(completionDir, u.currentShell.FileName())
|
||||||
|
|
||||||
instructions := fmt.Sprintf(`mkdir -p %s && docker completion %s > %s`, completionDir, shell, completionsFile)
|
instructions := fmt.Sprintf("\tmkdir -p %s\n\tdocker completion %s > %s", completionDir, u.currentShell, completionsFile)
|
||||||
|
|
||||||
if shell == zsh && !u.hasOhMyZsh {
|
if u.currentShell == zsh && !u.hasOhMyZsh {
|
||||||
instructions += "\n"
|
instructions += "\n"
|
||||||
instructions += fmt.Sprintf("cat <<EOT >> %s\n"+
|
instructions += fmt.Sprintf("cat <<EOT >> %s\n"+
|
||||||
"# The following lines have been added by Docker to enable Docker CLI completions.\n"+
|
"# The following lines have been added by Docker to enable Docker CLI completions.\n"+
|
||||||
|
@ -171,18 +175,18 @@ func (u *unixShellSetup) GetManualInstructions(shell supportedCompletionShell) s
|
||||||
return instructions
|
return instructions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supportedCompletionShell) error {
|
func (u *unixShellSetup) InstallCompletions(ctx context.Context) error {
|
||||||
completionDir := u.GetCompletionDir(shell)
|
completionDir := u.GetCompletionDir(ctx)
|
||||||
|
|
||||||
if err := os.MkdirAll(completionDir, filePerm); err != nil {
|
if err := os.MkdirAll(completionDir, filePerm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
completionFile := filepath.Join(completionDir, shell.FileName())
|
completionFile := filepath.Join(completionDir, u.currentShell.FileName())
|
||||||
|
|
||||||
_ = os.Remove(completionFile)
|
_ = os.Remove(completionFile)
|
||||||
|
|
||||||
completions, err := u.DockerCompletion(ctx, shell)
|
completions, err := u.GetCompletionScript(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -198,13 +202,13 @@ func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supported
|
||||||
}
|
}
|
||||||
|
|
||||||
// only configure fpath for zsh if oh-my-zsh is not installed
|
// only configure fpath for zsh if oh-my-zsh is not installed
|
||||||
if shell == zsh && !u.hasOhMyZsh {
|
if u.currentShell == zsh && !u.hasOhMyZsh {
|
||||||
|
|
||||||
// This should error if it does not exist.
|
// This should error if it does not exist.
|
||||||
zshrcContent, err := os.ReadFile(u.zshrc)
|
zshrcContent, err := os.ReadFile(u.zshrc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: what should we do here? The error message might not be too helpful.
|
// TODO: what should we do here? The error message might not be too helpful.
|
||||||
return fmt.Errorf("could not open %s. Please ensure that your .zshrc file is set up correctly before continuing.", u.zshrc)
|
return fmt.Errorf("could not open %s. Please ensure that your .zshrc file is set up correctly before continuing", u.zshrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
fpath := fmt.Sprintf("fpath=(%s $fpath)", completionDir)
|
fpath := fmt.Sprintf("fpath=(%s $fpath)", completionDir)
|
||||||
|
@ -237,3 +241,71 @@ func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supported
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *unixShellSetup) GetShell() supportedCompletionShell {
|
||||||
|
return u.currentShell
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *unixShellSetup) InstallStatus(ctx context.Context) (*ShellCompletionInstallStatus, error) {
|
||||||
|
installStatus := &ShellCompletionInstallStatus{
|
||||||
|
Shell: u.currentShellRawString,
|
||||||
|
Status: StatusNotInstalled,
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := u.currentShell.Supported()
|
||||||
|
if !ok {
|
||||||
|
installStatus.Status = StatusUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
installStatus.Reason = err.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
completionDir := u.GetCompletionDir(ctx)
|
||||||
|
completionFile := filepath.Join(completionDir, u.currentShell.FileName())
|
||||||
|
installStatus.CompletionPath = completionFile
|
||||||
|
|
||||||
|
if _, err := os.Stat(completionFile); err != nil {
|
||||||
|
installStatus.Reason = ErrCompletionNotInstalled.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
completionContent, err := os.ReadFile(completionFile)
|
||||||
|
if err != nil {
|
||||||
|
return installStatus, fmt.Errorf("could not open existing completion file: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
completionGenerated, err := u.GetCompletionScript(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return installStatus, fmt.Errorf("could not generate cli completions: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.EqualFold(string(completionContent), string(completionGenerated)) {
|
||||||
|
installStatus.Status = StatusOutdated
|
||||||
|
installStatus.Reason = ErrCompletionOutdated.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.currentShell == zsh && !u.hasOhMyZsh {
|
||||||
|
hasFpath, err := hasCompletionInFpath(u.zshrc, completionDir)
|
||||||
|
if err != nil || !hasFpath {
|
||||||
|
installStatus.Reason = ErrZshFpathNotFound.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
f, err := os.Stat(u.zshrc)
|
||||||
|
if err != nil {
|
||||||
|
installStatus.Reason = ErrZshrcCouldNotWrite.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
if f.Mode().Perm() < 0o600 {
|
||||||
|
installStatus.Reason = ErrZshrcCouldNotWrite.Error()
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
installStatus.Status = StatusInstalled
|
||||||
|
installStatus.Reason = fmt.Sprintf("Shell completion already installed for %s.", u.currentShell)
|
||||||
|
|
||||||
|
return installStatus, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
package completion
|
package completion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -14,47 +16,9 @@ import (
|
||||||
is "gotest.tools/v3/assert/cmp"
|
is "gotest.tools/v3/assert/cmp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testFuncs string
|
|
||||||
|
|
||||||
const (
|
|
||||||
testDockerCompletions testFuncs = "TestDockerCompletions"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed testdata
|
//go:embed testdata
|
||||||
var fixtures embed.FS
|
var fixtures embed.FS
|
||||||
|
|
||||||
// fakeExecCommand is a helper function that hooks
|
|
||||||
// the current test binary into an os/exec cmd.Run() call
|
|
||||||
// allowing us to mock out third party dependencies called through os/exec.
|
|
||||||
//
|
|
||||||
// testBinary is the current test binary that is running, can be accessed through os.Args[0]
|
|
||||||
// funcName is the name of the function you want to run as a sub-process of the current test binary
|
|
||||||
//
|
|
||||||
// The call path is as follows:
|
|
||||||
// - Register the function you want to run through TestMain
|
|
||||||
// - Call the cmd.Run() function from the returned exec.Cmd
|
|
||||||
// - TestMain will execute the function as a sub-process of the current test binary
|
|
||||||
func fakeExecCommand(t *testing.T, testBinary string, funcName testFuncs) func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
|
||||||
t.Helper()
|
|
||||||
return func(ctx context.Context, command string, args ...string) *exec.Cmd {
|
|
||||||
cmd := exec.Command(testBinary, append([]string{command}, args...)...)
|
|
||||||
cmd.Env = append(os.Environ(), "TEST_MAIN_FUNC="+string(funcName))
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMain is setup here to act as a dispatcher
|
|
||||||
// for functions hooked into the test binary through
|
|
||||||
// fakeExecCommand.
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
switch testFuncs(os.Getenv("TEST_MAIN_FUNC")) {
|
|
||||||
case testDockerCompletions:
|
|
||||||
FakeDockerCompletionsProcess()
|
|
||||||
default:
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is a test function that will only be run when
|
// this is a test function that will only be run when
|
||||||
// fakeExecCommand is hooked into a cmd.Run()/cmd.Output call
|
// fakeExecCommand is hooked into a cmd.Run()/cmd.Output call
|
||||||
// with the funcName as "TestDockerCompletions"
|
// with the funcName as "TestDockerCompletions"
|
||||||
|
@ -79,15 +43,51 @@ func FakeDockerCompletionsProcess() {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeGenerate struct{}
|
||||||
|
|
||||||
|
var _ generateCompletions = (*fakeGenerate)(nil)
|
||||||
|
|
||||||
|
func (f *fakeGenerate) GenBashCompletionV2(w io.Writer, includeDesc bool) error {
|
||||||
|
completions, err := fixtures.ReadFile("testdata/docker.bash")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(completions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGenerate) GenZshCompletion(w io.Writer) error {
|
||||||
|
completions, err := fixtures.ReadFile("testdata/docker.zsh")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(completions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGenerate) GenFishCompletion(w io.Writer, includeDesc bool) error {
|
||||||
|
completions, err := fixtures.ReadFile("testdata/docker.fish")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(completions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeGenerate() generateCompletions {
|
||||||
|
return &fakeGenerate{}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerCompletion(t *testing.T) {
|
func TestDockerCompletion(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
ds := NewUnixShellSetup("", "docker").(*unixShellSetup)
|
|
||||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
|
||||||
|
|
||||||
t.Run("zsh completion", func(t *testing.T) {
|
t.Run("zsh completion", func(t *testing.T) {
|
||||||
completions, err := ds.DockerCompletion(ctx, zsh)
|
t.Setenv("SHELL", "/bin/zsh")
|
||||||
|
ds, err := NewShellCompletionSetup("", newFakeGenerate())
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
completions, err := ds.GetCompletionScript(ctx)
|
||||||
assert.NilError(t, err, "expected docker completions to not error, got %s", err)
|
assert.NilError(t, err, "expected docker completions to not error, got %s", err)
|
||||||
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
||||||
|
|
||||||
|
@ -98,7 +98,11 @@ func TestDockerCompletion(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("bash completion", func(t *testing.T) {
|
t.Run("bash completion", func(t *testing.T) {
|
||||||
completions, err := ds.DockerCompletion(ctx, bash)
|
t.Setenv("SHELL", "/bin/bash")
|
||||||
|
ds, err := NewShellCompletionSetup("", newFakeGenerate())
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
completions, err := ds.GetCompletionScript(ctx)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
||||||
|
|
||||||
|
@ -109,7 +113,11 @@ func TestDockerCompletion(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("fish completion", func(t *testing.T) {
|
t.Run("fish completion", func(t *testing.T) {
|
||||||
completions, err := ds.DockerCompletion(ctx, fish)
|
t.Setenv("SHELL", "/bin/fish")
|
||||||
|
ds, err := NewShellCompletionSetup("", newFakeGenerate())
|
||||||
|
assert.NilError(t, err)
|
||||||
|
|
||||||
|
completions, err := ds.GetCompletionScript(ctx)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
assert.Check(t, len(completions) > 0, "expected docker completions to not be empty")
|
||||||
|
|
||||||
|
@ -179,17 +187,18 @@ func TestUnixDefaultShell(t *testing.T) {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
t.Setenv("SHELL", tc.path)
|
t.Setenv("SHELL", tc.path)
|
||||||
|
|
||||||
s, err := unixDefaultShell()
|
s, shellRaw, err := shellFromEnv()
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
|
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
|
||||||
}
|
} else {
|
||||||
assert.Equal(t, tc.expected, s)
|
assert.Equal(t, tc.expected, s)
|
||||||
|
assert.Equal(t, string(tc.expected), shellRaw)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstallCompletions(t *testing.T) {
|
func TestInstallCompletions(t *testing.T) {
|
||||||
|
|
||||||
zshSetup := func(t *testing.T, ds *unixShellSetup) *os.File {
|
zshSetup := func(t *testing.T, ds *unixShellSetup) *os.File {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
zshrcFile, err := os.OpenFile(ds.zshrc, os.O_RDWR|os.O_CREATE, filePerm)
|
zshrcFile, err := os.OpenFile(ds.zshrc, os.O_RDWR|os.O_CREATE, filePerm)
|
||||||
|
@ -226,9 +235,9 @@ func TestInstallCompletions(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
tmphome := t.TempDir()
|
tmphome := t.TempDir()
|
||||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
ds, err := NewShellCompletionSetup(tmphome, newFakeGenerate())
|
||||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
assert.NilError(t, err)
|
||||||
return ds
|
return ds.(*unixShellSetup)
|
||||||
}
|
}
|
||||||
|
|
||||||
testcases := []struct {
|
testcases := []struct {
|
||||||
|
@ -333,8 +342,8 @@ func TestInstallCompletions(t *testing.T) {
|
||||||
assert.NilError(t, os.MkdirAll(ohmyzsh, filePerm))
|
assert.NilError(t, os.MkdirAll(ohmyzsh, filePerm))
|
||||||
t.Setenv("ZSH", ohmyzsh)
|
t.Setenv("ZSH", ohmyzsh)
|
||||||
|
|
||||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
ds, err := newUnixShellSetup(tmphome, newFakeGenerate())
|
||||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
zshSetup(t, ds)
|
zshSetup(t, ds)
|
||||||
return ds
|
return ds
|
||||||
|
@ -361,8 +370,8 @@ func TestInstallCompletions(t *testing.T) {
|
||||||
tmphome := t.TempDir()
|
tmphome := t.TempDir()
|
||||||
t.Setenv("ZSH", filepath.Join(tmphome, ".oh-my-zsh"))
|
t.Setenv("ZSH", filepath.Join(tmphome, ".oh-my-zsh"))
|
||||||
|
|
||||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
ds, err := newUnixShellSetup(tmphome, newFakeGenerate())
|
||||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
assert.NilError(t, err)
|
||||||
|
|
||||||
zshSetup(t, ds)
|
zshSetup(t, ds)
|
||||||
return ds
|
return ds
|
||||||
|
@ -380,32 +389,47 @@ func TestInstallCompletions(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
t.Cleanup(cancel)
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
t.Setenv("SHELL", string(tc.shell))
|
||||||
ds := tc.setupFunc(t)
|
ds := tc.setupFunc(t)
|
||||||
assert.NilError(t, ds.InstallCompletions(ctx, tc.shell))
|
assert.NilError(t, ds.InstallCompletions(ctx))
|
||||||
tc.assertFunc(t, ds)
|
tc.assertFunc(t, ds)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCompletionDir(t *testing.T) {
|
func TestGetCompletionDir(t *testing.T) {
|
||||||
|
|
||||||
t.Run("standard shells", func(t *testing.T) {
|
t.Run("standard shells", func(t *testing.T) {
|
||||||
tmphome := t.TempDir()
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
ds := NewUnixShellSetup(tmphome, "docker")
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
assert.Equal(t, filepath.Join(tmphome, zshCompletionDir), ds.GetCompletionDir(zsh))
|
tmphome := t.TempDir()
|
||||||
assert.Equal(t, filepath.Join(tmphome, fishCompletionDir), ds.GetCompletionDir(fish))
|
|
||||||
assert.Equal(t, filepath.Join(tmphome, bashCompletionDir), ds.GetCompletionDir(bash))
|
for _, tc := range []struct {
|
||||||
assert.Equal(t, "", ds.GetCompletionDir(supportedCompletionShell("unsupported")))
|
shell string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"/bin/bash", filepath.Join(tmphome, bashCompletionDir)},
|
||||||
|
{"/bin/zsh", filepath.Join(tmphome, zshCompletionDir)},
|
||||||
|
{"/bin/fish", filepath.Join(tmphome, fishCompletionDir)},
|
||||||
|
} {
|
||||||
|
t.Setenv("SHELL", tc.shell)
|
||||||
|
ds, err := NewShellCompletionSetup(tmphome, newFakeGenerate())
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, tc.expected, ds.GetCompletionDir(ctx))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("oh-my-zsh", func(t *testing.T) {
|
t.Run("oh-my-zsh", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
tmphome := t.TempDir()
|
tmphome := t.TempDir()
|
||||||
ohMyZshTmpDir := filepath.Join(tmphome, ".oh-my-zsh")
|
ohMyZshTmpDir := filepath.Join(tmphome, ".oh-my-zsh")
|
||||||
assert.NilError(t, os.MkdirAll(ohMyZshTmpDir, filePerm))
|
assert.NilError(t, os.MkdirAll(ohMyZshTmpDir, filePerm))
|
||||||
|
t.Setenv("SHELL", "/bin/zsh")
|
||||||
t.Setenv("ZSH", ohMyZshTmpDir)
|
t.Setenv("ZSH", ohMyZshTmpDir)
|
||||||
ds := NewUnixShellSetup(tmphome, "docker")
|
ds, err := NewShellCompletionSetup(tmphome, newFakeGenerate())
|
||||||
assert.Equal(t, filepath.Join(ohMyZshTmpDir, "completions"), ds.GetCompletionDir(zsh))
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, filepath.Join(ohMyZshTmpDir, "completions"), ds.GetCompletionDir(ctx))
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
package completion
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShellCompletionSetup interface {
|
||||||
|
// Generate completions for the Docker CLI based on the provided shell.
|
||||||
|
GetCompletionScript(ctx context.Context) ([]byte, error)
|
||||||
|
// Set up completions for the Docker CLI for the provided shell.
|
||||||
|
//
|
||||||
|
// For zsh completions, this function should also configure the user's
|
||||||
|
// .zshrc file to load the completions correctly.
|
||||||
|
// Please see https://zsh.sourceforge.io/Doc/Release/Completion-System.html
|
||||||
|
// for more information.
|
||||||
|
InstallCompletions(ctx context.Context) error
|
||||||
|
// Check if the shell completion is already installed.
|
||||||
|
InstallStatus(ctx context.Context) (*ShellCompletionInstallStatus, error)
|
||||||
|
// Get the completion directory for the provided shell.
|
||||||
|
GetCompletionDir(ctx context.Context) string
|
||||||
|
// Get the manual instructions for the provided shell.
|
||||||
|
GetManualInstructions(ctx context.Context) string
|
||||||
|
// Get he current supported shell
|
||||||
|
GetShell() supportedCompletionShell
|
||||||
|
}
|
||||||
|
|
||||||
|
type completionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusInstalled completionStatus = "INSTALLED"
|
||||||
|
StatusNotInstalled completionStatus = "NOT_INSTALLED"
|
||||||
|
StatusUnsupported completionStatus = "UNSUPPORTED"
|
||||||
|
StatusOutdated completionStatus = "OUTDATED"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrShellEnvNotSet = errors.New("SHELL environment variable not set")
|
||||||
|
ErrShellUnsupported = errors.New("unsupported shell")
|
||||||
|
)
|
||||||
|
|
||||||
|
type supportedCompletionShell string
|
||||||
|
|
||||||
|
const (
|
||||||
|
bash supportedCompletionShell = "bash"
|
||||||
|
fish supportedCompletionShell = "fish"
|
||||||
|
zsh supportedCompletionShell = "zsh"
|
||||||
|
powershell supportedCompletionShell = "powershell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s supportedCompletionShell) FileName() string {
|
||||||
|
switch s {
|
||||||
|
case zsh:
|
||||||
|
return "_docker"
|
||||||
|
case bash:
|
||||||
|
return "docker"
|
||||||
|
case fish:
|
||||||
|
return "docker.fish"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s supportedCompletionShell) Supported() (bool, error) {
|
||||||
|
switch s {
|
||||||
|
case zsh, bash, fish:
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, ErrShellUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShellCompletionInstallStatus struct {
|
||||||
|
Status completionStatus
|
||||||
|
Shell string
|
||||||
|
CompletionPath string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type common struct {
|
||||||
|
homeDirectory string
|
||||||
|
command generateCompletions
|
||||||
|
currentShell supportedCompletionShell
|
||||||
|
currentShellRawString string
|
||||||
|
}
|
||||||
|
|
||||||
|
type generateCompletions interface {
|
||||||
|
GenBashCompletionV2(w io.Writer, includeDesc bool) error
|
||||||
|
GenZshCompletion(w io.Writer) error
|
||||||
|
GenFishCompletion(w io.Writer, includeDesc bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellFromEnv returns the shell type and its name.
|
||||||
|
func shellFromEnv() (supportedCompletionShell, string, error) {
|
||||||
|
currentShell := os.Getenv("SHELL")
|
||||||
|
|
||||||
|
if len(currentShell) == 0 {
|
||||||
|
return "", "", ErrShellEnvNotSet
|
||||||
|
}
|
||||||
|
|
||||||
|
t := strings.Split(currentShell, "/")
|
||||||
|
shellName := t[len(t)-1]
|
||||||
|
|
||||||
|
if ok, err := supportedCompletionShell(shellName).Supported(); !ok {
|
||||||
|
return "", shellName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return supportedCompletionShell(shellName), shellName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewShellCompletionOptsFunc func(*common)
|
||||||
|
|
||||||
|
func WithShellOverride(shell string) NewShellCompletionOptsFunc {
|
||||||
|
return func(u *common) {
|
||||||
|
u.currentShell = supportedCompletionShell(shell)
|
||||||
|
u.currentShellRawString = shell
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package completion
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func NewShellCompletionSetup(homeDirectory string, rootCobraCmd generateCompletions, opts ...NewShellCompletionOptsFunc) (ShellCompletionSetup, error) {
|
||||||
|
return newWindowsShellSetup(homeDirectory, rootCobraCmd, opts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type windowsShellSetup struct {
|
||||||
|
common
|
||||||
|
powershellCompletionDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ ShellCompletionSetup = (*windowsShellSetup)(nil)
|
||||||
|
|
||||||
|
func newWindowsShellSetup(homeDirectory string, rootCobraCmd generateCompletions, opts ...NewShellCompletionOptsFunc) (*windowsShellSetup, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) GetCompletionScript(ctx context.Context) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) GetCompletionDir(ctx context.Context) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) InstallCompletions(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) GetShell() supportedCompletionShell {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) GetManualInstructions(ctx context.Context) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *windowsShellSetup) InstallStatus(ctx context.Context) (*ShellCompletionInstallStatus, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
|
@ -17,23 +17,47 @@ func NewCompletionCommand(dockerCli command.Cli) *cobra.Command {
|
||||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell", "install"},
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell", "install"},
|
||||||
DisableFlagParsing: false,
|
DisableFlagParsing: false,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
shellSetup := NewUnixShellSetup("", "docker")
|
|
||||||
|
|
||||||
if cmd.Flag("manual").Changed {
|
|
||||||
_, _ = fmt.Fprint(dockerCli.Out(), shellSetup.GetManualInstructions(supportedCompletionShell(args[0])))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "install":
|
case "install":
|
||||||
return shellSetup.InstallCompletions(cmd.Context(), supportedCompletionShell(os.Getenv("SHELL")))
|
|
||||||
|
userHome, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []NewShellCompletionOptsFunc{}
|
||||||
|
if cmd.Flag("shell").Changed {
|
||||||
|
opts = append(opts, WithShellOverride(cmd.Flag("shell").Value.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
shellSetup, err := NewShellCompletionSetup(userHome, cmd.Root(), opts...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flag("manual").Changed {
|
||||||
|
_, _ = fmt.Fprint(dockerCli.Out(), shellSetup.GetManualInstructions(cmd.Context()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("\nDetected shell [%s]\n\nThe automatic installer will do the following:\n\n%s\n\nAre you sure you want to continue?", shellSetup.GetShell(), shellSetup.GetManualInstructions(cmd.Context()))
|
||||||
|
ok, err := command.PromptForConfirmation(cmd.Context(), dockerCli.In(), dockerCli.Out(), msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return shellSetup.InstallCompletions(cmd.Context())
|
||||||
case "bash":
|
case "bash":
|
||||||
|
|
||||||
return cmd.GenBashCompletionV2(dockerCli.Out(), true)
|
return cmd.Root().GenBashCompletionV2(dockerCli.Out(), true)
|
||||||
case "zsh":
|
case "zsh":
|
||||||
return cmd.GenZshCompletion(dockerCli.Out())
|
return cmd.Root().GenZshCompletion(dockerCli.Out())
|
||||||
case "fish":
|
case "fish":
|
||||||
return cmd.GenFishCompletion(dockerCli.Out(), true)
|
return cmd.Root().GenFishCompletion(dockerCli.Out(), true)
|
||||||
default:
|
default:
|
||||||
return command.ShowHelp(dockerCli.Err())(cmd, args)
|
return command.ShowHelp(dockerCli.Err())(cmd, args)
|
||||||
}
|
}
|
||||||
|
@ -41,6 +65,7 @@ func NewCompletionCommand(dockerCli command.Cli) *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.PersistentFlags().Bool("manual", false, "Display instructions for installing autocompletion")
|
cmd.PersistentFlags().Bool("manual", false, "Display instructions for installing autocompletion")
|
||||||
|
cmd.PersistentFlags().String("shell", "", "Shell type for autocompletion (bash, zsh, fish, powershell)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue