Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ words:
- Authenticode
- gofmt
- golangci
- lightspeed
- toplevel
- azcloud
- azdext
Expand Down
11 changes: 11 additions & 0 deletions cli/azd/cmd/actions/action_descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ type commandGroupAnnotationKey string
const (
// cmdGrouperKey is an annotation key that is added as part of a cobra annotations for assigning commands to a group.
cmdGrouperKey commandGroupAnnotationKey = "commandGrouper"

// AnnotationLightspeed is a cobra annotation key that marks a command as lightspeed.
// Lightspeed commands skip non-essential background work (update checks) so the
// process can exit as fast as possible. Set automatically by CobraBuilder when
// ActionDescriptorOptions.Lightspeed is true.
AnnotationLightspeed = "azd.lightspeed"
)

// GetGroupCommandAnnotation check if there is a grouping annotation for the command. Returns the annotation value as an
Expand Down Expand Up @@ -195,6 +201,11 @@ type ActionDescriptorOptions struct {
GroupingOptions CommandGroupOptions
// Whether or not the command requires a principal login
RequireLogin bool
// Whether the command should execute as fast as possible, skipping
// non-essential background work like update checks.
// Use for programmatic/hidden commands (e.g., auth token) where
// blocking process exit is unacceptable.
Lightspeed bool
}

// Completion function used for cobra command flag completion
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func authActions(root *actions.ActionDescriptor) *actions.ActionDescriptor {
ActionResolver: newAuthTokenAction,
OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat},
DefaultFormat: output.NoneFormat,
Lightspeed: true,
})

group.Add("login", &actions.ActionDescriptorOptions{
Expand Down
140 changes: 120 additions & 20 deletions cli/azd/cmd/auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ import (
"slices"
"strconv"
"strings"
"time"

"github.com/azure/azure-dev/cli/azd/cmd/actions"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect"
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"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/ioc"
"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"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -385,14 +389,79 @@ func tryAutoInstallExtension(
return true, nil
}

// startUpdateCheck launches a background goroutine that checks for a newer
// version of azd and returns a channel that will receive the result.
// The caller should read from the returned channel after command execution.
func startUpdateCheck(ctx context.Context) <-chan *update.VersionInfo {
ch := make(chan *update.VersionInfo, 1)

// Allow the user to skip the update check by setting AZD_SKIP_UPDATE_CHECK.
if value, has := os.LookupEnv("AZD_SKIP_UPDATE_CHECK"); has {
if setting, err := strconv.ParseBool(value); err == nil && setting {
log.Print("skipping update check since AZD_SKIP_UPDATE_CHECK is true")
close(ch)
return ch
} else if err != nil {
Comment thread
vhvb1989 marked this conversation as resolved.
log.Printf("could not parse value for AZD_SKIP_UPDATE_CHECK as a boolean "+
"(it was: %s), proceeding with update check", value)
}
}
Comment thread
vhvb1989 marked this conversation as resolved.

bgCtx, bgCancel := context.WithTimeout(ctx, 60*time.Second)

go func() {
defer close(ch)
defer bgCancel()

configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager()))
userConfig, err := configMgr.Load()
if err != nil {
userConfig = config.NewEmptyConfig()
}

cfg := update.LoadUpdateConfig(userConfig)

mgr := update.NewManager(nil, nil)
versionInfo, err := mgr.CheckForUpdate(bgCtx, cfg, false)
if err != nil {
log.Printf("failed to check for updates: %v, skipping update check", err)
return
}

ch <- versionInfo
}()

return ch
}

// ExecuteResult holds the outcome of ExecuteWithAutoInstall, including
// metadata about the executed command that callers may need for post-execution
// decisions (e.g., whether to wait for the background update check).
type ExecuteResult struct {
// Err is the error returned by the command execution, if any.
Err error
// IsLightspeed is true when the executed command was marked as Lightspeed.
// Callers should skip non-essential post-execution work (update checks, banners)
// for lightspeed commands so the process can exit quickly.
IsLightspeed bool
// LatestVersion receives the result of the background update check.
// Nil when the update check was not started (lightspeed commands).
// When the check is skipped via AZD_SKIP_UPDATE_CHECK, the returned channel
// is closed without a value.
LatestVersion <-chan *update.VersionInfo
}

// ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands.
func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error {
func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) *ExecuteResult {
result := &ExecuteResult{}

// Parse global flags BEFORE creating the command tree.
// This allows us to access flag values (like --no-prompt, --debug) early for auto-install logic.
// This also enables the global options to be set in the container for support during extension framework callbacks.
globalOpts := &internal.GlobalCommandOptions{}
if err := ParseGlobalFlags(os.Args[1:], globalOpts); err != nil {
return fmt.Errorf("Warning: failed to parse global flags: %w", err)
result.Err = fmt.Errorf("Warning: failed to parse global flags: %w", err)
return result
}

// Register GlobalCommandOptions as a singleton in the container BEFORE building the command tree.
Expand All @@ -411,13 +480,26 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
// This allows us to determine if a subcommand was provided or not or if the command is unknown.
foundCmd, originalArgs, err := rootCmd.Find(os.Args[1:])
if err == nil {
// Detect lightspeed commands from the cobra annotation set by CobraBuilder.
result.IsLightspeed = foundCmd.Annotations[actions.AnnotationLightspeed] == "true"

// Start the background update check AFTER command identification.
// Lightspeed commands (e.g., auth token) skip the update check entirely
// so the process can exit as fast as possible — this prevents the
// AzureDeveloperCLICredential 10-second subprocess timeout from being
// hit when the update check is slow (laptop wake, DNS stalls, etc.).
if !result.IsLightspeed {
result.LatestVersion = startUpdateCheck(ctx)
}
Comment thread
vhvb1989 marked this conversation as resolved.

// Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed)
if installed := tryAutoInstallForPartialNamespace(
ctx, rootContainer, foundCmd, originalArgs,
); installed {
// Extension was installed, rebuild command tree and execute
rootCmd = NewRootCmd(false, nil, rootContainer)
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

// Known command, proceed with normal execution
Expand All @@ -427,7 +509,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
// Other command errors (for example, unsupported output formats) should be returned directly.
unsupportedErr, ok := errors.AsType[*project.UnsupportedServiceHostError](err)
if !ok {
return err
result.Err = err
return result
}

if err := rootContainer.Resolve(&extensionManager); err != nil {
Expand All @@ -446,15 +529,15 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
// Do not fail if we couldn't check for extensions - just proceed to normal execution
log.Println("Error: check for extensions. Skipping auto-install:", err)
console.Message(ctx, unsupportedErr.ErrorMessage)
return nil
return result
}
// Note: We don't need to filter or check which extensions are installed.
// If any of these extensions would be installed, the auto-install wouldn't have been triggered because
// there would be at least one extensions providing the capability and provider.
if len(availableExtensionsForHost) == 0 {
// did not find an extension with the capability, just print the original error message
console.Message(ctx, unsupportedErr.ErrorMessage)
return nil
return result
}

console.Message(ctx,
Expand All @@ -470,7 +553,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
chosenExtension, err := promptForExtensionChoice(ctx, console, availableExtensionsForHost)
if err != nil {
console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err))
return err
result.Err = err
return result
}
extensionIdToInstall = *chosenExtension
}
Expand All @@ -479,18 +563,24 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
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())
return installErr
result.Err = installErr
return result
}

