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
8 changes: 5 additions & 3 deletions cli/azd/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ go build

### Test

**Note**: In CI environments like inside a GitHub coding agent session, run `go build` first as the automatic build is skipped and the azd binary must exist for tests that spawn the CLI process. This applies to snapshot tests and functional tests in `test/functional/`.

```bash
# Specific test
go test ./pkg/project/... -run TestProjectConfig
Expand Down Expand Up @@ -171,7 +173,7 @@ go build

Feature-specific docs are in `docs/` — refer to them as needed. Some key docs include:

- `docs/new-azd-command.md` - Adding new commands
- `docs/extension-framework.md` - Extension development using gRPC extension framework
- `docs/guiding-principles.md` - Design principles
- `docs/style-guidelines/new-azd-command.md` - Adding new commands
- `docs/extensions/extension-framework.md` - Extension development using gRPC extension framework
- `docs/style-guidelines/guiding-principles.md` - Design principles
- `docs/tracing-in-azd.md` - Tracing/telemetry guidelines
34 changes: 31 additions & 3 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
"text/tabwriter"

Expand Down Expand Up @@ -179,6 +180,7 @@ type extensionListItem struct {
Namespace string `json:"namespace"`
Version string `json:"version"`
InstalledVersion string `json:"installedVersion"`
UpdateAvailable bool `json:"updateAvailable"`
Source string `json:"source"`
}

Expand Down Expand Up @@ -207,24 +209,40 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e
extensionRows := []extensionListItem{}

for _, extension := range registryExtensions {
if len(extension.Versions) == 0 {
continue
}
Comment on lines +212 to +214
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this prevent extension packs from displaying? Extensions with only dependencies and not versions.


installedExtension, has := installedExtensions[extension.Id]
installed := has && installedExtension.Source == extension.Source

if a.flags.installed && !installed {
continue
}

latestVersion := extension.Versions[len(extension.Versions)-1].Version

var installedVersion string
var updateAvailable bool

if installed {
installedVersion = installedExtension.Version

// Compare versions to determine if an update is available
installedSemver, installedErr := semver.NewVersion(installedExtension.Version)
latestSemver, latestErr := semver.NewVersion(latestVersion)
if installedErr == nil && latestErr == nil {
updateAvailable = latestSemver.GreaterThan(installedSemver)
}
}

extensionRows = append(extensionRows, extensionListItem{
Id: extension.Id,
Name: extension.DisplayName,
Namespace: extension.Namespace,
Version: extension.Versions[len(extension.Versions)-1].Version,
Version: latestVersion,
InstalledVersion: installedVersion,
UpdateAvailable: updateAvailable,
Source: extension.Source,
})
}
Expand Down Expand Up @@ -260,12 +278,12 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e
ValueTemplate: "{{.Name}}",
},
{
Heading: "Version",
Heading: "Latest Version",
ValueTemplate: `{{.Version}}`,
},
{
Heading: "Installed Version",
ValueTemplate: `{{.InstalledVersion}}`,
ValueTemplate: `{{.InstalledVersion}}{{if .UpdateAvailable}}*{{end}}`,
},
{
Heading: "Source",
Expand All @@ -276,6 +294,16 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e
formatErr = a.formatter.Format(extensionRows, a.writer, output.TableFormatterOptions{
Columns: columns,
})

if formatErr == nil && slices.ContainsFunc(extensionRows, func(row extensionListItem) bool {
return row.UpdateAvailable
}) {
a.console.Message(ctx, "\n(*) Update available")
a.console.Message(ctx, fmt.Sprintf(
" To upgrade: %s", output.WithHighLightFormat("azd extension upgrade <extension-id>")))
a.console.Message(ctx, fmt.Sprintf(
" To upgrade all: %s", output.WithHighLightFormat("azd extension upgrade --all")))
}
} else {
formatErr = a.formatter.Format(extensionRows, a.writer, nil)
}
Expand Down
48 changes: 34 additions & 14 deletions cli/azd/cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log"
"os"
"strings"
"time"

"github.com/azure/azure-dev/cli/azd/cmd/actions"
"github.com/azure/azure-dev/cli/azd/internal/grpcserver"
Expand Down Expand Up @@ -160,14 +161,12 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error
showUpdateWarning := !isJsonOutputFromArgs(os.Args)
if showUpdateWarning {
updateResultChan := make(chan *updateCheckOutcome, 1)
// Create a shallow copy of extension data for the goroutine to avoid race condition.
// The goroutine mutates LastUpdateWarning, so we copy only the needed fields.
// Create a minimal copy with only the fields needed for update checking.
// Cannot copy the full Extension due to sync.Once (contains sync.noCopy).
// The goroutine will re-fetch the full extension from config when saving.
extForCheck := &extensions.Extension{
Id: extension.Id,
Namespace: extension.Namespace,
DisplayName: extension.DisplayName,
Description: extension.Description,
Version: extension.Version,
Source: extension.Source,
LastUpdateWarning: extension.LastUpdateWarning,
Expand All @@ -183,9 +182,12 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error
if result != nil && result.shouldShow && result.warning != nil {
a.console.MessageUxItem(ctx, result.warning)
a.console.Message(ctx, "")

// Record cooldown only after warning is actually displayed
a.recordUpdateWarningShown(result.extensionId, result.extensionSource)
}
default:
// Check didn't complete in time, skip warning
// Check didn't complete in time, skip warning (and don't record cooldown)
}
}()
}
Expand Down Expand Up @@ -256,8 +258,10 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error

// updateCheckOutcome holds the result of an async update check
type updateCheckOutcome struct {
shouldShow bool
warning *ux.WarningMessage
shouldShow bool
warning *ux.WarningMessage
extensionId string // Used to record cooldown only when warning is actually displayed
extensionSource string // Source of the extension for precise lookup
}

// checkForUpdateAsync performs the update check in a goroutine and sends the result to the channel.
Expand Down Expand Up @@ -306,17 +310,33 @@ func (a *extensionAction) checkForUpdateAsync(
if result.HasUpdate {
outcome.shouldShow = true
outcome.warning = extensions.FormatUpdateWarning(result)
outcome.extensionId = extension.Id
outcome.extensionSource = extension.Source
// Note: Cooldown is recorded by caller only when warning is actually displayed
}

// Record that we showed the warning (updates extension's LastUpdateWarning field)
updateChecker.RecordWarningShown(extension)
resultChan <- outcome
}

// Save the updated extension to config
if err := a.extensionManager.UpdateInstalled(extension); err != nil {
log.Printf("failed to save warning timestamp: %v", err)
}
// recordUpdateWarningShown saves the cooldown timestamp after a warning is displayed
func (a *extensionAction) recordUpdateWarningShown(extensionId, extensionSource string) {
// Re-fetch the full extension from config to avoid overwriting fields
fullExtension, err := a.extensionManager.GetInstalled(extensions.FilterOptions{
Id: extensionId,
Source: extensionSource,
})
if err != nil {
log.Printf("failed to get extension for saving warning timestamp: %v", err)
return
}

resultChan <- outcome
// Record the warning timestamp
fullExtension.LastUpdateWarning = time.Now().UTC().Format(time.RFC3339)

// Save the updated extension to config
if err := a.extensionManager.UpdateInstalled(fullExtension); err != nil {
log.Printf("failed to save warning timestamp: %v", err)
}
}

// refreshCacheForSource attempts to refresh the cache for a specific source
Expand Down
57 changes: 57 additions & 0 deletions cli/azd/pkg/extensions/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1127,3 +1127,60 @@ func Test_FetchAndCacheMetadata(t *testing.T) {
// The actual Install() function logs this as a warning but doesn't fail
})
}

func Test_GetInstalled_WithSourceFilter(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("AZD_CONFIG_DIR", tempDir)

mockContext := mocks.NewMockContext(context.Background())
fileConfigManager := config.NewFileConfigManager(config.NewManager())
userConfigManager := config.NewUserConfigManager(fileConfigManager)

sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient)
lazyRunner := lazy.NewLazy(func() (*Runner, error) {
return NewRunner(mockContext.CommandRunner), nil
})

manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient)
require.NoError(t, err)

// Install same extension ID from two different sources
extensions := map[string]*Extension{
"test.ext": {
Id: "test.ext",
Namespace: "test",
DisplayName: "Test Extension",
Version: "1.0.0",
Source: "azd",
Path: "extensions/test.ext/test-ext",
},
}

err = manager.userConfig.Set(installedConfigKey, extensions)
require.NoError(t, err)

t.Run("filter by ID only", func(t *testing.T) {
ext, err := manager.GetInstalled(FilterOptions{Id: "test.ext"})
require.NoError(t, err)
require.NotNil(t, ext)
require.Equal(t, "test.ext", ext.Id)
})

t.Run("filter by ID and Source", func(t *testing.T) {
ext, err := manager.GetInstalled(FilterOptions{Id: "test.ext", Source: "azd"})
require.NoError(t, err)
require.NotNil(t, ext)
require.Equal(t, "test.ext", ext.Id)
require.Equal(t, "azd", ext.Source)
})

t.Run("filter by ID and wrong Source returns not found", func(t *testing.T) {
_, err := manager.GetInstalled(FilterOptions{Id: "test.ext", Source: "local"})
require.ErrorIs(t, err, ErrInstalledExtensionNotFound)
})

t.Run("filter by non-existent ID returns not found", func(t *testing.T) {
_, err := manager.GetInstalled(FilterOptions{Id: "non.existent"})
require.ErrorIs(t, err, ErrInstalledExtensionNotFound)
})
}
Loading