diff --git a/cmd/self/completion/install/cmd.go b/cmd/self/completion/install/cmd.go index 90ca155dd..0bac45de9 100644 --- a/cmd/self/completion/install/cmd.go +++ b/cmd/self/completion/install/cmd.go @@ -60,20 +60,20 @@ This command will: By default, this command runs in preview mode. Use '--yes' to install directly.`, Example: ` # Preview what would be installed (default behavior): - ` + version.CliName + ` completion install + ` + version.CliName + ` self completion install # Install completions for your current shell: - ` + version.CliName + ` completion install --yes + ` + version.CliName + ` self completion install --yes # Install completions for a specific shell: - ` + version.CliName + ` completion install bash --yes - ` + version.CliName + ` completion install zsh --yes + ` + version.CliName + ` self completion install bash --yes + ` + version.CliName + ` self completion install zsh --yes # Preview installation for a specific shell: - ` + version.CliName + ` completion install bash + ` + version.CliName + ` self completion install bash # Force reinstall, even if completions are already installed: - ` + version.CliName + ` completion install --force --yes`, + ` + version.CliName + ` self completion install --force --yes`, Args: cobra.MaximumNArgs(1), ValidArgs: internalShell.SupportedShells(), RunE: func(cmd *cobra.Command, args []string) error { @@ -92,7 +92,7 @@ By default, this command runs in preview mode. Use '--yes' to install directly.` effectiveDryRun = true } - return runInstall(cmd.Root(), shell, force, yes, effectiveDryRun) + return runInstall(cmd, shell, force, yes, effectiveDryRun) }, } @@ -103,7 +103,8 @@ By default, this command runs in preview mode. Use '--yes' to install directly.` return cmd } -func runInstall(rootCmd *cobra.Command, specifiedShell string, force, yes, dryRun bool) error { +// runInstall keeps user-facing hints tied to the invoked command path. +func runInstall(cmd *cobra.Command, specifiedShell string, force, yes, dryRun bool) error { shell, err := internalShell.ResolveShell(specifiedShell) if err != nil { return fmt.Errorf("resolve shell: %w", err) @@ -120,11 +121,11 @@ func runInstall(rootCmd *cobra.Command, specifiedShell string, force, yes, dryRu return nil } - return installForShell(rootCmd, shell, shellType, force, yes, dryRun) + return installForShell(cmd, shell, shellType, force, yes, dryRun) } -func installForShell(rootCmd *cobra.Command, shell string, shellType internalShell.Shell, force, yes, dryRun bool) error { - installPath, installFunc, err := getInstallFunc(rootCmd, shellType, force) +func installForShell(cmd *cobra.Command, shell string, shellType internalShell.Shell, force, yes, dryRun bool) error { + installPath, installFunc, err := getInstallFunc(cmd.Root(), shellType, force) if err != nil { return err } @@ -132,7 +133,7 @@ func installForShell(rootCmd *cobra.Command, shell string, shellType internalShe // Check if already installed alreadyInstalled := fsutil.FileExists(installPath) if !force && alreadyInstalled { - showAlreadyInstalled(installPath) + showAlreadyInstalled(cmd, installPath) return nil } @@ -141,7 +142,7 @@ func installForShell(rootCmd *cobra.Command, shell string, shellType internalShe // Dry-run mode if dryRun { - showDryRunMessage(shell) + showDryRunMessage(cmd, shell) return nil } @@ -160,7 +161,7 @@ func installForShell(rootCmd *cobra.Command, shell string, shellType internalShe fmt.Println() // Install - if err := installFunc(rootCmd); err != nil { + if err := installFunc(cmd.Root()); err != nil { return fmt.Errorf("Failed to install completions: %w", err) } @@ -173,10 +174,10 @@ func installForShell(rootCmd *cobra.Command, shell string, shellType internalShe return nil } -func showAlreadyInstalled(installPath string) { +func showAlreadyInstalled(cmd *cobra.Command, installPath string) { fmt.Printf("%s Completion already installed at: %s.\n", successStyle.Render("✓"), installPath) fmt.Println() - fmt.Println(infoStyle.Render("To reinstall, use: " + version.CliName + " completion install --force --yes")) + fmt.Println(infoStyle.Render("To reinstall, use: " + cmd.CommandPath() + " --force --yes")) } func showInstallationPlan(shell, installPath string, alreadyInstalled bool) { @@ -193,11 +194,11 @@ func showInstallationPlan(shell, installPath string, alreadyInstalled bool) { fmt.Println() } -func showDryRunMessage(shell string) { +func showDryRunMessage(cmd *cobra.Command, shell string) { fmt.Println(infoStyle.Render("🔍 Dry-run mode (no changes will be made)")) fmt.Println() fmt.Println("To proceed with installation, run:") - fmt.Println(infoStyle.Render(" " + version.CliName + " completion install " + shell + " --yes")) + fmt.Println(infoStyle.Render(" " + cmd.CommandPath() + " " + shell + " --yes")) } func promptForConfirmation() (bool, error) { diff --git a/cmd/self/completion/install/cmd_test.go b/cmd/self/completion/install/cmd_test.go index 586cf9682..c371e4220 100644 --- a/cmd/self/completion/install/cmd_test.go +++ b/cmd/self/completion/install/cmd_test.go @@ -23,6 +23,8 @@ import ( "github.com/datarobot/cli/internal/fsutil" internalShell "github.com/datarobot/cli/internal/shell" + "github.com/datarobot/cli/internal/testutil" + "github.com/datarobot/cli/internal/version" "github.com/spf13/cobra" ) @@ -261,6 +263,40 @@ func TestInstallCmd(t *testing.T) { } } +// TestShowAlreadyInstalled guards against CFX-7958 command path drift. +func TestShowAlreadyInstalled(t *testing.T) { + cmd := Cmd() + testutil.WireCompletionCommandPath(cmd) + + out := testutil.CaptureStdout(t, func() { + showAlreadyInstalled(cmd, "/fake/path/_dr") + }) + + if !strings.Contains(out, "/fake/path/_dr") { + t.Errorf("output %q does not mention the install path", out) + } + + wantHint := version.CliName + " self completion install --force --yes" + if !strings.Contains(out, wantHint) { + t.Errorf("output %q does not contain expected reinstall hint %q", out, wantHint) + } +} + +// TestShowDryRunMessage guards against CFX-7958 command path drift. +func TestShowDryRunMessage(t *testing.T) { + cmd := Cmd() + testutil.WireCompletionCommandPath(cmd) + + out := testutil.CaptureStdout(t, func() { + showDryRunMessage(cmd, "zsh") + }) + + wantHint := version.CliName + " self completion install zsh --yes" + if !strings.Contains(out, wantHint) { + t.Errorf("output %q does not contain expected dry-run hint %q", out, wantHint) + } +} + func TestIsBashCompletionAvailable(_ *testing.T) { // This test just ensures the function doesn't panic // The actual result depends on the system @@ -505,16 +541,7 @@ func TestInstallPowerShell(t *testing.T) { } defer os.RemoveAll(tmpDir) - // On Windows, os.UserHomeDir() reads USERPROFILE, not HOME. - // Set both so the test isolates the profile path on all platforms. - origHome := os.Getenv("HOME") - origUserProfile := os.Getenv("USERPROFILE") - - os.Setenv("HOME", tmpDir) - os.Setenv("USERPROFILE", tmpDir) - - defer os.Setenv("HOME", origHome) - defer os.Setenv("USERPROFILE", origUserProfile) + testutil.SetTestHomeDir(t, tmpDir) profilePath, installFn := installPowerShell(rootCmd, false) diff --git a/cmd/self/completion/uninstall/cmd.go b/cmd/self/completion/uninstall/cmd.go index 46ac69cab..40e94bc36 100644 --- a/cmd/self/completion/uninstall/cmd.go +++ b/cmd/self/completion/uninstall/cmd.go @@ -53,14 +53,14 @@ This command will: By default, runs in preview mode. Use '--yes' to uninstall directly.`, Example: ` # Preview what would be removed (default behavior) - ` + version.CliName + ` completion uninstall + ` + version.CliName + ` self completion uninstall # Uninstall completions for your current shell - ` + version.CliName + ` completion uninstall --yes + ` + version.CliName + ` self completion uninstall --yes # Uninstall completions for a specific shell - ` + version.CliName + ` completion uninstall bash --yes - ` + version.CliName + ` completion uninstall zsh --yes`, + ` + version.CliName + ` self completion uninstall bash --yes + ` + version.CliName + ` self completion uninstall zsh --yes`, Args: cobra.MaximumNArgs(1), ValidArgs: internalShell.SupportedShells(), RunE: func(cmd *cobra.Command, args []string) error { @@ -79,7 +79,7 @@ By default, runs in preview mode. Use '--yes' to uninstall directly.`, effectiveDryRun = true } - return runUninstall(shell, yes, effectiveDryRun) + return runUninstall(cmd, shell, yes, effectiveDryRun) }, } @@ -89,7 +89,8 @@ By default, runs in preview mode. Use '--yes' to uninstall directly.`, return cmd } -func runUninstall(specifiedShell string, yes, dryRun bool) error { +// runUninstall keeps user-facing hints tied to the invoked command path. +func runUninstall(cmd *cobra.Command, specifiedShell string, yes, dryRun bool) error { shell, err := resolveShellForUninstall(specifiedShell) if err != nil { return err @@ -108,7 +109,7 @@ func runUninstall(specifiedShell string, yes, dryRun bool) error { // Dry-run mode if dryRun { - showUninstallDryRunMessage(shell) + showUninstallDryRunMessage(cmd, shell) return nil } @@ -172,11 +173,11 @@ func showUninstallationPlan(shell string, existingPaths []string) { fmt.Println() } -func showUninstallDryRunMessage(shell string) { +func showUninstallDryRunMessage(cmd *cobra.Command, shell string) { fmt.Println(infoStyle.Render("🔍 Dry-run mode (no changes will be made)")) fmt.Println() fmt.Println("To proceed with uninstallation, run:") - fmt.Println(infoStyle.Render(" " + version.CliName + " completion uninstall " + shell + " --yes")) + fmt.Println(infoStyle.Render(" " + cmd.CommandPath() + " " + shell + " --yes")) } func performUninstall(shell internalShell.Shell) error { diff --git a/cmd/self/completion/uninstall/cmd_test.go b/cmd/self/completion/uninstall/cmd_test.go index 7118f4d5c..ff15f1eb5 100644 --- a/cmd/self/completion/uninstall/cmd_test.go +++ b/cmd/self/completion/uninstall/cmd_test.go @@ -24,6 +24,7 @@ import ( "github.com/datarobot/cli/internal/fsutil" internalShell "github.com/datarobot/cli/internal/shell" "github.com/datarobot/cli/internal/testutil" + "github.com/datarobot/cli/internal/version" ) func TestFindExistingCompletions(t *testing.T) { @@ -131,6 +132,21 @@ func TestUninstallCmd(t *testing.T) { } } +// TestShowUninstallDryRunMessage guards against CFX-7958 command path drift. +func TestShowUninstallDryRunMessage(t *testing.T) { + cmd := Cmd() + testutil.WireCompletionCommandPath(cmd) + + out := testutil.CaptureStdout(t, func() { + showUninstallDryRunMessage(cmd, "zsh") + }) + + wantHint := version.CliName + " self completion uninstall zsh --yes" + if !strings.Contains(out, wantHint) { + t.Errorf("output %q does not contain expected dry-run hint %q", out, wantHint) + } +} + func TestGetUninstallPaths(t *testing.T) { testHome := "/test/home" testutil.SetTestHomeDir(t, testHome) diff --git a/go.mod b/go.mod index 6dfdfc288..3ed224173 100644 --- a/go.mod +++ b/go.mod @@ -81,7 +81,7 @@ require ( github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/net v0.57.0 // indirect ) diff --git a/go.sum b/go.sum index e925698eb..151289695 100644 --- a/go.sum +++ b/go.sum @@ -183,12 +183,12 @@ github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/testutil/command.go b/internal/testutil/command.go new file mode 100644 index 000000000..4cb48aec5 --- /dev/null +++ b/internal/testutil/command.go @@ -0,0 +1,66 @@ +// Copyright 2026 DataRobot, Inc. and its affiliates. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "io" + "os" + "testing" + + "github.com/datarobot/cli/internal/version" + "github.com/spf13/cobra" +) + +// CaptureStdout redirects os.Stdout while fn runs and returns the captured text. +func CaptureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + + t.Cleanup(func() { os.Stdout = orig }) + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("create pipe: %v", err) + } + + os.Stdout = w + + fn() + + os.Stdout = orig + + if err := w.Close(); err != nil { + t.Fatalf("close pipe: %v", err) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read pipe: %v", err) + } + + return string(out) +} + +// WireCompletionCommandPath attaches a command to the real completion parent path. +func WireCompletionCommandPath(cmd *cobra.Command) { + root := &cobra.Command{Use: version.CliName} + selfCmd := &cobra.Command{Use: "self"} + completionCmd := &cobra.Command{Use: "completion"} + + completionCmd.AddCommand(cmd) + selfCmd.AddCommand(completionCmd) + root.AddCommand(selfCmd) +}