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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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 (
|
||||
zshrc = ".zshrc"
|
||||
zshCompletionDir = ".docker/completions"
|
||||
|
@ -60,12 +25,6 @@ const (
|
|||
// is 0755/0751.
|
||||
const filePerm = 0755
|
||||
|
||||
type common struct {
|
||||
command func(ctx context.Context, name string, arg ...string) *exec.Cmd
|
||||
homeDirectory string
|
||||
dockerCliBinary string
|
||||
}
|
||||
|
||||
type unixShellSetup struct {
|
||||
zshrc string
|
||||
zshCompletionDir string
|
||||
|
@ -75,30 +34,52 @@ type unixShellSetup struct {
|
|||
common
|
||||
}
|
||||
|
||||
func unixDefaultShell() (supportedCompletionShell, error) {
|
||||
currentShell := os.Getenv("SHELL")
|
||||
var (
|
||||
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 {
|
||||
return "", errors.New("SHELL environment variable not set")
|
||||
var _ ShellCompletionSetup = &unixShellSetup{}
|
||||
|
||||
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, "/")
|
||||
|
||||
switch t[len(t)-1] {
|
||||
case "bash":
|
||||
return bash, nil
|
||||
case "zsh":
|
||||
return zsh, nil
|
||||
case "fish":
|
||||
return fish, nil
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
// override the default directory if ZDOTDIR is set
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return &unixShellSetup{
|
||||
|
||||
c := &common{
|
||||
homeDirectory: homeDirectory,
|
||||
command: generateCompletions,
|
||||
currentShell: shell,
|
||||
currentShellRawString: shellRawString,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
u := &unixShellSetup{
|
||||
zshrc: zshrcFile,
|
||||
zshCompletionDir: zshCompletionDir,
|
||||
fishCompletionDir: filepath.Join(homeDirectory, fishCompletionDir),
|
||||
bashCompletionDir: filepath.Join(homeDirectory, bashCompletionDir),
|
||||
hasOhMyZsh: hasOhMyZsh,
|
||||
common: common{
|
||||
homeDirectory: homeDirectory,
|
||||
dockerCliBinary: dockerCliBinary,
|
||||
command: exec.CommandContext,
|
||||
},
|
||||
common: *c,
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (u *unixShellSetup) DockerCompletion(ctx context.Context, shell supportedCompletionShell) ([]byte, error) {
|
||||
dockerCmd := u.command(ctx, u.dockerCliBinary, "completion", string(shell))
|
||||
out, err := dockerCmd.Output()
|
||||
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
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
|
||||
return buff.Bytes(), nil
|
||||
}
|
||||
|
||||
func (u *unixShellSetup) GetCompletionDir(shell supportedCompletionShell) string {
|
||||
switch shell {
|
||||
func (u *unixShellSetup) GetCompletionDir(ctx context.Context) string {
|
||||
switch u.currentShell {
|
||||
case zsh:
|
||||
return u.zshCompletionDir
|
||||
case fish:
|
||||
|
@ -151,13 +155,13 @@ func (u *unixShellSetup) GetCompletionDir(shell supportedCompletionShell) string
|
|||
return ""
|
||||
}
|
||||
|
||||
func (u *unixShellSetup) GetManualInstructions(shell supportedCompletionShell) string {
|
||||
completionDir := u.GetCompletionDir(shell)
|
||||
completionsFile := filepath.Join(completionDir, shell.FileName())
|
||||
func (u *unixShellSetup) GetManualInstructions(ctx context.Context) string {
|
||||
completionDir := u.GetCompletionDir(ctx)
|
||||
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 += fmt.Sprintf("cat <<EOT >> %s\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
|
||||
}
|
||||
|
||||
func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supportedCompletionShell) error {
|
||||
completionDir := u.GetCompletionDir(shell)
|
||||
func (u *unixShellSetup) InstallCompletions(ctx context.Context) error {
|
||||
completionDir := u.GetCompletionDir(ctx)
|
||||
|
||||
if err := os.MkdirAll(completionDir, filePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
completionFile := filepath.Join(completionDir, shell.FileName())
|
||||
completionFile := filepath.Join(completionDir, u.currentShell.FileName())
|
||||
|
||||
_ = os.Remove(completionFile)
|
||||
|
||||
completions, err := u.DockerCompletion(ctx, shell)
|
||||
completions, err := u.GetCompletionScript(ctx)
|
||||
if err != nil {
|
||||
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
|
||||
if shell == zsh && !u.hasOhMyZsh {
|
||||
if u.currentShell == zsh && !u.hasOhMyZsh {
|
||||
|
||||
// This should error if it does not exist.
|
||||
zshrcContent, err := os.ReadFile(u.zshrc)
|
||||
if err != nil {
|
||||
// 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)
|
||||
|
@ -237,3 +241,71 @@ func (u *unixShellSetup) InstallCompletions(ctx context.Context, shell supported
|
|||
|
||||
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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
@ -14,47 +16,9 @@ import (
|
|||
is "gotest.tools/v3/assert/cmp"
|
||||
)
|
||||
|
||||
type testFuncs string
|
||||
|
||||
const (
|
||||
testDockerCompletions testFuncs = "TestDockerCompletions"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
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
|
||||
// fakeExecCommand is hooked into a cmd.Run()/cmd.Output call
|
||||
// with the funcName as "TestDockerCompletions"
|
||||
|
@ -79,15 +43,51 @@ func FakeDockerCompletionsProcess() {
|
|||
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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
ds := NewUnixShellSetup("", "docker").(*unixShellSetup)
|
||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
||||
|
||||
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.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) {
|
||||
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.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) {
|
||||
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.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.Setenv("SHELL", tc.path)
|
||||
|
||||
s, err := unixDefaultShell()
|
||||
s, shellRaw, err := shellFromEnv()
|
||||
if tc.expectedErr != "" {
|
||||
assert.Check(t, is.ErrorContains(err, tc.expectedErr))
|
||||
} else {
|
||||
assert.Equal(t, tc.expected, s)
|
||||
assert.Equal(t, string(tc.expected), shellRaw)
|
||||
}
|
||||
assert.Equal(t, tc.expected, s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCompletions(t *testing.T) {
|
||||
|
||||
zshSetup := func(t *testing.T, ds *unixShellSetup) *os.File {
|
||||
t.Helper()
|
||||
zshrcFile, err := os.OpenFile(ds.zshrc, os.O_RDWR|os.O_CREATE, filePerm)
|
||||
|
@ -226,9 +235,9 @@ func TestInstallCompletions(t *testing.T) {
|
|||
t.Helper()
|
||||
|
||||
tmphome := t.TempDir()
|
||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
||||
return ds
|
||||
ds, err := NewShellCompletionSetup(tmphome, newFakeGenerate())
|
||||
assert.NilError(t, err)
|
||||
return ds.(*unixShellSetup)
|
||||
}
|
||||
|
||||
testcases := []struct {
|
||||
|
@ -333,8 +342,8 @@ func TestInstallCompletions(t *testing.T) {
|
|||
assert.NilError(t, os.MkdirAll(ohmyzsh, filePerm))
|
||||
t.Setenv("ZSH", ohmyzsh)
|
||||
|
||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
||||
ds, err := newUnixShellSetup(tmphome, newFakeGenerate())
|
||||
assert.NilError(t, err)
|
||||
|
||||
zshSetup(t, ds)
|
||||
return ds
|
||||
|
@ -361,8 +370,8 @@ func TestInstallCompletions(t *testing.T) {
|
|||
tmphome := t.TempDir()
|
||||
t.Setenv("ZSH", filepath.Join(tmphome, ".oh-my-zsh"))
|
||||
|
||||
ds := NewUnixShellSetup(tmphome, "docker").(*unixShellSetup)
|
||||
ds.command = fakeExecCommand(t, os.Args[0], testDockerCompletions)
|
||||
ds, err := newUnixShellSetup(tmphome, newFakeGenerate())
|
||||
assert.NilError(t, err)
|
||||
|
||||
zshSetup(t, ds)
|
||||
return ds
|
||||
|
@ -380,32 +389,47 @@ func TestInstallCompletions(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
t.Setenv("SHELL", string(tc.shell))
|
||||
ds := tc.setupFunc(t)
|
||||
assert.NilError(t, ds.InstallCompletions(ctx, tc.shell))
|
||||
assert.NilError(t, ds.InstallCompletions(ctx))
|
||||
tc.assertFunc(t, ds)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCompletionDir(t *testing.T) {
|
||||
|
||||
t.Run("standard shells", func(t *testing.T) {
|
||||
tmphome := t.TempDir()
|
||||
ds := NewUnixShellSetup(tmphome, "docker")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
assert.Equal(t, filepath.Join(tmphome, zshCompletionDir), ds.GetCompletionDir(zsh))
|
||||
assert.Equal(t, filepath.Join(tmphome, fishCompletionDir), ds.GetCompletionDir(fish))
|
||||
assert.Equal(t, filepath.Join(tmphome, bashCompletionDir), ds.GetCompletionDir(bash))
|
||||
assert.Equal(t, "", ds.GetCompletionDir(supportedCompletionShell("unsupported")))
|
||||
tmphome := t.TempDir()
|
||||
|
||||
for _, tc := range []struct {
|
||||
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) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tmphome := t.TempDir()
|
||||
ohMyZshTmpDir := filepath.Join(tmphome, ".oh-my-zsh")
|
||||
assert.NilError(t, os.MkdirAll(ohMyZshTmpDir, filePerm))
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("ZSH", ohMyZshTmpDir)
|
||||
ds := NewUnixShellSetup(tmphome, "docker")
|
||||
assert.Equal(t, filepath.Join(ohMyZshTmpDir, "completions"), ds.GetCompletionDir(zsh))
|
||||
ds, err := NewShellCompletionSetup(tmphome, newFakeGenerate())
|
||||
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"},
|
||||
DisableFlagParsing: false,
|
||||
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] {
|
||||
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":
|
||||
|
||||
return cmd.GenBashCompletionV2(dockerCli.Out(), true)
|
||||
return cmd.Root().GenBashCompletionV2(dockerCli.Out(), true)
|
||||
case "zsh":
|
||||
return cmd.GenZshCompletion(dockerCli.Out())
|
||||
return cmd.Root().GenZshCompletion(dockerCli.Out())
|
||||
case "fish":
|
||||
return cmd.GenFishCompletion(dockerCli.Out(), true)
|
||||
return cmd.Root().GenFishCompletion(dockerCli.Out(), true)
|
||||
default:
|
||||
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().String("shell", "", "Shell type for autocompletion (bash, zsh, fish, powershell)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue