Skip to content
Open
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
132 changes: 127 additions & 5 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,9 +913,16 @@ func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult
return nil, fmt.Errorf("failed to get installed extension: %w", err)
}

// Honor the source from which the extension was originally installed
// Only use the --source flag if explicitly provided by the user
sourceToUse := installed.Source
if a.flags.source != "" {
sourceToUse = a.flags.source
}
Comment on lines +916 to +921
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for honoring the installed extension's source is good, but there's a potential edge case when --all is used with --source. If a user runs azd extension upgrade --all --source specific-source, all installed extensions will be attempted to be upgraded from specific-source, even if they were originally installed from different sources. This will fail with "extension X not found in source specific-source" for extensions not available in that source. Consider adding validation to prevent this confusing combination, similar to the existing check for --version with multiple extensions at line 870-872.

Copilot uses AI. Check for mistakes.

filterOptions := &extensions.FilterOptions{
Id: extensionId,
Source: a.flags.source,
Source: sourceToUse,
Version: a.flags.version,
}

Expand All @@ -927,17 +934,46 @@ func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult

if len(matches) == 0 {
a.console.StopSpinner(ctx, stepMessage, input.StepFailed)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message when an extension is not found in the expected source now includes the source name, which is an improvement. However, when this error occurs during --all upgrades, it will cause the entire upgrade operation to fail and stop processing remaining extensions. Consider whether it would be better to skip the failed extension with a warning and continue with the remaining extensions, similar to how the newer version check errors are handled (lines 966-974).

Suggested change
a.console.StopSpinner(ctx, stepMessage, input.StepFailed)
a.console.StopSpinner(ctx, stepMessage, input.StepFailed)
// When upgrading all extensions, skip missing extensions with a warning
// instead of failing the entire operation.
if a.flags.all {
a.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: fmt.Sprintf(
"Skipping upgrade for extension %s because it was not found in source %s",
extensionId,
sourceToUse,
),
})
return nil, nil
}

Copilot uses AI. Check for mistakes.
return nil, fmt.Errorf("extension %s not found", extensionId)
return nil, fmt.Errorf("extension %s not found in source %s", extensionId, sourceToUse)
}

selectedExtension, err := selectDistinctExtension(ctx, a.console, extensionId, matches, a.flags.global)
if err != nil {
return nil, err
// When filtering by exact source and extension ID, there should only be one match
// However, if there are multiple matches (unexpected), we'll use the first one
if len(matches) > 1 {
a.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: fmt.Sprintf(
"Found %d matches for %s in source %s, using the first match",
len(matches),
extensionId,
sourceToUse,
),
})
Comment on lines +940 to +950
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning for multiple matches (lines 942-951) indicates an unexpected state when filtering by exact source and extension ID. However, this warning uses the first match without any explanation of why the first one was chosen or what criteria differentiate the matches. Consider either: (1) sorting the matches by version (descending) and documenting this in the warning, or (2) returning an error since this truly is an unexpected/invalid state in the extension registry that should be investigated rather than silently worked around.

Suggested change
// When filtering by exact source and extension ID, there should only be one match
// However, if there are multiple matches (unexpected), we'll use the first one
if len(matches) > 1 {
a.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: fmt.Sprintf(
"Found %d matches for %s in source %s, using the first match",
len(matches),
extensionId,
sourceToUse,
),
})
// When filtering by exact source and extension ID, there should only be one match.
// If there are multiple matches, this indicates an invalid or inconsistent state in the
// extension registry; fail explicitly so this can be investigated rather than silently
// proceeding with an arbitrary selection.
if len(matches) > 1 {
a.console.StopSpinner(ctx, stepMessage, input.StepFailed)
return nil, fmt.Errorf(
"found %d matches for extension %s in source %s; expected exactly one match",
len(matches),
extensionId,
sourceToUse,
)

Copilot uses AI. Check for mistakes.
}
selectedExtension := matches[0]

a.console.ShowSpinner(ctx, stepMessage, input.Step)
latestVersion := selectedExtension.Versions[len(selectedExtension.Versions)-1]

// Check if there's a newer version available in other sources
// Only do this if the user didn't explicitly specify a source
if a.flags.source == "" {
if err := a.checkForNewerVersionInOtherSources(
ctx,
extensionId,
installed.Source,
latestVersion.Version,
); err != nil {
// Log the error but don't fail the upgrade
// Using console warning instead of log.Printf for consistency
a.console.MessageUxItem(ctx, &ux.WarningMessage{
Description: fmt.Sprintf(
"Failed to check for newer versions in other sources: %v",
err,
),
})
}
}
Comment on lines +957 to +975
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning about newer versions in other sources is displayed while the spinner is still active (line 954 shows spinner, lines 957-975 show the warning). This will result in the warning being interleaved with the spinner output, making it hard to read. The warning should be displayed either before the spinner starts or after it's stopped. Consider moving the checkForNewerVersionInOtherSources call to after line 1011 (after the spinner is stopped) or stopping the spinner before showing the warning and restarting it afterward.

Copilot uses AI. Check for mistakes.

// Parse semantic versions for proper comparison
installedSemver, err := semver.NewVersion(installed.Version)
if err != nil {
Expand Down Expand Up @@ -982,6 +1018,92 @@ func (a *extensionUpgradeAction) Run(ctx context.Context) (*actions.ActionResult
}, nil
}

// checkForNewerVersionInOtherSources checks if there's a newer version of the extension
// available in sources other than the current one, and displays a warning if found.
func (a *extensionUpgradeAction) checkForNewerVersionInOtherSources(
ctx context.Context,
extensionId string,
currentSource string,
currentLatestVersion string,
) error {
// Find all versions of this extension across all sources (excluding current source)
allMatches, err := a.extensionManager.FindExtensions(ctx, &extensions.FilterOptions{
Id: extensionId,
})
if err != nil {
return err
}

// Parse the current latest version
currentSemver, err := semver.NewVersion(currentLatestVersion)
if err != nil {
return fmt.Errorf("failed to parse current latest version '%s': %w", currentLatestVersion, err)
}

// Check each source for newer versions
var newerVersionSource string
var newestSemver *semver.Version

for _, match := range allMatches {
// Skip the current source
if strings.EqualFold(match.Source, currentSource) {
continue
}

// Get the latest version from this source
if len(match.Versions) == 0 {
continue
}

latestInSource := match.Versions[len(match.Versions)-1]
latestSemver, err := semver.NewVersion(latestInSource.Version)
if err != nil {
// Skip versions that can't be parsed
continue
}

// Check if this version is newer than current AND newer than what we've found so far
if latestSemver.GreaterThan(currentSemver) {
if newestSemver == nil || latestSemver.GreaterThan(newestSemver) {
newestSemver = latestSemver
newerVersionSource = match.Source
}
}
}

// Display warning if a newer version was found
if newestSemver != nil {
warningDesc := fmt.Sprintf(
"A newer version (%s) of %s is available in source '%s', but the extension was installed "+
"from source '%s'. The extension will be upgraded to version %s from the original source.",
output.WithHighLightFormat(newestSemver.String()),
output.WithHighLightFormat(extensionId),
output.WithHighLightFormat(newerVersionSource),
output.WithHighLightFormat(currentSource),
output.WithHighLightFormat(currentLatestVersion),
)
a.console.MessageUxItem(ctx, &ux.WarningMessage{Description: warningDesc})
Comment on lines +1075 to +1085
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we showed the message after the upgrade instead of before and made it a little more concise? It feels a little verbose currently. Maybe something like:

E.g. upgrading from 0.0.1 to 0.0.2:

  (✓) Done: Upgrading microsoft.azd.concurx extension (0.0.2)

  A newer version 0.1.0 is available in source 'azd' (installed: 0.0.2 from 'local').
  To switch: azd ext uninstall microsoft.azd.concurx && azd ext install microsoft.azd.concurx --source azd

If no upgrade available on current source:

  (-) Skipped: Upgrading microsoft.azd.concurx extension (No upgrade available)

  A newer version 0.1.0 is available in source 'azd' (installed: 0.0.2 from 'local').
  To switch: azd ext uninstall microsoft.azd.concurx && azd ext install microsoft.azd.concurx --source azd


a.console.Message(ctx, "")
instructionMsg := fmt.Sprintf(
"To switch to the newer version from '%s', first uninstall the extension and then "+
"install it from the desired source:",
newerVersionSource,
)
a.console.Message(ctx, instructionMsg)

uninstallCmd := fmt.Sprintf("azd extension uninstall %s", extensionId)
a.console.Message(ctx, fmt.Sprintf(" %s", output.WithHighLightFormat(uninstallCmd)))

installCmd := fmt.Sprintf("azd extension install %s --source %s", extensionId, newerVersionSource)
a.console.Message(ctx, fmt.Sprintf(" %s", output.WithHighLightFormat(installCmd)))

a.console.Message(ctx, "")
}
Comment on lines +1088 to +1102
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instruction message for switching sources suggests running two separate commands (uninstall + install). However, this workflow could lose user configuration or settings associated with the extension. Consider adding a note about this potential data loss, or alternatively, document whether the extension manager preserves configuration across uninstall/install operations. Additionally, it might be helpful to mention that the user can specify --source <source> flag directly with the upgrade command to override the default behavior.

Copilot uses AI. Check for mistakes.

return nil
}

type extensionSourceListAction struct {
formatter output.Formatter
writer io.Writer
Expand Down
1 change: 1 addition & 0 deletions cli/azd/cmd/extensions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,4 @@
require.Equal(t, "Extension for fine tuning AI models.", finetuneCmd.Options.Command.Short)
require.Equal(t, "Extension for evaluating AI models.", evalCmd.Options.Command.Short)
}

Check failure on line 179 in cli/azd/cmd/extensions_test.go

View workflow job for this annotation

GitHub Actions / azd-lint (ubuntu-latest)

File is not properly formatted (gofmt)
2 changes: 1 addition & 1 deletion cli/azd/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/invopop/jsonschema v0.13.0
github.com/jmespath-community/go-jmespath v1.1.1
github.com/joho/godotenv v1.5.1
github.com/mark3labs/mcp-go v0.41.1
github.com/mattn/go-colorable v0.1.14
Expand Down Expand Up @@ -115,7 +116,6 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath-community/go-jmespath v1.1.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
Expand Down
Loading