feat: improve completion installer

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
This commit is contained in:
Alano Terblanche 2024-06-13 14:59:32 +02:00
parent f6018613db
commit c5cfb54b11
No known key found for this signature in database
GPG Key ID: 0E8FACD1BA98DE27
5 changed files with 441 additions and 159 deletions

View File

@ -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)
}
fpath := fmt.Sprintf("fpath=(%s $fpath)", completionDir)
if strings.Contains(string(zshrcContent), fpath) {
return true, nil
}
return false, nil
} }
var _ shellCompletionSetup = &unixShellSetup{} 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) DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error) { func (u *unixShellSetup) GetCompletionScript(ctx context.Context) ([]byte, error) {
dockerCmd := u.command(ctx, u.dockerCliBinary, "completion", string(shell)) var err error
out, err := dockerCmd.Output() 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
}
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
}

View File

@ -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))
}) })
} }

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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
} }