diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index f1c136d9aa8..7b679e3ebc4 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" @@ -71,6 +73,9 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() { actionResult, err := next(ctx) if err != nil { + if guidance := pwshFailureGuidance(err); guidance != "" { + e.console.Message(ctx, output.WithWarningFormat("Hint: %s", guidance)) + } if guidance := softDeleteHint(err); guidance != "" { e.console.Message(ctx, output.WithWarningFormat("Hint: %s", guidance)) @@ -88,6 +93,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) { @@ -496,3 +506,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, + } +}