Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment on lines +960 to +967

@tg-msft tg-msft Aug 12, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot - doing multiple searches is subpar. All of this logic (including the error message improvements) should be moved into FindExtensions rather than trying to call it multiple times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 511aacb. Missing-version detection and latest-version error details now come from FindExtensions during the same source enumeration, and the install path no longer performs a second lookup.


selectedExtension, err := selectDistinctExtension(ctx, a.console, extensionId, extensionMatches, a.flags.global)
if err != nil {
Expand Down Expand Up @@ -3355,6 +3368,59 @@ 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(
Expand Down
31 changes: 31 additions & 0 deletions cli/azd/cmd/extension_install_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading