From fd86df8490ef918f6cc0a4e066c3a325ea99ad33 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 22:38:55 +0000 Subject: [PATCH 01/14] feat(extensions): redesign auto-install UX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install.go | 251 ++++---- cli/azd/cmd/auto_install_test.go | 82 ++- cli/azd/cmd/auto_install_ux.go | 580 ++++++++++++++++++ cli/azd/cmd/auto_install_ux_test.go | 440 +++++++++++++ cli/azd/cmd/project_extension_auto_install.go | 180 ++++-- .../extension-resolution-and-versioning.md | 10 +- 6 files changed, 1352 insertions(+), 191 deletions(-) create mode 100644 cli/azd/cmd/auto_install_ux.go create mode 100644 cli/azd/cmd/auto_install_ux_test.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index f1cfa39afdc..23c4ef2cd83 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + "maps" "os" "path/filepath" "slices" @@ -26,7 +27,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/output" - "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/update" "github.com/spf13/cobra" @@ -214,6 +214,55 @@ func promptForExtensionChoice( return matches[choice], nil } +// chooseLogicalExtensionCandidates separates the rare choice between different extension IDs from +// source selection. All source candidates for the selected logical extension are preserved so the +// auto-install UX can present them after discovery is complete. +func chooseLogicalExtensionCandidates( + ctx context.Context, + console input.Console, + matches []*extensions.ExtensionMetadata, +) ([]*extensions.ExtensionMetadata, error) { + if len(matches) == 0 { + return nil, fmt.Errorf("no extensions to choose from") + } + + grouped := map[string][]*extensions.ExtensionMetadata{} + for _, match := range matches { + id := strings.ToLower(match.Id) + grouped[id] = append(grouped[id], match) + } + for id := range maps.Keys(grouped) { + slices.SortFunc(grouped[id], func(a, b *extensions.ExtensionMetadata) int { + return strings.Compare(strings.ToLower(a.Source), strings.ToLower(b.Source)) + }) + } + + ids := slices.Sorted(maps.Keys(grouped)) + if len(ids) == 1 { + return grouped[ids[0]], nil + } + + representatives := make([]*extensions.ExtensionMetadata, 0, len(ids)) + for _, id := range ids { + representatives = append(representatives, grouped[id][0]) + } + chosen, err := promptForExtensionChoice(ctx, console, representatives) + if err != nil { + return nil, err + } + return grouped[strings.ToLower(chosen.Id)], nil +} + +func requirementCandidates(requirement projectExtensionRequirement) []*extensions.ExtensionMetadata { + if len(requirement.candidates) > 0 { + return requirement.candidates + } + if requirement.extension == nil { + return nil + } + return []*extensions.ExtensionMetadata{requirement.extension} +} + // isBuiltInCommand checks if the given command is a built-in command by examining // the root command's command tree. This includes both core azd commands and any // installed extensions, preventing auto-install from triggering for known commands. @@ -286,10 +335,10 @@ func tryAutoInstallForPartialNamespace( rootContainer *ioc.NestedContainer, foundCmd *cobra.Command, remainingArgs []string, -) bool { +) (autoInstallResult, error) { if _, isExtensionCmd := foundCmd.Annotations["extension.id"]; isExtensionCmd { // Extension commands handle their own args via DisableFlagParsing - return false + return autoInstallResult{}, nil } var firstRemainingArg string @@ -301,64 +350,44 @@ func tryAutoInstallForPartialNamespace( } if firstRemainingArg == "" || hasSubcommand(foundCmd, firstRemainingArg) { - return false + return autoInstallResult{}, nil } argsForMatching := buildNamespaceArgs(foundCmd, remainingArgs) if len(argsForMatching) == 0 { - return false + return autoInstallResult{}, nil } var extensionManager *extensions.Manager var console input.Console if err := rootContainer.Resolve(&extensionManager); err != nil { log.Printf("failed to resolve extension manager: %v", err) - return false + return autoInstallResult{}, nil } if err := rootContainer.Resolve(&console); err != nil { log.Printf("failed to resolve console: %v", err) - return false + return autoInstallResult{}, nil } extensionMatches, err := checkForMatchingExtensions(ctx, extensionManager, argsForMatching) if err != nil { log.Printf("failed to check for matching extensions: %v", err) - return false + return autoInstallResult{}, nil } if len(extensionMatches) == 0 { - return false - } - - console.Message(ctx, - fmt.Sprintf("Command '%s' was not found, but there's an available extension that provides it\n", - strings.Join(argsForMatching, " "))) - - chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return false - } - if chosenExtension == nil { - return false - } - - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *chosenExtension) - if installErr != nil { - console.Message(ctx, installErr.Error()) - return false - } - - return installed -} - -// tryAutoInstallExtension attempts to auto-install an extension if the unknown command matches an available -// extension namespace. Returns true if an extension was found and installed, false otherwise. -func tryAutoInstallExtension( - ctx context.Context, - console input.Console, - extensionManager extensionAutoInstallManager, - extension extensions.ExtensionMetadata) (bool, error) { - return tryAutoInstallExtensionVersion(ctx, console, extensionManager, extension, "") + return autoInstallResult{}, nil + } + + return autoInstallCommandMatches( + ctx, + console, + extensionManager, + extensionMatches, + fmt.Sprintf( + "Command '%s' isn't available. Install the required extension to use this command.", + strings.Join(argsForMatching, " "), + ), + ) } type extensionAutoInstallManager interface { @@ -390,44 +419,16 @@ func tryAutoInstallExtensionVersion( return false, nil } - // Return error if running in CI/CD environment - if resource.IsRunningOnCI() { - return false, - fmt.Errorf( - "Auto-installation is not supported in CI/CD environments.\n"+ - "Run '%s' to install it manually.", - fmt.Sprintf("azd extension install %s", extension.Id)) - } - - console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: "You are about to install an extension!", - }) - console.Message(ctx, fmt.Sprintf("Source: %s", extension.Source)) - console.Message(ctx, fmt.Sprintf("Id: %s", extension.Id)) - console.Message(ctx, fmt.Sprintf("Name: %s", extension.DisplayName)) - console.Message(ctx, fmt.Sprintf("Description: %s", extension.Description)) - - // Ask user for permission to auto-install the extension - shouldInstall, err := console.Confirm(ctx, input.ConsoleOptions{ - DefaultValue: true, - Message: "Confirm installation", - }) - if err != nil { - return false, err - } - - if !shouldInstall { - return false, nil - } - - // Install the extension - console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", extension.Id)) - _, err = extensionManager.Install(ctx, &extension, versionPreference) + stepMessage := fmt.Sprintf("Installing extension '%s'", extension.Id) + console.ShowSpinner(ctx, stepMessage, input.Step) + installedVersion, err := extensionManager.Install(ctx, &extension, versionPreference) if err != nil { + console.StopSpinner(ctx, stepMessage, input.StepFailed) return false, fmt.Errorf("failed to install extension: %w", err) } - console.Message(ctx, fmt.Sprintf("Extension '%s' installed successfully!\n", extension.Id)) + stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) + console.StopSpinner(ctx, stepMessage, input.StepDone) return true, nil } @@ -610,6 +611,9 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai result.Err = err return result } + if projectExtensions.declined { + return result + } if projectExtensions.installed { rootCmd = newRootCmdWithoutRegistration(rootContainer) @@ -622,9 +626,22 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai } // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) - if installed := tryAutoInstallForPartialNamespace( + partialNamespace, partialErr := tryAutoInstallForPartialNamespace( ctx, rootContainer, foundCmd, originalArgs, - ); installed { + ) + if partialErr != nil { + if resolveErr := rootContainer.Resolve(&console); resolveErr != nil { + fmt.Fprintln(os.Stderr, output.WithErrorFormat("ERROR: %s", partialErr.Error())) + } else { + displayAutoInstallError(ctx, console, partialErr) + } + result.Err = partialErr + return result + } + if partialNamespace.declined { + return result + } + if partialNamespace.installed { // Extension was installed, rebuild command tree and execute rootCmd = newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) @@ -694,41 +711,30 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai return result } - console.Message(ctx, - fmt.Sprintf("Your project is using host '%s' which is not supported by default.\n", unsupportedErr.Host)) - - var extensionIdToInstall extensions.ExtensionMetadata - if len(availableExtensionsForHost) == 1 { - extensionIdToInstall = *availableExtensionsForHost[0] - console.Message(ctx, "An extension was found that provides support for this host.") - } else { - console.Message(ctx, "There are multiple extensions that provide support for this host.") - // Multiple matches found, prompt user to choose - chosenExtension, err := promptForExtensionChoice(ctx, console, availableExtensionsForHost) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - result.Err = err - return result - } - extensionIdToInstall = *chosenExtension - } - - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, extensionIdToInstall) + autoInstall, installErr := autoInstallCommandMatches( + ctx, + console, + extensionManager, + availableExtensionsForHost, + fmt.Sprintf( + "Your project requires support for host '%s'. Install the required extension to continue.", + unsupportedErr.Host, + ), + ) if installErr != nil { - // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime - console.Message(ctx, installErr.Error()) + displayAutoInstallError(ctx, console, installErr) result.Err = installErr return result } - - if installed { + if autoInstall.declined { + return result + } + if autoInstall.installed { // Extension was installed, build command tree and execute rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) return result } - - // The install was declined, so the command's failure stands. result.Err = commandErr return result } @@ -822,34 +828,25 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai log.Panic("failed to resolve console for auto-install:", err) } - console.Message(ctx, - fmt.Sprintf("Command '%s' was not found, but there's an available extension that provides it\n", - strings.Join(argsForMatching, " "))) - - // Prompt user to choose if multiple extensions match - chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - result.Err = rootCmd.ExecuteContext(ctx) - return result - } - - if chosenExtension == nil { - // User cancelled selection, proceed to normal execution - result.Err = rootCmd.ExecuteContext(ctx) - return result - } - - // Try to auto-install the chosen extension - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *chosenExtension) + autoInstall, installErr := autoInstallCommandMatches( + ctx, + console, + extensionManager, + extensionMatches, + fmt.Sprintf( + "Command '%s' isn't available. Install the required extension to use this command.", + strings.Join(argsForMatching, " "), + ), + ) if installErr != nil { - // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime - console.Message(ctx, installErr.Error()) + displayAutoInstallError(ctx, console, installErr) result.Err = installErr return result } - - if installed { + if autoInstall.declined { + return result + } + if autoInstall.installed { // Extension was installed, build command tree and execute rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 19ce1fd34a7..a8dada1b1fe 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -28,9 +28,10 @@ import ( ) type fakeExtensionAutoInstallManager struct { - available []*extensions.ExtensionMetadata - installed map[string]*extensions.Extension - findErr error + available []*extensions.ExtensionMetadata + installed map[string]*extensions.Extension + findErr error + installErr error } func (m *fakeExtensionAutoInstallManager) FindExtensions( @@ -91,6 +92,9 @@ func (m *fakeExtensionAutoInstallManager) Install( extension *extensions.ExtensionMetadata, _ string, ) (*extensions.ExtensionVersion, error) { + if m.installErr != nil { + return nil, m.installErr + } version := &extension.Versions[0] m.installed[extension.Id] = &extensions.Extension{ Id: extension.Id, @@ -240,7 +244,7 @@ func TestMissingProjectExtensionsSkipsInstalledProviderAcrossSources(t *testing. require.Empty(t, requirements) } -func TestMissingProjectExtensionsReusesSourceChoiceAcrossProviders(t *testing.T) { +func TestMissingProjectExtensionsPreservesSourceCandidatesAcrossProviders(t *testing.T) { providerVersion := extensions.ExtensionVersion{ Version: "0.7.0", Capabilities: []extensions.CapabilityType{ @@ -273,19 +277,14 @@ func TestMissingProjectExtensionsReusesSourceChoiceAcrossProviders(t *testing.T) }, Infra: provisioning.Options{Provider: "demo"}, } - selectCount := 0 console := mockinput.NewMockConsole() - console.WhenSelect(func(options input.ConsoleOptions) bool { - selectCount++ - return true - }).Respond(0) requirements, err := missingProjectExtensions(t.Context(), console, manager, projectConfig) require.NoError(t, err) require.Len(t, requirements, 1) require.Equal(t, "azd", requirements[0].extension.Source) - require.Equal(t, 1, selectCount) + require.Len(t, requirements[0].candidates, 2) } func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { @@ -383,6 +382,57 @@ func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { require.Equal(t, "microsoft.foundry", requirements[0].extension.Id) } +func TestMissingProjectExtensionsKeepsProviderWhenDependencyDiffersBySource(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "test.pack", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "test.provider"}}, + }}, + }, + { + Id: "test.pack", + Source: "local", + Versions: []extensions.ExtensionVersion{{Version: "1.0.0"}}, + }, + { + Id: "test.provider", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"test.pack": new("1.0.0")}, + }, + Services: map[string]*project.ServiceConfig{"demo": {Host: "demo"}}, + } + + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Len(t, requirements, 2) + assert.Equal(t, "test.pack", requirements[0].extension.Id) + assert.Equal(t, "test.provider", requirements[1].extension.Id) +} + // A pack pins the version of its dependency, so a later version of that dependency that publishes // the provider is not installable. Resolution adds no requirement for it and leaves the command to // report the missing provider. @@ -1706,8 +1756,10 @@ func Test_TryAutoInstall_NoAnnotation(t *testing.T) { t.Parallel() cmd := &cobra.Command{Use: "root"} container := ioc.NewNestedContainer(nil) - result := tryAutoInstallForPartialNamespace(t.Context(), container, cmd, nil) - assert.False(t, result) + result, err := tryAutoInstallForPartialNamespace(t.Context(), container, cmd, nil) + require.NoError(t, err) + assert.False(t, result.installed) + assert.False(t, result.declined) } func Test_TryAutoInstall_HasSubcommand(t *testing.T) { @@ -1717,8 +1769,10 @@ func Test_TryAutoInstall_HasSubcommand(t *testing.T) { root.AddCommand(child) container := ioc.NewNestedContainer(nil) // The "deploy" command already exists as sub-command, so partial namespace shouldn't trigger - result := tryAutoInstallForPartialNamespace(t.Context(), container, root, []string{"deploy"}) - assert.False(t, result) + result, err := tryAutoInstallForPartialNamespace(t.Context(), container, root, []string{"deploy"}) + require.NoError(t, err) + assert.False(t, result.installed) + assert.False(t, result.declined) } func TestHelpRequested(t *testing.T) { diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go new file mode 100644 index 00000000000..dee2969cc8d --- /dev/null +++ b/cli/azd/cmd/auto_install_ux.go @@ -0,0 +1,580 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +type autoInstallDisplayContext struct { + requiredByProject bool +} + +type autoInstallResult struct { + installed bool + declined bool +} + +type extensionInstallSelection struct { + requirement projectExtensionRequirement + extension *extensions.ExtensionMetadata +} + +func autoInstallCommandMatches( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + matches []*extensions.ExtensionMetadata, + intro string, +) (autoInstallResult, error) { + console.Message(ctx, intro) + candidates, err := chooseLogicalExtensionCandidates(ctx, console, matches) + if err != nil { + return autoInstallResult{}, err + } + + return autoInstallExtensionRequirements( + ctx, + console, + extensionManager, + []projectExtensionRequirement{{ + extension: candidates[0], + candidates: candidates, + }}, + autoInstallDisplayContext{}, + ) +} + +func autoInstallExtensionRequirements( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + requirements []projectExtensionRequirement, + display autoInstallDisplayContext, +) (autoInstallResult, error) { + if len(requirements) == 0 { + return autoInstallResult{}, nil + } + + displayExtensionRequirements(ctx, console, requirements, display) + + if resource.IsRunningOnCI() { + return autoInstallResult{}, manualInstallError( + requirements, + "Auto-installation is not supported in CI/CD environments.", + ) + } + + var selections []extensionInstallSelection + var declined bool + var err error + if console.IsNoPromptMode() { + selections, err = noPromptInstallPlan(requirements) + if err == nil { + console.Message(ctx, "\nNo-prompt mode: installing required extensions automatically.") + } + } else { + selections, declined, err = interactiveInstallPlan(ctx, console, requirements) + } + if err != nil { + return autoInstallResult{}, err + } + if declined { + console.Message(ctx, "\nCanceled: required extension isn't installed.") + return autoInstallResult{declined: true}, nil + } + + console.Message(ctx, "") + installedAny := false + for _, selection := range selections { + installed, err := tryAutoInstallExtensionVersion( + ctx, + console, + extensionManager, + *selection.extension, + selection.requirement.versionPreference, + ) + if err != nil { + return autoInstallResult{installed: installedAny}, err + } + installedAny = installedAny || installed + } + + return autoInstallResult{installed: installedAny}, nil +} + +func displayExtensionRequirements( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, + display autoInstallDisplayContext, +) { + if len(requirements) == 1 { + requirement := requirements[0] + extension := requirement.extension + console.Message(ctx, "") + console.Message(ctx, output.WithHighLightFormat("Extension required: %s", extension.DisplayName)) + + var details strings.Builder + tabs := tabwriter.NewWriter(&details, 0, 4, 2, ' ', 0) + fmt.Fprintf(tabs, " ID:\t%s\n", extension.Id) + sourceLabel := "Source" + if len(requirementCandidates(requirement)) > 1 { + sourceLabel = "Sources" + } + fmt.Fprintf(tabs, " %s:\t%s\n", sourceLabel, sourceSummary(requirement, false)) + fmt.Fprintf(tabs, " Description:\t%s\n", extension.Description) + _ = tabs.Flush() + console.Message(ctx, strings.TrimRight(details.String(), "\n")) + if display.requiredByProject { + console.Message(ctx, "Required by azure.yaml.") + } + return + } + + console.Message(ctx, "") + if display.requiredByProject { + console.Message(ctx, fmt.Sprintf("%d extensions required by azure.yaml:", len(requirements))) + } else { + console.Message(ctx, fmt.Sprintf("%d extensions required:", len(requirements))) + } + + usePluralSource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + sourceHeading := "Source" + if usePluralSource { + sourceHeading = "Sources" + } + + var table strings.Builder + tabs := tabwriter.NewWriter(&table, 0, 4, 2, ' ', 0) + fmt.Fprintf(tabs, " Extension\tID\t%s\n", sourceHeading) + for _, requirement := range requirements { + fmt.Fprintf( + tabs, + " %s\t%s\t%s\n", + requirement.extension.DisplayName, + requirement.extension.Id, + sourceSummary(requirement, true), + ) + } + _ = tabs.Flush() + console.Message(ctx, strings.TrimRight(table.String(), "\n")) +} + +func sourceSummary(requirement projectExtensionRequirement, compact bool) string { + candidates := sortedRequirementCandidates(requirement) + names := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + names = append(names, candidate.Source) + } + if !compact || len(names) <= 2 { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s (+%d more)", names[0], len(names)-1) +} + +func sortedRequirementCandidates( + requirement projectExtensionRequirement, +) []*extensions.ExtensionMetadata { + candidates := slices.Clone(requirementCandidates(requirement)) + recommended := recommendedSourceCandidate(requirement) + slices.SortFunc(candidates, func(a, b *extensions.ExtensionMetadata) int { + switch { + case recommended != nil && a == recommended: + return -1 + case recommended != nil && b == recommended: + return 1 + default: + return strings.Compare(strings.ToLower(a.Source), strings.ToLower(b.Source)) + } + }) + return candidates +} + +func recommendedSourceCandidate( + requirement projectExtensionRequirement, +) *extensions.ExtensionMetadata { + official := slices.DeleteFunc( + slices.Clone(requirementCandidates(requirement)), + func(candidate *extensions.ExtensionMetadata) bool { + return candidate.SourceCategoryOrUnknown() != extensions.SourceCategoryAzd + }, + ) + if len(official) == 1 { + return official[0] + } + + namedAzd := slices.DeleteFunc(official, func(candidate *extensions.ExtensionMetadata) bool { + return !strings.EqualFold(candidate.Source, "azd") + }) + if len(namedAzd) == 1 { + return namedAzd[0] + } + return nil +} + +func noPromptInstallPlan( + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + for _, requirement := range requirements { + if len(requirementCandidates(requirement)) != 1 { + return nil, manualInstallError( + requirements, + "Required extensions are available from more than one source.", + ) + } + } + + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + return selections, nil +} + +func manualInstallError( + requirements []projectExtensionRequirement, + message string, +) error { + var suggestion strings.Builder + suggestion.WriteString("Install the required extensions manually, then run this command again:") + for _, requirement := range requirements { + for _, candidate := range sortedRequirementCandidates(requirement) { + version, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + if err != nil { + continue + } + fmt.Fprintf( + &suggestion, + "\n azd extension install %s --source %s --version %s", + candidate.Id, + candidate.Source, + version.Version, + ) + } + } + + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("required extension installation needs manual action"), + Message: message, + Suggestion: suggestion.String(), + } +} + +func interactiveInstallPlan( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + if len(requirements) == 1 { + return interactiveSingleInstallPlan(ctx, console, requirements[0]) + } + return interactiveMultipleInstallPlan(ctx, console, requirements) +} + +func interactiveSingleInstallPlan( + ctx context.Context, + console input.Console, + requirement projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + candidates := requirementCandidates(requirement) + if len(candidates) == 1 { + confirmed, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install %s?", requirement.extension.DisplayName), + DefaultValue: true, + }) + if err != nil { + return nil, false, err + } + if !confirmed { + return nil, true, nil + } + return []extensionInstallSelection{{requirement: requirement, extension: candidates[0]}}, false, nil + } + + recommended := recommendedSourceCandidate(requirement) + if recommended != nil { + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Install %s from '%s'?", + requirement.extension.DisplayName, + recommended.Source, + ), + Options: []string{ + fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + "Install from a different source", + "Cancel", + }, + DefaultValue: fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + }) + if err != nil { + return nil, false, err + } + switch choice { + case 0: + return []extensionInstallSelection{{requirement: requirement, extension: recommended}}, false, nil + case 1: + selected, err := selectRequirementSource(ctx, console, requirement, recommended) + if err != nil { + return nil, false, err + } + return []extensionInstallSelection{{requirement: requirement, extension: selected}}, false, nil + default: + return nil, true, nil + } + } + + confirmed, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install %s?", requirement.extension.DisplayName), + DefaultValue: true, + }) + if err != nil { + return nil, false, err + } + if !confirmed { + return nil, true, nil + } + selected, err := selectRequirementSource(ctx, console, requirement, nil) + if err != nil { + return nil, false, err + } + return []extensionInstallSelection{{requirement: requirement, extension: selected}}, false, nil +} + +func interactiveMultipleInstallPlan( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + allSingleSource := !slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + if allSingleSource { + confirmed, err := confirmInstallAll(ctx, console, len(requirements)) + if err != nil || !confirmed { + return nil, !confirmed, err + } + return soleSourceSelections(requirements), false, nil + } + + if source, hasCommonRecommendedSource := commonRecommendedSource(requirements); hasCommonRecommendedSource { + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Install all %d required extensions from '%s'?", + len(requirements), + source, + ), + Options: []string{ + fmt.Sprintf("Install all from '%s' (recommended)", source), + "Install all from a different source", + "Cancel", + }, + DefaultValue: fmt.Sprintf("Install all from '%s' (recommended)", source), + }) + if err != nil { + return nil, false, err + } + switch choice { + case 0: + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: recommendedSourceCandidate(requirement), + }) + } + return selections, false, nil + case 1: + selections, err := selectDifferentSources(ctx, console, requirements) + return selections, false, err + default: + return nil, true, nil + } + } + + confirmed, err := confirmInstallAll(ctx, console, len(requirements)) + if err != nil || !confirmed { + return nil, !confirmed, err + } + selections, err := selectSourcesIndividually(ctx, console, requirements) + return selections, false, err +} + +func commonRecommendedSource(requirements []projectExtensionRequirement) (string, bool) { + if len(requirements) == 0 { + return "", false + } + + first := recommendedSourceCandidate(requirements[0]) + if first == nil { + return "", false + } + for _, requirement := range requirements[1:] { + candidate := recommendedSourceCandidate(requirement) + if candidate == nil || !strings.EqualFold(candidate.Source, first.Source) { + return "", false + } + } + return first.Source, true +} + +func confirmInstallAll(ctx context.Context, console input.Console, count int) (bool, error) { + return console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install all %d required extensions?", count), + DefaultValue: true, + }) +} + +func soleSourceSelections(requirements []projectExtensionRequirement) []extensionInstallSelection { + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + return selections +} + +func selectDifferentSources( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + selections := make([]extensionInstallSelection, 0, len(requirements)) + firstAmbiguous := slices.IndexFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + for _, requirement := range requirements[:firstAmbiguous] { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + + first := requirements[firstAmbiguous] + selected, err := selectRequirementSource( + ctx, + console, + first, + recommendedSourceCandidate(first), + ) + if err != nil { + return nil, err + } + selections = append(selections, extensionInstallSelection{requirement: first, extension: selected}) + + remaining := requirements[firstAmbiguous+1:] + if len(remaining) == 0 { + return selections, nil + } + if slices.ContainsFunc(remaining, func(requirement projectExtensionRequirement) bool { + return candidateForSource(requirement, selected.Source) == nil + }) { + individual, err := selectSourcesIndividually(ctx, console, remaining) + return append(selections, individual...), err + } + + useForRemaining, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install remaining extensions from '%s'?", selected.Source), + DefaultValue: true, + }) + if err != nil { + return nil, err + } + if useForRemaining { + for _, requirement := range remaining { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: candidateForSource(requirement, selected.Source), + }) + } + return selections, nil + } + + individual, err := selectSourcesIndividually(ctx, console, remaining) + return append(selections, individual...), err +} + +func selectSourcesIndividually( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + candidates := requirementCandidates(requirement) + selected := candidates[0] + var err error + if len(candidates) > 1 { + selected, err = selectRequirementSource(ctx, console, requirement, nil) + if err != nil { + return nil, err + } + } + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: selected, + }) + } + return selections, nil +} + +func selectRequirementSource( + ctx context.Context, + console input.Console, + requirement projectExtensionRequirement, + exclude *extensions.ExtensionMetadata, +) (*extensions.ExtensionMetadata, error) { + candidates := slices.DeleteFunc( + sortedRequirementCandidates(requirement), + func(candidate *extensions.ExtensionMetadata) bool { + return exclude != nil && candidate == exclude + }, + ) + options := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + options = append(options, candidate.Source) + } + + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Select a source for %s:", requirement.extension.DisplayName), + Options: options, + }) + if err != nil { + return nil, err + } + return candidates[choice], nil +} + +func candidateForSource( + requirement projectExtensionRequirement, + source string, +) *extensions.ExtensionMetadata { + for _, candidate := range requirementCandidates(requirement) { + if strings.EqualFold(candidate.Source, source) { + return candidate + } + } + return nil +} diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go new file mode 100644 index 00000000000..25438264e20 --- /dev/null +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func autoInstallTestExtension( + id string, + name string, + source string, + category extensions.SourceCategory, +) *extensions.ExtensionMetadata { + return &extensions.ExtensionMetadata{ + Id: id, + DisplayName: name, + Description: name + " description", + Source: source, + SourceCategory: category, + Versions: []extensions.ExtensionVersion{{Version: "1.2.3"}}, + } +} + +func autoInstallTestRequirement( + candidates ...*extensions.ExtensionMetadata, +) projectExtensionRequirement { + return projectExtensionRequirement{ + extension: candidates[0], + candidates: candidates, + } +} + +func TestRecommendedSourceCandidate(t *testing.T) { + t.Parallel() + + officialAlias := autoInstallTestExtension( + "demo", + "Demo", + "official", + extensions.SourceCategoryAzd, + ) + azd := autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo", "local", extensions.SourceCategoryLocal) + + tests := []struct { + name string + candidates []*extensions.ExtensionMetadata + expected *extensions.ExtensionMetadata + }{ + { + name: "unique official source", + candidates: []*extensions.ExtensionMetadata{local, officialAlias}, + expected: officialAlias, + }, + { + name: "literal azd wins among aliases", + candidates: []*extensions.ExtensionMetadata{officialAlias, azd, local}, + expected: azd, + }, + { + name: "ambiguous official aliases", + candidates: []*extensions.ExtensionMetadata{ + officialAlias, + autoInstallTestExtension("demo", "Demo", "mirror", extensions.SourceCategoryAzd), + }, + }, + { + name: "no official source", + candidates: []*extensions.ExtensionMetadata{local}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + requirement := autoInstallTestRequirement(tt.candidates...) + assert.Same(t, tt.expected, recommendedSourceCandidate(requirement)) + }) + } +} + +func TestCommonRecommendedSourceRequiresSameConfiguredName(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage", "official", extensions.SourceCategoryAzd), + ), + } + + source, ok := commonRecommendedSource(requirements) + + assert.False(t, ok) + assert.Empty(t, source) +} + +func TestInteractiveSingleInstallPlan(t *testing.T) { + t.Parallel() + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + + t.Run("single source confirms", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension?" + }).Respond(true) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, azd, selections[0].extension) + }) + + t.Run("recommended source selected", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + }).Respond(0) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, azd, selections[0].extension) + }) + + t.Run("different source selected", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension:" + }).Respond(0) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, local, selections[0].extension) + }) +} + +func TestInteractiveMultipleInstallPlanDifferentSourceShortcut(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("monitor", "Monitoring Tools", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("monitor", "Monitoring Tools", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.HasPrefix(options.Message, "Install all 3 required extensions from 'azd'?") + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension:" + }).Respond(0) + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install remaining extensions from 'local'?" + }).Respond(true) + + selections, declined, err := interactiveMultipleInstallPlan(t.Context(), console, requirements) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 3) + for _, selection := range selections { + assert.Equal(t, "local", selection.extension.Source) + } +} + +func TestInteractiveMultipleInstallPlanFallsBackToIndividualSources(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("storage", "Storage Helper", "private", extensions.SourceCategoryOther), + ), + } + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.HasPrefix(options.Message, "Install all 2 required extensions from 'azd'?") + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension:" + }).Respond(0) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Storage Helper:" + }).Respond(1) + + selections, declined, err := interactiveMultipleInstallPlan(t.Context(), console, requirements) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 2) + assert.Equal(t, "local", selections[0].extension.Source) + assert.Equal(t, "private", selections[1].extension.Source) +} + +func TestAutoInstallExtensionRequirementsDeclined(t *testing.T) { + clearAgentEnvVarsForTest(t) + + extension := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension?" + }).Respond(false) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(extension)}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + assert.True(t, result.declined) + assert.False(t, result.installed) + assert.Contains(t, strings.Join(console.Output(), "\n"), "Canceled: required extension isn't installed.") + assert.Empty(t, manager.installed) +} + +func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { + clearAgentEnvVarsForTest(t) + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + assert.True(t, result.installed) + assert.Contains( + t, + strings.Join(console.Output(), "\n"), + "No-prompt mode: installing required extensions automatically.", + ) + require.Len(t, console.SpinnerOps(), 4) + assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) + assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3)") + assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) +} + +func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { + clearAgentEnvVarsForTest(t) + + extension := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{}, + installErr: errors.New("download failed"), + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(extension)}, + autoInstallDisplayContext{}, + ) + + require.ErrorContains(t, err, "failed to install extension: download failed") + assert.False(t, result.installed) + require.Len(t, console.SpinnerOps(), 2) + assert.Equal(t, input.StepFailed, console.SpinnerOps()[1].Format) + assert.Equal(t, "Installing extension 'demo'", console.SpinnerOps()[1].Message) +} + +func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { + clearAgentEnvVarsForTest(t) + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(azd, local)}, + autoInstallDisplayContext{}, + ) + + assert.False(t, result.installed) + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd --version 1.2.3") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source local --version 1.2.3") + assert.Empty(t, manager.installed) +} + +func TestDisplayExtensionRequirements(t *testing.T) { + t.Parallel() + + t.Run("single project requirement", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + displayExtensionRequirements( + t.Context(), + console, + []projectExtensionRequirement{autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + )}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + output := strings.Join(console.Output(), "\n") + assert.Contains(t, output, "Extension required: Demo Extension") + assert.Contains(t, output, "ID:") + assert.Contains(t, output, "Source:") + assert.Contains(t, output, "Required by azure.yaml.") + }) + + t.Run("multiple requirements use sources heading", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + displayExtensionRequirements( + t.Context(), + console, + []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + ), + }, + autoInstallDisplayContext{requiredByProject: true}, + ) + + output := strings.Join(console.Output(), "\n") + assert.Contains(t, output, "2 extensions required by azure.yaml:") + assert.Contains(t, output, "Extension") + assert.Contains(t, output, "ID") + assert.Contains(t, output, "Sources") + assert.Contains(t, output, "Demo Extension") + assert.Contains(t, output, "azd, local") + }) +} + +func TestAutoInstallExtensionRequirementsCIListsAllRequirements(t *testing.T) { + clearAgentEnvVarsForTest(t) + t.Setenv("CI", "true") + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + _, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + assert.Equal(t, "Auto-installation is not supported in CI/CD environments.", suggestionErr.Message) + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd --version 1.2.3") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install storage --source local --version 1.2.3") + assert.Empty(t, manager.installed) +} diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index 332bde450bb..4c06d85aec5 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -26,6 +26,7 @@ import ( type projectExtensionRequirement struct { extension *extensions.ExtensionMetadata + candidates []*extensions.ExtensionMetadata versionPreference string explicit bool } @@ -154,7 +155,7 @@ func findExtensionForProvider( lookup providerLookup, capability extensions.CapabilityType, provider string, -) (*extensions.ExtensionMetadata, error) { +) ([]*extensions.ExtensionMetadata, error) { matches, err := extensionManager.FindExtensions(ctx, &extensions.FilterOptions{ Capability: capability, Provider: provider, @@ -168,7 +169,7 @@ func findExtensionForProvider( return nil, candidates.conflictError() } - return promptForExtensionChoice(ctx, console, candidates.installable) + return chooseLogicalExtensionCandidates(ctx, console, candidates.installable) } func uninstalledExtensionMatches( @@ -245,34 +246,81 @@ func resolveExtensionRequirementDependencies( requirements map[string]projectExtensionRequirement, ) map[string]resolvedExtensionDependency { resolved := map[string]resolvedExtensionDependency{} - resolving := map[extensionRef]struct{}{} for _, requirement := range sortedProjectExtensionRequirements(requirements) { - version, err := extensions.ResolveExtensionVersion( - requirement.extension, - requirement.versionPreference, - nil, - ) - if err != nil { - continue - } + var common map[string]resolvedExtensionDependency + for _, candidate := range requirementCandidates(requirement) { + version, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + if err != nil { + common = map[string]resolvedExtensionDependency{} + break + } - key := newExtensionRef(requirement.extension.Source, requirement.extension.Id) - resolving[key] = struct{}{} - resolveExtensionDependencies( - ctx, - extensionManager, - requirement.extension, - version.Dependencies, - resolved, - resolving, - ) - delete(resolving, key) + candidateResolved := map[string]resolvedExtensionDependency{} + key := newExtensionRef(candidate.Source, candidate.Id) + resolveExtensionDependencies( + ctx, + extensionManager, + candidate, + version.Dependencies, + candidateResolved, + map[extensionRef]struct{}{key: {}}, + ) + if common == nil { + common = candidateResolved + } else { + common = intersectResolvedDependencies(common, candidateResolved) + } + } + for id, dependency := range common { + if _, exists := resolved[id]; !exists { + resolved[id] = dependency + } + } } return resolved } +func intersectResolvedDependencies( + left map[string]resolvedExtensionDependency, + right map[string]resolvedExtensionDependency, +) map[string]resolvedExtensionDependency { + intersection := map[string]resolvedExtensionDependency{} + for id, leftDependency := range left { + rightDependency, exists := right[id] + if !exists { + continue + } + + intersection[id] = resolvedExtensionDependency{ + capabilities: slices.DeleteFunc( + slices.Clone(leftDependency.capabilities), + func(capability extensions.CapabilityType) bool { + return !slices.Contains(rightDependency.capabilities, capability) + }, + ), + providers: slices.DeleteFunc( + slices.Clone(leftDependency.providers), + func(provider extensions.Provider) bool { + return !slices.ContainsFunc( + rightDependency.providers, + func(candidate extensions.Provider) bool { + return candidate.Type == provider.Type && + strings.EqualFold(candidate.Name, provider.Name) + }, + ) + }, + ), + } + } + return intersection +} + func resolveExtensionDependencies( ctx context.Context, extensionManager extensionAutoInstallManager, @@ -496,13 +544,15 @@ func missingProjectExtensions( } } - extension, err := promptForExtensionChoice(ctx, console, matches) + candidates, err := chooseLogicalExtensionCandidates(ctx, console, matches) if err != nil { return nil, fmt.Errorf("selecting required extension %s: %w", extensionId, err) } + extension := candidates[0] requirements[extension.Id] = projectExtensionRequirement{ extension: extension, + candidates: candidates, versionPreference: versionPreference, explicit: true, } @@ -518,6 +568,33 @@ func missingProjectExtensions( requirementConflicts := map[string]error{} for _, extensionId := range slices.Sorted(maps.Keys(requirements)) { requirement := requirements[extensionId] + providingCandidates := slices.DeleteFunc( + slices.Clone(requirementCandidates(requirement)), + func(candidate *extensions.ExtensionMetadata) bool { + selectedVersion, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + return err != nil || !extensionVersionProvidesProvider(selectedVersion, capability, provider) + }, + ) + if len(providingCandidates) > 0 { + requirement.candidates = providingCandidates + requirement.extension = providingCandidates[0] + requirements[extensionId] = requirement + return nil + } + + hasProviderVersion := slices.ContainsFunc( + requirementCandidates(requirement), + func(candidate *extensions.ExtensionMetadata) bool { + return len(extensionForProvider(candidate, capability, provider).Versions) > 0 + }, + ) + if !hasProviderVersion { + continue + } selectedVersion, err := extensions.ResolveExtensionVersion( requirement.extension, requirement.versionPreference, @@ -526,13 +603,6 @@ func missingProjectExtensions( if err != nil { return fmt.Errorf("resolving required extension %s: %w", extensionId, err) } - if extensionVersionProvidesProvider(selectedVersion, capability, provider) { - return nil - } - - if len(extensionForProvider(requirement.extension, capability, provider).Versions) == 0 { - continue - } requirementConflicts[strings.ToLower(extensionId)] = fmt.Errorf( "required extension %s version %s does not provide %s %q", extensionId, @@ -549,7 +619,7 @@ func missingProjectExtensions( } } - extension, err := findExtensionForProvider( + candidates, err := findExtensionForProvider( ctx, console, extensionManager, @@ -561,12 +631,20 @@ func missingProjectExtensions( capability, provider, ) - if err != nil || extension == nil { + if err != nil || len(candidates) == 0 { return err } + extension := candidates[0] if requirement, alreadyRequired := requirements[extension.Id]; alreadyRequired { - requirement.extension = extensionForProvider(requirement.extension, capability, provider) - if len(requirement.extension.Versions) == 0 { + requirement.candidates = slices.DeleteFunc( + requirementCandidates(requirement), + func(candidate *extensions.ExtensionMetadata) bool { + return !slices.ContainsFunc(candidates, func(match *extensions.ExtensionMetadata) bool { + return strings.EqualFold(candidate.Source, match.Source) + }) + }, + ) + if len(requirement.candidates) == 0 { return fmt.Errorf( "required extension %s does not provide %s %q", extension.Id, @@ -574,10 +652,12 @@ func missingProjectExtensions( provider, ) } + requirement.extension = requirement.candidates[0] requirements[extension.Id] = requirement } else { requirements[extension.Id] = projectExtensionRequirement{ - extension: extension, + extension: extension, + candidates: candidates, } } return nil @@ -626,6 +706,8 @@ type projectExtensionResult struct { handled bool // installed reports that an extension was installed, so the command tree is out of date. installed bool + // declined reports that the user intentionally stopped before installation. + declined bool } func tryAutoInstallProjectExtensions( @@ -665,22 +747,22 @@ func tryAutoInstallProjectExtensions( return projectExtensionResult{}, nil } - installedAny := false - for _, requirement := range requirements { - installed, err := tryAutoInstallExtensionVersion( - ctx, - console, - extensionManager, - *requirement.extension, - requirement.versionPreference, - ) - if err != nil { - return projectExtensionResult{handled: true, installed: installedAny}, err - } - installedAny = installedAny || installed + result, err := autoInstallExtensionRequirements( + ctx, + console, + extensionManager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + if err != nil { + return projectExtensionResult{handled: true, installed: result.installed}, err } - return projectExtensionResult{handled: true, installed: installedAny}, nil + return projectExtensionResult{ + handled: true, + installed: result.installed, + declined: result.declined, + }, nil } func displayAutoInstallError(ctx context.Context, console input.Console, err error) { diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 65a1e6292cd..b8585961e4b 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -291,7 +291,15 @@ Resolution during project commands differs from `azd init` in two ways: - It **does** check installed extensions against the configured constraint, and fails with the conflicting constraint rather than proceeding with an unsatisfying version. - It resolves not only `requiredVersions.extensions` but also the providers the project implies (see below). -Resolution only prompts for extensions that are genuinely missing, and it is skipped when the command renders help instead of running, such as `azd up --help`. Each install is confirmed before it happens; `--no-prompt` accepts that confirmation, matching how the rest of `azd` treats declared configuration in scripts and CI. +Resolution only prompts for extensions that are genuinely missing, and it is skipped when the command renders help instead of running, such as `azd up --help`. + +Before installing anything, `azd` displays the complete set of missing extensions and their available configured sources. A single missing extension is shown with its ID, source or sources, and description. Multiple missing extensions are summarized in a table and confirmed together. + +When the official azd registry is one of several sources, `azd` recommends it but allows another configured source to be selected. For multiple extensions, a source selected for the first extension can be reused for the remaining extensions only when that source publishes every remaining requirement. Otherwise, azd prompts for each ambiguous source separately. Declining installation prints `Canceled: required extension isn't installed.`, stops the requested command before it runs, and exits successfully. + +With an explicit local `--no-prompt`, azd installs automatically only when every missing extension has one eligible source. The sources do not need to be the same. If any extension is available from several sources, azd stops and prints the exact `azd extension install` commands that can resolve the ambiguity. + +Auto-install remains disabled in detected CI/CD environments, including when CI detection enables no-prompt mode automatically. The error lists manual install commands for every missing extension so the pipeline can install them explicitly before rerunning the project command. ### Inferred extension requirements From d043fe648c93a59e1f5f5d4cdcf021631653bf05 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 22:41:15 +0000 Subject: [PATCH 02/14] fix(extensions): clarify manual source choices Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 6 +++++- cli/azd/cmd/auto_install_ux_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index dee2969cc8d..048d18230f3 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -255,7 +255,11 @@ func manualInstallError( var suggestion strings.Builder suggestion.WriteString("Install the required extensions manually, then run this command again:") for _, requirement := range requirements { - for _, candidate := range sortedRequirementCandidates(requirement) { + candidates := sortedRequirementCandidates(requirement) + if len(candidates) > 1 { + fmt.Fprintf(&suggestion, "\n\nChoose one source for %s:", requirement.extension.Id) + } + for _, candidate := range candidates { version, err := extensions.ResolveExtensionVersion( candidate, requirement.versionPreference, diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 25438264e20..1c4132f71b2 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -173,6 +173,24 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { require.Len(t, selections, 1) assert.Same(t, local, selections[0].extension) }) + + t.Run("cancel stops the plan", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + }).Respond(2) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + assert.True(t, declined) + assert.Empty(t, selections) + }) } func TestInteractiveMultipleInstallPlanDifferentSourceShortcut(t *testing.T) { @@ -353,6 +371,7 @@ func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { assert.False(t, result.installed) suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) require.True(t, ok) + assert.Contains(t, suggestionErr.Suggestion, "Choose one source for demo:") assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd --version 1.2.3") assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source local --version 1.2.3") assert.Empty(t, manager.installed) From cf1cc924c4d726537efd822b2247bb6264ccac69 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 22:52:41 +0000 Subject: [PATCH 03/14] fix(extensions): polish auto-install output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 30 ++++++++++++++++++++-------- cli/azd/cmd/auto_install_ux_test.go | 17 +++++++++++----- cli/azd/pkg/input/console.go | 2 ++ cli/azd/pkg/input/console_ux.go | 11 +++++----- cli/azd/pkg/input/console_ux_test.go | 9 ++++++--- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index 048d18230f3..421cdc64dcf 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -110,6 +110,9 @@ func autoInstallExtensionRequirements( } installedAny = installedAny || installed } + if installedAny { + console.Message(ctx, "") + } return autoInstallResult{installed: installedAny}, nil } @@ -143,11 +146,13 @@ func displayExtensionRequirements( return } - console.Message(ctx, "") if display.requiredByProject { - console.Message(ctx, fmt.Sprintf("%d extensions required by azure.yaml:", len(requirements))) + console.Message( + ctx, + output.WithHighLightFormat("%d extensions required by azure.yaml:", len(requirements)), + ) } else { - console.Message(ctx, fmt.Sprintf("%d extensions required:", len(requirements))) + console.Message(ctx, output.WithHighLightFormat("%d extensions required:", len(requirements))) } usePluralSource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { @@ -171,7 +176,14 @@ func displayExtensionRequirements( ) } _ = tabs.Flush() - console.Message(ctx, strings.TrimRight(table.String(), "\n")) + tableOutput := strings.TrimRight(table.String(), "\n") + if headerEnd := strings.IndexByte(tableOutput, '\n'); headerEnd >= 0 { + tableOutput = output.WithGrayFormat(tableOutput[:headerEnd]) + tableOutput[headerEnd:] + } else { + tableOutput = output.WithGrayFormat(tableOutput) + } + console.Message(ctx, tableOutput) + console.Message(ctx, "") } func sourceSummary(requirement projectExtensionRequirement, compact bool) string { @@ -320,7 +332,7 @@ func interactiveSingleInstallPlan( if recommended != nil { choice, err := console.Select(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( - "Install %s from '%s'?", + "Install %s from '%s'", requirement.extension.DisplayName, recommended.Source, ), @@ -329,7 +341,8 @@ func interactiveSingleInstallPlan( "Install from a different source", "Cancel", }, - DefaultValue: fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + DefaultValue: fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + EnableFiltering: new(false), }) if err != nil { return nil, false, err @@ -384,7 +397,7 @@ func interactiveMultipleInstallPlan( if source, hasCommonRecommendedSource := commonRecommendedSource(requirements); hasCommonRecommendedSource { choice, err := console.Select(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( - "Install all %d required extensions from '%s'?", + "Install all %d required extensions from '%s'", len(requirements), source, ), @@ -393,7 +406,8 @@ func interactiveMultipleInstallPlan( "Install all from a different source", "Cancel", }, - DefaultValue: fmt.Sprintf("Install all from '%s' (recommended)", source), + DefaultValue: fmt.Sprintf("Install all from '%s' (recommended)", source), + EnableFiltering: new(false), }) if err != nil { return nil, false, err diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 1c4132f71b2..1eb45370fdb 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -137,7 +137,8 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { t.Parallel() console := mockinput.NewMockConsole() console.WhenSelect(func(options input.ConsoleOptions) bool { - return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + return options.Message == "Install Demo Extension from 'azd'" && + options.EnableFiltering != nil && !*options.EnableFiltering }).Respond(0) selections, declined, err := interactiveSingleInstallPlan( @@ -156,7 +157,7 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { t.Parallel() console := mockinput.NewMockConsole() console.WhenSelect(func(options input.ConsoleOptions) bool { - return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + return options.Message == "Install Demo Extension from 'azd'" }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { return options.Message == "Select a source for Demo Extension:" @@ -178,7 +179,7 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { t.Parallel() console := mockinput.NewMockConsole() console.WhenSelect(func(options input.ConsoleOptions) bool { - return strings.HasPrefix(options.Message, "Install Demo Extension from 'azd'?") + return options.Message == "Install Demo Extension from 'azd'" }).Respond(2) selections, declined, err := interactiveSingleInstallPlan( @@ -212,7 +213,8 @@ func TestInteractiveMultipleInstallPlanDifferentSourceShortcut(t *testing.T) { } console := mockinput.NewMockConsole() console.WhenSelect(func(options input.ConsoleOptions) bool { - return strings.HasPrefix(options.Message, "Install all 3 required extensions from 'azd'?") + return options.Message == "Install all 3 required extensions from 'azd'" && + options.EnableFiltering != nil && !*options.EnableFiltering }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { return options.Message == "Select a source for Demo Extension:" @@ -246,7 +248,7 @@ func TestInteractiveMultipleInstallPlanFallsBackToIndividualSources(t *testing.T } console := mockinput.NewMockConsole() console.WhenSelect(func(options input.ConsoleOptions) bool { - return strings.HasPrefix(options.Message, "Install all 2 required extensions from 'azd'?") + return options.Message == "Install all 2 required extensions from 'azd'" }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { return options.Message == "Select a source for Demo Extension:" @@ -323,6 +325,8 @@ func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3)") assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) + require.NotEmpty(t, console.Output()) + assert.Empty(t, console.Output()[len(console.Output())-1]) } func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { @@ -418,12 +422,15 @@ func TestDisplayExtensionRequirements(t *testing.T) { ) output := strings.Join(console.Output(), "\n") + require.NotEmpty(t, console.Output()) + assert.NotEmpty(t, console.Output()[0]) assert.Contains(t, output, "2 extensions required by azure.yaml:") assert.Contains(t, output, "Extension") assert.Contains(t, output, "ID") assert.Contains(t, output, "Sources") assert.Contains(t, output, "Demo Extension") assert.Contains(t, output, "azd, local") + assert.Empty(t, console.Output()[len(console.Output())-1]) }) } diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 6164135a019..656eab7559e 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -195,6 +195,8 @@ type ConsoleOptions struct { // OptionDetails is an optional field that can be used to provide additional information about the options. OptionDetails []string DefaultValue any + // EnableFiltering controls whether select prompts allow filtering. Nil uses the component default. + EnableFiltering *bool // Prompt-only options IsPassword bool diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go index 917e4dc9d60..60a82e125c1 100644 --- a/cli/azd/pkg/input/console_ux.go +++ b/cli/azd/pkg/input/console_ux.go @@ -115,11 +115,12 @@ func newSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.SelectOpt choices, selectedIndex := selectChoices(options) return &uxlib.SelectOptions{ - Writer: writer, - Message: options.Message, - HelpMessage: options.Help, - Choices: choices, - SelectedIndex: new(selectedIndex), + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + SelectedIndex: new(selectedIndex), + EnableFiltering: options.EnableFiltering, } } diff --git a/cli/azd/pkg/input/console_ux_test.go b/cli/azd/pkg/input/console_ux_test.go index 48b9f3b7b3a..7c644a51227 100644 --- a/cli/azd/pkg/input/console_ux_test.go +++ b/cli/azd/pkg/input/console_ux_test.go @@ -238,9 +238,10 @@ func TestNewPromptOptions(t *testing.T) { func TestNewSelectOptions(t *testing.T) { buf := &bytes.Buffer{} opts := newSelectOptions(buf, ConsoleOptions{ - Message: "message", - Options: []string{"alpha", "beta", "gamma"}, - DefaultValue: "gamma", + Message: "message", + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + EnableFiltering: new(false), }) require.Equal(t, buf, opts.Writer) @@ -248,6 +249,8 @@ func TestNewSelectOptions(t *testing.T) { require.Len(t, opts.Choices, 3) require.NotNil(t, opts.SelectedIndex) require.Equal(t, 2, *opts.SelectedIndex) + require.NotNil(t, opts.EnableFiltering) + require.False(t, *opts.EnableFiltering) } func TestNewConfirmOptions(t *testing.T) { From 61ceb630f259699ef66bc3f2f57a9fcc2918f0d5 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 22:59:01 +0000 Subject: [PATCH 04/14] fix(extensions): clarify selected install sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install.go | 4 +++ cli/azd/cmd/auto_install_test.go | 1 + cli/azd/cmd/auto_install_ux.go | 21 +++++++-------- cli/azd/cmd/auto_install_ux_test.go | 41 ++++++++++++++++++++++++----- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 23c4ef2cd83..90fc589c13e 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -407,6 +407,7 @@ func tryAutoInstallExtensionVersion( extensionManager extensionAutoInstallManager, extension extensions.ExtensionMetadata, versionPreference string, + displaySource bool, ) (bool, error) { // Check if the extension is already installed installedExtension, err := extensionManager.GetInstalled(extensions.FilterOptions{ @@ -428,6 +429,9 @@ func tryAutoInstallExtensionVersion( } stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) + if displaySource { + stepMessage += fmt.Sprintf(" from '%s'", extension.Source) + } console.StopSpinner(ctx, stepMessage, input.StepDone) return true, nil } diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index a8dada1b1fe..a7aafb9d7d9 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -758,6 +758,7 @@ func TestTryAutoInstallExtensionVersionRejectsInstalledVersionConstraint(t *test manager, extensions.ExtensionMetadata{Id: "test.extension"}, ">=2.0.0", + false, ) require.False(t, installed) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index 421cdc64dcf..d1ca5d8d4db 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -97,6 +97,9 @@ func autoInstallExtensionRequirements( console.Message(ctx, "") installedAny := false + displaySource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) for _, selection := range selections { installed, err := tryAutoInstallExtensionVersion( ctx, @@ -104,6 +107,7 @@ func autoInstallExtensionRequirements( extensionManager, *selection.extension, selection.requirement.versionPreference, + displaySource, ) if err != nil { return autoInstallResult{installed: installedAny}, err @@ -351,7 +355,7 @@ func interactiveSingleInstallPlan( case 0: return []extensionInstallSelection{{requirement: requirement, extension: recommended}}, false, nil case 1: - selected, err := selectRequirementSource(ctx, console, requirement, recommended) + selected, err := selectRequirementSource(ctx, console, requirement) if err != nil { return nil, false, err } @@ -371,7 +375,7 @@ func interactiveSingleInstallPlan( if !confirmed { return nil, true, nil } - selected, err := selectRequirementSource(ctx, console, requirement, nil) + selected, err := selectRequirementSource(ctx, console, requirement) if err != nil { return nil, false, err } @@ -495,7 +499,6 @@ func selectDifferentSources( ctx, console, first, - recommendedSourceCandidate(first), ) if err != nil { return nil, err @@ -545,7 +548,7 @@ func selectSourcesIndividually( selected := candidates[0] var err error if len(candidates) > 1 { - selected, err = selectRequirementSource(ctx, console, requirement, nil) + selected, err = selectRequirementSource(ctx, console, requirement) if err != nil { return nil, err } @@ -562,21 +565,15 @@ func selectRequirementSource( ctx context.Context, console input.Console, requirement projectExtensionRequirement, - exclude *extensions.ExtensionMetadata, ) (*extensions.ExtensionMetadata, error) { - candidates := slices.DeleteFunc( - sortedRequirementCandidates(requirement), - func(candidate *extensions.ExtensionMetadata) bool { - return exclude != nil && candidate == exclude - }, - ) + candidates := sortedRequirementCandidates(requirement) options := make([]string, 0, len(candidates)) for _, candidate := range candidates { options = append(options, candidate.Source) } choice, err := console.Select(ctx, input.ConsoleOptions{ - Message: fmt.Sprintf("Select a source for %s:", requirement.extension.DisplayName), + Message: fmt.Sprintf("Select a source for %s", requirement.extension.DisplayName), Options: options, }) if err != nil { diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 1eb45370fdb..0430bafac2d 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -160,8 +160,9 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { return options.Message == "Install Demo Extension from 'azd'" }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Select a source for Demo Extension:" - }).Respond(0) + return options.Message == "Select a source for Demo Extension" && + assert.Equal(t, []string{"azd", "local"}, options.Options) + }).Respond(1) selections, declined, err := interactiveSingleInstallPlan( t.Context(), @@ -217,8 +218,8 @@ func TestInteractiveMultipleInstallPlanDifferentSourceShortcut(t *testing.T) { options.EnableFiltering != nil && !*options.EnableFiltering }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Select a source for Demo Extension:" - }).Respond(0) + return options.Message == "Select a source for Demo Extension" + }).Respond(1) console.WhenConfirm(func(options input.ConsoleOptions) bool { return options.Message == "Install remaining extensions from 'local'?" }).Respond(true) @@ -251,10 +252,10 @@ func TestInteractiveMultipleInstallPlanFallsBackToIndividualSources(t *testing.T return options.Message == "Install all 2 required extensions from 'azd'" }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Select a source for Demo Extension:" - }).Respond(0) + return options.Message == "Select a source for Demo Extension" + }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { - return options.Message == "Select a source for Storage Helper:" + return options.Message == "Select a source for Storage Helper" }).Respond(1) selections, declined, err := interactiveMultipleInstallPlan(t.Context(), console, requirements) @@ -324,6 +325,7 @@ func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { require.Len(t, console.SpinnerOps(), 4) assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3)") + assert.NotContains(t, console.SpinnerOps()[1].Message, " from ") assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) require.NotEmpty(t, console.Output()) assert.Empty(t, console.Output()[len(console.Output())-1]) @@ -381,6 +383,31 @@ func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { assert.Empty(t, manager.installed) } +func TestAutoInstallExtensionRequirementsShowsSourceForMultiSourcePlan(t *testing.T) { + clearAgentEnvVarsForTest(t) + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension from 'azd'" + }).Respond(0) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(azd, local)}, + autoInstallDisplayContext{}, + ) + + require.NoError(t, err) + assert.True(t, result.installed) + require.Len(t, console.SpinnerOps(), 2) + assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3) from 'azd'") +} + func TestDisplayExtensionRequirements(t *testing.T) { t.Parallel() From d764c8603d6291b2143310815a622ec407e2895f Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 23:04:22 +0000 Subject: [PATCH 05/14] fix(extensions): streamline required extension summary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 12 +++++++----- cli/azd/cmd/auto_install_ux_test.go | 7 +++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index d1ca5d8d4db..fbdf68a850c 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -39,6 +39,7 @@ func autoInstallCommandMatches( intro string, ) (autoInstallResult, error) { console.Message(ctx, intro) + console.Message(ctx, "") candidates, err := chooseLogicalExtensionCandidates(ctx, console, matches) if err != nil { return autoInstallResult{}, err @@ -130,8 +131,11 @@ func displayExtensionRequirements( if len(requirements) == 1 { requirement := requirements[0] extension := requirement.extension - console.Message(ctx, "") - console.Message(ctx, output.WithHighLightFormat("Extension required: %s", extension.DisplayName)) + header := "Extension required: %s" + if display.requiredByProject { + header = "Extension required by azure.yaml: %s" + } + console.Message(ctx, output.WithHighLightFormat(header, extension.DisplayName)) var details strings.Builder tabs := tabwriter.NewWriter(&details, 0, 4, 2, ' ', 0) @@ -144,9 +148,7 @@ func displayExtensionRequirements( fmt.Fprintf(tabs, " Description:\t%s\n", extension.Description) _ = tabs.Flush() console.Message(ctx, strings.TrimRight(details.String(), "\n")) - if display.requiredByProject { - console.Message(ctx, "Required by azure.yaml.") - } + console.Message(ctx, "") return } diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 0430bafac2d..cbe39b57406 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -424,10 +424,13 @@ func TestDisplayExtensionRequirements(t *testing.T) { ) output := strings.Join(console.Output(), "\n") - assert.Contains(t, output, "Extension required: Demo Extension") + require.NotEmpty(t, console.Output()) + assert.NotEmpty(t, console.Output()[0]) + assert.Contains(t, output, "Extension required by azure.yaml: Demo Extension") assert.Contains(t, output, "ID:") assert.Contains(t, output, "Source:") - assert.Contains(t, output, "Required by azure.yaml.") + assert.NotContains(t, output, "\nRequired by azure.yaml.") + assert.Empty(t, console.Output()[len(console.Output())-1]) }) t.Run("multiple requirements use sources heading", func(t *testing.T) { From 961ebb9b78aee1215953fce417cecb1b949077cc Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 23:07:39 +0000 Subject: [PATCH 06/14] fix(extensions): simplify manual install guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 11 +---------- cli/azd/cmd/auto_install_ux_test.go | 10 ++++++---- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index fbdf68a850c..a7220e8a700 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -278,20 +278,11 @@ func manualInstallError( fmt.Fprintf(&suggestion, "\n\nChoose one source for %s:", requirement.extension.Id) } for _, candidate := range candidates { - version, err := extensions.ResolveExtensionVersion( - candidate, - requirement.versionPreference, - nil, - ) - if err != nil { - continue - } fmt.Fprintf( &suggestion, - "\n azd extension install %s --source %s --version %s", + "\n azd extension install %s --source %s", candidate.Id, candidate.Source, - version.Version, ) } } diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index cbe39b57406..91992d6fbcf 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -378,8 +378,9 @@ func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) require.True(t, ok) assert.Contains(t, suggestionErr.Suggestion, "Choose one source for demo:") - assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd --version 1.2.3") - assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source local --version 1.2.3") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source local") + assert.NotContains(t, suggestionErr.Suggestion, "--version") assert.Empty(t, manager.installed) } @@ -490,7 +491,8 @@ func TestAutoInstallExtensionRequirementsCIListsAllRequirements(t *testing.T) { suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) require.True(t, ok) assert.Equal(t, "Auto-installation is not supported in CI/CD environments.", suggestionErr.Message) - assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd --version 1.2.3") - assert.Contains(t, suggestionErr.Suggestion, "azd extension install storage --source local --version 1.2.3") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install storage --source local") + assert.NotContains(t, suggestionErr.Suggestion, "--version") assert.Empty(t, manager.installed) } From cf529f372b57868bdbc208f590ca5eb539c22f15 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 23:11:00 +0000 Subject: [PATCH 07/14] fix(extensions): collapse long source lists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 4 ++-- cli/azd/cmd/auto_install_ux_test.go | 30 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index a7220e8a700..53044cc8093 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -198,10 +198,10 @@ func sourceSummary(requirement projectExtensionRequirement, compact bool) string for _, candidate := range candidates { names = append(names, candidate.Source) } - if !compact || len(names) <= 2 { + if !compact || len(names) <= 3 { return strings.Join(names, ", ") } - return fmt.Sprintf("%s (+%d more)", names[0], len(names)-1) + return fmt.Sprintf("%s %s", names[0], output.WithGrayFormat("(+%d more)", len(names)-1)) } func sortedRequirementCandidates( diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 91992d6fbcf..a331264425a 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -11,6 +11,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -90,6 +91,35 @@ func TestRecommendedSourceCandidate(t *testing.T) { } } +func TestSourceSummaryCollapsesFourOrMoreSources(t *testing.T) { + t.Parallel() + + candidates := []*extensions.ExtensionMetadata{ + autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo", "source-a", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-b", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-c", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-d", extensions.SourceCategoryOther), + } + requirement := autoInstallTestRequirement(candidates...) + + assert.Equal( + t, + "azd "+output.WithGrayFormat("(+4 more)"), + sourceSummary(requirement, true), + ) + assert.Equal( + t, + "azd, source-a, source-b, source-c, source-d", + sourceSummary(requirement, false), + ) + assert.Equal( + t, + "azd, source-a, source-b", + sourceSummary(autoInstallTestRequirement(candidates[:3]...), true), + ) +} + func TestCommonRecommendedSourceRequiresSameConfiguredName(t *testing.T) { t.Parallel() From 437e1c9afba223a5d57880751a9c20f71e3bca21 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Thu, 6 Aug 2026 23:17:00 +0000 Subject: [PATCH 08/14] fix(extensions): separate requirement heading and table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install_ux.go | 1 + cli/azd/cmd/auto_install_ux_test.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index 53044cc8093..d243c7d346b 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -160,6 +160,7 @@ func displayExtensionRequirements( } else { console.Message(ctx, output.WithHighLightFormat("%d extensions required:", len(requirements))) } + console.Message(ctx, "") usePluralSource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { return len(requirementCandidates(requirement)) > 1 diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index a331264425a..dfc120df82c 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -483,8 +483,9 @@ func TestDisplayExtensionRequirements(t *testing.T) { ) output := strings.Join(console.Output(), "\n") - require.NotEmpty(t, console.Output()) + require.GreaterOrEqual(t, len(console.Output()), 3) assert.NotEmpty(t, console.Output()[0]) + assert.Empty(t, console.Output()[1]) assert.Contains(t, output, "2 extensions required by azure.yaml:") assert.Contains(t, output, "Extension") assert.Contains(t, output, "ID") From 2a6b33c0732944bc1332c8eadbdf974c80ccb947 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 10 Aug 2026 20:45:09 +0000 Subject: [PATCH 09/14] fix(extensions): show auto-installed dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --- cli/azd/cmd/auto_install.go | 20 +++++++++++ cli/azd/cmd/auto_install_test.go | 4 +++ cli/azd/cmd/auto_install_ux_test.go | 54 +++++++++++++++++++++++++++++ cli/azd/cmd/extension.go | 10 +++++- 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 90fc589c13e..18ab8245293 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -420,6 +420,15 @@ func tryAutoInstallExtensionVersion( return false, nil } + installedBefore, err := extensionManager.ListInstalled() + if err != nil { + return false, fmt.Errorf("listing installed extensions: %w", err) + } + preInstalledIds := make(map[string]struct{}, len(installedBefore)) + for id := range installedBefore { + preInstalledIds[id] = struct{}{} + } + stepMessage := fmt.Sprintf("Installing extension '%s'", extension.Id) console.ShowSpinner(ctx, stepMessage, input.Step) installedVersion, err := extensionManager.Install(ctx, &extension, versionPreference) @@ -433,6 +442,17 @@ func tryAutoInstallExtensionVersion( stepMessage += fmt.Sprintf(" from '%s'", extension.Source) } console.StopSpinner(ctx, stepMessage, input.StepDone) + if len(installedVersion.Dependencies) > 0 { + displayInstalledDependencies( + ctx, + console, + extensionManager, + installedVersion.Dependencies, + preInstalledIds, + " ", + map[string]struct{}{extension.Id: {}}, + ) + } return true, nil } diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index a7aafb9d7d9..47be648ac66 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -32,6 +32,7 @@ type fakeExtensionAutoInstallManager struct { installed map[string]*extensions.Extension findErr error installErr error + installFn func(*extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) } func (m *fakeExtensionAutoInstallManager) FindExtensions( @@ -95,6 +96,9 @@ func (m *fakeExtensionAutoInstallManager) Install( if m.installErr != nil { return nil, m.installErr } + if m.installFn != nil { + return m.installFn(extension) + } version := &extension.Versions[0] m.installed[extension.Id] = &extensions.Extension{ Id: extension.Id, diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index dfc120df82c..e97f9a51ae2 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -387,6 +387,60 @@ func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { assert.Equal(t, "Installing extension 'demo'", console.SpinnerOps()[1].Message) } +func TestAutoInstallExtensionRequirementsDisplaysInstalledDependencies(t *testing.T) { + clearAgentEnvVarsForTest(t) + + parent := autoInstallTestExtension("parent", "Parent", "azd", extensions.SourceCategoryAzd) + parent.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "child", Version: "2.0.0"}} + child := autoInstallTestExtension("child", "Child", "azd", extensions.SourceCategoryAzd) + child.Versions[0].Version = "2.0.0" + child.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "grandchild", Version: "3.0.0"}} + grandchild := autoInstallTestExtension("grandchild", "Grandchild", "azd", extensions.SourceCategoryAzd) + grandchild.Versions[0].Version = "3.0.0" + + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{parent, child, grandchild}, + installed: map[string]*extensions.Extension{}, + } + manager.installFn = func(extension *extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) { + manager.installed[parent.Id] = &extensions.Extension{ + Id: parent.Id, + Version: parent.Versions[0].Version, + Source: parent.Source, + } + manager.installed[child.Id] = &extensions.Extension{ + Id: child.Id, + Version: child.Versions[0].Version, + Source: child.Source, + } + manager.installed[grandchild.Id] = &extensions.Extension{ + Id: grandchild.Id, + Version: grandchild.Versions[0].Version, + Source: grandchild.Source, + } + return &extension.Versions[0], nil + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(parent)}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + require.True(t, result.installed) + rendered := strings.Join(console.Output(), "\n") + require.Contains(t, rendered, "Installing child dependency") + require.Contains(t, rendered, "(2.0.0)") + require.Contains(t, rendered, "Installing grandchild dependency") + require.Contains(t, rendered, "(3.0.0)") + require.Less(t, strings.Index(rendered, "child dependency"), strings.Index(rendered, "grandchild dependency")) +} + func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { clearAgentEnvVarsForTest(t) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index a8a57708612..f71fc21711a 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -3249,10 +3249,18 @@ func displayExtensionUsageAndExamples( // displayInstalledDependencies renders newly installed and skipped dependencies // as flat rows aligned with the parent step. +type installedDependencyManager interface { + GetInstalled(options extensions.FilterOptions) (*extensions.Extension, error) + FindExtensions( + ctx context.Context, + options *extensions.FilterOptions, + ) ([]*extensions.ExtensionMetadata, error) +} + func displayInstalledDependencies( ctx context.Context, console input.Console, - manager *extensions.Manager, + manager installedDependencyManager, deps []extensions.ExtensionDependency, preInstalledIds map[string]struct{}, indent string, From 7cf9f3052693519676a4abf70df80b74771615a9 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 10 Aug 2026 20:54:20 +0000 Subject: [PATCH 10/14] fix(extensions): align auto-install task wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --- cli/azd/cmd/auto_install.go | 9 ++++----- cli/azd/cmd/auto_install_test.go | 1 - cli/azd/cmd/auto_install_ux.go | 4 ---- cli/azd/cmd/auto_install_ux_test.go | 13 +++++++++++-- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 18ab8245293..b79f6b90d74 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -407,7 +407,6 @@ func tryAutoInstallExtensionVersion( extensionManager extensionAutoInstallManager, extension extensions.ExtensionMetadata, versionPreference string, - displaySource bool, ) (bool, error) { // Check if the extension is already installed installedExtension, err := extensionManager.GetInstalled(extensions.FilterOptions{ @@ -429,7 +428,10 @@ func tryAutoInstallExtensionVersion( preInstalledIds[id] = struct{}{} } - stepMessage := fmt.Sprintf("Installing extension '%s'", extension.Id) + stepMessage := fmt.Sprintf( + "Installing %s extension", + output.WithHighLightFormat(extension.Id), + ) console.ShowSpinner(ctx, stepMessage, input.Step) installedVersion, err := extensionManager.Install(ctx, &extension, versionPreference) if err != nil { @@ -438,9 +440,6 @@ func tryAutoInstallExtensionVersion( } stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) - if displaySource { - stepMessage += fmt.Sprintf(" from '%s'", extension.Source) - } console.StopSpinner(ctx, stepMessage, input.StepDone) if len(installedVersion.Dependencies) > 0 { displayInstalledDependencies( diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 47be648ac66..ca55f239d6e 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -762,7 +762,6 @@ func TestTryAutoInstallExtensionVersionRejectsInstalledVersionConstraint(t *test manager, extensions.ExtensionMetadata{Id: "test.extension"}, ">=2.0.0", - false, ) require.False(t, installed) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index d243c7d346b..fd54e17cef9 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -98,9 +98,6 @@ func autoInstallExtensionRequirements( console.Message(ctx, "") installedAny := false - displaySource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { - return len(requirementCandidates(requirement)) > 1 - }) for _, selection := range selections { installed, err := tryAutoInstallExtensionVersion( ctx, @@ -108,7 +105,6 @@ func autoInstallExtensionRequirements( extensionManager, *selection.extension, selection.requirement.versionPreference, - displaySource, ) if err != nil { return autoInstallResult{installed: installedAny}, err diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index e97f9a51ae2..0f84a3bf1bd 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -384,7 +384,11 @@ func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { assert.False(t, result.installed) require.Len(t, console.SpinnerOps(), 2) assert.Equal(t, input.StepFailed, console.SpinnerOps()[1].Format) - assert.Equal(t, "Installing extension 'demo'", console.SpinnerOps()[1].Message) + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo")+" extension", + console.SpinnerOps()[1].Message, + ) } func TestAutoInstallExtensionRequirementsDisplaysInstalledDependencies(t *testing.T) { @@ -490,7 +494,12 @@ func TestAutoInstallExtensionRequirementsShowsSourceForMultiSourcePlan(t *testin require.NoError(t, err) assert.True(t, result.installed) require.Len(t, console.SpinnerOps(), 2) - assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3) from 'azd'") + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo")+" extension"+ + output.WithGrayFormat(" (1.2.3)"), + console.SpinnerOps()[1].Message, + ) } func TestDisplayExtensionRequirements(t *testing.T) { From af86e801b31cf3c7eb2647ce77f4f312fd1f1fa3 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 10 Aug 2026 21:19:48 +0000 Subject: [PATCH 11/14] fix(extensions): honor auto-install source plans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --- cli/azd/cmd/auto_install_test.go | 23 ++++-- cli/azd/cmd/auto_install_ux.go | 76 ++++++++++++++++++- cli/azd/cmd/auto_install_ux_test.go | 64 ++++++++++++++++ cli/azd/cmd/project_extension_auto_install.go | 62 ++++++++++++--- 4 files changed, 207 insertions(+), 18 deletions(-) diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index ca55f239d6e..e6760a1e967 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -386,7 +386,7 @@ func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { require.Equal(t, "microsoft.foundry", requirements[0].extension.Id) } -func TestMissingProjectExtensionsKeepsProviderWhenDependencyDiffersBySource(t *testing.T) { +func TestMissingProjectExtensionsNarrowsParentToSourceWhoseDependencyProvidesProvider(t *testing.T) { manager := &fakeExtensionAutoInstallManager{ available: []*extensions.ExtensionMetadata{ { @@ -398,9 +398,12 @@ func TestMissingProjectExtensionsKeepsProviderWhenDependencyDiffersBySource(t *t }}, }, { - Id: "test.pack", - Source: "local", - Versions: []extensions.ExtensionVersion{{Version: "1.0.0"}}, + Id: "test.pack", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "test.provider"}}, + }}, }, { Id: "test.provider", @@ -414,6 +417,13 @@ func TestMissingProjectExtensionsKeepsProviderWhenDependencyDiffersBySource(t *t }}, }}, }, + { + Id: "test.provider", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + }}, + }, }, installed: map[string]*extensions.Extension{}, } @@ -432,9 +442,10 @@ func TestMissingProjectExtensionsKeepsProviderWhenDependencyDiffersBySource(t *t ) require.NoError(t, err) - require.Len(t, requirements, 2) + require.Len(t, requirements, 1) assert.Equal(t, "test.pack", requirements[0].extension.Id) - assert.Equal(t, "test.provider", requirements[1].extension.Id) + require.Len(t, requirements[0].candidates, 1) + assert.Equal(t, "azd", requirements[0].candidates[0].Source) } // A pack pins the version of its dependency, so a later version of that dependency that publishes diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index fd54e17cef9..aed080aaee2 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -96,6 +96,11 @@ func autoInstallExtensionRequirements( return autoInstallResult{declined: true}, nil } + selections, err = orderInstallSelections(selections) + if err != nil { + return autoInstallResult{}, err + } + console.Message(ctx, "") installedAny := false for _, selection := range selections { @@ -118,6 +123,62 @@ func autoInstallExtensionRequirements( return autoInstallResult{installed: installedAny}, nil } +func orderInstallSelections( + selections []extensionInstallSelection, +) ([]extensionInstallSelection, error) { + byID := make(map[string]extensionInstallSelection, len(selections)) + for _, selection := range selections { + byID[strings.ToLower(selection.extension.Id)] = selection + } + + const ( + selectionVisiting = iota + 1 + selectionVisited + ) + state := make(map[string]int, len(selections)) + ordered := make([]extensionInstallSelection, 0, len(selections)) + var visit func(extensionInstallSelection) error + visit = func(selection extensionInstallSelection) error { + id := strings.ToLower(selection.extension.Id) + switch state[id] { + case selectionVisited: + return nil + case selectionVisiting: + // Installation reports dependency cycles. Avoid recursing forever while + // preserving a deterministic plan for the manager to validate. + return nil + } + state[id] = selectionVisiting + + version, err := extensions.ResolveExtensionVersion( + selection.extension, + selection.requirement.versionPreference, + nil, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", selection.extension.Id, err) + } + for _, dependency := range version.Dependencies { + if dependencySelection, selected := byID[strings.ToLower(dependency.Id)]; selected { + if err := visit(dependencySelection); err != nil { + return err + } + } + } + + state[id] = selectionVisited + ordered = append(ordered, selection) + return nil + } + + for _, selection := range selections { + if err := visit(selection); err != nil { + return nil, err + } + } + return ordered, nil +} + func displayExtensionRequirements( ctx context.Context, console input.Console, @@ -275,11 +336,24 @@ func manualInstallError( fmt.Fprintf(&suggestion, "\n\nChoose one source for %s:", requirement.extension.Id) } for _, candidate := range candidates { + versionArg := "" + if requirement.versionPreference != "" { + version, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", candidate.Id, err) + } + versionArg = fmt.Sprintf(" --version %s", version.Version) + } fmt.Fprintf( &suggestion, - "\n azd extension install %s --source %s", + "\n azd extension install %s --source %s%s", candidate.Id, candidate.Source, + versionArg, ) } } diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 0f84a3bf1bd..877cb536659 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -472,6 +472,70 @@ func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { assert.Empty(t, manager.installed) } +func TestManualInstallErrorIncludesResolvedVersion(t *testing.T) { + t.Parallel() + + candidate := autoInstallTestExtension( + "demo", + "Demo Extension", + "azd", + extensions.SourceCategoryAzd, + ) + candidate.Versions = append(candidate.Versions, extensions.ExtensionVersion{Version: "2.0.0"}) + requirement := autoInstallTestRequirement(candidate) + requirement.versionPreference = ">=1.0.0 <2.0.0" + + err := manualInstallError( + []projectExtensionRequirement{requirement}, + "Manual installation required.", + ) + + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + require.Contains( + t, + suggestionErr.Suggestion, + "azd extension install demo --source azd --version 1.2.3", + ) +} + +func TestAutoInstallExtensionRequirementsHonorsSelectedDependencySource(t *testing.T) { + clearAgentEnvVarsForTest(t) + parent := autoInstallTestExtension("parent", "Parent", "azd", extensions.SourceCategoryAzd) + parent.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "child"}} + child := autoInstallTestExtension("child", "Child", "local", extensions.SourceCategoryLocal) + + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + var installOrder []string + manager.installFn = func(extension *extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) { + installOrder = append(installOrder, extension.Id+"@"+extension.Source) + version := &extension.Versions[0] + manager.installed[extension.Id] = &extensions.Extension{ + Id: extension.Id, + Version: version.Version, + Source: extension.Source, + } + return version, nil + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{ + autoInstallTestRequirement(parent), + autoInstallTestRequirement(child), + }, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + require.True(t, result.installed) + require.Equal(t, []string{"child@local", "parent@azd"}, installOrder) +} + func TestAutoInstallExtensionRequirementsShowsSourceForMultiSourcePlan(t *testing.T) { clearAgentEnvVarsForTest(t) diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index 4c06d85aec5..bba9764df03 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -485,6 +485,40 @@ func resolvedDependencyProvidesProvider( ) } +func extensionCandidateProvidesProvider( + ctx context.Context, + extensionManager extensionAutoInstallManager, + candidate *extensions.ExtensionMetadata, + versionPreference string, + capability extensions.CapabilityType, + provider string, +) (bool, error) { + version, err := extensions.ResolveExtensionVersion(candidate, versionPreference, nil) + if err != nil { + return false, err + } + if extensionVersionProvidesProvider(version, capability, provider) { + return true, nil + } + + resolved := map[string]resolvedExtensionDependency{} + key := newExtensionRef(candidate.Source, candidate.Id) + resolveExtensionDependencies( + ctx, + extensionManager, + candidate, + version.Dependencies, + resolved, + map[extensionRef]struct{}{key: {}}, + ) + for dependency := range maps.Values(resolved) { + if resolvedDependencyProvidesProvider(dependency, capability, provider) { + return true, nil + } + } + return false, nil +} + // extensionForProvider narrows an extension to every version supplying the provider. Unlike // filterExtensionsForProvider this ignores which version would be selected, so callers can tell an // extension that cannot supply the provider from one whose selected version happens not to. @@ -568,17 +602,23 @@ func missingProjectExtensions( requirementConflicts := map[string]error{} for _, extensionId := range slices.Sorted(maps.Keys(requirements)) { requirement := requirements[extensionId] - providingCandidates := slices.DeleteFunc( - slices.Clone(requirementCandidates(requirement)), - func(candidate *extensions.ExtensionMetadata) bool { - selectedVersion, err := extensions.ResolveExtensionVersion( - candidate, - requirement.versionPreference, - nil, - ) - return err != nil || !extensionVersionProvidesProvider(selectedVersion, capability, provider) - }, - ) + var providingCandidates []*extensions.ExtensionMetadata + for _, candidate := range requirementCandidates(requirement) { + provides, err := extensionCandidateProvidesProvider( + ctx, + extensionManager, + candidate, + requirement.versionPreference, + capability, + provider, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", extensionId, err) + } + if provides { + providingCandidates = append(providingCandidates, candidate) + } + } if len(providingCandidates) > 0 { requirement.candidates = providingCandidates requirement.extension = providingCandidates[0] From 70cff94661e6d3ef3cb345032357d709bbc8275e Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 10 Aug 2026 22:46:45 +0000 Subject: [PATCH 12/14] fix(ux): simplify short source selections Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --- cli/azd/cmd/auto_install_ux.go | 5 +++-- cli/azd/cmd/auto_install_ux_test.go | 1 + cli/azd/pkg/ux/select.go | 5 ++++- cli/azd/pkg/ux/select_test.go | 22 ++++++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index aed080aaee2..be951ef4645 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -637,8 +637,9 @@ func selectRequirementSource( } choice, err := console.Select(ctx, input.ConsoleOptions{ - Message: fmt.Sprintf("Select a source for %s", requirement.extension.DisplayName), - Options: options, + Message: fmt.Sprintf("Select a source for %s", requirement.extension.DisplayName), + Options: options, + EnableFiltering: new(false), }) if err != nil { return nil, err diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 877cb536659..7984ed8a674 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -191,6 +191,7 @@ func TestInteractiveSingleInstallPlan(t *testing.T) { }).Respond(1) console.WhenSelect(func(options input.ConsoleOptions) bool { return options.Message == "Select a source for Demo Extension" && + options.EnableFiltering != nil && !*options.EnableFiltering && assert.Equal(t, []string{"azd", "local"}, options.Options) }).Respond(1) diff --git a/cli/azd/pkg/ux/select.go b/cli/azd/pkg/ux/select.go index 00f467a00c5..a9d479fecb2 100644 --- a/cli/azd/pkg/ux/select.go +++ b/cli/azd/pkg/ux/select.go @@ -352,9 +352,12 @@ func (p *Select) renderMessage(printer Printer) { printer.Fprintln() + if !p.cancelled && !p.complete { + printer.Fprintln() + } + // Filter if !p.cancelled && !p.complete && *p.options.EnableFiltering { - printer.Fprintln() printer.Fprintf(" Filter: ") if p.filter == "" { diff --git a/cli/azd/pkg/ux/select_test.go b/cli/azd/pkg/ux/select_test.go index ea4df2af296..537430c002c 100644 --- a/cli/azd/pkg/ux/select_test.go +++ b/cli/azd/pkg/ux/select_test.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "io" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -87,6 +88,27 @@ func TestSelect_Render_initial(t *testing.T) { assert.Contains(t, output, "Charlie") } +func TestSelect_Render_initial_withoutFilteringSeparatesPromptAndChoices(t *testing.T) { + var buf bytes.Buffer + printer := NewPrinter(&buf) + + s := NewSelect(&SelectOptions{ + Writer: io.Discard, + Message: "Choose", + Choices: []*SelectChoice{{Value: "a", Label: "Alpha"}}, + EnableFiltering: new(false), + }) + + require.NoError(t, s.Render(printer)) + + output := buf.String() + promptEnd := strings.Index(output, "\n") + require.GreaterOrEqual(t, promptEnd, 0) + assert.True(t, strings.HasPrefix(output[promptEnd:], "\n\n"), output) + assert.Contains(t, output, "Alpha") + assert.NotContains(t, output, "Filter:") +} + func TestSelect_Render_complete(t *testing.T) { var buf bytes.Buffer printer := NewPrinter(&buf) From e3f3407e0ca0b9c7213d4110c341722ceff670af Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 11 Aug 2026 23:43:44 +0000 Subject: [PATCH 13/14] fix(extensions): label mixed-source installs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install.go | 4 +++ cli/azd/cmd/auto_install_test.go | 1 + cli/azd/cmd/auto_install_ux.go | 13 +++++++++ cli/azd/cmd/auto_install_ux_test.go | 43 +++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index b79f6b90d74..768925a7e83 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -407,6 +407,7 @@ func tryAutoInstallExtensionVersion( extensionManager extensionAutoInstallManager, extension extensions.ExtensionMetadata, versionPreference string, + displaySource bool, ) (bool, error) { // Check if the extension is already installed installedExtension, err := extensionManager.GetInstalled(extensions.FilterOptions{ @@ -440,6 +441,9 @@ func tryAutoInstallExtensionVersion( } stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) + if displaySource { + stepMessage += fmt.Sprintf(" from '%s'", extension.Source) + } console.StopSpinner(ctx, stepMessage, input.StepDone) if len(installedVersion.Dependencies) > 0 { displayInstalledDependencies( diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index e6760a1e967..c736f68593a 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -773,6 +773,7 @@ func TestTryAutoInstallExtensionVersionRejectsInstalledVersionConstraint(t *test manager, extensions.ExtensionMetadata{Id: "test.extension"}, ">=2.0.0", + false, ) require.False(t, installed) diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go index be951ef4645..639fab6d16d 100644 --- a/cli/azd/cmd/auto_install_ux.go +++ b/cli/azd/cmd/auto_install_ux.go @@ -100,6 +100,7 @@ func autoInstallExtensionRequirements( if err != nil { return autoInstallResult{}, err } + displaySources := installSelectionsUseMultipleSources(selections) console.Message(ctx, "") installedAny := false @@ -110,6 +111,7 @@ func autoInstallExtensionRequirements( extensionManager, *selection.extension, selection.requirement.versionPreference, + displaySources, ) if err != nil { return autoInstallResult{installed: installedAny}, err @@ -123,6 +125,17 @@ func autoInstallExtensionRequirements( return autoInstallResult{installed: installedAny}, nil } +func installSelectionsUseMultipleSources(selections []extensionInstallSelection) bool { + sources := make(map[string]struct{}, len(selections)) + for _, selection := range selections { + sources[strings.ToLower(selection.extension.Source)] = struct{}{} + if len(sources) >= 2 { + return true + } + } + return false +} + func orderInstallSelections( selections []extensionInstallSelection, ) ([]extensionInstallSelection, error) { diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 7984ed8a674..64842d47bf2 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -356,8 +356,9 @@ func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { require.Len(t, console.SpinnerOps(), 4) assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3)") - assert.NotContains(t, console.SpinnerOps()[1].Message, " from ") + assert.Contains(t, console.SpinnerOps()[1].Message, " from 'azd'") assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) + assert.Contains(t, console.SpinnerOps()[3].Message, " from 'local'") require.NotEmpty(t, console.Output()) assert.Empty(t, console.Output()[len(console.Output())-1]) } @@ -537,7 +538,7 @@ func TestAutoInstallExtensionRequirementsHonorsSelectedDependencySource(t *testi require.Equal(t, []string{"child@local", "parent@azd"}, installOrder) } -func TestAutoInstallExtensionRequirementsShowsSourceForMultiSourcePlan(t *testing.T) { +func TestAutoInstallExtensionRequirementsOmitsSourceWhenSelectionUsesOneSource(t *testing.T) { clearAgentEnvVarsForTest(t) azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) @@ -567,6 +568,44 @@ func TestAutoInstallExtensionRequirementsShowsSourceForMultiSourcePlan(t *testin ) } +func TestInstallSelectionsUseMultipleSources(t *testing.T) { + t.Parallel() + + selection := func(id string, source string) extensionInstallSelection { + return extensionInstallSelection{ + extension: autoInstallTestExtension(id, id, source, extensions.SourceCategoryOther), + } + } + + tests := []struct { + name string + selections []extensionInstallSelection + expected bool + }{ + {name: "empty"}, + { + name: "single source", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "azd")}, + }, + { + name: "source names are case insensitive", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "AZD")}, + }, + { + name: "multiple sources", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "local")}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, installSelectionsUseMultipleSources(tt.selections)) + }) + } +} + func TestDisplayExtensionRequirements(t *testing.T) { t.Parallel() From 07548b3f34a9013d2c3b8bc2c2757a43ee7ed8df Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 12 Aug 2026 23:33:57 +0000 Subject: [PATCH 14/14] fix(extensions): simplify extension task labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7e3d56dc-15a3-451f-af46-25939d0e3fd3 --- cli/azd/cmd/auto_install.go | 7 ++--- cli/azd/cmd/auto_install_ux_test.go | 19 ++++++++++---- cli/azd/cmd/extension.go | 38 ++++++++++++--------------- cli/azd/cmd/extension_task.go | 18 +++++++++++++ cli/azd/cmd/extension_task_test.go | 27 +++++++++++++++++++ cli/azd/cmd/extension_upgrade_test.go | 4 +-- cli/azd/cmd/init.go | 2 +- 7 files changed, 81 insertions(+), 34 deletions(-) create mode 100644 cli/azd/cmd/extension_task.go create mode 100644 cli/azd/cmd/extension_task_test.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 768925a7e83..eec4e14352b 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -429,10 +429,7 @@ func tryAutoInstallExtensionVersion( preInstalledIds[id] = struct{}{} } - stepMessage := fmt.Sprintf( - "Installing %s extension", - output.WithHighLightFormat(extension.Id), - ) + stepMessage := extensionTaskMessage("Installing", extension.Id) console.ShowSpinner(ctx, stepMessage, input.Step) installedVersion, err := extensionManager.Install(ctx, &extension, versionPreference) if err != nil { @@ -442,7 +439,7 @@ func tryAutoInstallExtensionVersion( stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) if displaySource { - stepMessage += fmt.Sprintf(" from '%s'", extension.Source) + stepMessage += fmt.Sprintf(" from %s", extension.Source) } console.StopSpinner(ctx, stepMessage, input.StepDone) if len(installedVersion.Dependencies) > 0 { diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 64842d47bf2..3297e7a5aa5 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -355,10 +355,19 @@ func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { ) require.Len(t, console.SpinnerOps(), 4) assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) - assert.Contains(t, console.SpinnerOps()[1].Message, "(1.2.3)") - assert.Contains(t, console.SpinnerOps()[1].Message, " from 'azd'") + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo")+ + output.WithGrayFormat(" (1.2.3)")+" from azd", + console.SpinnerOps()[1].Message, + ) assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) - assert.Contains(t, console.SpinnerOps()[3].Message, " from 'local'") + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("storage")+ + output.WithGrayFormat(" (1.2.3)")+" from local", + console.SpinnerOps()[3].Message, + ) require.NotEmpty(t, console.Output()) assert.Empty(t, console.Output()[len(console.Output())-1]) } @@ -388,7 +397,7 @@ func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { assert.Equal(t, input.StepFailed, console.SpinnerOps()[1].Format) assert.Equal( t, - "Installing "+output.WithHighLightFormat("demo")+" extension", + "Installing "+output.WithHighLightFormat("demo"), console.SpinnerOps()[1].Message, ) } @@ -562,7 +571,7 @@ func TestAutoInstallExtensionRequirementsOmitsSourceWhenSelectionUsesOneSource(t require.Len(t, console.SpinnerOps(), 2) assert.Equal( t, - "Installing "+output.WithHighLightFormat("demo")+" extension"+ + "Installing "+output.WithHighLightFormat("demo")+ output.WithGrayFormat(" (1.2.3)"), console.SpinnerOps()[1].Message, ) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index f71fc21711a..fb90d1fb80b 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -933,7 +933,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult a.console.Message(ctx, "") } - stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Installing", extensionId) a.console.ShowSpinner(ctx, stepMessage, input.Step) // Check if extension is already installed @@ -2023,7 +2023,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu } for _, extensionId := range extensionIds { - stepMessage := fmt.Sprintf("Uninstalling %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Uninstalling", extensionId) installed, err := a.extensionManager.GetInstalled(extensions.FilterOptions{ Id: extensionId, @@ -2035,7 +2035,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu return nil, fmt.Errorf("failed to get installed extension: %w", err) } - stepMessage += fmt.Sprintf(" (%s)", installed.Version) + stepMessage = extensionTaskMessageWithVersion("Uninstalling", extensionId, installed.Version) a.console.ShowSpinner(ctx, stepMessage, input.Step) if err := a.extensionManager.Uninstall(ctx, extensionId); err != nil { @@ -2358,10 +2358,7 @@ func (a *extensionUpgradeAction) upgradeOneExtension( a.console.Message(ctx, "") } - stepMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), - ) + stepMsg := extensionTaskMessage("Updating", extensionId) if !isJsonOutput { a.console.ShowSpinner(ctx, stepMsg, input.Step) } @@ -2418,9 +2415,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( baseResult.SkipReason = "installed from a self-contained bundle; " + "reinstall with a newer bundle to update" if !isJsonOutput { - skipMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), + skipMsg := extensionTaskMessage( + "Updating", + extensionId, ) + output.WithGrayFormat( " (Installed from a bundle)", ) @@ -2520,9 +2517,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( "in any configured registry" } if !isJsonOutput { - skipMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), + skipMsg := extensionTaskMessage( + "Updating", + extensionId, ) + output.WithGrayFormat( " (No longer available in any registry)", ) @@ -2716,13 +2713,12 @@ func (a *extensionUpgradeAction) upgradeOneExtension( } if !isJsonOutput { - doneMsg := fmt.Sprintf( - "Updated %s extension %s", - output.WithHighLightFormat(extensionId), - output.WithGrayFormat( - "(%s \u2192 %s)", - installed.Version, extVersion.Version, - ), + doneMsg := extensionTaskMessage( + "Updated", + extensionId, + ) + " " + output.WithGrayFormat( + "(%s \u2192 %s)", + installed.Version, extVersion.Version, ) a.console.StopSpinner(ctx, doneMsg, input.StepDone) displayDependencyUpgradeResults(ctx, a.console, baseResult.DependencyUpgrades, " ") @@ -2747,7 +2743,7 @@ func (a *extensionUpgradeAction) displayPromotionWarning( ) { a.console.StopSpinner(ctx, stepMsg, input.StepWarning) a.console.Message(ctx, output.WithWarningFormat( - " (!) Warning: Updated %s extension (%s \u2192 %s, %s \u2192 %s registry)", + " (!) Warning: Updated %s (%s \u2192 %s, %s \u2192 %s registry)", output.WithHighLightFormat(extensionId), fromVersion, toVersion, output.WithHighLightFormat(oldSource), diff --git a/cli/azd/cmd/extension_task.go b/cli/azd/cmd/extension_task.go new file mode 100644 index 00000000000..fe936deeb61 --- /dev/null +++ b/cli/azd/cmd/extension_task.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +func extensionTaskMessage(action string, extensionId string) string { + return fmt.Sprintf("%s %s", action, output.WithHighLightFormat(extensionId)) +} + +func extensionTaskMessageWithVersion(action string, extensionId string, version string) string { + return extensionTaskMessage(action, extensionId) + output.WithGrayFormat(" (%s)", version) +} diff --git a/cli/azd/cmd/extension_task_test.go b/cli/azd/cmd/extension_task_test.go new file mode 100644 index 00000000000..7fae9f20745 --- /dev/null +++ b/cli/azd/cmd/extension_task_test.go @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/stretchr/testify/require" +) + +func TestExtensionTaskMessage(t *testing.T) { + t.Parallel() + + require.Equal( + t, + "Installing "+output.WithHighLightFormat("azure.ai.rle"), + extensionTaskMessage("Installing", "azure.ai.rle"), + ) + require.Equal( + t, + "Uninstalling "+output.WithHighLightFormat("azure.ai.rle")+ + output.WithGrayFormat(" (1.2.3)"), + extensionTaskMessageWithVersion("Uninstalling", "azure.ai.rle", "1.2.3"), + ) +} diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index c54b1be2d28..0e61b7f3314 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -779,7 +779,7 @@ func TestDisplayPromotionWarning(t *testing.T) { action := &extensionUpgradeAction{console: console} action.displayPromotionWarning( t.Context(), - "Updating test.extension extension", + "Updating test.extension", "test.extension", "1.0.0", "1.1.0", @@ -790,7 +790,7 @@ func TestDisplayPromotionWarning(t *testing.T) { require.Len(t, console.SpinnerOps(), 1) require.Equal(t, input.StepWarning, console.SpinnerOps()[0].Format) rendered := strings.Join(console.Output(), "\n") - require.Contains(t, rendered, "Updated test.extension extension") + require.Contains(t, rendered, "Updated test.extension") require.Contains(t, rendered, "1.0.0") require.Contains(t, rendered, "1.1.0") require.Contains(t, rendered, "promoted from the dev registry") diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index adace1e28f4..e828a0ac3fa 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -1130,7 +1130,7 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex i.console.Message(ctx, "\nInstalling required extensions...") for extensionId, versionConstraint := range projectConfig.RequiredVersions.Extensions { - stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Installing", extensionId) i.console.ShowSpinner(ctx, stepMessage, input.Step) installed, isInstalled := installedExtensions[extensionId]