Skip to content
Merged
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
13 changes: 13 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ words:
- chinacloudapi
- Codespace
- Codespaces
- cooldown
- customtype
- devcontainers
- errgroup
Expand All @@ -24,6 +25,7 @@ words:
- idxs
# Looks like the protogen has a spelling error for panics
- pancis
- pkgux
- proto
- protobuf
- protoc
Expand Down Expand Up @@ -206,6 +208,17 @@ overrides:
- filename: extensions/azure.ai.agents/internal/pkg/agents/registry_api/operations.go
words:
- Dataagent
- filename: extensions/azure.ai.agents/internal/project/parser.go
words:
- helloworld
- filename: extensions/azure.ai.agents/internal/project/service_target_agent.go
words:
- curr
- kval
- filename: pkg/project/dockerfile_builder.go
words:
- WORKDIR
- workdir
- filename: docs/new-azd-command.md
words:
- pflag
Expand Down
151 changes: 146 additions & 5 deletions cli/azd/cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package cmd
import (
"context"
"fmt"
"log"
"os"
"strings"

Expand All @@ -18,11 +19,27 @@ 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/lazy"
"github.com/azure/azure-dev/cli/azd/pkg/ux"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
pkgux "github.com/azure/azure-dev/cli/azd/pkg/ux"
"github.com/fatih/color"
"github.com/spf13/cobra"
)

// isJsonOutputFromArgs checks if --output json or -o json was passed in args
func isJsonOutputFromArgs(args []string) bool {
for i, arg := range args {
if arg == "--output" || arg == "-o" {
if i+1 < len(args) && args[i+1] == "json" {
return true
}
}
if arg == "--output=json" || arg == "-o=json" {
return true
}
}
return false
}

// bindExtension binds the extension to the root command
func bindExtension(
root *actions.ActionDescriptor,
Expand Down Expand Up @@ -138,6 +155,41 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error
return nil, fmt.Errorf("failed to get extension %s: %w", extensionId, err)
}

// Start update check in background while extension runs
// By the time extension finishes, we'll have the result ready
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.
// Cannot copy the full Extension due to sync.Once (contains sync.noCopy).
extForCheck := &extensions.Extension{
Id: extension.Id,
Namespace: extension.Namespace,
DisplayName: extension.DisplayName,
Description: extension.Description,
Version: extension.Version,
Source: extension.Source,
LastUpdateWarning: extension.LastUpdateWarning,
}
go a.checkForUpdateAsync(ctx, extForCheck, updateResultChan)
// Note: This defer runs AFTER the defer for a.azdServer.Stop() registered later,
// because defers execute in LIFO order. This is intentional - we want to show
// the warning after the extension completes but the server stop doesn't affect us.
defer func() {
// Collect result and show warning if needed (non-blocking read)
select {
case result := <-updateResultChan:
if result != nil && result.shouldShow && result.warning != nil {
a.console.MessageUxItem(ctx, result.warning)
a.console.Message(ctx, "")
}
default:
// Check didn't complete in time, skip warning
}
}()
}

tracing.SetUsageAttributes(
fields.ExtensionId.String(extension.Id),
fields.ExtensionVersion.String(extension.Version))
Expand All @@ -152,7 +204,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error

// Pass the console width down to the child process
// COLUMNS is a semi-standard environment variable used by many Unix programs to determine the width of the terminal.
width := ux.ConsoleWidth()
width := pkgux.ConsoleWidth()
if width > 0 {
allEnv = append(allEnv, fmt.Sprintf("COLUMNS=%d", width))
}
Expand Down Expand Up @@ -191,10 +243,99 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error
Interactive: true,
}

_, err = a.extensionRunner.Invoke(ctx, extension, options)
if err != nil {
return nil, err
_, invokeErr := a.extensionRunner.Invoke(ctx, extension, options)

// Update warning is shown via defer above (runs after invoke completes)

if invokeErr != nil {
return nil, invokeErr
}

return nil, nil
}

// updateCheckOutcome holds the result of an async update check
type updateCheckOutcome struct {
shouldShow bool
warning *ux.WarningMessage
}

