Skip to content
Closed
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 @@ -84,6 +84,7 @@ words:
- rustc
- figspec
- finetune
- finetuning
- gitdir
- bubbletea
- lipgloss
Expand Down
50 changes: 39 additions & 11 deletions cli/azd/cmd/auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,11 @@ 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 foo demo bar" will check for
// extensions with namespaces: "foo", "foo.demo", "foo.demo.bar"
// from the command arguments. For example, "azd foo demo bar" considers candidate
// namespaces "foo", "foo.demo", "foo.demo.bar". Only matches at the deepest
// candidate length are returned: a more specific extension is always preferred
// over a broader one (e.g., "azd ai finetuning" surfaces an `ai.finetuning`
// extension rather than also listing one that owns the bare `ai` namespace).
func checkForMatchingExtensions(
ctx context.Context, extensionManager *extensions.Manager, args []string) ([]*extensions.ExtensionMetadata, error) {
if len(args) == 0 {
Expand All @@ -150,17 +153,20 @@ func checkForMatchingExtensions(

var matchingExtensions []*extensions.ExtensionMetadata

// Generate all possible namespace combinations from the command arguments
// For "azd something demo foo" -> check "something", "something.demo", "something.demo.foo"
for i := 1; i <= len(args); i++ {
candidateNamespace := strings.Join(args[:i], ".")

// Check if any extension has this exact namespace
var levelMatches []*extensions.ExtensionMetadata
for _, ext := range registryExtensions {
if ext.Namespace == candidateNamespace {
matchingExtensions = append(matchingExtensions, ext)
levelMatches = append(levelMatches, ext)
}
}
// Deeper prefix wins. Overwrite (rather than append) so a hit at a
// later iteration replaces hits collected at earlier, shorter prefixes.
if len(levelMatches) > 0 {
matchingExtensions = levelMatches
}
}

return matchingExtensions, nil
Expand Down Expand Up @@ -263,17 +269,18 @@ func buildNamespaceArgs(foundCmd *cobra.Command, remainingArgs []string) []strin
// command group), but the user runs "azd ai bar init" where the "ai.bar" extension is not installed.
// Without this check, Cobra would find the "ai" command and show its help instead of prompting to install
// the "ai.bar" extension.
//
// Hybrid leaves (a node that is both an extension leaf and a parent of sibling-extension
// subcommands) are also considered: when the user reaches an extension leaf and the next
// positional arg matches no registered cobra child, that arg can still designate an
// uninstalled sibling extension (e.g. user has `azure.ai.agents` at `ai` and types
// `azd ai finetune ...` for the not-yet-installed `azure.ai.finetune` extension).
func tryAutoInstallForPartialNamespace(
ctx context.Context,
rootContainer *ioc.NestedContainer,
foundCmd *cobra.Command,
remainingArgs []string,
) bool {
if _, isExtensionCmd := foundCmd.Annotations["extension.id"]; isExtensionCmd {
// Extension commands handle their own args via DisableFlagParsing
return false
}

var firstRemainingArg string
for _, arg := range remainingArgs {
if !strings.HasPrefix(arg, "-") {
Expand All @@ -286,6 +293,15 @@ func tryAutoInstallForPartialNamespace(
return false
}

// Extension leaves use DisableFlagParsing and forward unknown args to the
// binary, so cobra won't error and the binary would print its own
// "unknown command" instead of letting azd auto-install. Bypass the
// pass-through only when the next positional arg looks like a candidate
// for a sibling extension under this leaf's namespace (handled below by
// checkForMatchingExtensions). We don't need an extra check here: if no
// extension matches, the function returns false and execution falls
// through to the leaf binary's normal pass-through.

argsForMatching := buildNamespaceArgs(foundCmd, remainingArgs)
if len(argsForMatching) == 0 {
return false
Expand All @@ -307,6 +323,18 @@ func tryAutoInstallForPartialNamespace(
log.Printf("failed to check for matching extensions: %v", err)
return false
}
// Drop already-installed extensions from the candidate list. They contribute
// no install action and surfacing them as "Command was not found, but
// there's an available extension that provides it" is misleading. This
// matters for hybrid leaves: when the user has `azure.ai.agents` at
// namespace `ai` and types `azd ai bogus`, the deepest-prefix match is
// `azure.ai.agents` (already installed); without this filter we'd print
// the install prompt only to immediately decline because the matched
// extension is already present.
extensionMatches = slices.DeleteFunc(extensionMatches, func(ext *extensions.ExtensionMetadata) bool {
_, err := extensionManager.GetInstalled(extensions.FilterOptions{Id: ext.Id})
return err == nil
})
if len(extensionMatches) == 0 {
return false
}
Expand Down
83 changes: 62 additions & 21 deletions cli/azd/cmd/auto_install_multi_namespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,26 @@ func TestCheckForMatchingExtensionsLogic(t *testing.T) {
DisplayName: "Other Extension",
Description: "Different namespace pattern",
},
// Fixture pair for the broad-leaf + nested-extension scenarios.
{
Id: "agents-leaf",
Namespace: "ai",
DisplayName: "AI Agents (leaf)",
Description: "Owns the bare `ai` namespace",
},
{
Id: "finetune",
Namespace: "ai.finetuning",
DisplayName: "AI Fine-tuning",
Description: "Nested under ai.finetuning",
},
}

// Helper function that mimics checkForMatchingExtensions logic
// Helper function that mimics checkForMatchingExtensions logic.
// Mirrors the production behavior: only matches at the deepest non-empty
// candidate prefix are returned (longest-match-wins). Shorter-prefix
// matches are dropped when a deeper prefix also has matches, so a more
// specific extension is always preferred over a broader one.
checkMatches := func(
args []string, availableExtensions []*extensions.ExtensionMetadata) []*extensions.ExtensionMetadata {
if len(args) == 0 {
Expand All @@ -52,16 +69,18 @@ func TestCheckForMatchingExtensionsLogic(t *testing.T) {

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
var levelMatches []*extensions.ExtensionMetadata
for _, ext := range availableExtensions {
if ext.Namespace == candidateNamespace {
matchingExtensions = append(matchingExtensions, ext)
levelMatches = append(levelMatches, ext)
}
}
if len(levelMatches) > 0 {
matchingExtensions = levelMatches
}
}

return matchingExtensions
Expand All @@ -83,14 +102,20 @@ func TestCheckForMatchingExtensionsLogic(t *testing.T) {
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: "three words returns only the deepest match",
args: []string{"vhvb", "demo", "advanced"},
// vhvb.demo and vhvb.demo.advanced both have matches, but only
// the deeper one is returned so the user isn't asked to choose
// between a broad and a specific extension that overlap.
expectedMatches: []string{"extension3"},
},
{
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: "multiple words returns only the deepest match",
args: []string{"vhvb", "demo", "advanced", "extra"},
// No extension has namespace vhvb.demo.advanced.extra; the
// deepest match is still vhvb.demo.advanced, so we return only
// that one and drop the shorter vhvb.demo match.
expectedMatches: []string{"extension3"},
},
{
name: "no matches for unknown namespace",
Expand All @@ -107,6 +132,26 @@ func TestCheckForMatchingExtensionsLogic(t *testing.T) {
args: []string{"vhvb"},
expectedMatches: []string{}, // No extension with namespace "vhvb" exists
},
{
name: "broad leaf shown when user typed an unknown subcommand",
// User has azure.ai.agents (namespace "ai") in the registry and
// types `azd ai blah` — `blah` matches nothing, but the leaf
// extension at `ai` is the right suggestion because once
// installed, the leaf binary can decide whether `blah` is a
// valid subcommand.
args: []string{"ai", "blah"},
expectedMatches: []string{"agents-leaf"},
},
{
name: "specific match wins over broader leaf",
// User has azure.ai.agents (namespace "ai") AND azure.ai.finetune
// (namespace "ai.finetuning"). Typing `azd ai finetuning` exactly
// matches the more specific extension; the broader leaf must NOT
// be offered because finetune is the only extension that
// actually contributes a top-level `finetuning` command.
args: []string{"ai", "finetuning"},
expectedMatches: []string{"finetune"},
},
}

for _, tt := range tests {
Expand All @@ -115,18 +160,14 @@ func TestCheckForMatchingExtensionsLogic(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)
// Tighten the assertion: the production helper now returns only
// the deepest matching prefix, so the matched IDs should equal
// the expected set exactly (not just be a subset).
matchedIds := make([]string, 0, len(matches))
for _, match := range matches {
matchedIds = append(matchedIds, match.Id)
}
assert.ElementsMatch(t, tt.expectedMatches, matchedIds)
})
}
}
8 changes: 7 additions & 1 deletion cli/azd/cmd/cmd_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,13 @@ func getCmdHelpDefaultDescription(cmd *cobra.Command) string {
func getCmdHelpDefaultUsage(cmd *cobra.Command) string {
return fmt.Sprintf("%s\n %s\n\n",
output.WithBold("%s", output.WithUnderline("Usage")),
"{{if .Runnable}}{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}{{.CommandPath}} [command]{{end}}",
// Hybrid commands (Runnable AND HasAvailableSubCommands) need the
// runnable UseLine and the "[command]" suffix on separate lines so
// the rendered help is readable. Pure leaves and pure parents still
// render exactly one line.
"{{if .Runnable}}{{.UseLine}}{{end}}"+
"{{if and .Runnable .HasAvailableSubCommands}}\n {{end}}"+
"{{if .HasAvailableSubCommands}}{{.CommandPath}} [command]{{end}}",
)
}

Expand Down
7 changes: 5 additions & 2 deletions cli/azd/cmd/cobra_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ func (cb *CobraBuilder) BuildCommand(descriptor *actions.ActionDescriptor) (*cob
}
}

// Configure action resolver for leaf commands
if !cmd.HasSubCommands() {
// Configure an action resolver for leaf commands and for hybrid commands
// that expose both an action and child subcommands. Pure parent groups
// (subcommands but no resolver) skip RunE binding so cobra renders help
// when the command is invoked without a subcommand.
if descriptor.Options.ActionResolver != nil || !cmd.HasSubCommands() {
if err := cb.configureActionResolver(cmd, descriptor); err != nil {
return nil, err
}
Expand Down
82 changes: 40 additions & 42 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -841,8 +841,16 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult
a.console.ShowSpinner(ctx, stepMessage, input.Step)
}

// Check for namespace conflicts with installed extensions
if err := checkNamespaceConflict(
// Determine target version for upgrade/downgrade decision-making below.
targetVersion := a.flags.version
if targetVersion == "" || targetVersion == "latest" {
targetVersion = extensions.LatestVersion(compatibleExtension.Versions).Version
}

// Block exact namespace collisions with already-installed extensions.
// Prefix-overlapping namespaces (e.g. "ai" + "ai.finetune") coexist
// via the hybrid-tree merge in bindExtension, so they are allowed.
if err := checkNamespaceCommandOverlap(
compatibleExtension.Id,
compatibleExtension.Namespace,
allInstalled,
Expand All @@ -851,12 +859,6 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult
return nil, err
}

// Determine target version
targetVersion := a.flags.version
if targetVersion == "" || targetVersion == "latest" {
targetVersion = extensions.LatestVersion(compatibleExtension.Versions).Version
}

var extensionVersion *extensions.ExtensionVersion

if alreadyInstalled {
Expand Down Expand Up @@ -1452,7 +1454,10 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
return baseResult
}

// Perform the upgrade
// Perform the upgrade. Cobra's deterministic runtime resolution and the
// merged help renderer mean install-time metadata-driven shadow detection
// is intentionally out of scope; users discover what is reachable where
// via `azd <namespace> --help`.
extVersion, err := a.extensionManager.Upgrade(
ctx, compatExt, a.flags.version,
)
Expand Down Expand Up @@ -1930,9 +1935,12 @@ func selectDistinctExtension(
return matches[*sourceResponseIndex], nil
}

// checkNamespaceConflict checks if the given namespace conflicts with any installed extension.
// Two namespaces conflict if one is a prefix of the other (e.g., "ai" and "ai.agent").
func checkNamespaceConflict(
// checkNamespaceCommandOverlap blocks installs that exact-match an already-installed
// extension's namespace. Prefix-overlapping namespaces ("ai" + "ai.finetune") are
// allowed: bindExtension merges them into a single hybrid cobra node, child
// resolution wins over leaf pass-through, and the merged help renderer surfaces
// what is reachable where. The comparison is case-insensitive.
func checkNamespaceCommandOverlap(
newExtId string,
newNamespace string,
installedExtensions map[string]*extensions.Extension,
Expand All @@ -1941,51 +1949,41 @@ func checkNamespaceConflict(
return nil
}

newLower := strings.ToLower(newNamespace)

for extId, ext := range installedExtensions {
if extId == newExtId {
continue // Skip self (for upgrades)
continue // Skip self (re-install or upgrade)
}
if ext.Namespace == "" {
continue
}

if conflict, _ := namespacesConflict(newNamespace, ext.Namespace); conflict {
return &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"namespace '%s' conflicts with installed extension '%s' (namespace '%s')",
newNamespace, extId, ext.Namespace,
),
Suggestion: fmt.Sprintf(
"Suggestion: uninstall %s first or choose a different extension.",
output.WithHighLightFormat(extId),
),
}
if strings.ToLower(ext.Namespace) != newLower {
continue
}
return &internal.ErrorWithSuggestion{
Err: fmt.Errorf(
"namespace '%s' conflicts with installed extension '%s' (namespace '%s')",
newNamespace, ext.Id, ext.Namespace,
),
Suggestion: fmt.Sprintf(
"Uninstall %s first or choose a different extension.",
output.WithHighLightFormat(ext.Id),
),
}
}

return nil
}

// namespacesConflict checks if two namespaces conflict.
// Returns true and a description if they conflict.
// Comparison is case-insensitive.
// namespacesConflict reports whether two extension namespaces conflict at install
// time. Today only exact (case-insensitive) namespace matches conflict;
// prefix-overlapping namespaces are supported via the hybrid-tree merge in
// bindExtension.
func namespacesConflict(ns1, ns2 string) (bool, string) {
// Normalize to lowercase for case-insensitive comparison
ns1Lower := strings.ToLower(ns1)
ns2Lower := strings.ToLower(ns2)

if ns1Lower == ns2Lower {
if strings.EqualFold(ns1, ns2) {
return true, "the same namespace"
}

// Check if one is a prefix of the other (with dot separator)
if strings.HasPrefix(ns1Lower, ns2Lower+".") {
return true, "overlapping namespaces"
}
if strings.HasPrefix(ns2Lower, ns1Lower+".") {
return true, "overlapping namespaces"
}

return false, ""
}

Expand Down
Loading
Loading