From f90ce5984fd7b10fe0b60b94f5f7a711724818f5 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 18 Sep 2025 22:57:24 +0000 Subject: [PATCH 01/16] feat: implement auto-installation workflow for extensions - Add auto-installation support when users run unknown commands that match extension namespaces - Prompt users to install matching extensions with clear descriptions - Automatically execute the original command after successful installation - Maintain existing behavior for commands without matching extensions - Improves discoverability and reduces friction for extension adoption Fixes #5752 --- cli/azd/cmd/auto_install.go | 118 ++++++++++++++++++++++++++++++++++++ cli/azd/main.go | 4 +- 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 cli/azd/cmd/auto_install.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go new file mode 100644 index 00000000000..228216cad5c --- /dev/null +++ b/cli/azd/cmd/auto_install.go @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" +) + +// 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, rootContainer *ioc.NestedContainer, unknownCommand string, + originalArgs []string) (bool, error) { + var extensionManager *extensions.Manager + + if err := rootContainer.Resolve(&extensionManager); err != nil { + // If we can't resolve the extension manager, we can't auto-install + return false, nil + } + + // Check if the unknown command matches any available extension namespace + options := &extensions.ListOptions{} + registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) + if err != nil { + // If we can't list registry extensions, we can't auto-install + return false, nil + } + + var matchingExtension *extensions.ExtensionMetadata + for _, ext := range registryExtensions { + // Check if the namespace matches the unknown command + namespaceParts := strings.Split(ext.Namespace, ".") + if len(namespaceParts) > 0 && namespaceParts[0] == unknownCommand { + matchingExtension = ext + break + } + } + + if matchingExtension == nil { + // No matching extension found + return false, nil + } + + // Check if the extension is already installed + _, err = extensionManager.GetInstalled(extensions.LookupOptions{ + Id: matchingExtension.Id, + }) + if err == nil { + // Extension is already installed, this shouldn't happen but let's be safe + return false, nil + } + + // Ask user for permission to auto-install the extension + fmt.Printf("Command '%s' not found, but there's an available extension that provides it.\n", unknownCommand) + fmt.Printf("Extension: %s (%s)\n", matchingExtension.DisplayName, matchingExtension.Description) + fmt.Printf("Would you like to install it? (y/N): ") + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "y" && response != "yes" { + return false, nil + } + + // Install the extension + fmt.Printf("Installing extension '%s'...\n", matchingExtension.Id) + filterOptions := &extensions.FilterOptions{} + _, err = extensionManager.Install(ctx, matchingExtension.Id, filterOptions) + if err != nil { + return false, fmt.Errorf("failed to install extension: %w", err) + } + + fmt.Printf("Extension '%s' installed successfully!\n", matchingExtension.Id) + return true, nil +} + +// ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. +func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer, originalArgs []string) error { + // First, try to execute the command normally + rootCmd := NewRootCmd(false, nil, rootContainer) + rootCmd.SetArgs(originalArgs) + err := rootCmd.Execute() + + if err != nil { + // Check if this is an "unknown command" error + errMsg := err.Error() + if strings.Contains(errMsg, "unknown command") && len(originalArgs) > 0 { + // Extract the unknown command from the arguments + unknownCommand := originalArgs[0] + + // Try to auto-install an extension for this command + installed, installErr := tryAutoInstallExtension(ctx, rootContainer, unknownCommand, originalArgs) + if installErr != nil { + // If auto-install failed, return the original error + return err + } + + if installed { + // Extension was installed, rebuild the command tree and try again + rootCmd = NewRootCmd(false, nil, rootContainer) + // Set the arguments again to execute the original command + rootCmd.SetArgs(originalArgs) + return rootCmd.Execute() + } + } + + // Return the original error if we couldn't auto-install + return err + } + + return nil +} diff --git a/cli/azd/main.go b/cli/azd/main.go index 0be81691ef6..dafab5cfa93 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -62,7 +62,9 @@ func main() { rootContainer := ioc.NewNestedContainer(nil) ioc.RegisterInstance(rootContainer, ctx) - cmdErr := cmd.NewRootCmd(false, nil, rootContainer).ExecuteContext(ctx) + + // Execute command with auto-installation support for extensions + cmdErr := cmd.ExecuteWithAutoInstall(ctx, rootContainer, os.Args[1:]) oneauth.Shutdown() From 9f37b138932f717cb52775e389f319e29925f63e Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 19 Sep 2025 04:35:29 +0000 Subject: [PATCH 02/16] use console and confirm --- cli/azd/cmd/auto_install.go | 57 ++++++++++++++++++++++++------------- cli/azd/main.go | 2 +- 2 files changed, 39 insertions(+), 20 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 228216cad5c..01495df26f7 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -6,20 +6,24 @@ package cmd import ( "context" "fmt" + "log" + "os" "strings" "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" ) // 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, rootContainer *ioc.NestedContainer, unknownCommand string, - originalArgs []string) (bool, error) { +func tryAutoInstallExtension( + ctx context.Context, rootContainer *ioc.NestedContainer, unknownCommand string) (bool, error) { var extensionManager *extensions.Manager if err := rootContainer.Resolve(&extensionManager); err != nil { // If we can't resolve the extension manager, we can't auto-install + log.Println("Failed to resolve extension manager for auto-install extension:", err) return false, nil } @@ -28,6 +32,7 @@ func tryAutoInstallExtension(ctx context.Context, rootContainer *ioc.NestedConta registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) if err != nil { // If we can't list registry extensions, we can't auto-install + log.Println("Failed to list registry extensions:", err) return false, nil } @@ -43,6 +48,7 @@ func tryAutoInstallExtension(ctx context.Context, rootContainer *ioc.NestedConta if matchingExtension == nil { // No matching extension found + log.Println("No matching extension found for auto-install") return false, nil } @@ -52,42 +58,57 @@ func tryAutoInstallExtension(ctx context.Context, rootContainer *ioc.NestedConta }) if err == nil { // Extension is already installed, this shouldn't happen but let's be safe + log.Println("Extension already installed during auto-install check:", matchingExtension.Id) return false, nil } - // Ask user for permission to auto-install the extension - fmt.Printf("Command '%s' not found, but there's an available extension that provides it.\n", unknownCommand) - fmt.Printf("Extension: %s (%s)\n", matchingExtension.DisplayName, matchingExtension.Description) - fmt.Printf("Would you like to install it? (y/N): ") + var console input.Console + if err := rootContainer.Resolve(&console); err != nil { + log.Println("Failed to resolve console for auto-install extension:", err) + return false, nil + } - var response string - fmt.Scanln(&response) - response = strings.ToLower(strings.TrimSpace(response)) + // Ask user for permission to auto-install the extension + console.Message(ctx, + fmt.Sprintf("Command '%s' not found, but there's an available extension that provides it.\n", unknownCommand)) + console.Message(ctx, + fmt.Sprintf("Extension: %s (%s)\n", matchingExtension.DisplayName, matchingExtension.Description)) + shouldInstall, err := console.Confirm(ctx, input.ConsoleOptions{ + DefaultValue: true, + Message: "Would you like to install it?", + }) + if err != nil { + log.Println("Failed to get user confirmation for auto-install extension:", err) + return false, nil + } - if response != "y" && response != "yes" { + if !shouldInstall { + log.Println("User declined to install extension:", matchingExtension.Id) return false, nil } // Install the extension - fmt.Printf("Installing extension '%s'...\n", matchingExtension.Id) + console.Message(ctx, + fmt.Sprintf("Installing extension '%s'...\n", matchingExtension.Id)) filterOptions := &extensions.FilterOptions{} _, err = extensionManager.Install(ctx, matchingExtension.Id, filterOptions) if err != nil { return false, fmt.Errorf("failed to install extension: %w", err) } - fmt.Printf("Extension '%s' installed successfully!\n", matchingExtension.Id) + console.Message(ctx, + fmt.Sprintf("Extension '%s' installed successfully!\n", matchingExtension.Id)) return true, nil } // ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. -func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer, originalArgs []string) error { +func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error { // First, try to execute the command normally rootCmd := NewRootCmd(false, nil, rootContainer) - rootCmd.SetArgs(originalArgs) - err := rootCmd.Execute() + err := rootCmd.ExecuteContext(ctx) if err != nil { + originalArgs := os.Args[1:] // Check if this is an "unknown command" error errMsg := err.Error() if strings.Contains(errMsg, "unknown command") && len(originalArgs) > 0 { @@ -95,7 +116,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai unknownCommand := originalArgs[0] // Try to auto-install an extension for this command - installed, installErr := tryAutoInstallExtension(ctx, rootContainer, unknownCommand, originalArgs) + installed, installErr := tryAutoInstallExtension(ctx, rootContainer, unknownCommand) if installErr != nil { // If auto-install failed, return the original error return err @@ -104,9 +125,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installed { // Extension was installed, rebuild the command tree and try again rootCmd = NewRootCmd(false, nil, rootContainer) - // Set the arguments again to execute the original command - rootCmd.SetArgs(originalArgs) - return rootCmd.Execute() + return rootCmd.ExecuteContext(ctx) } } diff --git a/cli/azd/main.go b/cli/azd/main.go index dafab5cfa93..8114c7c4f40 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -64,7 +64,7 @@ func main() { ioc.RegisterInstance(rootContainer, ctx) // Execute command with auto-installation support for extensions - cmdErr := cmd.ExecuteWithAutoInstall(ctx, rootContainer, os.Args[1:]) + cmdErr := cmd.ExecuteWithAutoInstall(ctx, rootContainer) oneauth.Shutdown() From 06a71ed7ab6576941a9baf3d28daa7c21b631912 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 19 Sep 2025 05:08:30 +0000 Subject: [PATCH 03/16] update approach to check if the command is an extension --- cli/azd/cmd/auto_install.go | 101 ++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 01495df26f7..eca5a4d3e30 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -6,7 +6,6 @@ package cmd import ( "context" "fmt" - "log" "os" "strings" @@ -15,24 +14,43 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ioc" ) -// 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, rootContainer *ioc.NestedContainer, unknownCommand string) (bool, error) { - var extensionManager *extensions.Manager +// findFirstNonFlagArg finds the first argument that doesn't start with '-' +func findFirstNonFlagArg(args []string) string { + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + return arg + } + } + return "" +} - if err := rootContainer.Resolve(&extensionManager); err != nil { - // If we can't resolve the extension manager, we can't auto-install - log.Println("Failed to resolve extension manager for auto-install extension:", err) - return false, nil +// checkForMatchingExtension checks if the first argument matches any available extension namespace +func checkForMatchingExtension(ctx context.Context, extensionManager *extensions.Manager, command string) bool { + options := &extensions.ListOptions{} + registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) + if err != nil { + return false + } + + for _, ext := range registryExtensions { + namespaceParts := strings.Split(ext.Namespace, ".") + if len(namespaceParts) > 0 && namespaceParts[0] == command { + return true + } } + return false +} + +// 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 *extensions.Manager, unknownCommand string) (bool, error) { // Check if the unknown command matches any available extension namespace options := &extensions.ListOptions{} registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) if err != nil { // If we can't list registry extensions, we can't auto-install - log.Println("Failed to list registry extensions:", err) return false, nil } @@ -48,7 +66,6 @@ func tryAutoInstallExtension( if matchingExtension == nil { // No matching extension found - log.Println("No matching extension found for auto-install") return false, nil } @@ -58,13 +75,6 @@ func tryAutoInstallExtension( }) if err == nil { // Extension is already installed, this shouldn't happen but let's be safe - log.Println("Extension already installed during auto-install check:", matchingExtension.Id) - return false, nil - } - - var console input.Console - if err := rootContainer.Resolve(&console); err != nil { - log.Println("Failed to resolve console for auto-install extension:", err) return false, nil } @@ -78,60 +88,59 @@ func tryAutoInstallExtension( Message: "Would you like to install it?", }) if err != nil { - log.Println("Failed to get user confirmation for auto-install extension:", err) return false, nil } if !shouldInstall { - log.Println("User declined to install extension:", matchingExtension.Id) return false, nil } // Install the extension - console.Message(ctx, - fmt.Sprintf("Installing extension '%s'...\n", matchingExtension.Id)) + console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", matchingExtension.Id)) filterOptions := &extensions.FilterOptions{} _, err = extensionManager.Install(ctx, matchingExtension.Id, filterOptions) if err != nil { return false, fmt.Errorf("failed to install extension: %w", err) } - console.Message(ctx, - fmt.Sprintf("Extension '%s' installed successfully!\n", matchingExtension.Id)) + console.Message(ctx, fmt.Sprintf("Extension '%s' installed successfully!\n", matchingExtension.Id)) return true, nil } // ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error { - // First, try to execute the command normally + // Creating the RootCmd takes care of registering common dependencies in rootContainer rootCmd := NewRootCmd(false, nil, rootContainer) - err := rootCmd.ExecuteContext(ctx) - - if err != nil { - originalArgs := os.Args[1:] - // Check if this is an "unknown command" error - errMsg := err.Error() - if strings.Contains(errMsg, "unknown command") && len(originalArgs) > 0 { - // Extract the unknown command from the arguments - unknownCommand := originalArgs[0] - - // Try to auto-install an extension for this command - installed, installErr := tryAutoInstallExtension(ctx, rootContainer, unknownCommand) - if installErr != nil { - // If auto-install failed, return the original error + originalArgs := os.Args[1:] + // Find the first non-flag argument (the actual command) + unknownCommand := findFirstNonFlagArg(originalArgs) + + // If we have a command, check if it might be an extension command + if unknownCommand != "" { + var extensionManager *extensions.Manager + if err := rootContainer.Resolve(&extensionManager); err != nil { + return err + } + // Check if this command might match an extension before trying to execute + if checkForMatchingExtension(ctx, extensionManager, unknownCommand) { + // Try to auto-install the extension first + var console input.Console + if err := rootContainer.Resolve(&console); err != nil { return err } + installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, unknownCommand) + if installErr != nil { + return installErr + } if installed { - // Extension was installed, rebuild the command tree and try again - rootCmd = NewRootCmd(false, nil, rootContainer) + // Extension was installed, build command tree and execute + rootCmd := NewRootCmd(false, nil, rootContainer) return rootCmd.ExecuteContext(ctx) } } - - // Return the original error if we couldn't auto-install - return err } - return nil + // Normal execution path - either no args, no matching extension, or user declined install + return rootCmd.ExecuteContext(ctx) } From 3633cc954b663cf1599392213dc7d18b5e3ca2f5 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 19 Sep 2025 05:22:18 +0000 Subject: [PATCH 04/16] impl updates --- cli/azd/cmd/auto_install.go | 59 ++++++++++++++----------------------- 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index eca5a4d3e30..61e52392555 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -25,64 +25,45 @@ func findFirstNonFlagArg(args []string) string { } // checkForMatchingExtension checks if the first argument matches any available extension namespace -func checkForMatchingExtension(ctx context.Context, extensionManager *extensions.Manager, command string) bool { +func checkForMatchingExtension( + ctx context.Context, extensionManager *extensions.Manager, command string) (*extensions.ExtensionMetadata, error) { options := &extensions.ListOptions{} registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) if err != nil { - return false + return nil, err } for _, ext := range registryExtensions { namespaceParts := strings.Split(ext.Namespace, ".") if len(namespaceParts) > 0 && namespaceParts[0] == command { - return true + return ext, nil } } - return false + return nil, nil } // 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 *extensions.Manager, unknownCommand string) (bool, error) { - // Check if the unknown command matches any available extension namespace - options := &extensions.ListOptions{} - registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) - if err != nil { - // If we can't list registry extensions, we can't auto-install - return false, nil - } - - var matchingExtension *extensions.ExtensionMetadata - for _, ext := range registryExtensions { - // Check if the namespace matches the unknown command - namespaceParts := strings.Split(ext.Namespace, ".") - if len(namespaceParts) > 0 && namespaceParts[0] == unknownCommand { - matchingExtension = ext - break - } - } - - if matchingExtension == nil { - // No matching extension found - return false, nil - } + ctx context.Context, + console input.Console, + extensionManager *extensions.Manager, + extension extensions.ExtensionMetadata) (bool, error) { // Check if the extension is already installed - _, err = extensionManager.GetInstalled(extensions.LookupOptions{ - Id: matchingExtension.Id, + _, err := extensionManager.GetInstalled(extensions.LookupOptions{ + Id: extension.Id, }) if err == nil { - // Extension is already installed, this shouldn't happen but let's be safe return false, nil } // Ask user for permission to auto-install the extension console.Message(ctx, - fmt.Sprintf("Command '%s' not found, but there's an available extension that provides it.\n", unknownCommand)) + fmt.Sprintf("Command '%s' not found, but there's an available extension that provides it.\n", extension.Namespace)) console.Message(ctx, - fmt.Sprintf("Extension: %s (%s)\n", matchingExtension.DisplayName, matchingExtension.Description)) + fmt.Sprintf("Extension: %s (%s)\n", extension.DisplayName, extension.Description)) shouldInstall, err := console.Confirm(ctx, input.ConsoleOptions{ DefaultValue: true, Message: "Would you like to install it?", @@ -96,14 +77,14 @@ func tryAutoInstallExtension( } // Install the extension - console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", matchingExtension.Id)) + console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", extension.Id)) filterOptions := &extensions.FilterOptions{} - _, err = extensionManager.Install(ctx, matchingExtension.Id, filterOptions) + _, err = extensionManager.Install(ctx, extension.Id, filterOptions) if err != nil { return false, fmt.Errorf("failed to install extension: %w", err) } - console.Message(ctx, fmt.Sprintf("Extension '%s' installed successfully!\n", matchingExtension.Id)) + console.Message(ctx, fmt.Sprintf("Extension '%s' installed successfully!\n", extension.Id)) return true, nil } @@ -122,13 +103,17 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai return err } // Check if this command might match an extension before trying to execute - if checkForMatchingExtension(ctx, extensionManager, unknownCommand) { + extensionMatch, err := checkForMatchingExtension(ctx, extensionManager, unknownCommand) + if err != nil { + return err + } + if extensionMatch != nil { // Try to auto-install the extension first var console input.Console if err := rootContainer.Resolve(&console); err != nil { return err } - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, unknownCommand) + installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *extensionMatch) if installErr != nil { return installErr } From 6cbf772396bdb72d7b125eb1ea3564124554cfc1 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 19 Sep 2025 05:30:12 +0000 Subject: [PATCH 05/16] skip instead of error --- cli/azd/cmd/auto_install.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 61e52392555..932ebcfde34 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "log" "os" "strings" @@ -105,13 +106,15 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Check if this command might match an extension before trying to execute extensionMatch, err := checkForMatchingExtension(ctx, extensionManager, unknownCommand) if err != nil { - return err + // 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) } if extensionMatch != nil { // Try to auto-install the extension first var console input.Console if err := rootContainer.Resolve(&console); err != nil { - return err + return fmt.Errorf("failed to resolve console for auto-install: %w", err) } installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *extensionMatch) if installErr != nil { From 3726e75a345714c95c8a85680f04786b8df46bda Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 19 Sep 2025 05:47:26 +0000 Subject: [PATCH 06/16] some tests --- cli/azd/cmd/auto_install_test.go | 166 +++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 cli/azd/cmd/auto_install_test.go diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go new file mode 100644 index 00000000000..ccf5575a572 --- /dev/null +++ b/cli/azd/cmd/auto_install_test.go @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/stretchr/testify/assert" +) + +func TestFindFirstNonFlagArg(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + { + name: "first arg is command", + args: []string{"demo", "--flag", "value"}, + expected: "demo", + }, + { + name: "command after flags", + args: []string{"--verbose", "demo", "--other"}, + expected: "demo", + }, + { + name: "only flags", + args: []string{"--help", "--version"}, + expected: "", + }, + { + name: "empty args", + args: []string{}, + expected: "", + }, + { + name: "flags with equals", + args: []string{"--config=file", "init", "--template=web"}, + expected: "init", + }, + { + name: "single character flags", + args: []string{"-v", "-h", "up", "--debug"}, + expected: "up", + }, + { + name: "command with flag value", + args: []string{"--output", "json", "demo", "subcommand"}, + expected: "json", + }, + { + name: "no arguments", + args: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := findFirstNonFlagArg(tt.args) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCheckForMatchingExtension_Unit(t *testing.T) { + // This is a unit test that tests the logic without external dependencies + // We'll create a mock-like test by testing the namespace matching logic directly + + testCases := []struct { + name string + command string + extensions []*extensions.ExtensionMetadata + expectedMatch bool + expectedExtId string + }{ + { + name: "matches extension by first namespace part", + command: "demo", + extensions: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.demo", + Namespace: "demo.commands", + }, + }, + expectedMatch: true, + expectedExtId: "microsoft.azd.demo", + }, + { + name: "no match for command", + command: "nonexistent", + extensions: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.demo", + Namespace: "demo.commands", + }, + }, + expectedMatch: false, + }, + { + name: "matches complex namespace", + command: "complex", + extensions: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.complex", + Namespace: "complex.deep.namespace.structure", + }, + }, + expectedMatch: true, + expectedExtId: "microsoft.azd.complex", + }, + { + name: "multiple extensions, finds correct match", + command: "x", + extensions: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.demo", + Namespace: "demo.commands", + }, + { + Id: "microsoft.azd.x", + Namespace: "x.tools", + }, + { + Id: "microsoft.azd.other", + Namespace: "other.namespace", + }, + }, + expectedMatch: true, + expectedExtId: "microsoft.azd.x", + }, + { + name: "empty extensions list", + command: "demo", + extensions: []*extensions.ExtensionMetadata{}, + expectedMatch: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Test the namespace matching logic directly + var foundExtension *extensions.ExtensionMetadata + for _, ext := range tc.extensions { + namespaceParts := strings.Split(ext.Namespace, ".") + if len(namespaceParts) > 0 && namespaceParts[0] == tc.command { + foundExtension = ext + break + } + } + + if tc.expectedMatch { + assert.NotNil(t, foundExtension, "Expected to find matching extension") + if foundExtension != nil { + assert.Equal(t, tc.expectedExtId, foundExtension.Id) + } + } else { + assert.Nil(t, foundExtension, "Expected no matching extension") + } + }) + } +} From b4f3430861c2922110a4aade41bece2981c6fde2 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Sat, 20 Sep 2025 06:30:22 +0000 Subject: [PATCH 07/16] block auto-install in CI/CD. We can't use --no-prompt b/c it would mix the args for auto-install with the args for the extension command --- cli/azd/cmd/auto_install.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 932ebcfde34..657046729d7 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -10,6 +10,8 @@ import ( "os" "strings" + "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "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" @@ -60,6 +62,17 @@ func tryAutoInstallExtension( return false, nil } + // Return error if running in CI/CD environment + if resource.IsRunningOnCI() { + return false, + fmt.Errorf( + "Command '%s' not found, but there's an available extension that provides it.\n"+ + "However, auto-installation is not supported in CI/CD environments.\n"+ + "Run '%s' to install it manually.", + extension.Namespace, + fmt.Sprintf("azd extension install %s", extension.Id)) + } + // Ask user for permission to auto-install the extension console.Message(ctx, fmt.Sprintf("Command '%s' not found, but there's an available extension that provides it.\n", extension.Namespace)) @@ -93,6 +106,21 @@ func tryAutoInstallExtension( func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error { // Creating the RootCmd takes care of registering common dependencies in rootContainer rootCmd := NewRootCmd(false, nil, rootContainer) + + // Continue only if extensions feature is enabled + err := rootContainer.Invoke(func(alphaFeatureManager *alpha.FeatureManager) error { + if !alphaFeatureManager.IsEnabled(extensions.FeatureExtensions) { + return fmt.Errorf("extensions feature is not enabled") + } + return nil + }) + if err != nil { + // Error here means extensions are not enabled or failed to resolve the feature manager + // In either case, we just proceed to normal execution + log.Println("auto-install extensions: ", err) + return rootCmd.ExecuteContext(ctx) + } + originalArgs := os.Args[1:] // Find the first non-flag argument (the actual command) unknownCommand := findFirstNonFlagArg(originalArgs) @@ -101,7 +129,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if unknownCommand != "" { var extensionManager *extensions.Manager if err := rootContainer.Resolve(&extensionManager); err != nil { - return err + log.Panic("failed to resolve extension manager for auto-install:", err) } // Check if this command might match an extension before trying to execute extensionMatch, err := checkForMatchingExtension(ctx, extensionManager, unknownCommand) @@ -114,10 +142,12 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Try to auto-install the extension first var console input.Console if err := rootContainer.Resolve(&console); err != nil { - return fmt.Errorf("failed to resolve console for auto-install: %w", err) + log.Panic("failed to resolve console for auto-install:", err) } installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *extensionMatch) 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 } From 5471b79bc8351dfe49726bb7e3ba49770a41ae58 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Sat, 20 Sep 2025 07:28:22 +0000 Subject: [PATCH 08/16] support using flags before extension command --- cli/azd/cmd/auto_install.go | 131 +++++++++++++- cli/azd/cmd/auto_install_integration_test.go | 74 ++++++++ cli/azd/cmd/auto_install_test.go | 179 ++++++++++++++++++- 3 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 cli/azd/cmd/auto_install_integration_test.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 657046729d7..4ff2ce651fd 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -15,16 +15,104 @@ import ( "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/spf13/cobra" + "github.com/spf13/pflag" ) -// findFirstNonFlagArg finds the first argument that doesn't start with '-' -func findFirstNonFlagArg(args []string) string { - for _, arg := range args { +// extractFlagsWithValues extracts flags that take values from a cobra command. +// This ensures we have a single source of truth for flag definitions by +// dynamically inspecting the command's flag definitions instead of +// maintaining a separate hardcoded list. +// +// The function inspects both regular flags and persistent flags, checking +// the flag's value type to determine if it takes an argument: +// - Bool flags don't take values +// - String, Int, StringSlice, etc. flags do take values +func extractFlagsWithValues(cmd *cobra.Command) map[string]bool { + flagsWithValues := make(map[string]bool) + + // Extract flags that take values from the command + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + // String, StringSlice, StringArray, Int, Int64, etc. all take values + // Bool flags don't take values + if flag.Value.Type() != "bool" { + flagsWithValues["--"+flag.Name] = true + if flag.Shorthand != "" { + flagsWithValues["-"+flag.Shorthand] = true + } + } + }) + + // Also check persistent flags (global flags) + cmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Value.Type() != "bool" { + flagsWithValues["--"+flag.Name] = true + if flag.Shorthand != "" { + flagsWithValues["-"+flag.Shorthand] = true + } + } + }) + + return flagsWithValues +} + +// findFirstNonFlagArg finds the first argument that doesn't start with '-' and isn't a flag value. +// This function properly handles flags that take values (like --output json) to avoid +// incorrectly identifying flag values as commands. +// Returns the command and any unknown flags encountered before the command. +func findFirstNonFlagArg(args []string, flagsWithValues map[string]bool) (command string, unknownFlags []string) { + // Initialize as empty slice instead of nil for consistent behavior + unknownFlags = []string{} + + skipNext := false + for i, arg := range args { + // Skip this argument if it's marked as a flag value from previous iteration + if skipNext { + skipNext = false + continue + } + + // If it doesn't start with '-', it's a potential command if !strings.HasPrefix(arg, "-") { - return arg + return arg, unknownFlags + } + + // Check if this is a known flag that takes a value + if flagsWithValues[arg] { + // This flag takes a value, so skip the next argument + skipNext = true + continue + } + + // Handle flags with '=' syntax like --output=json + if strings.Contains(arg, "=") { + parts := strings.SplitN(arg, "=", 2) + if flagsWithValues[parts[0]] { + // This is a known flag=value format, no need to skip next + continue + } + // Unknown flag with equals - record it + unknownFlags = append(unknownFlags, parts[0]) + continue + } + + // This is an unknown flag - record it + unknownFlags = append(unknownFlags, arg) + + // Conservative heuristic: if the next argument doesn't start with '-' + // and there are more args after it, assume the unknown flag takes a value + if i+1 < len(args) && i+2 < len(args) { + nextArg := args[i+1] + argAfterNext := args[i+2] + if !strings.HasPrefix(nextArg, "-") && !strings.HasPrefix(argAfterNext, "-") { + // Pattern: --unknown value command + // Skip the value, let command be found next + skipNext = true + } } } - return "" + + return "", unknownFlags } // checkForMatchingExtension checks if the first argument matches any available extension namespace @@ -121,9 +209,38 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai return rootCmd.ExecuteContext(ctx) } + // Get the original args passed to the command (excluding the program name) originalArgs := os.Args[1:] - // Find the first non-flag argument (the actual command) - unknownCommand := findFirstNonFlagArg(originalArgs) + + // Extract flags that take values from the root command + flagsWithValues := extractFlagsWithValues(rootCmd) + + // Find the first non-flag argument (the actual command) and check for unknown flags + unknownCommand, unknownFlags := findFirstNonFlagArg(originalArgs, flagsWithValues) + + // If unknown flags were found before a command, return an error with helpful guidance + if len(unknownFlags) > 0 && unknownCommand != "" { + var console input.Console + if err := rootContainer.Resolve(&console); err != nil { + log.Panic("failed to resolve console for unknown flags error:", err) + } + + flagsList := strings.Join(unknownFlags, ", ") + errorMsg := fmt.Sprintf( + "Unknown flags detected before command '%s': %s\n\n"+ + "If you're trying to run an extension command, the extension name must come BEFORE any flags.\n"+ + "This is because extension-specific flags are not known until the extension is installed.\n\n"+ + "Correct usage:\n"+ + " azd %s %s # Extension name first, then flags\n"+ + " azd %s --help # Get help for the extension\n\n"+ + "If this is not an extension command, please check the flag names for typos.", + unknownCommand, flagsList, + unknownCommand, strings.Join(unknownFlags, " "), + unknownCommand) + + console.Message(ctx, errorMsg) + return fmt.Errorf("unknown flags before command: %s", flagsList) + } // If we have a command, check if it might be an extension command if unknownCommand != "" { diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go new file mode 100644 index 00000000000..45719e41d8e --- /dev/null +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// TestExecuteWithAutoInstallIntegration tests the integration between +// extractFlagsWithValues and findFirstNonFlagArg in the context of +// the auto-install feature. +func TestExecuteWithAutoInstallIntegration(t *testing.T) { + // Save original args + originalArgs := os.Args + + // Test cases that would have failed before the fix + testCases := []struct { + name string + args []string + expected string + }{ + { + name: "output flag with demo command", + args: []string{"azd", "--output", "json", "demo"}, + expected: "demo", + }, + { + name: "cwd flag with init command", + args: []string{"azd", "--cwd", "/project", "init"}, + expected: "init", + }, + { + name: "mixed flags", + args: []string{"azd", "--debug", "--output", "table", "--no-prompt", "deploy"}, + expected: "deploy", + }, + { + name: "short form flags", + args: []string{"azd", "-o", "json", "-C", "/path", "up"}, + expected: "up", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set test args + os.Args = tc.args + + // Create a test root command to extract flags from + rootCmd := &cobra.Command{Use: "azd"} + + // Add the flags that azd actually uses + rootCmd.PersistentFlags().StringP("output", "o", "", "Output format") + rootCmd.PersistentFlags().StringP("cwd", "C", "", "Working directory") + rootCmd.PersistentFlags().Bool("debug", false, "Debug mode") + rootCmd.PersistentFlags().Bool("no-prompt", false, "No prompting") + + // Extract flags and test our parsing + flagsWithValues := extractFlagsWithValues(rootCmd) + result, _ := findFirstNonFlagArg(os.Args[1:], flagsWithValues) + + assert.Equal(t, tc.expected, result, + "Failed to correctly identify command in args: %v", tc.args) + }) + } + + // Restore original args + os.Args = originalArgs +} diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index ccf5575a572..2ffc0428b7a 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -8,10 +8,22 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) func TestFindFirstNonFlagArg(t *testing.T) { + // Mock flags that take values for testing + flagsWithValues := map[string]bool{ + "--output": true, + "-o": true, + "--cwd": true, + "-C": true, + "--trace-log-file": true, + "--trace-log-url": true, + "--config": true, // Additional test flag + } + tests := []struct { name string args []string @@ -23,8 +35,8 @@ func TestFindFirstNonFlagArg(t *testing.T) { expected: "demo", }, { - name: "command after flags", - args: []string{"--verbose", "demo", "--other"}, + name: "command after boolean flags", + args: []string{"--debug", "--no-prompt", "demo"}, expected: "demo", }, { @@ -39,34 +51,185 @@ func TestFindFirstNonFlagArg(t *testing.T) { }, { name: "flags with equals", - args: []string{"--config=file", "init", "--template=web"}, - expected: "init", + args: []string{"--output=json", "demo", "--template=web"}, + expected: "demo", }, { - name: "single character flags", + name: "single character boolean flags", args: []string{"-v", "-h", "up", "--debug"}, expected: "up", }, { - name: "command with flag value", + name: "command with output flag value (the original problem)", args: []string{"--output", "json", "demo", "subcommand"}, - expected: "json", + expected: "demo", // Fixed: should be "demo", not "json" + }, + { + name: "command with cwd flag value", + args: []string{"--cwd", "/some/path", "demo"}, + expected: "demo", + }, + { + name: "command with short output flag", + args: []string{"-o", "table", "init"}, + expected: "init", + }, + { + name: "command with short cwd flag", + args: []string{"-C", "/path", "up"}, + expected: "up", + }, + { + name: "mixed flags with values and boolean", + args: []string{"--debug", "--output", "json", "--no-prompt", "deploy"}, + expected: "deploy", }, { name: "no arguments", args: nil, expected: "", }, + { + name: "trace log flags", + args: []string{"--trace-log-file", "debug.log", "monitor"}, + expected: "monitor", + }, + { + name: "complex real world example", + args: []string{"--debug", "--cwd", "/project", "--output", "json", "demo", "--template", "minimal"}, + expected: "demo", + }, + { + name: "test with custom config flag", + args: []string{"--config", "myconfig.yaml", "deploy"}, + expected: "deploy", + }, + { + name: "unknown flag that appears boolean", + args: []string{"--unknown", "command"}, + expected: "command", + }, + { + name: "unknown flag that takes value - PROBLEMATIC CASE", + args: []string{"--unknown-flag", "some-value", "command"}, + expected: "command", // Currently returns "some-value" - this is the problem! + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := findFirstNonFlagArg(tt.args) + result, _ := findFirstNonFlagArg(tt.args, flagsWithValues) assert.Equal(t, tt.expected, result) }) } } +func TestFindFirstNonFlagArgWithUnknownFlags(t *testing.T) { + flagsWithValues := map[string]bool{ + "--output": true, + "-o": true, + "--cwd": true, + "-C": true, + } + + tests := []struct { + name string + args []string + expectedCommand string + expectedUnknownFlags []string + }{ + { + name: "no unknown flags", + args: []string{"--output", "json", "deploy"}, + expectedCommand: "deploy", + expectedUnknownFlags: []string{}, + }, + { + name: "single unknown flag before command", + args: []string{"--unknown", "command"}, + expectedCommand: "command", + expectedUnknownFlags: []string{"--unknown"}, + }, + { + name: "unknown flag that takes value", + args: []string{"--unknown-flag", "some-value", "command"}, + expectedCommand: "command", + expectedUnknownFlags: []string{"--unknown-flag"}, + }, + { + name: "multiple unknown flags", + args: []string{"--flag1", "--flag2", "value", "command"}, + expectedCommand: "command", + expectedUnknownFlags: []string{"--flag1", "--flag2"}, + }, + { + name: "mixed known and unknown flags", + args: []string{"--output", "json", "--unknown", "deploy"}, + expectedCommand: "deploy", + expectedUnknownFlags: []string{"--unknown"}, + }, + { + name: "unknown flag with equals", + args: []string{"--unknown=value", "command"}, + expectedCommand: "command", + expectedUnknownFlags: []string{"--unknown"}, + }, + { + name: "only unknown flags, no command", + args: []string{"--unknown1", "--unknown2"}, + expectedCommand: "", + expectedUnknownFlags: []string{"--unknown1", "--unknown2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command, unknownFlags := findFirstNonFlagArg(tt.args, flagsWithValues) + assert.Equal(t, tt.expectedCommand, command) + assert.Equal(t, tt.expectedUnknownFlags, unknownFlags) + }) + } +} + +func TestExtractFlagsWithValues(t *testing.T) { + // Create a test command with various flag types + cmd := &cobra.Command{ + Use: "test", + } + + // Add flags that take values + cmd.Flags().StringP("output", "o", "", "Output format") + cmd.PersistentFlags().StringP("cwd", "C", "", "Working directory") + cmd.Flags().String("config", "", "Config file") + + // Add boolean flags (should not be included) + cmd.Flags().Bool("debug", false, "Debug mode") + cmd.PersistentFlags().Bool("no-prompt", false, "No prompting") + + // Add flags with other value types + cmd.Flags().Int("port", 8080, "Port number") + cmd.Flags().StringSlice("tags", []string{}, "Tags") + + // Extract flags + flagsWithValues := extractFlagsWithValues(cmd) + + // Test that flags with values are included + assert.True(t, flagsWithValues["--output"], "Should include --output flag") + assert.True(t, flagsWithValues["-o"], "Should include -o shorthand") + assert.True(t, flagsWithValues["--cwd"], "Should include --cwd persistent flag") + assert.True(t, flagsWithValues["-C"], "Should include -C shorthand") + assert.True(t, flagsWithValues["--config"], "Should include --config flag") + assert.True(t, flagsWithValues["--port"], "Should include --port flag (int type)") + assert.True(t, flagsWithValues["--tags"], "Should include --tags flag (slice type)") + + // Test that boolean flags are NOT included + assert.False(t, flagsWithValues["--debug"], "Should not include boolean --debug flag") + assert.False(t, flagsWithValues["--no-prompt"], "Should not include boolean --no-prompt flag") + + // Test non-existent flags + assert.False(t, flagsWithValues["--nonexistent"], "Should not include non-existent flags") +} + func TestCheckForMatchingExtension_Unit(t *testing.T) { // This is a unit test that tests the logic without external dependencies // We'll create a mock-like test by testing the namespace matching logic directly From 52a46fbe689307307b580ada6e7b8140c20ad16d Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 23 Sep 2025 04:19:07 +0000 Subject: [PATCH 09/16] Implement extension matching logic and add tests for built-in commands and multi-namespace extensions --- cli/azd/cmd/auto_install.go | 179 ++++++++++++++---- cli/azd/cmd/auto_install_builtin_test.go | 85 +++++++++ .../cmd/auto_install_multi_namespace_test.go | 126 ++++++++++++ 3 files changed, 354 insertions(+), 36 deletions(-) create mode 100644 cli/azd/cmd/auto_install_builtin_test.go create mode 100644 cli/azd/cmd/auto_install_multi_namespace_test.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 4ff2ce651fd..da9bf496b5f 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -115,23 +115,94 @@ func findFirstNonFlagArg(args []string, flagsWithValues map[string]bool) (comman return "", unknownFlags } -// checkForMatchingExtension checks if the first argument matches any available extension namespace -func checkForMatchingExtension( - ctx context.Context, extensionManager *extensions.Manager, command string) (*extensions.ExtensionMetadata, error) { +// checkForMatchingExtensions checks for extensions that match any possible namespace +// from the command arguments. For example, "azd vhvb demo foo" will check for +// extensions with namespaces: "vhvb", "vhvb.demo", "vhvb.demo.foo" +func checkForMatchingExtensions( + ctx context.Context, extensionManager *extensions.Manager, args []string) ([]*extensions.ExtensionMetadata, error) { + if len(args) == 0 { + return nil, nil + } + options := &extensions.ListOptions{} registryExtensions, err := extensionManager.ListFromRegistry(ctx, options) if err != nil { return nil, err } - for _, ext := range registryExtensions { - namespaceParts := strings.Split(ext.Namespace, ".") - if len(namespaceParts) > 0 && namespaceParts[0] == command { - return ext, nil + var matchingExtensions []*extensions.ExtensionMetadata + + // Generate all possible namespace combinations from the command arguments + // For "azd vhvb demo foo" -> check "vhvb", "vhvb.demo", "vhvb.demo.foo" + for i := 1; i <= len(args); i++ { + candidateNamespace := strings.Join(args[:i], ".") + + // Check if any extension has this exact namespace + for _, ext := range registryExtensions { + if ext.Namespace == candidateNamespace { + matchingExtensions = append(matchingExtensions, ext) + } + } + } + + return matchingExtensions, nil +} + +// promptForExtensionChoice prompts the user to choose from multiple matching extensions +func promptForExtensionChoice( + ctx context.Context, console input.Console, extensions []*extensions.ExtensionMetadata) (*extensions.ExtensionMetadata, error) { + + if len(extensions) == 0 { + return nil, nil + } + + if len(extensions) == 1 { + return extensions[0], nil + } + + console.Message(ctx, "Multiple extensions found that match your command:") + console.Message(ctx, "") + + options := make([]string, len(extensions)) + for i, ext := range extensions { + options[i] = fmt.Sprintf("%s (%s) - %s", ext.Namespace, ext.DisplayName, ext.Description) + console.Message(ctx, fmt.Sprintf(" %d. %s", i+1, options[i])) + } + console.Message(ctx, "") + + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: "Which extension would you like to install?", + Options: options, + }) + if err != nil { + return nil, err + } + + return extensions[choice], nil +} + +// 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. +func isBuiltInCommand(rootCmd *cobra.Command, commandName string) bool { + if commandName == "" { + return false + } + + // Check if the command exists in the root command's subcommands + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == commandName { + return true + } + // Also check aliases + for _, alias := range cmd.Aliases { + if alias == commandName { + return true + } } } - return nil, nil + return false } // tryAutoInstallExtension attempts to auto-install an extension if the unknown command matches an available @@ -218,50 +289,86 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Find the first non-flag argument (the actual command) and check for unknown flags unknownCommand, unknownFlags := findFirstNonFlagArg(originalArgs, flagsWithValues) - // If unknown flags were found before a command, return an error with helpful guidance - if len(unknownFlags) > 0 && unknownCommand != "" { - var console input.Console - if err := rootContainer.Resolve(&console); err != nil { - log.Panic("failed to resolve console for unknown flags error:", err) + // If we have a command, check if it's a built-in command first + if unknownCommand != "" { + // 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) } - flagsList := strings.Join(unknownFlags, ", ") - errorMsg := fmt.Sprintf( - "Unknown flags detected before command '%s': %s\n\n"+ - "If you're trying to run an extension command, the extension name must come BEFORE any flags.\n"+ - "This is because extension-specific flags are not known until the extension is installed.\n\n"+ - "Correct usage:\n"+ - " azd %s %s # Extension name first, then flags\n"+ - " azd %s --help # Get help for the extension\n\n"+ - "If this is not an extension command, please check the flag names for typos.", - unknownCommand, flagsList, - unknownCommand, strings.Join(unknownFlags, " "), - unknownCommand) - - console.Message(ctx, errorMsg) - return fmt.Errorf("unknown flags before command: %s", flagsList) - } + // If unknown flags were found before a non-built-in command, return an error with helpful guidance + if len(unknownFlags) > 0 { + var console input.Console + if err := rootContainer.Resolve(&console); err != nil { + log.Panic("failed to resolve console for unknown flags error:", err) + } + + flagsList := strings.Join(unknownFlags, ", ") + errorMsg := fmt.Sprintf( + "Unknown flags detected before command '%s': %s\n\n"+ + "If you're trying to run an extension command, the extension name must come BEFORE any flags.\n"+ + "This is because extension-specific flags are not known until the extension is installed.\n\n"+ + "Correct usage:\n"+ + " azd %s %s # Extension name first, then flags\n"+ + " azd %s --help # Get help for the extension\n\n"+ + "If this is not an extension command, please check the flag names for typos.", + unknownCommand, flagsList, + unknownCommand, strings.Join(unknownFlags, " "), + unknownCommand) + + console.Message(ctx, errorMsg) + return fmt.Errorf("unknown flags before command: %s", flagsList) + } - // If we have a command, check if it might be an extension command - if unknownCommand != "" { var extensionManager *extensions.Manager if err := rootContainer.Resolve(&extensionManager); err != nil { log.Panic("failed to resolve extension manager for auto-install:", err) } - // Check if this command might match an extension before trying to execute - extensionMatch, err := checkForMatchingExtension(ctx, extensionManager, unknownCommand) + + // Get all remaining arguments starting from the command for namespace matching + // This allows checking longer namespaces like "vhvb.demo.foo" from "azd vhvb demo foo" + var argsForMatching []string + for i, arg := range originalArgs { + if !strings.HasPrefix(arg, "-") && arg == unknownCommand { + // Found the command, collect all non-flag arguments from here + for j := i; j < len(originalArgs); j++ { + if !strings.HasPrefix(originalArgs[j], "-") { + argsForMatching = append(argsForMatching, originalArgs[j]) + } + } + break + } + } + + // Check if any commands might match extensions with various namespace lengths + extensionMatches, err := checkForMatchingExtensions(ctx, extensionManager, argsForMatching) 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) } - if extensionMatch != nil { - // Try to auto-install the extension first + + if len(extensionMatches) > 0 { var console input.Console if err := rootContainer.Resolve(&console); err != nil { log.Panic("failed to resolve console for auto-install:", err) } - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *extensionMatch) + + // 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)) + return rootCmd.ExecuteContext(ctx) + } + + if chosenExtension == nil { + // User cancelled selection, proceed to normal execution + return rootCmd.ExecuteContext(ctx) + } + + // 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()) diff --git a/cli/azd/cmd/auto_install_builtin_test.go b/cli/azd/cmd/auto_install_builtin_test.go new file mode 100644 index 00000000000..c01c38542a5 --- /dev/null +++ b/cli/azd/cmd/auto_install_builtin_test.go @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestIsBuiltInCommand(t *testing.T) { + // Create a mock root command with some subcommands + rootCmd := &cobra.Command{ + Use: "azd", + } + + // Add some built-in commands + upCmd := &cobra.Command{ + Use: "up", + } + rootCmd.AddCommand(upCmd) + + initCmd := &cobra.Command{ + Use: "init", + Aliases: []string{"initialize"}, + } + rootCmd.AddCommand(initCmd) + + downCmd := &cobra.Command{ + Use: "down", + } + rootCmd.AddCommand(downCmd) + + tests := []struct { + name string + commandName string + expected bool + }{ + { + name: "built-in command up returns true", + commandName: "up", + expected: true, + }, + { + name: "built-in command init returns true", + commandName: "init", + expected: true, + }, + { + name: "built-in command down returns true", + commandName: "down", + expected: true, + }, + { + name: "command alias initialize returns true", + commandName: "initialize", + expected: true, + }, + { + name: "non-existent command returns false", + commandName: "demo", + expected: false, + }, + { + name: "empty command name returns false", + commandName: "", + expected: false, + }, + { + name: "unknown command returns false", + commandName: "nonexistent", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isBuiltInCommand(rootCmd, tt.commandName) + if result != tt.expected { + t.Errorf("isBuiltInCommand(%q) = %v, expected %v", tt.commandName, result, tt.expected) + } + }) + } +} diff --git a/cli/azd/cmd/auto_install_multi_namespace_test.go b/cli/azd/cmd/auto_install_multi_namespace_test.go new file mode 100644 index 00000000000..29a4b3bbd04 --- /dev/null +++ b/cli/azd/cmd/auto_install_multi_namespace_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/stretchr/testify/assert" +) + +func TestCheckForMatchingExtensionsLogic(t *testing.T) { + // Test the core logic without needing to mock the extension manager + // We'll create a simple function that mimics the matching logic + + testExtensions := []*extensions.ExtensionMetadata{ + { + Id: "extension1", + Namespace: "demo", + DisplayName: "Demo Extension", + Description: "Simple demo extension", + }, + { + Id: "extension2", + Namespace: "vhvb.demo", + DisplayName: "VHVB Demo Extension", + Description: "VHVB namespace demo extension", + }, + { + Id: "extension3", + Namespace: "vhvb.demo.advanced", + DisplayName: "Advanced VHVB Demo", + Description: "Advanced demo with longer namespace", + }, + { + Id: "extension4", + Namespace: "other.namespace", + DisplayName: "Other Extension", + Description: "Different namespace pattern", + }, + } + + // Helper function that mimics checkForMatchingExtensions logic + checkMatches := func(args []string, availableExtensions []*extensions.ExtensionMetadata) []*extensions.ExtensionMetadata { + if len(args) == 0 { + return nil + } + + var matchingExtensions []*extensions.ExtensionMetadata + + // Generate all possible namespace combinations from the command arguments + for i := 1; i <= len(args); i++ { + candidateNamespace := strings.Join(args[:i], ".") + + // Check if any extension has this exact namespace + for _, ext := range availableExtensions { + if ext.Namespace == candidateNamespace { + matchingExtensions = append(matchingExtensions, ext) + } + } + } + + return matchingExtensions + } + + tests := []struct { + name string + args []string + expectedMatches []string // Extension IDs that should match + }{ + { + name: "single word matches single extension", + args: []string{"demo"}, + expectedMatches: []string{"extension1"}, + }, + { + name: "two words matches nested namespace", + args: []string{"vhvb", "demo"}, + expectedMatches: []string{"extension2"}, + }, + { + name: "three words matches deep namespace", + args: []string{"vhvb", "demo", "advanced"}, + expectedMatches: []string{"extension2", "extension3"}, // Both vhvb.demo and vhvb.demo.advanced should match + }, + { + name: "multiple matches for progressive namespaces", + args: []string{"vhvb", "demo", "advanced", "extra"}, + expectedMatches: []string{"extension2", "extension3"}, // Both vhvb.demo and vhvb.demo.advanced should match + }, + { + name: "no matches for unknown namespace", + args: []string{"unknown", "command"}, + expectedMatches: []string{}, + }, + { + name: "empty args returns no matches", + args: []string{}, + expectedMatches: []string{}, + }, + { + name: "partial namespace without full match", + args: []string{"vhvb"}, + expectedMatches: []string{}, // No extension with namespace "vhvb" exists + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Execute function + matches := checkMatches(tt.args, testExtensions) + + // Verify results + assert.Equal(t, len(tt.expectedMatches), len(matches), "Number of matches should be correct") + + // Check that the right extensions were matched + matchedIds := make([]string, len(matches)) + for i, match := range matches { + matchedIds[i] = match.Id + } + + for _, expectedId := range tt.expectedMatches { + assert.Contains(t, matchedIds, expectedId, "Expected extension %s to be in matches", expectedId) + } + }) + } +} From 356c13d6eb5b84730f6667d5cd7db7db63051737 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Tue, 23 Sep 2025 04:24:31 +0000 Subject: [PATCH 10/16] lint --- cli/azd/cmd/auto_install.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index da9bf496b5f..9635cec95c8 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -150,7 +150,9 @@ func checkForMatchingExtensions( // promptForExtensionChoice prompts the user to choose from multiple matching extensions func promptForExtensionChoice( - ctx context.Context, console input.Console, extensions []*extensions.ExtensionMetadata) (*extensions.ExtensionMetadata, error) { + ctx context.Context, + console input.Console, + extensions []*extensions.ExtensionMetadata) (*extensions.ExtensionMetadata, error) { if len(extensions) == 0 { return nil, nil From f697ccf7ad7d3a9aba1f4701aa7b2959d839cdc4 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 24 Sep 2025 00:11:44 +0000 Subject: [PATCH 11/16] remove redundant code --- cli/azd/cmd/auto_install.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 9635cec95c8..8a6158235c4 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -43,16 +43,6 @@ func extractFlagsWithValues(cmd *cobra.Command) map[string]bool { } }) - // Also check persistent flags (global flags) - cmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { - if flag.Value.Type() != "bool" { - flagsWithValues["--"+flag.Name] = true - if flag.Shorthand != "" { - flagsWithValues["-"+flag.Shorthand] = true - } - } - }) - return flagsWithValues } From b4512fcf588ec63c40a0b9650198e8ad90d3d434 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Wed, 24 Sep 2025 00:14:02 +0000 Subject: [PATCH 12/16] lint --- cli/azd/cmd/auto_install_multi_namespace_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auto_install_multi_namespace_test.go b/cli/azd/cmd/auto_install_multi_namespace_test.go index 29a4b3bbd04..454d54e2fee 100644 --- a/cli/azd/cmd/auto_install_multi_namespace_test.go +++ b/cli/azd/cmd/auto_install_multi_namespace_test.go @@ -40,7 +40,8 @@ func TestCheckForMatchingExtensionsLogic(t *testing.T) { } // Helper function that mimics checkForMatchingExtensions logic - checkMatches := func(args []string, availableExtensions []*extensions.ExtensionMetadata) []*extensions.ExtensionMetadata { + checkMatches := func( + args []string, availableExtensions []*extensions.ExtensionMetadata) []*extensions.ExtensionMetadata { if len(args) == 0 { return nil } From 37ba71c32f62288875933226799616fe948fae31 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 26 Sep 2025 07:06:37 +0000 Subject: [PATCH 13/16] revert the code removal and add note about why this is required --- cli/azd/cmd/auto_install.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 8a6158235c4..b6b0d3f7707 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -43,6 +43,21 @@ func extractFlagsWithValues(cmd *cobra.Command) map[string]bool { } }) + // Also check persistent flags (global flags) + // IMPORTANT: cmd.Flags().VisitAll() does NOT include persistent flags. + // In Cobra, cmd.Flags() only returns local flags specific to that command, + // while cmd.PersistentFlags() returns flags that are inherited by subcommands. + // These are separate flag sets, so we must call both VisitAll functions + // to capture all flags that can take values. + cmd.PersistentFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Value.Type() != "bool" { + flagsWithValues["--"+flag.Name] = true + if flag.Shorthand != "" { + flagsWithValues["-"+flag.Shorthand] = true + } + } + }) + return flagsWithValues } From 104b60b27b829e86a559ccdd2b392c41b9287573 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 26 Sep 2025 18:09:14 +0000 Subject: [PATCH 14/16] lint and cr --- cli/azd/cmd/auto_install.go | 4 ++-- cli/azd/cmd/auto_install_multi_namespace_test.go | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index b6b0d3f7707..625cb1e0e95 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -121,8 +121,8 @@ func findFirstNonFlagArg(args []string, flagsWithValues map[string]bool) (comman } // checkForMatchingExtensions checks for extensions that match any possible namespace -// from the command arguments. For example, "azd vhvb demo foo" will check for -// extensions with namespaces: "vhvb", "vhvb.demo", "vhvb.demo.foo" +// from the command arguments. For example, "azd foo demo bar" will check for +// extensions with namespaces: "foo", "foo.demo", "foo.demo.bar" func checkForMatchingExtensions( ctx context.Context, extensionManager *extensions.Manager, args []string) ([]*extensions.ExtensionMetadata, error) { if len(args) == 0 { diff --git a/cli/azd/cmd/auto_install_multi_namespace_test.go b/cli/azd/cmd/auto_install_multi_namespace_test.go index 454d54e2fee..f53b34e4ca6 100644 --- a/cli/azd/cmd/auto_install_multi_namespace_test.go +++ b/cli/azd/cmd/auto_install_multi_namespace_test.go @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + package cmd import ( From 62ebc3e1013b4de797ef457a1ef399be2902257d Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Fri, 26 Sep 2025 18:15:28 +0000 Subject: [PATCH 15/16] cspell --- cli/azd/cmd/auto_install.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 625cb1e0e95..08e24147d4c 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -138,7 +138,7 @@ func checkForMatchingExtensions( var matchingExtensions []*extensions.ExtensionMetadata // Generate all possible namespace combinations from the command arguments - // For "azd vhvb demo foo" -> check "vhvb", "vhvb.demo", "vhvb.demo.foo" + // For "azd something demo foo" -> check "something", "something.demo", "something.demo.foo" for i := 1; i <= len(args); i++ { candidateNamespace := strings.Join(args[:i], ".") @@ -334,7 +334,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai } // Get all remaining arguments starting from the command for namespace matching - // This allows checking longer namespaces like "vhvb.demo.foo" from "azd vhvb demo foo" + // This allows checking longer namespaces like "something.demo.foo" from "azd something demo foo" var argsForMatching []string for i, arg := range originalArgs { if !strings.HasPrefix(arg, "-") && arg == unknownCommand { From f8785d03de132055e98838061a5637c1225ce235 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Thu, 2 Oct 2025 19:50:06 +0000 Subject: [PATCH 16/16] use Find() to exit fast and skip auto-install for known commands w/o parsing flags for auto-install --- cli/azd/cmd/auto_install.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 08e24147d4c..e39d08eab6b 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -287,8 +287,19 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai return rootCmd.ExecuteContext(ctx) } - // Get the original args passed to the command (excluding the program name) - originalArgs := os.Args[1:] + // rootCmd.Find() returns the root command if no subcommand is identified. Cobra checks all the registered commands + // and returns the longest matching command. If no subcommand is found, it returns the root command itself. + // This allows us to determine if a subcommand was provided or not or if the command is unknown. + topCommand, originalArgs, err := rootCmd.Find(os.Args[1:]) + if err != nil { + // If we can't parse the command, just proceed to normal execution + log.Println("Error: parse command. Skipping auto-install:", err) + return rootCmd.ExecuteContext(ctx) + } + if topCommand != rootCmd || len(originalArgs) == 0 { + // known command to be run OR no subcommand provided - skip auto-install + return rootCmd.ExecuteContext(ctx) + } // Extract flags that take values from the root command flagsWithValues := extractFlagsWithValues(rootCmd)