// checkForUpdateAsync performs the update check in a goroutine and sends the result to the channel.
// This runs in parallel with the extension execution, so by the time the extension finishes,
// we have the result ready with zero added latency.
func (a *extensionAction) checkForUpdateAsync(
ctx context.Context,
extension *extensions.Extension,
resultChan chan<- *updateCheckOutcome,
) {
defer close(resultChan)

outcome := &updateCheckOutcome{shouldShow: false}

// Create cache manager
cacheManager, err := extensions.NewRegistryCacheManager()
if err != nil {
log.Printf("failed to create cache manager: %v", err)
resultChan <- outcome
return
}

// Check if cache needs refresh - if so, refresh it now (we have time while extension runs)
if cacheManager.IsExpiredOrMissing(ctx, extension.Source) {
a.refreshCacheForSource(ctx, cacheManager, extension.Source)
}

// Create update checker
updateChecker := extensions.NewUpdateChecker(cacheManager)

// Check if we should show a warning (respecting cooldown)
// Uses extension's LastUpdateWarning field
if !updateChecker.ShouldShowWarning(extension) {
resultChan <- outcome
return
}

// Check for updates
result, err := updateChecker.CheckForUpdate(ctx, extension)
if err != nil {
log.Printf("failed to check for extension update: %v", err)
resultChan <- outcome
return
}

if result.HasUpdate {
outcome.shouldShow = true
outcome.warning = extensions.FormatUpdateWarning(result)

// Record that we showed the warning (updates extension's LastUpdateWarning field)
updateChecker.RecordWarningShown(extension)

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

resultChan <- outcome
}

// refreshCacheForSource attempts to refresh the cache for a specific source
func (a *extensionAction) refreshCacheForSource(
ctx context.Context,
cacheManager *extensions.RegistryCacheManager,
sourceName string,
) {
// Find extensions from this source to get registry data
sourceExtensions, err := a.extensionManager.FindExtensions(ctx, &extensions.FilterOptions{
Source: sourceName,
})
if err != nil {
log.Printf("failed to fetch extensions from source %s: %v", sourceName, err)
return
}

// Cache the extensions
if err := cacheManager.Set(ctx, sourceName, sourceExtensions); err != nil {
log.Printf("failed to cache extensions for source %s: %v", sourceName, err)
}
}
23 changes: 12 additions & 11 deletions cli/azd/pkg/extensions/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import (

// Extension represents an installed extension.
type Extension struct {
Id string `json:"id"`
Namespace string `json:"namespace"`
Capabilities []CapabilityType `json:"capabilities,omitempty"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Version string `json:"version"`
Usage string `json:"usage"`
Path string `json:"path"`
Source string `json:"source"`
Providers []Provider `json:"providers,omitempty"`
McpConfig *McpConfig `json:"mcp,omitempty"`
Id string `json:"id"`
Namespace string `json:"namespace"`
Capabilities []CapabilityType `json:"capabilities,omitempty"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Version string `json:"version"`
Usage string `json:"usage"`
Path string `json:"path"`
Source string `json:"source"`
Providers []Provider `json:"providers,omitempty"`
McpConfig *McpConfig `json:"mcp,omitempty"`
LastUpdateWarning string `json:"lastUpdateWarning,omitempty"`

stdin *bytes.Buffer
stdout *output.DynamicMultiWriter
Expand Down
27 changes: 27 additions & 0 deletions cli/azd/pkg/extensions/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,33 @@ func (m *Manager) GetInstalled(options FilterOptions) (*Extension, error) {
return nil, ErrInstalledExtensionNotFound
}

// UpdateInstalled updates an installed extension's metadata in the config
func (m *Manager) UpdateInstalled(extension *Extension) error {
extensions, err := m.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed extensions: %w", err)
}

if _, exists := extensions[extension.Id]; !exists {
return ErrInstalledExtensionNotFound
}

extensions[extension.Id] = extension

if err := m.userConfig.Set(installedConfigKey, extensions); err != nil {
return fmt.Errorf("failed to set extensions section: %w", err)
}

if err := m.configManager.Save(m.userConfig); err != nil {
return fmt.Errorf("failed to save user config: %w", err)
}

// Invalidate cache so subsequent calls reflect the updated extension
m.installed = nil

return nil
}

func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([]*ExtensionMetadata, error) {
allExtensions := []*ExtensionMetadata{}

Expand Down
Loading
Loading