From 5e30f1351933e1a3cf324ce3a2db4e57af8b546c Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 6 Aug 2025 17:29:30 -0400 Subject: [PATCH 1/2] feat(powershell): add fallback to PowerShell 5 when PowerShell 7 is unavailable --- cli/azd/pkg/ext/hooks_runner.go | 8 +- cli/azd/pkg/tools/powershell/powershell.go | 98 +++++++--- .../pkg/tools/powershell/powershell_test.go | 181 +++++++++++++++++- cli/azd/resources/alpha_features.yaml | 2 + 4 files changed, 256 insertions(+), 33 deletions(-) diff --git a/cli/azd/pkg/ext/hooks_runner.go b/cli/azd/pkg/ext/hooks_runner.go index 66448a581c6..ec99d22bd23 100644 --- a/cli/azd/pkg/ext/hooks_runner.go +++ b/cli/azd/pkg/ext/hooks_runner.go @@ -10,6 +10,7 @@ import ( "os" "strings" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -128,7 +129,12 @@ func (h *HooksRunner) GetScript(hookConfig *HookConfig, envVars []string) (tools case ShellTypeBash: return bash.NewBashScript(h.commandRunner, h.cwd, envVars), nil case ShellTypePowershell: - return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars), nil + var alphaFeaturesManager *alpha.FeatureManager + if err := h.serviceLocator.Resolve(&alphaFeaturesManager); err != nil { + // If we can't resolve the alpha features manager, continue without it + alphaFeaturesManager = nil + } + return powershell.NewPowershellScript(h.commandRunner, h.cwd, envVars, alphaFeaturesManager), nil default: return nil, fmt.Errorf( "shell type '%s' is not a valid option. Only 'sh' and 'pwsh' are supported", diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 9a5c43c6043..6d1a636812d 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -1,6 +1,28 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// Package powershell provides functionality to execute PowerShell scripts. +// +// PowerShell 5 Fallback Feature: +// When the alpha feature "powershell.fallback5" is enabled, the PowerShell script runner +// will attempt to fallback to PowerShell 5 (powershell.exe) if PowerShell 7 (pwsh.exe) +// is not available. +// +// To enable this feature: +// azd config set alpha.powershell.fallback5 on +// +// To disable this feature: +// azd config set alpha.powershell.fallback5 off +// +// When enabled: +// - First tries to use PowerShell 7 (pwsh) +// - If PowerShell 7 is not available, falls back to PowerShell 5 (powershell) +// - If neither is available, returns an appropriate error message +// +// When disabled (default): +// - Only tries to use PowerShell 7 (pwsh) +// - If PowerShell 7 is not available, returns an error with instructions to install PowerShell 7 + package powershell import ( @@ -9,18 +31,20 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" ) // Creates a new PowershellScript command runner -func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string) tools.Script { +func NewPowershellScript(commandRunner exec.CommandRunner, cwd string, envVars []string, alphaFeaturesManager *alpha.FeatureManager) tools.Script { return &powershellScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - checkInstalled: checkPath, + commandRunner: commandRunner, + cwd: cwd, + envVars: envVars, + checkInstalled: checkPath, + alphaFeaturesManager: alphaFeaturesManager, } } @@ -29,40 +53,70 @@ func NewPowershellScriptWithMockCheckPath( commandRunner exec.CommandRunner, cwd string, envVars []string, - mockCheckPath checkInstalled) tools.Script { + mockCheckPath checkInstalled, + alphaFeaturesManager *alpha.FeatureManager) tools.Script { return &powershellScript{ - commandRunner: commandRunner, - cwd: cwd, - envVars: envVars, - checkInstalled: mockCheckPath, + commandRunner: commandRunner, + cwd: cwd, + envVars: envVars, + checkInstalled: mockCheckPath, + alphaFeaturesManager: alphaFeaturesManager, } } type powershellScript struct { - commandRunner exec.CommandRunner - cwd string - envVars []string - checkInstalled checkInstalled + commandRunner exec.CommandRunner + cwd string + envVars []string + checkInstalled checkInstalled + alphaFeaturesManager *alpha.FeatureManager } -type checkInstalled func(options tools.ExecOptions) error +type checkInstalled func(options tools.ExecOptions, enableFallback bool) (command string, err error) + +func checkPath(options tools.ExecOptions, enableFallback bool) (command string, err error) { + // First try pwsh (PowerShell 7) + pwshCommand := strings.Split(options.UserPwsh, " ")[0] + err = tools.ToolInPath(pwshCommand) + if err == nil { + return options.UserPwsh, nil + } + + // If pwsh is not available and fallback is enabled, try powershell (PowerShell 5) + if enableFallback { + err = tools.ToolInPath("powershell") + if err == nil { + return "powershell", nil + } + } -func checkPath(options tools.ExecOptions) (err error) { - return tools.ToolInPath(strings.Split(options.UserPwsh, " ")[0]) + // Return original error if no fallback or both failed + return "", tools.ToolInPath(pwshCommand) } // Executes the specified powershell script // When interactive is true will attach to stdin, stdout & stderr func (bs *powershellScript) Execute(ctx context.Context, path string, options tools.ExecOptions) (exec.RunResult, error) { - if err := bs.checkInstalled(options); err != nil { + // Check if PowerShell 5 fallback is enabled + enableFallback := bs.alphaFeaturesManager != nil && bs.alphaFeaturesManager.IsEnabled(alpha.FeatureId("powershell.fallback5")) + + command, err := bs.checkInstalled(options, enableFallback) + if err != nil { + suggestion := fmt.Sprintf("PowerShell 7 is not installed or not in the path. To install PowerShell 7, visit %s", + output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")) + + if enableFallback { + suggestion = fmt.Sprintf("PowerShell is not available. Either install PowerShell 7 from %s or ensure PowerShell 5 is available", + output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")) + } + return exec.RunResult{}, &internal.ErrorWithSuggestion{ - Err: err, - Suggestion: fmt.Sprintf("PowerShell 7 is not installed or not in the path. To install PowerShell 7, visit %s", - output.WithLinkFormat("https://learn.microsoft.com/powershell/scripting/install/installing-powershell")), + Err: err, + Suggestion: suggestion, } } - runArgs := exec.NewRunArgs(options.UserPwsh, path). + runArgs := exec.NewRunArgs(command, path). WithCwd(bs.cwd). WithEnv(bs.envVars). WithShell(true) diff --git a/cli/azd/pkg/tools/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index 317fac4f4ae..44f3a62df00 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -44,9 +46,10 @@ func Test_Powershell_Execute(t *testing.T) { mockContext.CommandRunner, workingDir, env, - func(options tools.ExecOptions) error { - return nil - }) + func(options tools.ExecOptions, enableFallback bool) (string, error) { + return options.UserPwsh, nil + }, + nil) runResult, err := PowershellScript.Execute( *mockContext.Context, scriptPath, @@ -70,9 +73,10 @@ func Test_Powershell_Execute(t *testing.T) { mockContext.CommandRunner, workingDir, env, - func(options tools.ExecOptions) error { - return nil - }) + func(options tools.ExecOptions, enableFallback bool) (string, error) { + return options.UserPwsh, nil + }, + nil) runResult, err := PowershellScript.Execute( *mockContext.Context, scriptPath, @@ -86,7 +90,7 @@ func Test_Powershell_Execute(t *testing.T) { t.Run("NoPowerShellInstalled", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - PowershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env) + PowershellScript := NewPowershellScript(mockContext.CommandRunner, workingDir, env, nil) _, err := PowershellScript.Execute( *mockContext.Context, scriptPath, @@ -119,9 +123,10 @@ func Test_Powershell_Execute(t *testing.T) { mockContext.CommandRunner, workingDir, env, - func(options tools.ExecOptions) error { - return nil - }) + func(options tools.ExecOptions, enableFallback bool) (string, error) { + return options.UserPwsh, nil + }, + nil) runResult, err := PowershellScript.Execute(*mockContext.Context, scriptPath, test.value) require.NotNil(t, runResult) @@ -129,3 +134,159 @@ func Test_Powershell_Execute(t *testing.T) { }) } } + +func Test_Alpha_Feature_Detection(t *testing.T) { + t.Run("CheckIfFeatureExistsInRealFeaturesList", func(t *testing.T) { + // Check if our feature exists in the features list + featureId, isValidFeature := alpha.IsFeatureKey("powershell.fallback5") + require.True(t, isValidFeature, "powershell.fallback5 should be a valid alpha feature") + require.Equal(t, alpha.FeatureId("powershell.fallback5"), featureId) + }) + + t.Run("FeatureEnabled", func(t *testing.T) { + mockConfig := config.NewConfig(nil) + err := mockConfig.Set("alpha.powershell.fallback5", "on") + require.NoError(t, err) + + alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig) + + enabled := alphaManager.IsEnabled(alpha.FeatureId("powershell.fallback5")) + require.True(t, enabled, "powershell.fallback5 feature should be enabled") + }) + + t.Run("FeatureDisabled", func(t *testing.T) { + alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewConfig(nil)) + + enabled := alphaManager.IsEnabled(alpha.FeatureId("powershell.fallback5")) + require.False(t, enabled, "powershell.fallback5 feature should be disabled by default") + }) +} + +func Test_Powershell_Fallback_To_PowerShell5(t *testing.T) { + workingDir := "cwd" + scriptPath := "path/script.ps1" + env := []string{"a=apple", "b=banana"} + + t.Run("FallbackEnabled_PwshNotAvailable_PowerShellAvailable", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + // Create a mock config that enables the fallback feature + mockConfig := config.NewConfig(nil) + err := mockConfig.Set("alpha.powershell.fallback5", "on") + require.NoError(t, err) + alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig) + + // Mock command runner to expect 'powershell' command + mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { + return strings.Contains(args.Cmd, "powershell") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + require.Equal(t, "powershell", args.Cmd) + require.Equal(t, workingDir, args.Cwd) + require.Equal(t, scriptPath, args.Args[0]) + require.Equal(t, env, args.Env) + return exec.NewRunResult(0, "", ""), nil + }) + + PowershellScript := NewPowershellScriptWithMockCheckPath( + mockContext.CommandRunner, + workingDir, + env, + func(options tools.ExecOptions, enableFallback bool) (string, error) { + // Simulate pwsh not available, but powershell is available + if enableFallback { + return "powershell", nil + } + return "", errors.New("pwsh not found") + }, + alphaManager) + + runResult, err := PowershellScript.Execute( + *mockContext.Context, + scriptPath, + tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}, + ) + + require.NotNil(t, runResult) + require.NoError(t, err) + }) + + t.Run("FallbackDisabled_PwshNotAvailable_ShouldFail", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + // No alpha config, so fallback is disabled + alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewConfig(nil)) + + PowershellScript := NewPowershellScriptWithMockCheckPath( + mockContext.CommandRunner, + workingDir, + env, + func(options tools.ExecOptions, enableFallback bool) (string, error) { + // Simulate pwsh not available and fallback disabled + require.False(t, enableFallback) + return "", errors.New("pwsh not found") + }, + alphaManager) + + _, err := PowershellScript.Execute( + *mockContext.Context, + scriptPath, + tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}, + ) + + require.Error(t, err) + require.Contains(t, err.Error(), "pwsh not found") + }) + + t.Run("FallbackEnabled_BothNotAvailable_ShouldFail", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + // Enable fallback feature + mockConfig := config.NewConfig(nil) + err := mockConfig.Set("alpha.powershell.fallback5", "on") + require.NoError(t, err) + alphaManager := alpha.NewFeaturesManagerWithConfig(mockConfig) + + PowershellScript := NewPowershellScriptWithMockCheckPath( + mockContext.CommandRunner, + workingDir, + env, + func(options tools.ExecOptions, enableFallback bool) (string, error) { + // Simulate both pwsh and powershell not available + require.True(t, enableFallback) + return "", errors.New("neither pwsh nor powershell found") + }, + alphaManager) + + _, execErr := PowershellScript.Execute( + *mockContext.Context, + scriptPath, + tools.ExecOptions{UserPwsh: "pwsh", Interactive: to.Ptr(true)}, + ) + + require.Error(t, execErr) + require.Contains(t, execErr.Error(), "neither pwsh nor powershell found") + }) +} + +func Test_Powershell_Integration_CheckPathFunction(t *testing.T) { + // Test the actual checkPath function behavior + t.Run("FallbackDisabled_PwshAvailable", func(t *testing.T) { + options := tools.ExecOptions{UserPwsh: "pwsh"} + // This may fail in environments where pwsh is not installed, which is expected + command, err := checkPath(options, false) + if err == nil { + require.Equal(t, "pwsh", command) + } + // If pwsh is not available, the function should return an error + }) + + t.Run("FallbackEnabled_BothAvailable_ShouldPreferPwsh", func(t *testing.T) { + options := tools.ExecOptions{UserPwsh: "pwsh"} + // This may fail in environments where pwsh is not installed + command, err := checkPath(options, true) + if err == nil { + require.Equal(t, "pwsh", command) + } + // If neither is available, the function should return an error + }) +} diff --git a/cli/azd/resources/alpha_features.yaml b/cli/azd/resources/alpha_features.yaml index 04004a9cd0f..85694185557 100644 --- a/cli/azd/resources/alpha_features.yaml +++ b/cli/azd/resources/alpha_features.yaml @@ -14,4 +14,6 @@ description: "Enables the use of `azd` extension packages." - id: llm description: "Enables the use of LLMs in the CLI." +- id: powershell.fallback5 + description: "Enable fallback to PowerShell 5 when PowerShell 7 is not available." \ No newline at end of file From dfdc4cc7831496da8decc021782e568a87d3eecc Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 7 Aug 2025 11:48:08 -0400 Subject: [PATCH 2/2] Improve PowerShell 5 fallback error handling and add comprehensive tests - Enhanced checkPath function to return descriptive error messages when both PowerShell 7 and PowerShell 5 fail - Added comprehensive test suite covering alpha feature detection, fallback scenarios, and error handling - Improved user experience with clear error messaging when fallback is enabled but both PowerShell versions are unavailable --- cli/azd/pkg/tools/powershell/powershell.go | 10 ++++++---- .../pkg/tools/powershell/powershell_test.go | 20 +++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/cli/azd/pkg/tools/powershell/powershell.go b/cli/azd/pkg/tools/powershell/powershell.go index 6d1a636812d..a2ef1f72118 100644 --- a/cli/azd/pkg/tools/powershell/powershell.go +++ b/cli/azd/pkg/tools/powershell/powershell.go @@ -84,14 +84,16 @@ func checkPath(options tools.ExecOptions, enableFallback bool) (command string, // If pwsh is not available and fallback is enabled, try powershell (PowerShell 5) if enableFallback { - err = tools.ToolInPath("powershell") - if err == nil { + ps5Err := tools.ToolInPath("powershell") + if ps5Err == nil { return "powershell", nil } + // Both failed, return a descriptive error + return "", fmt.Errorf("neither PowerShell 7 (%s) nor PowerShell 5 (powershell) found in PATH", pwshCommand) } - // Return original error if no fallback or both failed - return "", tools.ToolInPath(pwshCommand) + // Return original error if no fallback + return "", err } // Executes the specified powershell script diff --git a/cli/azd/pkg/tools/powershell/powershell_test.go b/cli/azd/pkg/tools/powershell/powershell_test.go index 44f3a62df00..3afeac4d452 100644 --- a/cli/azd/pkg/tools/powershell/powershell_test.go +++ b/cli/azd/pkg/tools/powershell/powershell_test.go @@ -253,7 +253,7 @@ func Test_Powershell_Fallback_To_PowerShell5(t *testing.T) { func(options tools.ExecOptions, enableFallback bool) (string, error) { // Simulate both pwsh and powershell not available require.True(t, enableFallback) - return "", errors.New("neither pwsh nor powershell found") + return "", errors.New("neither PowerShell 7 (pwsh) nor PowerShell 5 (powershell) found in PATH") }, alphaManager) @@ -264,7 +264,7 @@ func Test_Powershell_Fallback_To_PowerShell5(t *testing.T) { ) require.Error(t, execErr) - require.Contains(t, execErr.Error(), "neither pwsh nor powershell found") + require.Contains(t, execErr.Error(), "neither PowerShell 7 (pwsh) nor PowerShell 5 (powershell) found in PATH") }) } @@ -279,7 +279,7 @@ func Test_Powershell_Integration_CheckPathFunction(t *testing.T) { } // If pwsh is not available, the function should return an error }) - + t.Run("FallbackEnabled_BothAvailable_ShouldPreferPwsh", func(t *testing.T) { options := tools.ExecOptions{UserPwsh: "pwsh"} // This may fail in environments where pwsh is not installed @@ -289,4 +289,16 @@ func Test_Powershell_Integration_CheckPathFunction(t *testing.T) { } // If neither is available, the function should return an error }) -} + + t.Run("FallbackEnabled_ErrorMessage_ShouldMentionBoth", func(t *testing.T) { + options := tools.ExecOptions{UserPwsh: "nonexistent-pwsh-command"} + // Use a command that definitely doesn't exist + _, err := checkPath(options, true) + if err != nil { + // When fallback is enabled and both fail, error should mention both + require.Contains(t, err.Error(), "neither PowerShell 7") + require.Contains(t, err.Error(), "PowerShell 5") + require.Contains(t, err.Error(), "PATH") + } + }) +} \ No newline at end of file