if installed {
// Extension was installed, build command tree and execute
rootCmd := NewRootCmd(false, nil, rootContainer)
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

return err
result.Err = err
return result
}

// Unknown command path — always start the update check since these aren't lightspeed.
result.LatestVersion = startUpdateCheck(ctx)

// Extract flags that take values from the root command
flagsWithValues := extractFlagsWithValues(rootCmd)

Expand All @@ -502,7 +592,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
// Check if this is a built-in command first (includes core commands and installed extensions)
if isBuiltInCommand(rootCmd, unknownCommand) {
// This is a built-in command, proceed with normal execution without checking for extensions
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

if err := rootContainer.Resolve(&extensionManager); err != nil {
Expand All @@ -516,12 +607,14 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
if unknownCommand == "login" {
console.Message(ctx, "Error: The 'azd login' command has been removed.")
console.Message(ctx, "Please use 'azd auth login' instead.")
return fmt.Errorf("unknown command 'login'")
result.Err = fmt.Errorf("unknown command 'login'")
return result
}
if unknownCommand == "logout" {
console.Message(ctx, "Error: The 'azd logout' command has been removed.")
console.Message(ctx, "Please use 'azd auth logout' instead.")
return fmt.Errorf("unknown command 'logout'")
result.Err = fmt.Errorf("unknown command 'logout'")
return result
}

// If unknown flags were found before a non-built-in command, return an error with helpful guidance
Expand All @@ -540,7 +633,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
unknownCommand)

console.Message(ctx, errorMsg)
return fmt.Errorf("unknown flags before command: %s", flagsList)
result.Err = fmt.Errorf("unknown flags before command: %s", flagsList)
return result
}

// Get all remaining arguments starting from the command for namespace matching
Expand All @@ -563,7 +657,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
if err != nil {
// Do not fail if we couldn't check for extensions - just proceed to normal execution
log.Println("Error: check for extensions. Skipping auto-install:", err)
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

if len(extensionMatches) > 0 {
Expand All @@ -580,32 +675,37 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai
chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches)
if err != nil {
console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err))
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

if chosenExtension == nil {
// User cancelled selection, proceed to normal execution
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

// Try to auto-install the chosen extension
installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *chosenExtension)
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())
return installErr
result.Err = installErr
return result
}

if installed {
// Extension was installed, build command tree and execute
rootCmd := NewRootCmd(false, nil, rootContainer)
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}
}
}

// Normal execution path - either no args, no matching extension, or user declined install
return rootCmd.ExecuteContext(ctx)
result.Err = rootCmd.ExecuteContext(ctx)
return result
}

// CreateGlobalFlagSet creates a new flag set with all global flags defined.
Expand Down
Loading
Loading