diff --git a/cli/azd/AGENTS.md b/cli/azd/AGENTS.md index d4abfa37846..178f4b68b23 100644 --- a/cli/azd/AGENTS.md +++ b/cli/azd/AGENTS.md @@ -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 @@ -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 diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 4aaf1f1c653..05569bc7120 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "slices" "strings" "text/tabwriter" @@ -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"` } @@ -207,6 +209,10 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e extensionRows := []extensionListItem{} for _, extension := range registryExtensions { + if len(extension.Versions) == 0 { + continue + } + installedExtension, has := installedExtensions[extension.Id] installed := has && installedExtension.Source == extension.Source @@ -214,17 +220,29 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e 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, }) } @@ -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", @@ -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 "))) + 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) } diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 0f21792b612..e2956b0177f 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -160,14 +160,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, @@ -183,9 +181,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) } }() } @@ -256,8 +257,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. @@ -306,17 +309,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 + extensions.RecordUpdateWarningShown(fullExtension) + + // 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 diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 519760e6098..be0b882e454 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -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) + }) +} diff --git a/cli/azd/pkg/extensions/update_checker.go b/cli/azd/pkg/extensions/update_checker.go index e4d26898c3d..a1da3316c68 100644 --- a/cli/azd/pkg/extensions/update_checker.go +++ b/cli/azd/pkg/extensions/update_checker.go @@ -104,9 +104,9 @@ func (c *UpdateChecker) ShouldShowWarning(extension *Extension) bool { return time.Now().UTC().After(lastTime.Add(warningCoolDownPeriod)) } -// RecordWarningShown updates the extension's LastUpdateWarning timestamp +// RecordUpdateWarningShown updates the extension's LastUpdateWarning timestamp. // Mutates the provided extension in place (caller should save it via Manager.UpdateInstalled) -func (c *UpdateChecker) RecordWarningShown(extension *Extension) { +func RecordUpdateWarningShown(extension *Extension) { extension.LastUpdateWarning = time.Now().UTC().Format(time.RFC3339) } diff --git a/cli/azd/pkg/extensions/update_checker_test.go b/cli/azd/pkg/extensions/update_checker_test.go index 7334bfd43d1..f8335feda8b 100644 --- a/cli/azd/pkg/extensions/update_checker_test.go +++ b/cli/azd/pkg/extensions/update_checker_test.go @@ -108,7 +108,7 @@ func Test_UpdateChecker_WarningCooldown(t *testing.T) { require.True(t, updateChecker.ShouldShowWarning(extension)) // Record warning shown (updates extension's LastUpdateWarning) - updateChecker.RecordWarningShown(extension) + RecordUpdateWarningShown(extension) require.NotEmpty(t, extension.LastUpdateWarning) // Should not show warning again (within cooldown) diff --git a/cli/azd/pkg/extensions/update_integration_test.go b/cli/azd/pkg/extensions/update_integration_test.go index 5c9cd75a34a..29824f37721 100644 --- a/cli/azd/pkg/extensions/update_integration_test.go +++ b/cli/azd/pkg/extensions/update_integration_test.go @@ -131,7 +131,7 @@ func Test_Integration_UpdateCheck_FullFlow(t *testing.T) { require.True(t, updateChecker.ShouldShowWarning(extension)) // Record warning (updates extension's LastUpdateWarning field) - updateChecker.RecordWarningShown(extension) + RecordUpdateWarningShown(extension) require.NotEmpty(t, extension.LastUpdateWarning) // Immediately after - should not show warning (cooldown)