From 22fb706dd8f3efce8641be2d923c2c381b8ebea5 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 19 Feb 2026 15:18:40 -0500 Subject: [PATCH] add actionable guidance for PowerShell hook failures (#6797) When PowerShell hooks fail, detect common failure patterns in stderr output and display targeted hints to help users resolve the issue. Patterns detected include missing modules, Az module not found, execution policy restrictions, and expired authentication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/middleware/error.go | 57 +++++++- cli/azd/cmd/middleware/pwsh_guidance_test.go | 125 ++++++++++++++++++ .../pkg/models/pending_upload.go | 20 +-- cli/azd/pkg/exec/runresult.go | 16 +++ 4 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 cli/azd/cmd/middleware/pwsh_guidance_test.go diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index 3b38cb4b8ec..1008e82227e 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/cmd/actions" @@ -18,6 +19,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "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/input" "github.com/azure/azure-dev/cli/azd/pkg/llm" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -69,7 +71,14 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action // - User specified --no-prompt (non-interactive mode) // - Running in CI/CD environment where user interaction is not possible if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() { - return next(ctx) + actionResult, err := next(ctx) + // Display actionable guidance even in non-interactive mode + if err != nil { + if guidance := pwshFailureGuidance(err); guidance != "" { + e.console.Message(ctx, output.WithWarningFormat("Hint: %s", guidance)) + } + } + return actionResult, err } actionResult, err := next(ctx) @@ -81,6 +90,11 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action return actionResult, err } + // Display actionable guidance for PowerShell hook failures + if guidance := pwshFailureGuidance(err); guidance != "" { + e.console.Message(ctx, output.WithWarningFormat("Hint: %s", guidance)) + } + // Error already has a suggestion, no need for AI var suggestionErr *internal.ErrorWithSuggestion if errors.As(err, &suggestionErr) { @@ -483,3 +497,44 @@ func promptUserForSolution(ctx context.Context, solutions []string, agentName st return selectedValue, false, nil // User selected a solution } } + +// pwshFailureGuidance returns an actionable suggestion for common PowerShell hook failures. +// It inspects the stderr output of the failed command to detect known failure patterns. +func pwshFailureGuidance(err error) string { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return "" + } + + cmdName := filepath.Base(exitErr.Cmd) + if !strings.Contains(strings.ToLower(cmdName), "pwsh") && + !strings.Contains(strings.ToLower(cmdName), "powershell") { + return "" + } + + stderr := exitErr.StderrOutput() + switch { + case strings.Contains(stderr, "Import-Module") && strings.Contains(stderr, "not loaded"): + return "A required PowerShell module could not be loaded. " + + "Install the missing module with 'Install-Module -Scope CurrentUser'." + case strings.Contains(stderr, "'Az.") && + (strings.Contains(stderr, "is not recognized") || strings.Contains(stderr, "not found")): + return "The Azure PowerShell module (Az) is required but not installed. " + + "Install it with 'Install-Module Az -Scope CurrentUser -Repository PSGallery -Force'." + case strings.Contains(stderr, "UnauthorizedAccess"): + return "PowerShell execution policy is blocking the script. " + + "Check your policy with 'Get-ExecutionPolicy' and consider setting it with " + + "'Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser'." + case strings.Contains(stderr, "Connect-AzAccount") && + (strings.Contains(stderr, "expired") || strings.Contains(stderr, "credentials")) && + !strings.Contains(stderr, "is not recognized") && !strings.Contains(stderr, "not found"): + return "The Azure authentication session may have expired. Try running 'azd auth login' again." + case strings.Contains(stderr, "login") && strings.Contains(stderr, "expired"): + return "The Azure authentication session may have expired. Try running 'azd auth login' again." + case strings.Contains(stderr, "ErrorActionPreference"): + return "The hook script has an issue with error handling configuration. " + + "Ensure '$ErrorActionPreference = \"Stop\"' is set at the top of the script." + default: + return "" + } +} diff --git a/cli/azd/cmd/middleware/pwsh_guidance_test.go b/cli/azd/cmd/middleware/pwsh_guidance_test.go new file mode 100644 index 00000000000..5df93222f06 --- /dev/null +++ b/cli/azd/cmd/middleware/pwsh_guidance_test.go @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "fmt" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/stretchr/testify/require" +) + +func Test_pwshFailureGuidance(t *testing.T) { + tests := []struct { + name string + err error + wantHint string + }{ + { + name: "NonExitError", + err: fmt.Errorf("some error"), + wantHint: "", + }, + { + name: "NonPwshCommand", + err: exec.NewTestExitError("node", 1, "Import-Module: not loaded"), + wantHint: "", + }, + { + name: "MissingModule", + err: exec.NewTestExitError( + "pwsh", 1, + "Import-Module: The specified module 'Az.CognitiveServices' was not loaded"+ + " because no valid module file was found.", + ), + wantHint: "A required PowerShell module could not be loaded. " + + "Install the missing module with 'Install-Module -Scope CurrentUser'.", + }, + { + name: "AzModuleNotFound", + err: exec.NewTestExitError( + "pwsh", 1, + "The term 'Az.Accounts' is not recognized as a name of a cmdlet", + ), + wantHint: "The Azure PowerShell module (Az) is required but not installed. " + + "Install it with 'Install-Module Az -Scope CurrentUser -Repository PSGallery -Force'.", + }, + { + name: "ExecutionPolicy", + err: exec.NewTestExitError( + "powershell.exe", 1, + "UnauthorizedAccess: File script.ps1 cannot be loaded.", + ), + wantHint: "PowerShell execution policy is blocking the script. " + + "Check your policy with 'Get-ExecutionPolicy' and consider setting it with " + + "'Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser'.", + }, + { + name: "AuthExpired", + err: exec.NewTestExitError( + "pwsh", 1, + "Please run Connect-AzAccount to set up your credentials.", + ), + wantHint: "The Azure authentication session may have expired. " + + "Try running 'azd auth login' again.", + }, + { + name: "ErrorActionPreference", + err: exec.NewTestExitError( + "pwsh", 1, + "ErrorActionPreference is set incorrectly", + ), + wantHint: "The hook script has an issue with error handling configuration. " + + "Ensure '$ErrorActionPreference = \"Stop\"' is set at the top of the script.", + }, + { + name: "ConnectAzAccountNotRecognized", + err: exec.NewTestExitError( + "pwsh", 1, + "Connect-AzAccount: The term 'Connect-AzAccount' is not recognized as a name of a cmdlet", + ), + wantHint: "", + }, + { + name: "LoginExpired", + err: exec.NewTestExitError( + "pwsh", 1, + "login token has expired", + ), + wantHint: "The Azure authentication session may have expired. " + + "Try running 'azd auth login' again.", + }, + { + name: "NonAzureAzPrefixedNotFound", + err: exec.NewTestExitError( + "pwsh", 1, + "The term 'MyAz.Custom' is not recognized as a name of a cmdlet", + ), + wantHint: "", + }, + { + name: "ConnectAzAccountSucceeded", + err: exec.NewTestExitError( + "pwsh", 1, + "Connect-AzAccount succeeded but something else failed", + ), + wantHint: "", + }, + { + name: "NoMatchingPattern", + err: exec.NewTestExitError( + "pwsh", 1, + "Some random error occurred", + ), + wantHint: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pwshFailureGuidance(tt.err) + require.Equal(t, tt.wantHint, got) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.models/pkg/models/pending_upload.go b/cli/azd/extensions/azure.ai.models/pkg/models/pending_upload.go index 0e05fbf9c5a..1af9b559ed1 100644 --- a/cli/azd/extensions/azure.ai.models/pkg/models/pending_upload.go +++ b/cli/azd/extensions/azure.ai.models/pkg/models/pending_upload.go @@ -5,20 +5,20 @@ package models // PendingUploadResponse is the API response from startPendingUpload. type PendingUploadResponse struct { - BlobReferenceForConsumption *BlobReferenceForConsumption `json:"blobReferenceForConsumption"` - ImageReferenceForConsumption interface{} `json:"imageReferenceForConsumption"` - TemporaryDataReferenceID string `json:"temporaryDataReferenceId"` - TemporaryDataReferenceType *string `json:"temporaryDataReferenceType"` + BlobReferenceForConsumption *BlobReferenceForConsumption `json:"blobReferenceForConsumption"` + ImageReferenceForConsumption interface{} `json:"imageReferenceForConsumption"` + TemporaryDataReferenceID string `json:"temporaryDataReferenceId"` + TemporaryDataReferenceType *string `json:"temporaryDataReferenceType"` } // BlobReferenceForConsumption contains the blob storage details for upload. type BlobReferenceForConsumption struct { - BlobURI string `json:"blobUri"` - StorageAccountArmID string `json:"storageAccountArmId"` - Credential BlobCredential `json:"credential"` - IsSingleFile bool `json:"isSingleFile"` - BlobManifestDigest *string `json:"blobManifestDigest"` - ConnectionName *string `json:"connectionName"` + BlobURI string `json:"blobUri"` + StorageAccountArmID string `json:"storageAccountArmId"` + Credential BlobCredential `json:"credential"` + IsSingleFile bool `json:"isSingleFile"` + BlobManifestDigest *string `json:"blobManifestDigest"` + ConnectionName *string `json:"connectionName"` } // BlobCredential contains the SAS credential for blob upload. diff --git a/cli/azd/pkg/exec/runresult.go b/cli/azd/pkg/exec/runresult.go index 2c533c84299..dcaa291ac4e 100644 --- a/cli/azd/pkg/exec/runresult.go +++ b/cli/azd/pkg/exec/runresult.go @@ -76,3 +76,19 @@ func (e *ExitError) Error() string { return fmt.Sprintf("%s, stdout: %s, stderr: %s", errorPrefix, e.stdOut, e.stdErr) } + +// StderrOutput returns the stderr output captured from the command. +func (e *ExitError) StderrOutput() string { + return e.stdErr +} + +// NewTestExitError creates an ExitError suitable for unit tests +// where constructing an os/exec.ExitError is impractical. +func NewTestExitError(cmd string, exitCode int, stderr string) *ExitError { + return &ExitError{ + Cmd: cmd, + ExitCode: exitCode, + stdErr: stderr, + outputAvailable: true, + } +}