From 4ee957fc193a7b23d9ace87ea6c151f7e8ca5e25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:16:07 +0000 Subject: [PATCH 1/4] Initial plan From 481af35a9e1532cc58d619d1b018d2962478de2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:23:08 +0000 Subject: [PATCH 2/4] Improve missing extension version error Co-authored-by: tg-msft <1179329+tg-msft@users.noreply.github.com> --- cli/azd/cmd/extension.go | 68 ++++++++++++++++++++ cli/azd/cmd/extension_install_source_test.go | 31 +++++++++ 2 files changed, 99 insertions(+) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 7d00b672f28..f4f2d34c34a 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -962,6 +962,19 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult a.console.StopSpinner(ctx, stepMessage, input.StepFailed) return nil, fmt.Errorf("failed to find extension: %w", err) } + if len(extensionMatches) == 0 { + unversionedOptions := *filterOptions + unversionedOptions.Version = "" + unversionedMatches, err := a.extensionManager.FindExtensions(ctx, &unversionedOptions) + if err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, fmt.Errorf("failed to find extension: %w", err) + } + if err := extensionVersionNotFoundError(extensionId, a.flags.source, a.flags.version, unversionedMatches); err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, err + } + } selectedExtension, err := selectDistinctExtension(ctx, a.console, extensionId, extensionMatches, a.flags.global) if err != nil { @@ -3355,6 +3368,61 @@ func defaultExtensionSourceIndex(matches []*extensions.ExtensionMetadata) *int { return new(0) } +func extensionVersionNotFoundError( + extensionId string, + source string, + version string, + matches []*extensions.ExtensionMetadata, +) error { + if version == "" || strings.EqualFold(version, "latest") || len(matches) == 0 { + return nil + } + + latestVersions := make([]string, 0, len(matches)) + for _, match := range matches { + latestVersion := extensions.LatestVersion(match.Versions) + if latestVersion == nil { + continue + } + + if len(matches) == 1 { + latestVersions = append(latestVersions, latestVersion.Version) + } else { + latestVersions = append(latestVersions, fmt.Sprintf("%s: %s", match.Source, latestVersion.Version)) + } + } + if len(latestVersions) == 0 { + return nil + } + + if len(latestVersions) == 1 { + command := fmt.Sprintf("azd extension install %s --version %s", extensionId, latestVersions[0]) + if source != "" { + command += fmt.Sprintf(" --source %s", source) + } + + message := fmt.Sprintf( + "extension '%s' version '%s' was not found; latest version is '%s'", + extensionId, version, latestVersions[0], + ) + return &internal.ErrorWithSuggestion{ + Err: errors.New(message), + Message: message, + Suggestion: fmt.Sprintf("Run '%s' to install the latest version.", command), + } + } + + message := fmt.Sprintf( + "extension '%s' version '%s' was not found; latest versions are %s", + extensionId, version, strings.Join(latestVersions, ", "), + ) + return &internal.ErrorWithSuggestion{ + Err: errors.New(message), + Message: message, + Suggestion: "Specify the extension source using the --source flag, or choose an available version.", + } +} + // checkNamespaceConflict checks if the given namespace conflicts with any installed extension. // Two namespaces conflict if one is a prefix of the other (e.g., "ai" and "ai.agent"). func checkNamespaceConflict( diff --git a/cli/azd/cmd/extension_install_source_test.go b/cli/azd/cmd/extension_install_source_test.go index c876c3d1065..6a061d630a1 100644 --- a/cli/azd/cmd/extension_install_source_test.go +++ b/cli/azd/cmd/extension_install_source_test.go @@ -397,6 +397,37 @@ func TestResolveSourceLocation_NoPromptFileDirectsToSourceAdd(t *testing.T) { } } +func TestExtensionInstall_MissingVersionReportsLatest(t *testing.T) { + t.Parallel() + + action, _ := newBundleInstallTestAction(t) + registryPath := writeRegistryFile(t) + require.NoError(t, action.sourceManager.Add(t.Context(), "local-dev", &extensions.SourceConfig{ + Name: "local-dev", + Type: extensions.SourceKindFile, + Location: registryPath, + })) + action.args = []string{"test.ext"} + action.flags.source = "local-dev" + action.flags.version = "0.1.0" + + _, err := action.Run(t.Context()) + require.Error(t, err) + require.Contains( + t, + err.Error(), + "extension 'test.ext' version '0.1.0' was not found; latest version is '1.0.0'", + ) + + var errWithSuggestion *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &errWithSuggestion) + require.Contains( + t, + errWithSuggestion.Suggestion, + "azd extension install test.ext --version 1.0.0 --source local-dev", + ) +} + func newInstallSourceTestAction(t *testing.T) (*extensionInstallAction, *mocks.MockContext) { t.Helper() From 12d25c890e72453cd4df844375b5677468247f0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:26:07 +0000 Subject: [PATCH 3/4] Avoid duplicate extension error details Co-authored-by: tg-msft <1179329+tg-msft@users.noreply.github.com> --- cli/azd/cmd/extension.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index f4f2d34c34a..fd852e5acbb 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -3407,7 +3407,6 @@ func extensionVersionNotFoundError( ) return &internal.ErrorWithSuggestion{ Err: errors.New(message), - Message: message, Suggestion: fmt.Sprintf("Run '%s' to install the latest version.", command), } } @@ -3418,7 +3417,6 @@ func extensionVersionNotFoundError( ) return &internal.ErrorWithSuggestion{ Err: errors.New(message), - Message: message, Suggestion: "Specify the extension source using the --source flag, or choose an available version.", } } From 511aacb42e64a03ea4218ccfeefbe0320cbd10c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:52:48 +0000 Subject: [PATCH 4/4] Move extension version lookup errors into manager Co-authored-by: tg-msft <1179329+tg-msft@users.noreply.github.com> --- cli/azd/cmd/extension.go | 124 ++++--------------------- cli/azd/pkg/extensions/manager.go | 121 ++++++++++++++++++++---- cli/azd/pkg/extensions/manager_test.go | 35 +++++++ 3 files changed, 155 insertions(+), 125 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index fd852e5acbb..e457777abfd 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -960,20 +960,10 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult extensionMatches, err := a.extensionManager.FindExtensions(ctx, filterOptions) if err != nil { a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return nil, fmt.Errorf("failed to find extension: %w", err) - } - if len(extensionMatches) == 0 { - unversionedOptions := *filterOptions - unversionedOptions.Version = "" - unversionedMatches, err := a.extensionManager.FindExtensions(ctx, &unversionedOptions) - if err != nil { - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return nil, fmt.Errorf("failed to find extension: %w", err) - } - if err := extensionVersionNotFoundError(extensionId, a.flags.source, a.flags.version, unversionedMatches); err != nil { - a.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return nil, err + if _, ok := errors.AsType[*extensions.ExtensionVersionNotFoundError](err); ok { + return nil, wrapDependencyError(err) } + return nil, fmt.Errorf("failed to find extension: %w", err) } selectedExtension, err := selectDistinctExtension(ctx, a.console, extensionId, extensionMatches, a.flags.global) @@ -1253,18 +1243,18 @@ func (a *extensionInstallAction) confirmReplace( return true, nil } -// wrapDependencyError augments dependency resolution failures with actionable -// guidance. Other errors pass through unchanged. +// wrapDependencyError augments extension resolution failures with actionable +// guidance when available. Other errors pass through unchanged. func wrapDependencyError(err error) error { - type dependencyErrorWithSuggestion interface { + type errorWithSuggestion interface { error Suggestion() string } - if depErr, ok := errors.AsType[dependencyErrorWithSuggestion](err); ok { + if suggestionErr, ok := errors.AsType[errorWithSuggestion](err); ok { return &internal.ErrorWithSuggestion{ - Err: depErr, - Suggestion: depErr.Suggestion(), + Err: suggestionErr, + Suggestion: suggestionErr.Suggestion(), } } @@ -2446,39 +2436,14 @@ func (a *extensionUpgradeAction) upgradeOneExtension( allMatchOptions.Source = a.flags.source } - versionMismatchError := func() error { - if a.flags.version == "" || strings.EqualFold(a.flags.version, "latest") { - return nil - } - - unversionedOptions := *allMatchOptions - unversionedOptions.Version = "" - unversionedMatches, err := a.extensionManager.FindExtensions( - ctx, &unversionedOptions, - ) - if err != nil { - if isNetworkError(err) { - return fmt.Errorf( - "network error looking up extension %s "+ - "(check your connection and retry): %w", - extensionId, err, - ) - } - return fmt.Errorf( - "failed to find extension %s: %w", extensionId, err, - ) - } - + versionMismatchError := func(unversionedMatches []*extensions.ExtensionMetadata) error { res := extensions.ResolveUpgradeSource( installed, unversionedMatches, a.flags.source, ) if res == nil { - if len(unversionedMatches) > 0 { - return upgradeSourceResolutionError( - extensionId, a.flags.source, installed.Source, - ) - } - return nil + return upgradeSourceResolutionError( + extensionId, a.flags.source, installed.Source, + ) } return upgradeVersionResolutionError( extensionId, a.flags.version, res.NewSource, @@ -2489,6 +2454,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( ctx, allMatchOptions, ) if err != nil { + if versionErr, ok := errors.AsType[*extensions.ExtensionVersionNotFoundError](err); ok { + return fail(versionMismatchError(versionErr.Matches)) + } if isNetworkError(err) { return fail(fmt.Errorf( "network error looking up extension %s "+ @@ -2501,10 +2469,6 @@ func (a *extensionUpgradeAction) upgradeOneExtension( )) } if len(matches) == 0 { - if err := versionMismatchError(); err != nil { - return fail(err) - } - // Explicit --source miss: neither the requested version nor any other // version of the extension exists in that source. if a.flags.source != "" && !a.flags.all { @@ -2549,9 +2513,6 @@ func (a *extensionUpgradeAction) upgradeOneExtension( installed, matches, a.flags.source, ) if res == nil { - if err := versionMismatchError(); err != nil { - return fail(err) - } return fail(upgradeSourceResolutionError( extensionId, a.flags.source, installed.Source, )) @@ -3368,59 +3329,6 @@ func defaultExtensionSourceIndex(matches []*extensions.ExtensionMetadata) *int { return new(0) } -func extensionVersionNotFoundError( - extensionId string, - source string, - version string, - matches []*extensions.ExtensionMetadata, -) error { - if version == "" || strings.EqualFold(version, "latest") || len(matches) == 0 { - return nil - } - - latestVersions := make([]string, 0, len(matches)) - for _, match := range matches { - latestVersion := extensions.LatestVersion(match.Versions) - if latestVersion == nil { - continue - } - - if len(matches) == 1 { - latestVersions = append(latestVersions, latestVersion.Version) - } else { - latestVersions = append(latestVersions, fmt.Sprintf("%s: %s", match.Source, latestVersion.Version)) - } - } - if len(latestVersions) == 0 { - return nil - } - - if len(latestVersions) == 1 { - command := fmt.Sprintf("azd extension install %s --version %s", extensionId, latestVersions[0]) - if source != "" { - command += fmt.Sprintf(" --source %s", source) - } - - message := fmt.Sprintf( - "extension '%s' version '%s' was not found; latest version is '%s'", - extensionId, version, latestVersions[0], - ) - return &internal.ErrorWithSuggestion{ - Err: errors.New(message), - Suggestion: fmt.Sprintf("Run '%s' to install the latest version.", command), - } - } - - message := fmt.Sprintf( - "extension '%s' version '%s' was not found; latest versions are %s", - extensionId, version, strings.Join(latestVersions, ", "), - ) - return &internal.ErrorWithSuggestion{ - Err: errors.New(message), - Suggestion: "Specify the extension source using the --source flag, or choose an available version.", - } -} - // checkNamespaceConflict checks if the given namespace conflicts with any installed extension. // Two namespaces conflict if one is a prefix of the other (e.g., "ai" and "ai.agent"). func checkNamespaceConflict( diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 7d69a33c011..ac798d1212a 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -97,6 +97,80 @@ func (e *DependencyVersionNotFoundError) Suggestion() string { ) } +// ExtensionVersionNotFoundError indicates an extension exists but does not have +// a version matching the requested version filter. +type ExtensionVersionNotFoundError struct { + // ExtensionId is the id of the extension without a matching version. + ExtensionId string + // Version is the requested version or constraint that could not be matched. + Version string + // Source is the requested source, if one was provided. + Source string + // Matches contains the extension metadata found without the version filter. + Matches []*ExtensionMetadata +} + +func (e *ExtensionVersionNotFoundError) Error() string { + latestVersions := e.latestVersions() + if len(latestVersions) == 1 { + return fmt.Sprintf( + "extension '%s' version '%s' was not found; latest version is '%s'", + e.ExtensionId, e.Version, latestVersions[0].Version, + ) + } + + displayVersions := make([]string, 0, len(latestVersions)) + for _, latest := range latestVersions { + displayVersions = append(displayVersions, fmt.Sprintf("%s: %s", latest.Source, latest.Version)) + } + + return fmt.Sprintf( + "extension '%s' version '%s' was not found; latest versions are %s", + e.ExtensionId, e.Version, strings.Join(displayVersions, ", "), + ) +} + +// Suggestion returns actionable guidance for installing an available version. +func (e *ExtensionVersionNotFoundError) Suggestion() string { + latestVersions := e.latestVersions() + if len(latestVersions) == 1 { + latest := latestVersions[0] + command := fmt.Sprintf("azd extension install %s --version %s", e.ExtensionId, latest.Version) + source := e.Source + if source == "" && !strings.EqualFold(latest.Source, MainRegistryName) { + source = latest.Source + } + if source != "" { + command += fmt.Sprintf(" --source %s", source) + } + + return fmt.Sprintf("Run '%s' to install the latest version.", command) + } + + return "Specify the extension source using the --source flag, or choose an available version." +} + +type extensionLatestVersion struct { + Source string + Version string +} + +func (e *ExtensionVersionNotFoundError) latestVersions() []extensionLatestVersion { + latestVersions := make([]extensionLatestVersion, 0, len(e.Matches)) + for _, match := range e.Matches { + latestVersion := LatestVersion(match.Versions) + if latestVersion == nil { + continue + } + latestVersions = append(latestVersions, extensionLatestVersion{ + Source: match.Source, + Version: latestVersion.Version, + }) + } + + return latestVersions +} + // DependencyAzdVersionIncompatibleError indicates that dependency versions // satisfy the parent's constraint, but none support the running azd version. type DependencyAzdVersionIncompatibleError struct { @@ -329,7 +403,7 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { } // Check Version filter - extension must have at least one matching version. - if options.Version != "" && options.Version != "latest" { + if hasExplicitVersionFilter(options) { hasVersion := slices.ContainsFunc(extension.Versions, func(version ExtensionVersion) bool { return matchesVersionConstraint(options.Version, version.Version) }) @@ -388,6 +462,10 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { } } +func hasExplicitVersionFilter(options *FilterOptions) bool { + return options.Version != "" && !strings.EqualFold(options.Version, "latest") +} + // Manager is responsible for managing extensions type Manager struct { sourceManager *SourceManager @@ -505,6 +583,7 @@ func (m *Manager) UpdateInstalled(extension *Extension) error { func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([]*ExtensionMetadata, error) { allExtensions := []*ExtensionMetadata{} + versionMismatches := []*ExtensionMetadata{} if options == nil { options = &FilterOptions{} @@ -526,6 +605,12 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ // Use the centralized extension filter extensionFilter := createExtensionFilter(filterOptions) + var versionlessFilter extensionFilterPredicate + if filterOptions.Id != "" && hasExplicitVersionFilter(filterOptions) { + versionlessOptions := *filterOptions + versionlessOptions.Version = "" + versionlessFilter = createExtensionFilter(&versionlessOptions) + } var sources []Source var err error @@ -555,6 +640,8 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ for _, extension := range sourceExtensions { if extensionFilter(extension) { filteredExtensions = append(filteredExtensions, extension) + } else if versionlessFilter != nil && versionlessFilter(extension) { + versionMismatches = append(versionMismatches, extension) } } @@ -570,6 +657,15 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ allExtensions = append(allExtensions, filteredExtensions...) } + if len(allExtensions) == 0 && len(versionMismatches) > 0 { + return nil, &ExtensionVersionNotFoundError{ + ExtensionId: filterOptions.Id, + Version: filterOptions.Version, + Source: filterOptions.Source, + Matches: versionMismatches, + } + } + return allExtensions, nil } @@ -693,26 +789,17 @@ func (m *Manager) installInternal( dependencyMatches, err := m.FindExtensions(ctx, dependencyOptions) if err != nil { + if _, ok := errors.AsType[*ExtensionVersionNotFoundError](err); ok { + return nil, &DependencyVersionNotFoundError{ + DependencyId: dependency.Id, + ParentId: extension.Id, + Constraint: dependency.Version, + } + } return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) } if len(dependencyMatches) == 0 { - if dependency.Version != "" && !strings.EqualFold(dependency.Version, "latest") { - unconstrainedOptions := *dependencyOptions - unconstrainedOptions.Version = "" - unconstrainedMatches, err := m.FindExtensions(ctx, &unconstrainedOptions) - if err != nil { - return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) - } - if len(unconstrainedMatches) > 0 { - return nil, &DependencyVersionNotFoundError{ - DependencyId: dependency.Id, - ParentId: extension.Id, - Constraint: dependency.Version, - } - } - } - return nil, &DependencyNotFoundError{DependencyId: dependency.Id, ParentId: extension.Id} } diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 010885c49d4..ef04b1d0af1 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -381,6 +381,41 @@ func Test_MatchesVersionConstraint(t *testing.T) { } } +func Test_FindExtensions_MissingVersionReportsLatest(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + createRegistryMocks(mockContext) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + matches, err := manager.FindExtensions(t.Context(), &FilterOptions{ + Id: "test.extension", + Version: "0.1.0", + }) + require.Error(t, err) + require.Nil(t, matches) + + var versionErr *ExtensionVersionNotFoundError + require.ErrorAs(t, err, &versionErr) + require.Equal(t, "test.extension", versionErr.ExtensionId) + require.Equal(t, "0.1.0", versionErr.Version) + require.Contains( + t, + err.Error(), + "extension 'test.extension' version '0.1.0' was not found; latest version is '3.1.0'", + ) + require.Contains( + t, + versionErr.Suggestion(), + "azd extension install test.extension --version 3.1.0", + ) +} + func TestResolveExtensionVersionNil(t *testing.T) { version, err := ResolveExtensionVersion(nil, "", nil)