From c5cfb54b11a95dbf424e0b5d583a8544cf437f41 Mon Sep 17 00:00:00 2001 From: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:59:32 +0200 Subject: [PATCH] feat: improve completion installer Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com> --- cli/command/completion/autocomplete.go | 238 ++++++++++++------ cli/command/completion/autocomplete_test.go | 156 +++++++----- .../completion/autocompletions_common.go | 119 +++++++++ .../completion/autocompletions_windows.go | 42 ++++ cli/command/completion/command.go | 45 +++- 5 files changed, 441 insertions(+), 159 deletions(-) create mode 100644 cli/command/completion/autocompletions_common.go create mode 100644 cli/command/completion/autocompletions_windows.go diff --git a/cli/command/completion/autocomplete.go b/cli/command/completion/autocomplete.go index 5c90180bf8..00a99e971c 100644 --- a/cli/command/completion/autocomplete.go +++ b/cli/command/completion/autocomplete.go @@ -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 <> %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 +} diff --git a/cli/command/completion/autocomplete_test.go b/cli/command/completion/autocomplete_test.go index 18c7f42115..7c7353bfac 100644 --- a/cli/command/completion/autocomplete_test.go +++ b/cli/command/completion/autocomplete_test.go @@ -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)) }) - } diff --git a/cli/command/completion/autocompletions_common.go b/cli/command/completion/autocompletions_common.go new file mode 100644 index 0000000000..88de908d88 --- /dev/null +++ b/cli/command/completion/autocompletions_common.go @@ -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 + } +} diff --git a/cli/command/completion/autocompletions_windows.go b/cli/command/completion/autocompletions_windows.go new file mode 100644 index 0000000000..4e880d511d --- /dev/null +++ b/cli/command/completion/autocompletions_windows.go @@ -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 +} diff --git a/cli/command/completion/command.go b/cli/command/completion/command.go index 7e6b2481f9..c814503072 100644 --- a/cli/command/completion/command.go +++ b/cli/command/completion/command.go @@ -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 }