Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 145 additions & 121 deletions cli/azd/cmd/auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"io"
"log"
"maps"
"os"
"path/filepath"
"slices"
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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 autoInstallResult{}, nil
}

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 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 {
Expand All @@ -378,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{
Expand All @@ -390,44 +420,42 @@ 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",
})
installedBefore, err := extensionManager.ListInstalled()
if err != nil {
return false, err
return false, fmt.Errorf("listing installed extensions: %w", err)
}

if !shouldInstall {
return false, nil
preInstalledIds := make(map[string]struct{}, len(installedBefore))
for id := range installedBefore {
preInstalledIds[id] = struct{}{}
}

// Install the extension
console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", extension.Id))
_, err = extensionManager.Install(ctx, &extension, versionPreference)
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 {
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)
if displaySource {
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
}

Expand Down Expand Up @@ -610,6 +638,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)
Expand All @@ -622,9 +653,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)
Expand Down Expand Up @@ -694,41 +738,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
}
Expand Down Expand Up @@ -822,34 +855,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)
Expand Down
Loading
Loading