diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 32e0aaf77d3..3f13877ef92 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -29,6 +29,8 @@ words: # CDN host name - gfgac2cmf7b8cuay - Getenv + - GOWORK + - Gowork - goversioninfo - OPENCODE - opencode @@ -383,6 +385,9 @@ overrides: words: - covdata - GOWORK + - filename: pkg/tool/manifest.go + words: + - azureresourcegroups ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index a50503adfdf..d16deaae8a0 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -66,6 +66,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/state" "github.com/azure/azure-dev/cli/azd/pkg/templates" + "github.com/azure/azure-dev/cli/azd/pkg/tool" "github.com/azure/azure-dev/cli/azd/pkg/tools/az" "github.com/azure/azure-dev/cli/azd/pkg/tools/bash" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" @@ -952,6 +953,34 @@ func registerCommonDependencies(container *ioc.NestedContainer) { }) }) + // Tool management + container.MustRegisterSingleton(func(commandRunner exec.CommandRunner) *tool.PlatformDetector { + return tool.NewPlatformDetector(commandRunner) + }) + container.MustRegisterSingleton(func(commandRunner exec.CommandRunner) tool.Detector { + return tool.NewDetector(commandRunner) + }) + container.MustRegisterSingleton(func( + commandRunner exec.CommandRunner, + platformDetector *tool.PlatformDetector, + detector tool.Detector, + ) tool.Installer { + return tool.NewInstaller(commandRunner, platformDetector, detector) + }) + container.MustRegisterSingleton(func( + configManager config.UserConfigManager, + detector tool.Detector, + ) *tool.UpdateChecker { + return tool.NewUpdateChecker(configManager, detector, config.GetUserConfigDir) + }) + container.MustRegisterSingleton(func( + detector tool.Detector, + installer tool.Installer, + updateChecker *tool.UpdateChecker, + ) *tool.Manager { + return tool.NewManager(detector, installer, updateChecker) + }) + // gRPC Server container.MustRegisterScoped(grpcserver.NewServer) container.MustRegisterScoped(grpcserver.NewProjectService) diff --git a/cli/azd/cmd/middleware/tool_first_run.go b/cli/azd/cmd/middleware/tool_first_run.go new file mode 100644 index 00000000000..41aa3b63541 --- /dev/null +++ b/cli/azd/cmd/middleware/tool_first_run.go @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/tool" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" +) + +// configKeyFirstRunCompleted is the user-config path that records +// the timestamp of a completed first-run experience. +const configKeyFirstRunCompleted = "tool.firstRunCompleted" + +// envKeySkipFirstRun is the environment variable that, when set to +// "true", suppresses the first-run tool check entirely. +const envKeySkipFirstRun = "AZD_SKIP_FIRST_RUN" + +// ToolFirstRunMiddleware presents a one-time welcome experience +// on the very first invocation of azd. It detects the user's +// installed Azure development tools and optionally offers to +// install any missing recommended tools. +type ToolFirstRunMiddleware struct { + configManager config.UserConfigManager + console input.Console + manager *tool.Manager + options *internal.GlobalCommandOptions +} + +// NewToolFirstRunMiddleware creates a new [ToolFirstRunMiddleware]. +func NewToolFirstRunMiddleware( + configManager config.UserConfigManager, + console input.Console, + manager *tool.Manager, + options *internal.GlobalCommandOptions, +) Middleware { + return &ToolFirstRunMiddleware{ + configManager: configManager, + console: console, + manager: manager, + options: options, + } +} + +// Run executes the first-run experience if it has not been completed +// yet. Regardless of whether the experience runs, the middleware +// always delegates to nextFn so the user's intended command is +// never blocked. +func (m *ToolFirstRunMiddleware) Run(ctx context.Context, nextFn NextFn) (*actions.ActionResult, error) { + // Skip for child actions (e.g. workflow steps). + if IsChildAction(ctx) { + return nextFn(ctx) + } + + if m.shouldSkip(ctx) { + return nextFn(ctx) + } + + // Run the first-run experience. Errors are logged but never + // propagated — the user's command must always proceed. + if err := m.runFirstRunExperience(ctx); err != nil { + log.Printf("tool first-run experience failed: %v", err) + } + + return nextFn(ctx) +} + +// shouldSkip returns true when the first-run experience should be +// bypassed. The reasons are checked in order of cost (cheapest +// first). +func (m *ToolFirstRunMiddleware) shouldSkip(ctx context.Context) bool { + // 1. Env-var opt-out. + if skip, _ := strconv.ParseBool(os.Getenv(envKeySkipFirstRun)); skip { + return true + } + + // 2. Non-interactive mode (--no-prompt). + if m.options.NoPrompt { + return true + } + + // 3. CI/CD environment — never prompt in CI. + if resource.IsRunningOnCI() { + return true + } + + // 4. Non-interactive terminal (piped stdin/stdout). + if m.console.IsNoPromptMode() { + return true + } + + // 5. Already completed. + cfg, err := m.configManager.Load() + if err != nil { + log.Printf("tool first-run: failed to load user config: %v", err) + return true // err on the side of not blocking the user + } + + if _, ok := cfg.Get(configKeyFirstRunCompleted); ok { + return true + } + + return false +} + +// runFirstRunExperience drives the interactive welcome flow. +func (m *ToolFirstRunMiddleware) runFirstRunExperience(ctx context.Context) error { + // --------------------------------------------------------------- + // Welcome banner + // --------------------------------------------------------------- + m.console.Message(ctx, "") + m.console.Message(ctx, output.WithBold("Welcome to Azure Developer CLI! 🚀")) + m.console.Message(ctx, "") + m.console.Message(ctx, "azd can help you set up your Azure development") + m.console.Message(ctx, "environment with the right tools.") + m.console.Message(ctx, "") + + // --------------------------------------------------------------- + // Opt-in prompt + // --------------------------------------------------------------- + confirm := uxlib.NewConfirm(&uxlib.ConfirmOptions{ + Message: "Would you like to check your Azure development tools?", + DefaultValue: new(true), + }) + runCheck, err := confirm.Ask(ctx) + if err != nil { + // Confirm can fail on interrupt/cancel — don't mark completed + // so the user gets another chance on next invocation. + if errors.Is(err, uxlib.ErrCancelled) { + return nil + } + return fmt.Errorf("prompting for tool check: %w", err) + } + + if runCheck == nil || !*runCheck { + m.markCompleted() + return nil + } + + // --------------------------------------------------------------- + // Tool detection + // --------------------------------------------------------------- + m.console.Message(ctx, "") + + var statuses []*tool.ToolStatus + detectSpinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ + Text: "Detecting tools...", + ClearOnStop: true, + }) + if err := detectSpinner.Run(ctx, func(ctx context.Context) error { + var detectErr error + statuses, detectErr = m.manager.DetectAll(ctx) + return detectErr + }); err != nil { + // Detection failed — don't mark completed, let user retry next time. + log.Printf("tool first-run: detection failed: %v", err) + return fmt.Errorf("detecting tools: %w", err) + } + + // --------------------------------------------------------------- + // Display results + // --------------------------------------------------------------- + m.console.Message(ctx, "") + m.displayToolStatuses(ctx, statuses) + + // --------------------------------------------------------------- + // Offer to install missing recommended tools + // --------------------------------------------------------------- + var missingRecommended []*tool.ToolStatus + for _, s := range statuses { + if !s.Installed && s.Tool != nil && s.Tool.Priority == tool.ToolPriorityRecommended { + missingRecommended = append(missingRecommended, s) + } + } + + if len(missingRecommended) > 0 { + if err := m.offerInstall(ctx, missingRecommended); err != nil { + log.Printf("tool first-run: install offer failed: %v", err) + } + } + + m.markCompleted() + return nil +} + +// displayToolStatuses prints a summary line for each tool. +func (m *ToolFirstRunMiddleware) displayToolStatuses( + ctx context.Context, + statuses []*tool.ToolStatus, +) { + for _, s := range statuses { + if s.Tool == nil { + continue + } + + if s.Installed { + version := s.InstalledVersion + if version == "" { + version = "installed" + } + m.console.Message(ctx, + output.WithSuccessFormat(" ✓ %s (%s)", s.Tool.Name, version)) + } else { + m.console.Message(ctx, + output.WithWarningFormat(" ○ %s — not installed", s.Tool.Name)) + } + } + + m.console.Message(ctx, "") +} + +// offerInstall prompts the user to select missing recommended tools +// for installation and installs any selected tools. +func (m *ToolFirstRunMiddleware) offerInstall( + ctx context.Context, + missing []*tool.ToolStatus, +) error { + choices := make([]*uxlib.MultiSelectChoice, len(missing)) + for i, s := range missing { + choices[i] = &uxlib.MultiSelectChoice{ + Value: s.Tool.Id, + Label: fmt.Sprintf("%s — %s", s.Tool.Name, s.Tool.Description), + Selected: true, // pre-select all recommended + } + } + + multiSelect := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ + Message: "Select recommended tools to install:", + Choices: choices, + }) + + selected, err := multiSelect.Ask(ctx) + if err != nil { + if errors.Is(err, uxlib.ErrCancelled) { + return nil + } + return fmt.Errorf("prompting for tool selection: %w", err) + } + + if len(selected) == 0 { + m.console.Message(ctx, output.WithGrayFormat( + "No tools selected. You can install them later with 'azd tool install'.")) + return nil + } + + // Extract selected tool IDs. + selectedIDs := make([]string, 0, len(selected)) + for _, choice := range selected { + selectedIDs = append(selectedIDs, choice.Value) + } + + // Install selected tools. + m.console.Message(ctx, "") + + var results []*tool.InstallResult + installSpinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ + Text: "Installing tools...", + ClearOnStop: true, + }) + if err := installSpinner.Run(ctx, func(ctx context.Context) error { + var installErr error + results, installErr = m.manager.InstallTools(ctx, selectedIDs) + return installErr + }); err != nil { + return fmt.Errorf("installing tools: %w", err) + } + + // Display install results. + m.console.Message(ctx, "") + for _, r := range results { + if r.Tool == nil { + continue + } + + if r.Success { + version := r.InstalledVersion + if version == "" { + version = "ok" + } + m.console.Message(ctx, + output.WithSuccessFormat(" ✓ %s installed (%s)", r.Tool.Name, version)) + } else { + errMsg := "unknown error" + if r.Error != nil { + errMsg = r.Error.Error() + } + m.console.Message(ctx, + output.WithWarningFormat(" ✗ %s — %s", r.Tool.Name, errMsg)) + } + } + + m.console.Message(ctx, "") + return nil +} + +// markCompleted persists a timestamp in the user config so the +// first-run experience is not shown again. +func (m *ToolFirstRunMiddleware) markCompleted() { + cfg, err := m.configManager.Load() + if err != nil { + log.Printf("tool first-run: failed to load config for marking complete: %v", err) + return + } + + if err := cfg.Set(configKeyFirstRunCompleted, time.Now().Format(time.RFC3339)); err != nil { + log.Printf("tool first-run: failed to set config key: %v", err) + return + } + + if err := m.configManager.Save(cfg); err != nil { + log.Printf("tool first-run: failed to save config: %v", err) + } +} diff --git a/cli/azd/cmd/middleware/tool_update_check.go b/cli/azd/cmd/middleware/tool_update_check.go new file mode 100644 index 00000000000..f1ce4b1fcb7 --- /dev/null +++ b/cli/azd/cmd/middleware/tool_update_check.go @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "context" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/tool" +) + +// ToolUpdateCheckMiddleware periodically checks for tool updates in the +// background and displays cached update notifications before command +// execution. Notifications are only shown when the console is +// interactive and the current command is not a tool-management +// subcommand. +type ToolUpdateCheckMiddleware struct { + manager *tool.Manager + console input.Console + options *Options + globalOptions *internal.GlobalCommandOptions +} + +// NewToolUpdateCheckMiddleware creates a new [ToolUpdateCheckMiddleware]. +// All dependencies are resolved by the IoC container. +func NewToolUpdateCheckMiddleware( + manager *tool.Manager, + console input.Console, + options *Options, + globalOptions *internal.GlobalCommandOptions, +) Middleware { + return &ToolUpdateCheckMiddleware{ + manager: manager, + console: console, + options: options, + globalOptions: globalOptions, + } +} + +// Run executes the tool update check middleware. Before the command +// runs it displays any cached update notifications. After the command +// completes it triggers a background update check when the configured +// check interval has elapsed. +func (m *ToolUpdateCheckMiddleware) Run(ctx context.Context, nextFn NextFn) (*actions.ActionResult, error) { + // Skip all notification and background-check logic for child + // actions (e.g. workflow steps invoked by a parent command). + if !IsChildAction(ctx) { + m.showNotificationIfNeeded(ctx) + } + + // Execute the actual command. + result, err := nextFn(ctx) + + // Trigger an asynchronous update check after the command + // completes so that results are cached for the next run. + if !IsChildAction(ctx) { + m.triggerBackgroundCheckIfNeeded(ctx) + } + + return result, err +} + +// showNotificationIfNeeded displays a one-line update notification +// when cached results indicate that tool updates are available and +// the current console session is interactive. +func (m *ToolUpdateCheckMiddleware) showNotificationIfNeeded(ctx context.Context) { + if m.shouldSkipNotification() { + return + } + + hasUpdates, count, err := m.manager.HasUpdatesAvailable(ctx) + if err != nil { + log.Printf("tool-update-check: error checking cached updates: %v", err) + return + } + + if !hasUpdates || count == 0 { + return + } + + m.console.Message(ctx, output.WithHighLightFormat( + "ℹ Updates available for %d Azure tool(s). Run 'azd tool check' to see details.", count, + )) + + if markErr := m.manager.MarkUpdateNotificationShown(ctx); markErr != nil { + log.Printf("tool-update-check: error marking notification shown: %v", markErr) + } +} + +// shouldSkipNotification returns true when update notifications should +// be suppressed for the current invocation. +func (m *ToolUpdateCheckMiddleware) shouldSkipNotification() bool { + // Non-interactive mode — user opted out of prompts and banners. + if m.globalOptions.NoPrompt { + return true + } + + // CI/CD environment — no notifications. + if resource.IsRunningOnCI() { + return true + } + + // Machine-readable output (JSON, table, etc.) — keep stdout clean. + if !m.console.IsUnformatted() { + return true + } + + // Non-interactive terminal (piped stdin/stdout). + if m.console.IsNoPromptMode() { + return true + } + + // The "azd tool" family of commands manages its own update UX; + // showing a banner there would be redundant and noisy. + if m.isToolCommand() { + return true + } + + return false +} + +// isToolCommand reports whether the current command is "azd tool" or +// one of its subcommands (e.g. "azd tool check", "azd tool install"). +func (m *ToolUpdateCheckMiddleware) isToolCommand() bool { + return strings.HasPrefix(m.options.CommandPath, "azd tool") +} + +// triggerBackgroundCheckIfNeeded spawns a non-blocking goroutine that +// performs a full tool update check when the configured check interval +// has elapsed. The goroutine uses [context.WithoutCancel] so that it +// survives command completion, and includes a recover guard to prevent +// panics from crashing the CLI. +func (m *ToolUpdateCheckMiddleware) triggerBackgroundCheckIfNeeded(ctx context.Context) { + // Honour the same opt-out signal used by the first-run experience. + if skip, _ := strconv.ParseBool(os.Getenv(envKeySkipFirstRun)); skip { + return + } + + // CI/CD environment — skip background checks. + if resource.IsRunningOnCI() { + return + } + + if !m.manager.ShouldCheckForUpdates(ctx) { + return + } + + //nolint:gosec // G118 – intentional: goroutine outlives request; parent ctx is cancelled on return. + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("tool-update-check: recovered from panic in background check: %v", r) + } + }() + + // Use a bounded timeout so the goroutine always terminates, + // even if tool detection hangs. This prevents goroutine leaks + // that cause CI pipelines to time out. + checkCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := m.manager.CheckForUpdates(checkCtx); err != nil { + log.Printf("tool-update-check: background check failed: %v", err) + } + }() +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index d99c9c8337e..586b94e4779 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -196,6 +196,7 @@ func newRootCmd( hooksActions(root) mcpActions(root) copilotActions(root) + toolActions(root) root.Add("version", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ @@ -281,6 +282,9 @@ func newRootCmd( ActionResolver: newBuildAction, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupBeta, + }, }). UseMiddleware("hooks", middleware.NewHooksMiddleware). UseMiddleware("extensions", middleware.NewExtensionsMiddleware) @@ -453,7 +457,21 @@ func newRootCmd( } return false - }) + }). + UseMiddlewareWhen( + "toolFirstRun", + middleware.NewToolFirstRunMiddleware, + func(descriptor *actions.ActionDescriptor) bool { + return isWorkflowCommand(descriptor) + }, + ). + UseMiddlewareWhen( + "toolUpdateCheck", + middleware.NewToolUpdateCheckMiddleware, + func(descriptor *actions.ActionDescriptor) bool { + return isWorkflowCommand(descriptor) + }, + ) ioc.RegisterNamedInstance(rootContainer, "root-cmd", rootCmd) @@ -528,6 +546,31 @@ func newRootCmd( return cmd } +// isWorkflowCommand reports whether the command is a primary workflow +// command where a first-run tool check adds value. Utility commands +// (auth, config, env, show, etc.) are excluded so they are never +// blocked by the first-run experience or update notifications. +// +// Instead of maintaining a hard-coded allowlist, we rely on the +// RootLevelHelpOption group annotation so that any new command added +// to a workflow group is automatically covered. +func isWorkflowCommand(descriptor *actions.ActionDescriptor) bool { + // Walk up to the root-level subcommand (first segment after "azd"). + current := descriptor + for current.Parent() != nil && current.Parent().Parent() != nil { + current = current.Parent() + } + + if current.Options == nil { + return false + } + + group := current.Options.GroupingOptions.RootLevelHelp + return group == actions.CmdGroupStart || + group == actions.CmdGroupAzure || + group == actions.CmdGroupBeta +} + func getCmdRootHelpFooter(cmd *cobra.Command) string { return fmt.Sprintf("%s\n%s\n%s\n\n%s\n\n%s", output.WithBold("%s", output.WithUnderline("Deploying a sample application")), diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 8dce57c192c..7eea2c6ba65 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2594,6 +2594,49 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['tool'], + description: 'Manage Azure development tools.', + subcommands: [ + { + name: ['check'], + description: 'Check for tool updates.', + }, + { + name: ['install'], + description: 'Install specified tools.', + options: [ + { + name: ['--all'], + description: 'Install all recommended tools', + }, + ], + args: { + name: 'tool-name...', + isOptional: true, + }, + }, + { + name: ['list'], + description: 'List all tools with status.', + }, + { + name: ['show'], + description: 'Show details for a specific tool.', + args: { + name: 'tool-name', + }, + }, + { + name: ['upgrade'], + description: 'Upgrade installed tools.', + args: { + name: 'tool-name...', + isOptional: true, + }, + }, + ], + }, { name: ['up'], description: 'Provision and deploy your project to Azure with a single command.', @@ -3433,6 +3476,32 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['tool'], + description: 'Manage Azure development tools.', + subcommands: [ + { + name: ['check'], + description: 'Check for tool updates.', + }, + { + name: ['install'], + description: 'Install specified tools.', + }, + { + name: ['list'], + description: 'List all tools with status.', + }, + { + name: ['show'], + description: 'Show details for a specific tool.', + }, + { + name: ['upgrade'], + description: 'Upgrade installed tools.', + }, + ], + }, { name: ['up'], description: 'Provision and deploy your project to Azure with a single command.', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap new file mode 100644 index 00000000000..dcd5751406d --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap @@ -0,0 +1,17 @@ + +Check for tool updates. + +Usage + azd tool check [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool check in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for check. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap new file mode 100644 index 00000000000..736ff3f8c3b --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap @@ -0,0 +1,20 @@ + +Install specified tools. + +Usage + azd tool install [tool-name...] [flags] + +Flags + --all : Install all recommended tools + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool install in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for install. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-list.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-list.snap new file mode 100644 index 00000000000..8d4a8117334 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-list.snap @@ -0,0 +1,17 @@ + +List all tools with status. + +Usage + azd tool list [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool list in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for list. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-show.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-show.snap new file mode 100644 index 00000000000..289c950c3f9 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-show.snap @@ -0,0 +1,17 @@ + +Show details for a specific tool. + +Usage + azd tool show [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool show in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for show. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap new file mode 100644 index 00000000000..cdcb2fba4ca --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap @@ -0,0 +1,17 @@ + +Upgrade installed tools. + +Usage + azd tool upgrade [tool-name...] [flags] + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool upgrade in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for upgrade. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool.snap new file mode 100644 index 00000000000..32d21e12559 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool.snap @@ -0,0 +1,26 @@ + +Manage Azure development tools. + +Usage + azd tool [command] + +Available Commands + check : Check for tool updates. + install : Install specified tools. + list : List all tools with status. + show : Show details for a specific tool. + upgrade : Upgrade installed tools. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd tool in your web browser. + -e, --environment string : The name of the environment to use. + -h, --help : Gets help for tool. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Use azd tool [command] --help to view examples and more information about a specific command. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index 46400066c1f..182539590d9 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -21,10 +21,12 @@ Commands config : Manage azd configurations (ex: default Azure subscription, location). env : Manage environments (ex: default environment, environment variables). show : Display information about your project and its resources. + tool : Manage Azure development tools. version : Print the version number of Azure Developer CLI. Beta commands add : Add a component to your project. + build : Builds the application's code. extension : Manage azd extensions. hooks : Develop, test and run hooks for a project. infra : Manage your Infrastructure as Code (IaC). diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go new file mode 100644 index 00000000000..7aaf39d5e39 --- /dev/null +++ b/cli/azd/cmd/tool.go @@ -0,0 +1,855 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/tool" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/spf13/cobra" +) + +// toolActions registers the "azd tool" command group and all of its subcommands. +func toolActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { + group := root.Add("tool", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "tool", + Short: "Manage Azure development tools.", + Long: "Discover, install, upgrade, and check status of Azure development tools.", + }, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupManage, + }, + ActionResolver: newToolAction, + }) + + // azd tool list + group.Add("list", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "list", + Short: "List all tools with status.", + }, + OutputFormats: []output.Format{output.JsonFormat, output.TableFormat}, + DefaultFormat: output.TableFormat, + ActionResolver: newToolListAction, + }) + + // azd tool install [tool-name...] + group.Add("install", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "install [tool-name...]", + Short: "Install specified tools.", + }, + ActionResolver: newToolInstallAction, + FlagsResolver: newToolInstallFlags, + }) + + // azd tool upgrade [tool-name...] + group.Add("upgrade", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "upgrade [tool-name...]", + Short: "Upgrade installed tools.", + }, + ActionResolver: newToolUpgradeAction, + }) + + // azd tool check + group.Add("check", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "check", + Short: "Check for tool updates.", + }, + OutputFormats: []output.Format{output.JsonFormat, output.TableFormat}, + DefaultFormat: output.TableFormat, + ActionResolver: newToolCheckAction, + }) + + // azd tool show + group.Add("show", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "show ", + Short: "Show details for a specific tool.", + }, + ActionResolver: newToolShowAction, + }) + + return group +} + +// --------------------------------------------------------------------------- +// azd tool (bare command) — interactive flow +// --------------------------------------------------------------------------- + +type toolAction struct { + manager *tool.Manager + console input.Console +} + +func newToolAction( + manager *tool.Manager, + console input.Console, +) actions.Action { + return &toolAction{ + manager: manager, + console: console, + } +} + +func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Azure Development Tools (azd tool)", + TitleNote: "Discover and install tools for Azure development", + }) + + // 1. Detect all tools. + statuses, err := a.manager.DetectAll(ctx) + if err != nil { + return nil, fmt.Errorf("detecting tools: %w", err) + } + + // 2. Display current status. + a.console.Message(ctx, "") + for _, s := range statuses { + if s.Installed { + version := s.InstalledVersion + if version == "" { + version = "unknown" + } + a.console.Message(ctx, fmt.Sprintf( + " %s %s %s", + output.WithSuccessFormat("(✔)"), + s.Tool.Name, + output.WithGrayFormat("(%s)", version), + )) + } else if s.Tool.Priority == tool.ToolPriorityRecommended { + a.console.Message(ctx, fmt.Sprintf( + " %s %s %s", + output.WithWarningFormat("(○)"), + s.Tool.Name, + output.WithWarningFormat("[recommended]"), + )) + } else { + a.console.Message(ctx, fmt.Sprintf( + " %s %s %s", + output.WithGrayFormat("(○)"), + s.Tool.Name, + output.WithGrayFormat("[optional]"), + )) + } + } + a.console.Message(ctx, "") + + // 3. Collect uninstalled tools for interactive selection. + var uninstalled []*tool.ToolStatus + for _, s := range statuses { + if !s.Installed { + uninstalled = append(uninstalled, s) + } + } + + if len(uninstalled) == 0 { + a.console.Message(ctx, output.WithSuccessFormat("All tools are installed!")) + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "All tools are already installed", + }, + }, nil + } + + // 4. MultiSelect uninstalled tools. + choices := make([]*uxlib.MultiSelectChoice, len(uninstalled)) + for i, s := range uninstalled { + choices[i] = &uxlib.MultiSelectChoice{ + Value: s.Tool.Id, + Label: s.Tool.Name, + Selected: s.Tool.Priority == tool.ToolPriorityRecommended, + } + } + + multiSelect := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ + Writer: a.console.Handles().Stdout, + Reader: a.console.Handles().Stdin, + Message: "Select tools to install", + Choices: choices, + }) + + selected, err := multiSelect.Ask(ctx) + if err != nil { + return nil, fmt.Errorf("selecting tools: %w", err) + } + + // 5. Install selected tools using TaskList. + var ids []string + for _, choice := range selected { + if choice.Selected { + ids = append(ids, choice.Value) + } + } + + if len(ids) == 0 { + return nil, nil + } + + taskList := uxlib.NewTaskList( + &uxlib.TaskListOptions{ContinueOnError: true}, + ) + + for _, id := range ids { + capturedID := id + toolDef, findErr := a.manager.FindTool(capturedID) + if findErr != nil { + return nil, findErr + } + + taskList.AddTask(uxlib.TaskOptions{ + Title: fmt.Sprintf("Installing %s", toolDef.Name), + Action: func(setProgress uxlib.SetProgressFunc) (uxlib.TaskState, error) { + results, installErr := a.manager.InstallTools(ctx, []string{capturedID}) + if installErr != nil { + return uxlib.Error, installErr + } + if len(results) > 0 && !results[0].Success { + return uxlib.Error, results[0].Error + } + return uxlib.Success, nil + }, + }) + } + + if err := taskList.Run(); err != nil { + a.console.Message(ctx, output.WithWarningFormat( + "\nSome tools could not be installed. Run 'azd tool list' for details.", + )) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Tool installation complete", + }, + }, nil +} + +// --------------------------------------------------------------------------- +// azd tool list +// --------------------------------------------------------------------------- + +type toolListItem struct { + Id string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Priority string `json:"priority"` + Status string `json:"status"` + Version string `json:"version"` +} + +type toolListAction struct { + manager *tool.Manager + console input.Console + formatter output.Formatter + writer io.Writer +} + +func newToolListAction( + manager *tool.Manager, + console input.Console, + formatter output.Formatter, + writer io.Writer, +) actions.Action { + return &toolListAction{ + manager: manager, + console: console, + formatter: formatter, + writer: writer, + } +} + +func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) { + statuses, err := a.manager.DetectAll(ctx) + if err != nil { + return nil, fmt.Errorf("detecting tools: %w", err) + } + + rows := make([]toolListItem, 0, len(statuses)) + for _, s := range statuses { + status := "Not Installed" + version := "" + if s.Installed { + status = "Installed" + version = s.InstalledVersion + } + + rows = append(rows, toolListItem{ + Id: s.Tool.Id, + Name: s.Tool.Name, + Category: string(s.Tool.Category), + Priority: string(s.Tool.Priority), + Status: status, + Version: version, + }) + } + + if len(rows) == 0 { + a.console.Message(ctx, output.WithWarningFormat("No tools found in the registry.")) + return nil, nil + } + + var formatErr error + + if a.formatter.Kind() == output.TableFormat { + columns := []output.Column{ + {Heading: "Id", ValueTemplate: "{{.Id}}"}, + {Heading: "Name", ValueTemplate: "{{.Name}}"}, + {Heading: "Category", ValueTemplate: "{{.Category}}"}, + {Heading: "Priority", ValueTemplate: "{{.Priority}}"}, + {Heading: "Status", ValueTemplate: "{{.Status}}"}, + {Heading: "Version", ValueTemplate: "{{.Version}}"}, + } + + formatErr = a.formatter.Format( + rows, a.writer, output.TableFormatterOptions{Columns: columns}, + ) + } else { + formatErr = a.formatter.Format(rows, a.writer, nil) + } + + return nil, formatErr +} + +// --------------------------------------------------------------------------- +// azd tool install [tool-name...] +// --------------------------------------------------------------------------- + +type toolInstallFlags struct { + all bool +} + +func newToolInstallFlags(cmd *cobra.Command) *toolInstallFlags { + flags := &toolInstallFlags{} + cmd.Flags().BoolVar( + &flags.all, "all", false, "Install all recommended tools", + ) + return flags +} + +type toolInstallAction struct { + args []string + flags *toolInstallFlags + manager *tool.Manager + console input.Console +} + +func newToolInstallAction( + args []string, + flags *toolInstallFlags, + manager *tool.Manager, + console input.Console, +) actions.Action { + return &toolInstallAction{ + args: args, + flags: flags, + manager: manager, + console: console, + } +} + +func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Install Azure development tools (azd tool install)", + TitleNote: "Installs specified tools onto the local machine", + }) + + ids, err := a.resolveToolIds(ctx) + if err != nil { + return nil, err + } + + if len(ids) == 0 { + a.console.Message(ctx, output.WithSuccessFormat("Nothing to install.")) + return nil, nil + } + + taskList := uxlib.NewTaskList( + &uxlib.TaskListOptions{ContinueOnError: true}, + ) + + for _, id := range ids { + capturedID := id + toolDef, findErr := a.manager.FindTool(capturedID) + if findErr != nil { + return nil, findErr + } + + taskList.AddTask(uxlib.TaskOptions{ + Title: fmt.Sprintf("Installing %s", toolDef.Name), + Action: func(setProgress uxlib.SetProgressFunc) (uxlib.TaskState, error) { + results, installErr := a.manager.InstallTools(ctx, []string{capturedID}) + if installErr != nil { + return uxlib.Error, installErr + } + if len(results) > 0 && !results[0].Success { + return uxlib.Error, results[0].Error + } + return uxlib.Success, nil + }, + }) + } + + if err := taskList.Run(); err != nil { + a.console.Message(ctx, output.WithWarningFormat( + "\nSome tools could not be installed. Run 'azd tool list' for details.", + )) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Tool installation complete", + }, + }, nil +} + +// resolveToolIds determines which tool IDs to install based on flags and arguments. +func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error) { + // --all: install all recommended tools that are not already installed. + if a.flags.all { + statuses, err := a.manager.DetectAll(ctx) + if err != nil { + return nil, fmt.Errorf("detecting tools: %w", err) + } + + var ids []string + for _, s := range statuses { + if !s.Installed && s.Tool.Priority == tool.ToolPriorityRecommended { + ids = append(ids, s.Tool.Id) + } + } + return ids, nil + } + + // Positional args: install specified tools by ID. + if len(a.args) > 0 { + return a.args, nil + } + + // Interactive: let the user pick from uninstalled tools. + statuses, err := a.manager.DetectAll(ctx) + if err != nil { + return nil, fmt.Errorf("detecting tools: %w", err) + } + + var uninstalled []*tool.ToolStatus + for _, s := range statuses { + if !s.Installed { + uninstalled = append(uninstalled, s) + } + } + + if len(uninstalled) == 0 { + return nil, nil + } + + choices := make([]*uxlib.MultiSelectChoice, len(uninstalled)) + for i, s := range uninstalled { + choices[i] = &uxlib.MultiSelectChoice{ + Value: s.Tool.Id, + Label: s.Tool.Name, + Selected: s.Tool.Priority == tool.ToolPriorityRecommended, + } + } + + multiSelect := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ + Writer: a.console.Handles().Stdout, + Reader: a.console.Handles().Stdin, + Message: "Select tools to install", + Choices: choices, + }) + + selected, err := multiSelect.Ask(ctx) + if err != nil { + return nil, fmt.Errorf("selecting tools: %w", err) + } + + var ids []string + for _, choice := range selected { + if choice.Selected { + ids = append(ids, choice.Value) + } + } + return ids, nil +} + +// --------------------------------------------------------------------------- +// azd tool upgrade [tool-name...] +// --------------------------------------------------------------------------- + +type toolUpgradeAction struct { + args []string + manager *tool.Manager + console input.Console +} + +func newToolUpgradeAction( + args []string, + manager *tool.Manager, + console input.Console, +) actions.Action { + return &toolUpgradeAction{ + args: args, + manager: manager, + console: console, + } +} + +func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, error) { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Upgrade Azure development tools (azd tool upgrade)", + TitleNote: "Upgrades installed tools to their latest versions", + }) + + // Determine which tools to upgrade — resolve tool definitions + // up front but defer the actual upgrade work to each task callback + // so that the spinner reflects real-time progress. + var toolsToUpgrade []*tool.ToolDefinition + + if len(a.args) > 0 { + for _, id := range a.args { + toolDef, findErr := a.manager.FindTool(id) + if findErr != nil { + return nil, findErr + } + toolsToUpgrade = append(toolsToUpgrade, toolDef) + } + } else { + statuses, detectErr := a.manager.DetectAll(ctx) + if detectErr != nil { + return nil, fmt.Errorf("detecting installed tools: %w", detectErr) + } + for _, s := range statuses { + if s.Installed { + toolsToUpgrade = append(toolsToUpgrade, s.Tool) + } + } + } + + if len(toolsToUpgrade) == 0 { + a.console.Message(ctx, output.WithGrayFormat("No installed tools to upgrade.")) + return nil, nil + } + + taskList := uxlib.NewTaskList( + &uxlib.TaskListOptions{ContinueOnError: true}, + ) + + for _, t := range toolsToUpgrade { + capturedTool := t + taskList.AddTask(uxlib.TaskOptions{ + Title: fmt.Sprintf("Upgrading %s", capturedTool.Name), + Action: func(setProgress uxlib.SetProgressFunc) (uxlib.TaskState, error) { + results, upgradeErr := a.manager.UpgradeTools(ctx, []string{capturedTool.Id}) + if upgradeErr != nil { + return uxlib.Error, upgradeErr + } + if len(results) > 0 { + r := results[0] + if r.Error != nil { + return uxlib.Error, r.Error + } + if !r.Success { + return uxlib.Warning, fmt.Errorf("upgrade did not succeed: %w", internal.ErrToolUpgradeFailed) + } + if r.InstalledVersion != "" { + setProgress(r.InstalledVersion) + } + } + return uxlib.Success, nil + }, + }) + } + + if err := taskList.Run(); err != nil { + a.console.Message(ctx, output.WithWarningFormat( + "\nSome tools could not be upgraded. Run 'azd tool check' for details.", + )) + } + + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Tool upgrade complete", + }, + }, nil +} + +// --------------------------------------------------------------------------- +// azd tool check +// --------------------------------------------------------------------------- + +type toolCheckItem struct { + Id string `json:"id"` + Name string `json:"name"` + InstalledVersion string `json:"installedVersion"` + LatestVersion string `json:"latestVersion"` + UpdateAvailable bool `json:"updateAvailable"` +} + +type toolCheckAction struct { + manager *tool.Manager + console input.Console + formatter output.Formatter + writer io.Writer +} + +func newToolCheckAction( + manager *tool.Manager, + console input.Console, + formatter output.Formatter, + writer io.Writer, +) actions.Action { + return &toolCheckAction{ + manager: manager, + console: console, + formatter: formatter, + writer: writer, + } +} + +func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error) { + results, err := a.manager.CheckForUpdates(ctx) + if err != nil { + return nil, fmt.Errorf("checking for updates: %w", err) + } + + rows := make([]toolCheckItem, 0, len(results)) + for _, r := range results { + rows = append(rows, toolCheckItem{ + Id: r.Tool.Id, + Name: r.Tool.Name, + InstalledVersion: r.CurrentVersion, + LatestVersion: r.LatestVersion, + UpdateAvailable: r.UpdateAvailable, + }) + } + + if len(rows) == 0 { + a.console.Message(ctx, output.WithGrayFormat("No tools found.")) + return nil, nil + } + + var formatErr error + + if a.formatter.Kind() == output.TableFormat { + columns := []output.Column{ + {Heading: "Id", ValueTemplate: "{{.Id}}"}, + {Heading: "Name", ValueTemplate: "{{.Name}}"}, + {Heading: "Installed Version", ValueTemplate: "{{.InstalledVersion}}"}, + {Heading: "Latest Version", ValueTemplate: "{{.LatestVersion}}"}, + { + Heading: "Update Available", + ValueTemplate: `{{if .UpdateAvailable}}Yes{{else}}No{{end}}`, + }, + } + + formatErr = a.formatter.Format( + rows, a.writer, output.TableFormatterOptions{Columns: columns}, + ) + + if formatErr == nil { + hasUpdates := false + for _, r := range rows { + if r.UpdateAvailable { + hasUpdates = true + break + } + } + + if hasUpdates { + a.console.Message(ctx, "") + a.console.Message(ctx, fmt.Sprintf( + "Run %s to upgrade all installed tools.", + output.WithHighLightFormat("azd tool upgrade"), + )) + } + } + } else { + formatErr = a.formatter.Format(rows, a.writer, nil) + } + + return nil, formatErr +} + +// --------------------------------------------------------------------------- +// azd tool show +// --------------------------------------------------------------------------- + +type toolShowAction struct { + args []string + console input.Console + manager *tool.Manager + writer io.Writer +} + +func newToolShowAction( + args []string, + manager *tool.Manager, + console input.Console, + writer io.Writer, +) actions.Action { + return &toolShowAction{ + args: args, + manager: manager, + console: console, + writer: writer, + } +} + +func (a *toolShowAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if len(a.args) == 0 { + return nil, &internal.ErrorWithSuggestion{ + Err: internal.ErrNoArgsProvided, + Suggestion: "Run 'azd tool show ' specifying the tool ID.", + } + } + + if len(a.args) > 1 { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "cannot specify multiple tools: %w", + internal.ErrInvalidFlagCombination, + ), + Suggestion: "Specify a single tool ID.", + } + } + + toolID := a.args[0] + + toolDef, err := a.manager.FindTool(toolID) + if err != nil { + return nil, fmt.Errorf("finding tool: %w", err) + } + + status, err := a.manager.DetectTool(ctx, toolID) + if err != nil { + return nil, fmt.Errorf("detecting tool: %w", err) + } + + if displayErr := a.displayToolDetails(toolDef, status); displayErr != nil { + return nil, displayErr + } + + return nil, nil +} + +// displayToolDetails renders a formatted tool information view to the writer. +func (a *toolShowAction) displayToolDetails( + toolDef *tool.ToolDefinition, + status *tool.ToolStatus, +) error { + writeSection := func(header string, rows [][]string) error { + if len(rows) == 0 { + return nil + } + + underlinedHeader := output.WithUnderline("%s", header) + boldHeader := output.WithBold("%s", underlinedHeader) + if _, err := fmt.Fprintf(a.writer, "%s\n", boldHeader); err != nil { + return err + } + + tabs := tabwriter.NewWriter( + a.writer, + 0, + output.TableTabSize, + 1, + output.TablePadCharacter, + output.TableFlags, + ) + + for _, row := range rows { + if _, err := tabs.Write([]byte(strings.Join(row, "\t") + "\n")); err != nil { + return err + } + } + + if err := tabs.Flush(); err != nil { + return err + } + + _, err := fmt.Fprintln(a.writer) + return err + } + + // Tool Information + installedVersion := "Not Installed" + if status.Installed { + installedVersion = status.InstalledVersion + if installedVersion == "" { + installedVersion = "unknown" + } + } + + toolInfo := [][]string{ + {"Id", ":", toolDef.Id}, + {"Name", ":", toolDef.Name}, + {"Description", ":", toolDef.Description}, + {"Category", ":", string(toolDef.Category)}, + {"Priority", ":", string(toolDef.Priority)}, + } + if toolDef.Website != "" { + toolInfo = append(toolInfo, []string{"Website", ":", toolDef.Website}) + } + toolInfo = append(toolInfo, []string{"Installed Version", ":", installedVersion}) + + if err := writeSection("Tool Information", toolInfo); err != nil { + return err + } + + // Install Strategies + if len(toolDef.InstallStrategies) > 0 { + var strategyRows [][]string + for _, platform := range slices.Sorted(maps.Keys(toolDef.InstallStrategies)) { + strategy := toolDef.InstallStrategies[platform] + label := strategy.PackageManager + if label == "" { + label = "command" + } + detail := strategy.PackageId + if detail == "" { + detail = strategy.InstallCommand + } + strategyRows = append(strategyRows, []string{ + platform, ":", fmt.Sprintf("%s (%s)", label, detail), + }) + } + if err := writeSection("Install Strategies", strategyRows); err != nil { + return err + } + } + + // Dependencies + if len(toolDef.Dependencies) > 0 { + var depRows [][]string + for _, dep := range toolDef.Dependencies { + depRows = append(depRows, []string{"-", dep}) + } + if err := writeSection("Dependencies", depRows); err != nil { + return err + } + } + + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 32fc26169c2..6630bc2ce95 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -290,6 +290,8 @@ func classifySentinel(err error) string { return "update.elevationRequired" case errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo): return "internal.remote_not_azdo" + case errors.Is(err, internal.ErrToolUpgradeFailed): + return "internal.tool_upgrade_failed" default: return "" } diff --git a/cli/azd/internal/errors.go b/cli/azd/internal/errors.go index 0a75d42fc51..2e0e4f15257 100644 --- a/cli/azd/internal/errors.go +++ b/cli/azd/internal/errors.go @@ -116,3 +116,8 @@ var ( var ( ErrMcpToolsLoadFailed = errors.New("failed to load MCP host tools") ) + +// Tool command errors +var ( + ErrToolUpgradeFailed = errors.New("tool upgrade did not succeed") +) diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go new file mode 100644 index 00000000000..6a2c96f1480 --- /dev/null +++ b/cli/azd/pkg/tool/detector.go @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "errors" + "fmt" + osexec "os/exec" + "regexp" + "sync" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" +) + +// ToolStatus captures the detection result for a single tool. +type ToolStatus struct { + // Tool is the definition that was probed. + Tool *ToolDefinition + // Installed is true when the tool was found on the local machine. + Installed bool + // InstalledVersion is the version string extracted from the tool's + // output. It is empty when the tool is not installed or when + // version parsing fails. + InstalledVersion string + // Error records any unexpected failure during detection (e.g. a + // timeout). A tool that is simply not installed has Error == nil. + Error error +} + +// Detector checks whether tools are installed and extracts their +// versions. +type Detector interface { + // DetectTool probes a single tool and returns its status. + DetectTool( + ctx context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) + + // DetectAll probes every tool concurrently and returns a status + // entry for each one. Individual detection failures are captured + // in [ToolStatus.Error]; the returned error is non-nil only for + // programming mistakes such as a nil tool pointer. + DetectAll( + ctx context.Context, + tools []*ToolDefinition, + ) ([]*ToolStatus, error) +} + +type detector struct { + commandRunner exec.CommandRunner +} + +// NewDetector creates a [Detector] backed by the given +// [exec.CommandRunner]. +func NewDetector(commandRunner exec.CommandRunner) Detector { + return &detector{commandRunner: commandRunner} +} + +// DetectTool dispatches to category-specific detection logic. +func (d *detector) DetectTool( + ctx context.Context, + tool *ToolDefinition, +) (*ToolStatus, error) { + if tool == nil { + return nil, errors.New( + "tool definition must not be nil", + ) + } + + switch tool.Category { + case ToolCategoryCLI: + return d.detectCLI(ctx, tool), nil + case ToolCategoryExtension: + return d.detectExtension(ctx, tool), nil + case ToolCategoryServer, ToolCategoryLibrary: + return d.detectCommandBased(ctx, tool), nil + default: + return &ToolStatus{Tool: tool}, nil + } +} + +// DetectAll probes every tool concurrently using +// [sync.WaitGroup.Go] and collects the results. It never fails +// fast — every tool gets a chance to complete regardless of +// individual errors. +func (d *detector) DetectAll( + ctx context.Context, + tools []*ToolDefinition, +) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + + var wg sync.WaitGroup + + for i, t := range tools { + if t == nil { + results[i] = &ToolStatus{} + continue + } + + wg.Go(func() { + // DetectTool only returns an error for nil tools, + // which we guard above. + status, _ := d.DetectTool(ctx, t) + results[i] = status + }) + } + + wg.Wait() + + return results, nil +} + +// --------------------------------------------------------------------------- +// Category-specific detectors +// --------------------------------------------------------------------------- + +// detectCLI checks for a standalone CLI binary on PATH and extracts +// its version by running the configured version command. +func (d *detector) detectCLI( + ctx context.Context, + tool *ToolDefinition, +) *ToolStatus { + status := &ToolStatus{Tool: tool} + + if tool.DetectCommand == "" { + return status + } + + // Fast-path: if the binary is not on PATH it is not installed. + if err := d.commandRunner.ToolInPath( + tool.DetectCommand, + ); err != nil { + if errors.Is(err, osexec.ErrNotFound) { + return status + } + + status.Error = fmt.Errorf( + "checking PATH for %s: %w", + tool.DetectCommand, err, + ) + + return status + } + + // Run the version command. Even on a non-zero exit the + // RunResult contains captured stdout/stderr that may hold a + // version string. + result, err := d.commandRunner.Run(ctx, exec.RunArgs{ + Cmd: tool.DetectCommand, + Args: tool.VersionArgs, + }) + + if err != nil { + if isNotFoundErr(err) { + return status + } + + if isContextErr(err) { + status.Error = fmt.Errorf( + "running %s: %w", tool.DetectCommand, err, + ) + return status + } + + // Non-zero exit: the binary exists, so try to parse + // the version from whatever output was captured. + } + + status.Installed = true + status.InstalledVersion = matchVersion( + result.Stdout+result.Stderr, tool.VersionRegex, + ) + + return status +} + +// detectExtension checks for a VS Code extension by listing +// installed extensions with `code --list-extensions --show-versions` +// and matching the output against the tool's [ToolDefinition.VersionRegex]. +func (d *detector) detectExtension( + ctx context.Context, + tool *ToolDefinition, +) *ToolStatus { + status := &ToolStatus{Tool: tool} + + detectCmd := tool.DetectCommand + if detectCmd == "" { + detectCmd = "code" + } + + // VS Code must be on PATH for extension detection. + if err := d.commandRunner.ToolInPath( + detectCmd, + ); err != nil { + if errors.Is(err, osexec.ErrNotFound) { + return status + } + + status.Error = fmt.Errorf( + "checking PATH for %s: %w", detectCmd, err, + ) + + return status + } + + result, err := d.commandRunner.Run(ctx, exec.RunArgs{ + Cmd: detectCmd, + Args: tool.VersionArgs, + }) + + if err != nil { + if isNotFoundErr(err) { + return status + } + + if isContextErr(err) { + status.Error = fmt.Errorf( + "listing VS Code extensions: %w", err, + ) + return status + } + + // Non-zero exit but output may still be usable. + } + + version := matchVersion( + result.Stdout+result.Stderr, tool.VersionRegex, + ) + + if version != "" { + status.Installed = true + status.InstalledVersion = version + } + + return status +} + +// detectCommandBased handles server and library tools that specify a +// DetectCommand. If no DetectCommand is configured the tool is +// reported as not installed. +func (d *detector) detectCommandBased( + ctx context.Context, + tool *ToolDefinition, +) *ToolStatus { + status := &ToolStatus{Tool: tool} + + if tool.DetectCommand == "" { + return status + } + + if err := d.commandRunner.ToolInPath( + tool.DetectCommand, + ); err != nil { + if errors.Is(err, osexec.ErrNotFound) { + return status + } + + status.Error = fmt.Errorf( + "checking PATH for %s: %w", + tool.DetectCommand, err, + ) + + return status + } + + if len(tool.VersionArgs) == 0 { + // The command exists but there is nothing to run for + // version extraction. + status.Installed = true + return status + } + + result, err := d.commandRunner.Run(ctx, exec.RunArgs{ + Cmd: tool.DetectCommand, + Args: tool.VersionArgs, + }) + + if err != nil { + if isNotFoundErr(err) { + return status + } + + if isContextErr(err) { + status.Error = fmt.Errorf( + "running %s: %w", tool.DetectCommand, err, + ) + return status + } + + // Non-zero exit: the binary exists, so try to parse + // any captured output. + } + + version := matchVersion( + result.Stdout+result.Stderr, tool.VersionRegex, + ) + + if version != "" { + status.Installed = true + status.InstalledVersion = version + } else if tool.VersionRegex == "" { + // No regex configured — the command ran successfully, + // so treat the binary as installed. + status.Installed = true + } + + return status +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// matchVersion compiles the regex pattern and returns the first +// capture-group match from output. Returns "" when the pattern is +// empty, the regex is invalid, or no match is found. +func matchVersion(output, pattern string) string { + if pattern == "" || output == "" { + return "" + } + + re, err := regexp.Compile(pattern) + if err != nil { + return "" + } + + m := re.FindStringSubmatch(output) + if len(m) < 2 { + return "" + } + + return m[1] +} + +// isNotFoundErr reports whether err indicates the command binary +// could not be located. +func isNotFoundErr(err error) bool { + return errors.Is(err, osexec.ErrNotFound) +} + +// isContextErr reports whether err originated from a cancelled or +// timed-out context. +func isContextErr(err error) bool { + return errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) +} diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go new file mode 100644 index 00000000000..b210f804c7d --- /dev/null +++ b/cli/azd/pkg/tool/detector_test.go @@ -0,0 +1,682 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "errors" + osexec "os/exec" + "slices" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// DetectTool — CLI tools +// --------------------------------------------------------------------------- + +func TestDetectTool_CLI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tool *ToolDefinition + setup func(*mockexec.MockCommandRunner) + expectInstalled bool + expectVersion string + expectError bool + expectErrContain string + }{ + { + name: "InstalledWithVersion", + tool: &ToolDefinition{ + Id: "az-cli", + Category: ToolCategoryCLI, + DetectCommand: "az", + VersionArgs: []string{"--version"}, + VersionRegex: `azure-cli\s+(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("az", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "az" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "azure-cli 2.64.0\ncore 2.64.0", + }) + }, + expectInstalled: true, + expectVersion: "2.64.0", + }, + { + name: "NotInstalledWhenNotOnPATH", + tool: &ToolDefinition{ + Id: "az-cli", + Category: ToolCategoryCLI, + DetectCommand: "az", + VersionArgs: []string{"--version"}, + VersionRegex: `azure-cli\s+(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("az", osexec.ErrNotFound) + }, + expectInstalled: false, + }, + { + name: "InstalledButVersionRegexDoesNotMatch", + tool: &ToolDefinition{ + Id: "custom-cli", + Category: ToolCategoryCLI, + DetectCommand: "custom", + VersionArgs: []string{"--version"}, + VersionRegex: `custom-cli\s+(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("custom", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "custom" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "completely different output format", + }) + }, + expectInstalled: true, + expectVersion: "", // regex doesn't match + }, + { + name: "InstalledWithNonZeroExitStillParsesVersion", + tool: &ToolDefinition{ + Id: "quirky-cli", + Category: ToolCategoryCLI, + DetectCommand: "quirky", + VersionArgs: []string{"--version"}, + VersionRegex: `(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("quirky", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "quirky" + }).RespondFn( + func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: 1, + Stderr: "quirky 1.2.3", + }, errors.New("exit status 1") + }, + ) + }, + expectInstalled: true, + expectVersion: "1.2.3", + }, + { + name: "EmptyDetectCommandReturnsNotInstalled", + tool: &ToolDefinition{ + Id: "no-detect", + Category: ToolCategoryCLI, + DetectCommand: "", + }, + setup: func(_ *mockexec.MockCommandRunner) {}, + expectInstalled: false, + }, + { + name: "ContextCancelledReturnsError", + tool: &ToolDefinition{ + Id: "slow-cli", + Category: ToolCategoryCLI, + DetectCommand: "slow", + VersionArgs: []string{"--version"}, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("slow", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "slow" + }).SetError(context.Canceled) + }, + expectInstalled: false, + expectError: true, + expectErrContain: "running slow", + }, + { + name: "VersionParsedFromStderr", + tool: &ToolDefinition{ + Id: "stderr-ver", + Category: ToolCategoryCLI, + DetectCommand: "stderr-ver", + VersionArgs: []string{"--version"}, + VersionRegex: `v(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("stderr-ver", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "stderr-ver" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "", + Stderr: "stderr-ver v3.5.1", + }) + }, + expectInstalled: true, + expectVersion: "3.5.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + tt.setup(runner) + + d := NewDetector(runner) + status, err := d.DetectTool(t.Context(), tt.tool) + + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, tt.expectInstalled, status.Installed) + assert.Equal(t, tt.expectVersion, status.InstalledVersion) + + if tt.expectError { + require.Error(t, status.Error) + if tt.expectErrContain != "" { + assert.Contains(t, status.Error.Error(), + tt.expectErrContain) + } + } else { + assert.NoError(t, status.Error) + } + }) + } +} + +// --------------------------------------------------------------------------- +// DetectTool — VS Code extensions +// --------------------------------------------------------------------------- + +func TestDetectTool_Extension(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tool *ToolDefinition + setup func(*mockexec.MockCommandRunner) + expectInstalled bool + expectVersion string + expectError bool + expectErrContain string + }{ + { + name: "ExtensionFoundInList", + tool: &ToolDefinition{ + Id: "vscode-bicep", + Category: ToolCategoryExtension, + DetectCommand: "code", + VersionArgs: []string{ + "--list-extensions", "--show-versions", + }, + VersionRegex: `ms-azuretools\.vscode-bicep@(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "code" && + slices.Contains(args.Args, "--list-extensions") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "ms-azuretools.vscode-bicep@0.29.47\n" + + "ms-python.python@2024.0.1\n", + }) + }, + expectInstalled: true, + expectVersion: "0.29.47", + }, + { + name: "ExtensionNotInList", + tool: &ToolDefinition{ + Id: "vscode-bicep", + Category: ToolCategoryExtension, + DetectCommand: "code", + VersionArgs: []string{ + "--list-extensions", "--show-versions", + }, + VersionRegex: `ms-azuretools\.vscode-bicep@(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "code" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "ms-python.python@2024.0.1\n", + }) + }, + expectInstalled: false, + expectVersion: "", + }, + { + name: "CodeNotOnPATH", + tool: &ToolDefinition{ + Id: "vscode-bicep", + Category: ToolCategoryExtension, + DetectCommand: "code", + VersionArgs: []string{ + "--list-extensions", "--show-versions", + }, + VersionRegex: `ms-azuretools\.vscode-bicep@(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", osexec.ErrNotFound) + }, + expectInstalled: false, + expectVersion: "", + }, + { + name: "DefaultsDetectCommandToCode", + tool: &ToolDefinition{ + Id: "vscode-ext", + Category: ToolCategoryExtension, + DetectCommand: "", // empty => defaults to "code" + VersionArgs: []string{ + "--list-extensions", "--show-versions", + }, + VersionRegex: `my-ext@(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "code" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "my-ext@1.0.0\n", + }) + }, + expectInstalled: true, + expectVersion: "1.0.0", + }, + { + name: "ContextCancelledReturnsError", + tool: &ToolDefinition{ + Id: "vscode-ext-timeout", + Category: ToolCategoryExtension, + DetectCommand: "code", + VersionArgs: []string{ + "--list-extensions", "--show-versions", + }, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "code" + }).SetError(context.Canceled) + }, + expectInstalled: false, + expectError: true, + expectErrContain: "listing VS Code extensions", + }, + { + name: "NonErrNotFoundPathError", + tool: &ToolDefinition{ + Id: "vscode-ext-perms", + Category: ToolCategoryExtension, + DetectCommand: "code", + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("code", + errors.New("permission denied")) + }, + expectInstalled: false, + expectError: true, + expectErrContain: "checking PATH", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + tt.setup(runner) + + d := NewDetector(runner) + status, err := d.DetectTool(t.Context(), tt.tool) + + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, tt.expectInstalled, status.Installed) + assert.Equal(t, tt.expectVersion, status.InstalledVersion) + + if tt.expectError { + require.Error(t, status.Error) + if tt.expectErrContain != "" { + assert.Contains(t, status.Error.Error(), + tt.expectErrContain) + } + } else { + assert.NoError(t, status.Error) + } + }) + } +} + +// --------------------------------------------------------------------------- +// DetectTool — Server / Library (commandBased) +// --------------------------------------------------------------------------- + +func TestDetectTool_CommandBased(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tool *ToolDefinition + setup func(*mockexec.MockCommandRunner) + expectInstalled bool + expectVersion string + expectError bool + expectErrContain string + }{ + { + name: "ServerToolDetected", + tool: &ToolDefinition{ + Id: "azure-mcp-server", + Category: ToolCategoryServer, + DetectCommand: "npx", + VersionArgs: []string{"@azure/mcp@latest", "--version"}, + VersionRegex: `(\d+\.\d+\.\d+)`, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("npx", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npx" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "1.0.0", + }) + }, + expectInstalled: true, + expectVersion: "1.0.0", + }, + { + name: "LibraryToolWithNoVersionArgs", + tool: &ToolDefinition{ + Id: "simple-lib", + Category: ToolCategoryLibrary, + DetectCommand: "simple", + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("simple", nil) + }, + expectInstalled: true, + expectVersion: "", + }, + { + name: "NoDetectCommandReturnsNotInstalled", + tool: &ToolDefinition{ + Id: "no-cmd", + Category: ToolCategoryServer, + }, + setup: func(_ *mockexec.MockCommandRunner) {}, + expectInstalled: false, + }, + { + name: "ContextCancelledReturnsError", + tool: &ToolDefinition{ + Id: "slow-server", + Category: ToolCategoryServer, + DetectCommand: "slow", + VersionArgs: []string{"--version"}, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("slow", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "slow" + }).SetError(context.DeadlineExceeded) + }, + expectInstalled: false, + expectError: true, + expectErrContain: "running slow", + }, + { + name: "NonErrNotFoundPathError", + tool: &ToolDefinition{ + Id: "perm-denied", + Category: ToolCategoryServer, + DetectCommand: "restricted", + VersionArgs: []string{"--version"}, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("restricted", + errors.New("permission denied")) + }, + expectInstalled: false, + expectError: true, + expectErrContain: "checking PATH", + }, + { + name: "NotFoundOnRunReturnsNotInstalled", + tool: &ToolDefinition{ + Id: "transient", + Category: ToolCategoryLibrary, + DetectCommand: "transient", + VersionArgs: []string{"--version"}, + }, + setup: func(runner *mockexec.MockCommandRunner) { + runner.MockToolInPath("transient", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "transient" + }).SetError(osexec.ErrNotFound) + }, + expectInstalled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + tt.setup(runner) + + d := NewDetector(runner) + status, err := d.DetectTool(t.Context(), tt.tool) + + require.NoError(t, err) + require.NotNil(t, status) + assert.Equal(t, tt.expectInstalled, status.Installed) + assert.Equal(t, tt.expectVersion, status.InstalledVersion) + + if tt.expectError { + require.Error(t, status.Error) + if tt.expectErrContain != "" { + assert.Contains(t, status.Error.Error(), + tt.expectErrContain) + } + } else { + assert.NoError(t, status.Error) + } + }) + } +} + +// --------------------------------------------------------------------------- +// DetectTool — edge cases +// --------------------------------------------------------------------------- + +func TestDetectTool_NilTool(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + d := NewDetector(runner) + + status, err := d.DetectTool(t.Context(), nil) + require.Error(t, err) + require.Nil(t, status) + assert.Contains(t, err.Error(), "nil") +} + +func TestDetectTool_UnknownCategory(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + d := NewDetector(runner) + + tool := &ToolDefinition{ + Id: "weird", + Category: ToolCategory("unknown-cat"), + } + + status, err := d.DetectTool(t.Context(), tool) + require.NoError(t, err) + require.NotNil(t, status) + assert.False(t, status.Installed) +} + +// --------------------------------------------------------------------------- +// DetectAll +// --------------------------------------------------------------------------- + +func TestDetectAll(t *testing.T) { + t.Parallel() + + t.Run("RunsAllToolsAndReturnsResults", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Tool 1: found + runner.MockToolInPath("toolA", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "toolA" + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "toolA 1.0.0", + }) + + // Tool 2: not found + runner.MockToolInPath("toolB", osexec.ErrNotFound) + + tools := []*ToolDefinition{ + { + Id: "tool-a", + Category: ToolCategoryCLI, + DetectCommand: "toolA", + VersionArgs: []string{"--version"}, + VersionRegex: `(\d+\.\d+\.\d+)`, + }, + { + Id: "tool-b", + Category: ToolCategoryCLI, + DetectCommand: "toolB", + VersionArgs: []string{"--version"}, + }, + } + + d := NewDetector(runner) + results, err := d.DetectAll(t.Context(), tools) + + require.NoError(t, err) + require.Len(t, results, 2) + + assert.True(t, results[0].Installed) + assert.Equal(t, "1.0.0", results[0].InstalledVersion) + + assert.False(t, results[1].Installed) + }) + + t.Run("HandlesNilToolInSlice", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + d := NewDetector(runner) + + tools := []*ToolDefinition{nil} + results, err := d.DetectAll(t.Context(), tools) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.NotNil(t, results[0]) + }) + + t.Run("EmptySliceReturnsEmptyResults", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + d := NewDetector(runner) + + results, err := d.DetectAll(t.Context(), []*ToolDefinition{}) + + require.NoError(t, err) + require.Empty(t, results) + }) +} + +// --------------------------------------------------------------------------- +// matchVersion helper +// --------------------------------------------------------------------------- + +func TestMatchVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + pattern string + expect string + }{ + { + name: "MatchesAzureCLI", + output: "azure-cli 2.64.0\ncore 2.64.0", + pattern: `azure-cli\s+(\d+\.\d+\.\d+)`, + expect: "2.64.0", + }, + { + name: "MatchesSimpleVersion", + output: "v1.2.3", + pattern: `v(\d+\.\d+\.\d+)`, + expect: "1.2.3", + }, + { + name: "EmptyOutputReturnsEmpty", + output: "", + pattern: `(\d+\.\d+\.\d+)`, + expect: "", + }, + { + name: "EmptyPatternReturnsEmpty", + output: "1.2.3", + pattern: "", + expect: "", + }, + { + name: "NoMatchReturnsEmpty", + output: "no version here", + pattern: `(\d+\.\d+\.\d+)`, + expect: "", + }, + { + name: "InvalidRegexReturnsEmpty", + output: "1.2.3", + pattern: `(((invalid`, + expect: "", + }, + { + name: "NoCaptureGroupReturnsEmpty", + output: "version 1.2.3", + pattern: `\d+\.\d+\.\d+`, // no capture group + expect: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := matchVersion(tt.output, tt.pattern) + assert.Equal(t, tt.expect, result) + }) + } +} diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go new file mode 100644 index 00000000000..5adc1ac1911 --- /dev/null +++ b/cli/azd/pkg/tool/installer.go @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "fmt" + "runtime" + "strings" + "sync" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" +) + +// InstallResult captures the outcome of an install or upgrade operation. +type InstallResult struct { + // Tool is the definition that was installed or upgraded. + Tool *ToolDefinition + // Success indicates whether the operation completed successfully + // and the tool is now available on the local machine. + Success bool + // InstalledVersion is the version detected after installation. + InstalledVersion string + // Strategy describes what was used to install the tool + // (e.g. "winget", "brew", "manual"). + Strategy string + // Duration is the wall-clock time the operation took. + Duration time.Duration + // Error holds any error encountered during the operation. + Error error +} + +// Installer defines the contract for installing and upgrading tools on +// the current platform. +type Installer interface { + // Install attempts to install the given tool using the best + // strategy available for the current platform. + Install( + ctx context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) + + // Upgrade attempts to upgrade the given tool to its latest + // version. When no upgrade-specific command exists the + // operation falls back to a regular install. + Upgrade( + ctx context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) +} + +// installer is the default, unexported implementation of [Installer]. +type installer struct { + commandRunner exec.CommandRunner + platformDetector *PlatformDetector + detector Detector + platformOnce sync.Once + platform *Platform // lazily populated by ensurePlatform + platformErr error +} + +// NewInstaller creates an [Installer] backed by the provided +// dependencies. Platform detection is deferred until the first +// Install or Upgrade call. +func NewInstaller( + commandRunner exec.CommandRunner, + platformDetector *PlatformDetector, + detector Detector, +) Installer { + return &installer{ + commandRunner: commandRunner, + platformDetector: platformDetector, + detector: detector, + } +} + +// Install detects the current platform, selects an appropriate +// strategy, runs the installation command, and verifies the result. +func (i *installer) Install( + ctx context.Context, + tool *ToolDefinition, +) (*InstallResult, error) { + return i.run(ctx, tool, false) +} + +// Upgrade detects the current platform, selects an appropriate +// strategy, runs the upgrade command, and verifies the result. If +// no upgrade-specific path exists the operation falls back to a +// regular install. +func (i *installer) Upgrade( + ctx context.Context, + tool *ToolDefinition, +) (*InstallResult, error) { + return i.run(ctx, tool, true) +} + +// run is the shared implementation for Install and Upgrade. +func (i *installer) run( + ctx context.Context, + tool *ToolDefinition, + upgrade bool, +) (*InstallResult, error) { + start := time.Now() + result := &InstallResult{Tool: tool} + + // 1. Detect the platform (cached after the first call). + platform, err := i.ensurePlatform(ctx) + if err != nil { + result.Error = fmt.Errorf( + "detecting platform: %w", err, + ) + result.Duration = time.Since(start) + return result, nil + } + + // 2. Select the best install strategy for this platform. + strategy := i.platformDetector.SelectStrategy( + tool, platform, + ) + if strategy == nil { + result.Error = fmt.Errorf( + "no install strategy for %s on platform %s", + tool.Name, platform.OS, + ) + result.Duration = time.Since(start) + return result, nil + } + + // Determine a human-readable label for the strategy. + strategyLabel := strategy.PackageManager + if strategyLabel == "" { + strategyLabel = "command" + } + result.Strategy = strategyLabel + + // 3. When the strategy names a package manager but has no + // explicit InstallCommand, verify the manager is available. + if strategy.PackageManager != "" && + strategy.InstallCommand == "" && + !platform.HasManager(strategy.PackageManager) { + result.Strategy = "manual" + result.Error = i.managerUnavailableError( + tool, strategy, + ) + result.Duration = time.Since(start) + return result, nil + } + + // 4. Execute the install or upgrade command. + if err := i.executeStrategy( + ctx, strategy, upgrade, + ); err != nil { + result.Error = fmt.Errorf( + "running install command for %s: %w", + tool.Name, err, + ) + result.Duration = time.Since(start) + return result, nil + } + + // 5. Verify installation by detecting the tool again. + status, err := i.detector.DetectTool(ctx, tool) + if err != nil { + result.Error = fmt.Errorf( + "verifying installation of %s: %w", + tool.Name, err, + ) + result.Duration = time.Since(start) + return result, nil + } + + if !status.Installed { + result.Error = fmt.Errorf( + "%s was installed but verification failed", + tool.Name, + ) + result.Duration = time.Since(start) + return result, nil + } + + // 6. Success — record the detected version and duration. + result.Success = true + result.InstalledVersion = status.InstalledVersion + result.Duration = time.Since(start) + + return result, nil +} + +// ensurePlatform lazily detects the current platform using sync.Once +// to guarantee thread-safe initialization. The first context passed +// wins, which is acceptable since platform detection is OS-level and +// does not depend on request-scoped context. +func (i *installer) ensurePlatform( + ctx context.Context, +) (*Platform, error) { + i.platformOnce.Do(func() { + p, err := i.platformDetector.Detect(ctx) + if err != nil { + i.platformErr = fmt.Errorf("platform detection: %w", err) + return + } + i.platform = p + }) + return i.platform, i.platformErr +} + +// executeStrategy runs the command described by the given strategy. +// When upgrade is true the upgrade variant of the command is used +// where applicable. Commands containing shell operators (pipes, +// redirects, etc.) are executed through the system shell. +func (i *installer) executeStrategy( + ctx context.Context, + strategy *InstallStrategy, + upgrade bool, +) error { + // When the strategy has an explicit InstallCommand that uses + // shell operators, delegate to the system shell directly so + // that pipes and redirects work correctly (e.g. + // "curl -sL ... | sudo bash"). + if strategy.InstallCommand != "" && + containsShellOperators(strategy.InstallCommand) { + return i.executeShellCommand(ctx, strategy.InstallCommand) + } + + cmd, args := i.buildCommand(strategy, upgrade) + if cmd == "" { + return fmt.Errorf("strategy produced an empty command") + } + + runArgs := exec.NewRunArgs(cmd, args...) + _, err := i.commandRunner.Run(ctx, runArgs) + return err +} + +// buildCommand constructs the executable name and argument list for +// the given strategy. For upgrades the package-manager upgrade +// variant is preferred; when unavailable the install command is used +// as a fallback. +func (i *installer) buildCommand( + strategy *InstallStrategy, + upgrade bool, +) (string, []string) { + // For upgrades, prefer the package-manager upgrade command + // when both PackageManager and PackageId are available. + if upgrade && + strategy.PackageManager != "" && + strategy.PackageId != "" { + return buildManagerCommand( + strategy.PackageManager, + strategy.PackageId, + true, + ) + } + + // Use an explicit InstallCommand when present. + if strategy.InstallCommand != "" { + return splitCommand(strategy.InstallCommand) + } + + // Fall back to package-manager install command. + if strategy.PackageManager != "" && + strategy.PackageId != "" { + return buildManagerCommand( + strategy.PackageManager, + strategy.PackageId, + false, + ) + } + + return "", nil +} + +// managerUnavailableError builds an [errorhandler.ErrorWithSuggestion] +// for the case where the required package manager is not installed. +func (i *installer) managerUnavailableError( + tool *ToolDefinition, + strategy *InstallStrategy, +) error { + suggestion := fmt.Sprintf( + "Package manager %q is not available. "+ + "Install it first or install %s manually.", + strategy.PackageManager, tool.Name, + ) + + var links []errorhandler.ErrorLink + if strategy.FallbackUrl != "" { + suggestion = fmt.Sprintf( + "Install %s manually from: %s", + tool.Name, strategy.FallbackUrl, + ) + links = append(links, errorhandler.ErrorLink{ + URL: strategy.FallbackUrl, + Title: tool.Name + " installation instructions", + }) + } + + return &errorhandler.ErrorWithSuggestion{ + Err: fmt.Errorf( + "package manager %q not available on this platform", + strategy.PackageManager, + ), + Message: "Cannot install " + tool.Name, + Suggestion: suggestion, + Links: links, + } +} + +// ----------------------------------------------------------------------- +// Package-manager command builders +// ----------------------------------------------------------------------- + +// buildManagerCommand returns the command and arguments for a +// well-known package manager install or upgrade operation. +func buildManagerCommand( + manager string, + packageID string, + upgrade bool, +) (string, []string) { + switch manager { + case "winget": + return buildWingetCommand(packageID, upgrade) + case "brew": + return buildBrewCommand(packageID, upgrade) + case "apt": + return buildAptCommand(packageID, upgrade) + case "npm": + return buildNpmCommand(packageID, upgrade) + case "code": + return buildCodeCommand(packageID, upgrade) + default: + return "", nil + } +} + +func buildWingetCommand( + packageID string, upgrade bool, +) (string, []string) { + action := "install" + if upgrade { + action = "upgrade" + } + return "winget", []string{ + action, + "--id", packageID, + "--accept-source-agreements", + "--accept-package-agreements", + "-e", + } +} + +func buildBrewCommand( + packageID string, upgrade bool, +) (string, []string) { + action := "install" + if upgrade { + action = "upgrade" + } + return "brew", []string{action, packageID} +} + +func buildAptCommand( + packageID string, upgrade bool, +) (string, []string) { + if upgrade { + return "sudo", []string{ + "apt-get", "install", + "--only-upgrade", "-y", packageID, + } + } + return "sudo", []string{ + "apt-get", "install", "-y", packageID, + } +} + +func buildNpmCommand( + packageID string, upgrade bool, +) (string, []string) { + if upgrade { + return "npm", []string{"update", "-g", packageID} + } + return "npm", []string{"install", "-g", packageID} +} + +func buildCodeCommand( + packageID string, upgrade bool, +) (string, []string) { + args := []string{"--install-extension", packageID} + if upgrade { + args = append(args, "--force") + } + return "code", args +} + +// ----------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------- + +// splitCommand splits a whitespace-delimited command string into the +// executable name and its arguments. +func splitCommand(command string) (string, []string) { + parts := strings.Fields(command) + if len(parts) == 0 { + return "", nil + } + return parts[0], parts[1:] +} + +// containsShellOperators reports whether the command string contains +// shell metacharacters (pipes, redirects, background operators, or +// command chaining) that require execution through a system shell. +func containsShellOperators(cmd string) bool { + return strings.ContainsAny(cmd, "|><&;") +} + +// executeShellCommand runs a command string through the system shell +// so that shell operators such as pipes and redirects are +// interpreted correctly. +func (i *installer) executeShellCommand( + ctx context.Context, + command string, +) error { + var shell string + var args []string + + if runtime.GOOS == "windows" { + shell = "cmd" + args = []string{"/C", command} + } else { + shell = "sh" + args = []string{"-c", command} + } + + runArgs := exec.NewRunArgs(shell, args...) + _, err := i.commandRunner.Run(ctx, runArgs) + return err +} diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go new file mode 100644 index 00000000000..9f748f6b1d0 --- /dev/null +++ b/cli/azd/pkg/tool/installer_test.go @@ -0,0 +1,553 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockDetector — simple in-package mock for the Detector interface +// --------------------------------------------------------------------------- + +type mockDetector struct { + detectToolFn func( + ctx context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) + detectAllFn func( + ctx context.Context, + tools []*ToolDefinition, + ) ([]*ToolStatus, error) +} + +func (m *mockDetector) DetectTool( + ctx context.Context, + tool *ToolDefinition, +) (*ToolStatus, error) { + if m.detectToolFn != nil { + return m.detectToolFn(ctx, tool) + } + return &ToolStatus{Tool: tool}, nil +} + +func (m *mockDetector) DetectAll( + ctx context.Context, + tools []*ToolDefinition, +) ([]*ToolStatus, error) { + if m.detectAllFn != nil { + return m.detectAllFn(ctx, tools) + } + results := make([]*ToolStatus, len(tools)) + for i, t := range tools { + results[i] = &ToolStatus{Tool: t} + } + return results, nil +} + +// --------------------------------------------------------------------------- +// Install tests +// --------------------------------------------------------------------------- + +func TestInstall_WithPackageManager(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Platform detection: npm is available (cross-platform). + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + runner.MockToolInPath("npm", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ExitCode: 0, Stdout: "10.2.0"}) + + // Capture the install command. + var capturedCmd string + var capturedArgs []string + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && + slices.Contains(args.Args, "install") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedCmd = args.Cmd + capturedArgs = args.Args + return exec.RunResult{ExitCode: 0}, nil + }) + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "2.64.0", + }, nil + }, + } + + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + // Use allPlatforms so the test works on any OS. + tool := &ToolDefinition{ + Id: "test-tool", + Name: "Test Tool", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "npm", + PackageId: "@test/tool", + }), + } + + result, err := inst.Install(t.Context(), tool) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, "2.64.0", result.InstalledVersion) + + // Verify the correct command was constructed. + assert.Equal(t, "npm", capturedCmd) + assert.Contains(t, capturedArgs, "install") + assert.Contains(t, capturedArgs, "@test/tool") +} + +func TestInstall_WithInstallCommand(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Platform detection: no managers available. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + + // The install command itself. + var capturedCmd string + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "curl" + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedCmd = args.Cmd + return exec.RunResult{ExitCode: 0}, nil + }) + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "1.0.0", + }, nil + }, + } + + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + tool := &ToolDefinition{ + Id: "custom-tool", + Name: "Custom Tool", + Category: ToolCategoryCLI, + InstallStrategies: map[string]InstallStrategy{ + "windows": { + InstallCommand: "curl -sL https://example.com/install.sh", + }, + "darwin": { + InstallCommand: "curl -sL https://example.com/install.sh", + }, + "linux": { + InstallCommand: "curl -sL https://example.com/install.sh", + }, + }, + } + + result, err := inst.Install(t.Context(), tool) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, "curl", capturedCmd) +} + +func TestInstall_ManagerUnavailable_FallbackError(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // All managers unavailable. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + + det := &mockDetector{} + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + tool := &ToolDefinition{ + Id: "needs-manager", + Name: "Needs Manager", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "special-mgr", + PackageId: "Some.Package", + FallbackUrl: "https://example.com/install", + }), + } + + result, err := inst.Install(t.Context(), tool) + + require.NoError(t, err) // error is in result, not returned + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Equal(t, "manual", result.Strategy) + require.Error(t, result.Error) + + // Check it is an ErrorWithSuggestion. + var ewi *errorhandler.ErrorWithSuggestion + assert.True(t, errors.As(result.Error, &ewi), + "expected ErrorWithSuggestion") + if ewi != nil { + assert.Contains(t, ewi.Suggestion, "https://example.com/install") + } +} + +func TestInstall_NoStrategyForPlatform(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // All managers unavailable. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + + det := &mockDetector{} + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + tool := &ToolDefinition{ + Id: "no-strategy", + Name: "No Strategy", + Category: ToolCategoryCLI, + InstallStrategies: map[string]InstallStrategy{}, + } + + result, err := inst.Install(t.Context(), tool) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), "no install strategy") +} + +func TestInstall_VerificationFails(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Platform detection. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + + // Install command itself. + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "curl" + }).Respond(exec.RunResult{ExitCode: 0}) + + // Detection after install: not found. + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{ + Tool: tool, + Installed: false, + }, nil + }, + } + + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + tool := &ToolDefinition{ + Id: "verify-fail", + Name: "Verify Fail", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + InstallCommand: "curl -sL https://example.com/install.sh", + }), + } + + result, err := inst.Install(t.Context(), tool) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Success) + assert.Contains(t, result.Error.Error(), "verification failed") +} + +// --------------------------------------------------------------------------- +// Upgrade tests +// --------------------------------------------------------------------------- + +func TestUpgrade_UsesUpgradeCommand(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Platform detection: npm available on all platforms. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + runner.MockToolInPath("npm", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ExitCode: 0, Stdout: "10.2.0"}) + + // Capture the upgrade command. + var capturedCmd string + var capturedArgs []string + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && + (slices.Contains(args.Args, "update") || + slices.Contains(args.Args, "install")) + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + capturedCmd = args.Cmd + capturedArgs = args.Args + return exec.RunResult{ExitCode: 0}, nil + }) + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "1.1.0", + }, nil + }, + } + + pd := NewPlatformDetector(runner) + inst := NewInstaller(runner, pd, det) + + // Use allPlatforms so the strategy works regardless of OS. + tool := &ToolDefinition{ + Id: "test-npm-tool", + Name: "Test NPM Tool", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "npm", + PackageId: "@test/tool", + }), + } + + result, err := inst.Upgrade(t.Context(), tool) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, "npm", capturedCmd) + + // npm upgrade uses "update" subcommand. + assert.Contains(t, capturedArgs, "update") + assert.Contains(t, capturedArgs, "@test/tool") +} + +// --------------------------------------------------------------------------- +// buildManagerCommand / buildCommand helpers +// --------------------------------------------------------------------------- + +func TestBuildManagerCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manager string + packageID string + upgrade bool + expectCmd string + expectArg string // action keyword to find in args + }{ + { + name: "WingetInstall", + manager: "winget", + packageID: "Microsoft.AzureCLI", + upgrade: false, + expectCmd: "winget", + expectArg: "install", + }, + { + name: "WingetUpgrade", + manager: "winget", + packageID: "Microsoft.AzureCLI", + upgrade: true, + expectCmd: "winget", + expectArg: "upgrade", + }, + { + name: "BrewInstall", + manager: "brew", + packageID: "azure-cli", + upgrade: false, + expectCmd: "brew", + expectArg: "install", + }, + { + name: "BrewUpgrade", + manager: "brew", + packageID: "azure-cli", + upgrade: true, + expectCmd: "brew", + expectArg: "upgrade", + }, + { + name: "AptInstall", + manager: "apt", + packageID: "azure-cli", + upgrade: false, + expectCmd: "sudo", + expectArg: "install", + }, + { + name: "AptUpgrade", + manager: "apt", + packageID: "azure-cli", + upgrade: true, + expectCmd: "sudo", + expectArg: "--only-upgrade", + }, + { + name: "NpmInstall", + manager: "npm", + packageID: "@azure/mcp", + upgrade: false, + expectCmd: "npm", + expectArg: "install", + }, + { + name: "NpmUpgrade", + manager: "npm", + packageID: "@azure/mcp", + upgrade: true, + expectCmd: "npm", + expectArg: "update", + }, + { + name: "CodeInstall", + manager: "code", + packageID: "ms-azuretools.vscode-bicep", + upgrade: false, + expectCmd: "code", + expectArg: "--install-extension", + }, + { + name: "CodeUpgrade", + manager: "code", + packageID: "ms-azuretools.vscode-bicep", + upgrade: true, + expectCmd: "code", + expectArg: "--force", + }, + { + name: "UnknownManagerReturnsEmpty", + manager: "unknown-mgr", + packageID: "pkg", + upgrade: false, + expectCmd: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd, args := buildManagerCommand( + tt.manager, tt.packageID, tt.upgrade, + ) + + assert.Equal(t, tt.expectCmd, cmd) + if tt.expectArg != "" { + assert.Contains(t, args, tt.expectArg) + } + }) + } +} + +func TestSplitCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command string + expectCmd string + expectArgs []string + }{ + { + name: "SimpleCommand", + command: "npm install -g @azure/mcp", + expectCmd: "npm", + expectArgs: []string{"install", "-g", "@azure/mcp"}, + }, + { + name: "SingleBinary", + command: "docker", + expectCmd: "docker", + expectArgs: []string{}, + }, + { + name: "EmptyString", + command: "", + expectCmd: "", + }, + { + name: "ExtraWhitespace", + command: " curl -sL https://example.com ", + expectCmd: "curl", + expectArgs: []string{"-sL", "https://example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmd, args := splitCommand(tt.command) + assert.Equal(t, tt.expectCmd, cmd) + if tt.expectArgs != nil { + assert.Equal(t, tt.expectArgs, args) + } + }) + } +} diff --git a/cli/azd/pkg/tool/manager.go b/cli/azd/pkg/tool/manager.go new file mode 100644 index 00000000000..8074ab5f787 --- /dev/null +++ b/cli/azd/pkg/tool/manager.go @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "fmt" + "slices" +) + +// Manager is the top-level orchestrator for tool management. It wires +// together the built-in tool manifest, a [Detector] for probing the +// local machine, an [Installer] for installing and upgrading tools, +// and an [UpdateChecker] for periodic update notifications. +type Manager struct { + manifest []*ToolDefinition + detector Detector + installer Installer + updateChecker *UpdateChecker +} + +// NewManager creates a [Manager] that operates on the built-in tool +// registry. The detector, installer, and updateChecker are injected +// so that callers can supply test doubles when needed. +func NewManager( + detector Detector, + installer Installer, + updateChecker *UpdateChecker, +) *Manager { + return &Manager{ + manifest: BuiltInTools(), + detector: detector, + installer: installer, + updateChecker: updateChecker, + } +} + +// GetAllTools returns a shallow clone of the full tool manifest. +// Callers may safely modify the returned slice without affecting the +// manager's internal state. +func (m *Manager) GetAllTools() []*ToolDefinition { + return slices.Clone(m.manifest) +} + +// GetToolsByCategory returns every tool in the manifest whose +// [ToolDefinition.Category] matches the given category. +func (m *Manager) GetToolsByCategory( + category ToolCategory, +) []*ToolDefinition { + var result []*ToolDefinition + for _, t := range m.manifest { + if t.Category == category { + result = append(result, t) + } + } + return result +} + +// FindTool looks up a tool by its unique identifier in the manifest. +// It returns an error when no tool with that id exists. +func (m *Manager) FindTool(id string) (*ToolDefinition, error) { + for _, t := range m.manifest { + if t.Id == id { + return t, nil + } + } + return nil, fmt.Errorf("finding tool %q: not found", id) +} + +// DetectAll probes every tool in the manifest and returns a status +// entry for each one. Individual detection failures are captured in +// each [ToolStatus.Error]; the returned error is non-nil only for +// programming mistakes such as a nil manifest entry. +func (m *Manager) DetectAll( + ctx context.Context, +) ([]*ToolStatus, error) { + return m.detector.DetectAll(ctx, m.manifest) +} + +// DetectTool probes a single tool identified by its unique id and +// returns its [ToolStatus]. It returns an error when the id is not +// found in the manifest. +func (m *Manager) DetectTool( + ctx context.Context, + id string, +) (*ToolStatus, error) { + tool, err := m.FindTool(id) + if err != nil { + return nil, err + } + return m.detector.DetectTool(ctx, tool) +} + +// InstallTools installs the requested tools by id, automatically +// resolving and prepending any missing dependencies. Tools are +// installed in dependency order: if a dependency installation fails +// the dependent tool is skipped and an error is recorded in its +// [InstallResult]. +func (m *Manager) InstallTools( + ctx context.Context, + ids []string, +) ([]*InstallResult, error) { + // 1. Resolve every requested id to its definition. + requested, err := m.resolveTools(ids) + if err != nil { + return nil, err + } + + // 2. Build an ordered install list: dependencies first, then + // the requested tools. The dependency graph in this POC is + // at most one level deep, so a single linear pass suffices. + ordered, err := m.buildInstallOrder(ctx, requested) + if err != nil { + return nil, err + } + + // 3. Install each tool in order, tracking failures so that + // dependents can be skipped. + failed := map[string]bool{} + var results []*InstallResult + + for _, tool := range ordered { + if m.hasMissingDependency(tool, failed) { + results = append(results, &InstallResult{ + Tool: tool, + Error: fmt.Errorf( + "skipped: a required dependency failed to install", + ), + }) + failed[tool.Id] = true + continue + } + + result, installErr := m.installer.Install(ctx, tool) + if installErr != nil { + results = append(results, &InstallResult{ + Tool: tool, + Error: installErr, + }) + failed[tool.Id] = true + continue + } + + if !result.Success { + failed[tool.Id] = true + } + results = append(results, result) + } + + return results, nil +} + +// UpgradeTools upgrades the tools identified by the given ids. Each +// id is resolved against the manifest and then passed to the +// installer's Upgrade method. +func (m *Manager) UpgradeTools( + ctx context.Context, + ids []string, +) ([]*InstallResult, error) { + tools, err := m.resolveTools(ids) + if err != nil { + return nil, err + } + + var results []*InstallResult + for _, tool := range tools { + result, upgradeErr := m.installer.Upgrade(ctx, tool) + if upgradeErr != nil { + results = append(results, &InstallResult{ + Tool: tool, + Error: upgradeErr, + }) + continue + } + results = append(results, result) + } + return results, nil +} + +// UpgradeAll detects every tool in the manifest, filters to those +// that are already installed, and upgrades each one. +func (m *Manager) UpgradeAll( + ctx context.Context, +) ([]*InstallResult, error) { + statuses, err := m.detector.DetectAll(ctx, m.manifest) + if err != nil { + return nil, fmt.Errorf("detecting installed tools: %w", err) + } + + var results []*InstallResult + for _, status := range statuses { + if !status.Installed { + continue + } + + result, upgradeErr := m.installer.Upgrade(ctx, status.Tool) + if upgradeErr != nil { + results = append(results, &InstallResult{ + Tool: status.Tool, + Error: upgradeErr, + }) + continue + } + results = append(results, result) + } + return results, nil +} + +// CheckForUpdates delegates to the [UpdateChecker] to check all +// tools in the manifest for available updates. +func (m *Manager) CheckForUpdates( + ctx context.Context, +) ([]*UpdateCheckResult, error) { + return m.updateChecker.Check(ctx, m.manifest) +} + +// ShouldCheckForUpdates reports whether enough time has elapsed since +// the last update check to warrant a new one. +func (m *Manager) ShouldCheckForUpdates( + ctx context.Context, +) bool { + return m.updateChecker.ShouldCheck(ctx) +} + +// HasUpdatesAvailable reports whether cached update-check results +// indicate that one or more tools have updates available. It returns +// a boolean flag, the count of tools with updates, and any error +// encountered while reading the cache. +func (m *Manager) HasUpdatesAvailable( + ctx context.Context, +) (bool, int, error) { + return m.updateChecker.HasUpdatesAvailable(ctx) +} + +// MarkUpdateNotificationShown records that the user has been notified +// about available updates so that the notification is not repeated +// too soon. +func (m *Manager) MarkUpdateNotificationShown( + ctx context.Context, +) error { + return m.updateChecker.MarkNotificationShown(ctx) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// resolveTools converts a list of tool ids into their corresponding +// [ToolDefinition] pointers. It returns an error if any id is not +// present in the manifest. +func (m *Manager) resolveTools( + ids []string, +) ([]*ToolDefinition, error) { + tools := make([]*ToolDefinition, 0, len(ids)) + for _, id := range ids { + tool, err := m.FindTool(id) + if err != nil { + return nil, err + } + tools = append(tools, tool) + } + return tools, nil +} + +// buildInstallOrder produces a flat, dependency-first list of tools +// to install. For each requested tool, any uninstalled dependencies +// that are not already in the list are prepended. +func (m *Manager) buildInstallOrder( + ctx context.Context, + requested []*ToolDefinition, +) ([]*ToolDefinition, error) { + var ordered []*ToolDefinition + seen := map[string]bool{} + + for _, tool := range requested { + for _, depID := range tool.Dependencies { + if seen[depID] { + continue + } + + dep, err := m.FindTool(depID) + if err != nil { + return nil, fmt.Errorf( + "resolving dependency %q for tool %q: %w", + depID, tool.Id, err, + ) + } + + // Only add the dependency if it is not already + // installed on the machine. + status, detectErr := m.detector.DetectTool(ctx, dep) + if detectErr != nil { + return nil, fmt.Errorf( + "detecting dependency %q: %w", + depID, detectErr, + ) + } + + if !status.Installed { + ordered = append(ordered, dep) + } + seen[depID] = true + } + + if !seen[tool.Id] { + ordered = append(ordered, tool) + seen[tool.Id] = true + } + } + + return ordered, nil +} + +// hasMissingDependency reports whether any of the tool's declared +// dependencies are recorded in the failed set. +func (m *Manager) hasMissingDependency( + tool *ToolDefinition, + failed map[string]bool, +) bool { + for _, depID := range tool.Dependencies { + if failed[depID] { + return true + } + } + return false +} diff --git a/cli/azd/pkg/tool/manager_test.go b/cli/azd/pkg/tool/manager_test.go new file mode 100644 index 00000000000..bbee61c0b07 --- /dev/null +++ b/cli/azd/pkg/tool/manager_test.go @@ -0,0 +1,593 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockInstaller — in-package mock for the Installer interface +// --------------------------------------------------------------------------- + +type mockInstaller struct { + installFn func( + ctx context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) + upgradeFn func( + ctx context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) +} + +func (m *mockInstaller) Install( + ctx context.Context, + tool *ToolDefinition, +) (*InstallResult, error) { + if m.installFn != nil { + return m.installFn(ctx, tool) + } + return &InstallResult{ + Tool: tool, + Success: true, + }, nil +} + +func (m *mockInstaller) Upgrade( + ctx context.Context, + tool *ToolDefinition, +) (*InstallResult, error) { + if m.upgradeFn != nil { + return m.upgradeFn(ctx, tool) + } + return &InstallResult{ + Tool: tool, + Success: true, + }, nil +} + +// --------------------------------------------------------------------------- +// GetAllTools +// --------------------------------------------------------------------------- + +func TestManager_GetAllTools(t *testing.T) { + t.Parallel() + + mgr := NewManager(&mockDetector{}, &mockInstaller{}, nil) + tools := mgr.GetAllTools() + + require.Len(t, tools, len(BuiltInTools())) + + // Mutating returned slice should not affect manager. + tools[0] = &ToolDefinition{Id: "mutated"} + second := mgr.GetAllTools() + assert.NotEqual(t, "mutated", second[0].Id) +} + +// --------------------------------------------------------------------------- +// GetToolsByCategory +// --------------------------------------------------------------------------- + +func TestManager_GetToolsByCategory(t *testing.T) { + t.Parallel() + + mgr := NewManager(&mockDetector{}, &mockInstaller{}, nil) + cliTools := mgr.GetToolsByCategory(ToolCategoryCLI) + + require.NotEmpty(t, cliTools) + for _, tool := range cliTools { + assert.Equal(t, ToolCategoryCLI, tool.Category) + } +} + +// --------------------------------------------------------------------------- +// FindTool +// --------------------------------------------------------------------------- + +func TestManager_FindTool(t *testing.T) { + t.Parallel() + + t.Run("Found", func(t *testing.T) { + t.Parallel() + + mgr := NewManager( + &mockDetector{}, &mockInstaller{}, nil, + ) + + tool, err := mgr.FindTool("az-cli") + require.NoError(t, err) + require.NotNil(t, tool) + assert.Equal(t, "az-cli", tool.Id) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + mgr := NewManager( + &mockDetector{}, &mockInstaller{}, nil, + ) + + tool, err := mgr.FindTool("nonexistent") + require.Error(t, err) + assert.Nil(t, tool) + assert.Contains(t, err.Error(), "not found") + }) +} + +// --------------------------------------------------------------------------- +// DetectAll / DetectTool +// --------------------------------------------------------------------------- + +func TestManager_DetectAll(t *testing.T) { + t.Parallel() + + det := &mockDetector{ + detectAllFn: func( + _ context.Context, + tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + for i, tool := range tools { + results[i] = &ToolStatus{ + Tool: tool, + Installed: true, + } + } + return results, nil + }, + } + + mgr := NewManager(det, &mockInstaller{}, nil) + results, err := mgr.DetectAll(t.Context()) + + require.NoError(t, err) + assert.Len(t, results, len(BuiltInTools())) +} + +func TestManager_DetectTool(t *testing.T) { + t.Parallel() + + t.Run("KnownToolDelegatesToDetector", func(t *testing.T) { + t.Parallel() + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "2.64.0", + }, nil + }, + } + + mgr := NewManager(det, &mockInstaller{}, nil) + status, err := mgr.DetectTool(t.Context(), "az-cli") + + require.NoError(t, err) + require.NotNil(t, status) + assert.True(t, status.Installed) + assert.Equal(t, "2.64.0", status.InstalledVersion) + }) + + t.Run("UnknownToolReturnsError", func(t *testing.T) { + t.Parallel() + + mgr := NewManager( + &mockDetector{}, &mockInstaller{}, nil, + ) + + status, err := mgr.DetectTool(t.Context(), "nonexistent") + require.Error(t, err) + assert.Nil(t, status) + }) +} + +// --------------------------------------------------------------------------- +// InstallTools +// --------------------------------------------------------------------------- + +func TestManager_InstallTools(t *testing.T) { + t.Parallel() + + t.Run("ValidIDsInstalled", func(t *testing.T) { + t.Parallel() + + var installedIDs []string + inst := &mockInstaller{ + installFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + installedIDs = append(installedIDs, tool.Id) + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) { + // All dependencies are pre-installed. + return &ToolStatus{ + Tool: tool, + Installed: true, + }, nil + }, + } + + mgr := NewManager(det, inst, nil) + results, err := mgr.InstallTools( + t.Context(), + []string{"az-cli"}, + ) + + require.NoError(t, err) + require.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Contains(t, installedIDs, "az-cli") + }) + + t.Run("UnknownIDReturnsError", func(t *testing.T) { + t.Parallel() + + mgr := NewManager( + &mockDetector{}, &mockInstaller{}, nil, + ) + + results, err := mgr.InstallTools( + t.Context(), + []string{"nonexistent"}, + ) + + require.Error(t, err) + assert.Nil(t, results) + }) + + t.Run("ResolvesUninstalledDependencies", func(t *testing.T) { + t.Parallel() + + var installedIDs []string + inst := &mockInstaller{ + installFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + installedIDs = append(installedIDs, tool.Id) + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) { + // az-cli is NOT installed yet, triggering + // dependency resolution. + if tool.Id == "az-cli" { + return &ToolStatus{ + Tool: tool, + Installed: false, + }, nil + } + return &ToolStatus{ + Tool: tool, + Installed: true, + }, nil + }, + } + + mgr := NewManager(det, inst, nil) + + // azd-ai-extensions depends on az-cli. + results, err := mgr.InstallTools( + t.Context(), + []string{"azd-ai-extensions"}, + ) + + require.NoError(t, err) + require.Len(t, results, 2, + "should install dep + requested tool") + + // az-cli should be first (dependency). + assert.Equal(t, "az-cli", installedIDs[0]) + assert.Equal(t, "azd-ai-extensions", installedIDs[1]) + }) + + t.Run("SkipsDependencyAlreadyInstalled", func(t *testing.T) { + t.Parallel() + + var installedIDs []string + inst := &mockInstaller{ + installFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + installedIDs = append(installedIDs, tool.Id) + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) { + // az-cli IS already installed. + return &ToolStatus{ + Tool: tool, + Installed: true, + }, nil + }, + } + + mgr := NewManager(det, inst, nil) + results, err := mgr.InstallTools( + t.Context(), + []string{"azd-ai-extensions"}, + ) + + require.NoError(t, err) + // Only 1 result: the requested tool (dep skipped). + require.Len(t, results, 1) + assert.Equal(t, "azd-ai-extensions", results[0].Tool.Id) + }) + + t.Run("FailedDependencySkipsDependent", func(t *testing.T) { + t.Parallel() + + inst := &mockInstaller{ + installFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + if tool.Id == "az-cli" { + return &InstallResult{ + Tool: tool, + Success: false, + Error: errors.New("install failed"), + }, nil + } + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*ToolStatus, error) { + // az-cli not installed => triggers dep install. + if tool.Id == "az-cli" { + return &ToolStatus{ + Tool: tool, + Installed: false, + }, nil + } + return &ToolStatus{ + Tool: tool, + Installed: true, + }, nil + }, + } + + mgr := NewManager(det, inst, nil) + results, err := mgr.InstallTools( + t.Context(), + []string{"azd-ai-extensions"}, + ) + + require.NoError(t, err) + require.Len(t, results, 2) + + // Both should have errors: dep failed, dependent skipped. + assert.Error(t, results[0].Error) + assert.Error(t, results[1].Error) + assert.Contains(t, results[1].Error.Error(), + "dependency failed") + }) +} + +// --------------------------------------------------------------------------- +// UpgradeTools +// --------------------------------------------------------------------------- + +func TestManager_UpgradeTools(t *testing.T) { + t.Parallel() + + t.Run("DelegatesToInstaller", func(t *testing.T) { + t.Parallel() + + var upgradedIDs []string + inst := &mockInstaller{ + upgradeFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + upgradedIDs = append(upgradedIDs, tool.Id) + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + mgr := NewManager( + &mockDetector{}, inst, nil, + ) + + results, err := mgr.UpgradeTools( + t.Context(), + []string{"az-cli", "github-copilot-cli"}, + ) + + require.NoError(t, err) + require.Len(t, results, 2) + assert.Contains(t, upgradedIDs, "az-cli") + assert.Contains(t, upgradedIDs, "github-copilot-cli") + }) + + t.Run("UnknownIDReturnsError", func(t *testing.T) { + t.Parallel() + + mgr := NewManager( + &mockDetector{}, &mockInstaller{}, nil, + ) + + results, err := mgr.UpgradeTools( + t.Context(), + []string{"nonexistent"}, + ) + + require.Error(t, err) + assert.Nil(t, results) + }) +} + +// --------------------------------------------------------------------------- +// Manager — UpdateChecker delegation methods +// --------------------------------------------------------------------------- + +func TestManager_CheckForUpdates(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr2 := newMockUserConfigManager() + + det := &mockDetector{ + detectAllFn: func( + _ context.Context, + tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + for i, tool := range tools { + results[i] = &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "1.0.0", + } + } + return results, nil + }, + } + + uc := NewUpdateChecker(mgr2, det, staticDir(tmpDir)) + m := NewManager(det, &mockInstaller{}, uc) + + results, err := m.CheckForUpdates(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, results) +} + +func TestManager_ShouldCheckForUpdates(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr2 := newMockUserConfigManager() + uc := NewUpdateChecker(mgr2, &mockDetector{}, staticDir(tmpDir)) + + m := NewManager(&mockDetector{}, &mockInstaller{}, uc) + + // First time — no lastUpdateCheck set — should return true. + assert.True(t, m.ShouldCheckForUpdates(t.Context())) +} + +func TestManager_HasUpdatesAvailable(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr2 := newMockUserConfigManager() + det := &mockDetector{} + uc := NewUpdateChecker(mgr2, det, staticDir(tmpDir)) + + m := NewManager(det, &mockInstaller{}, uc) + + hasUpdates, count, err := m.HasUpdatesAvailable(t.Context()) + require.NoError(t, err) + assert.False(t, hasUpdates) + assert.Equal(t, 0, count) +} + +func TestManager_MarkUpdateNotificationShown(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr2 := newMockUserConfigManager() + uc := NewUpdateChecker(mgr2, &mockDetector{}, staticDir(tmpDir)) + + m := NewManager(&mockDetector{}, &mockInstaller{}, uc) + err := m.MarkUpdateNotificationShown(t.Context()) + require.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// UpgradeAll +// --------------------------------------------------------------------------- + +func TestManager_UpgradeAll(t *testing.T) { + t.Parallel() + + t.Run("OnlyUpgradesInstalledTools", func(t *testing.T) { + t.Parallel() + + var upgradedIDs []string + inst := &mockInstaller{ + upgradeFn: func( + _ context.Context, + tool *ToolDefinition, + ) (*InstallResult, error) { + upgradedIDs = append(upgradedIDs, tool.Id) + return &InstallResult{ + Tool: tool, + Success: true, + }, nil + }, + } + + det := &mockDetector{ + detectAllFn: func( + _ context.Context, + tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + for i, tool := range tools { + // Only first tool is installed. + results[i] = &ToolStatus{ + Tool: tool, + Installed: i == 0, + } + } + return results, nil + }, + } + + mgr := NewManager(det, inst, nil) + results, err := mgr.UpgradeAll(t.Context()) + + require.NoError(t, err) + require.Len(t, results, 1, "only installed tools upgraded") + }) +} diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go new file mode 100644 index 00000000000..7a1805bb6f0 --- /dev/null +++ b/cli/azd/pkg/tool/manifest.go @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package tool provides the type system and built-in registry for azd tool definitions. +// +// Tools are external programs, VS Code extensions, servers, or libraries that +// complement the Azure Developer CLI. Each tool carries detection, versioning, +// and per-platform installation metadata so that azd can check, install, and +// upgrade the developer toolchain automatically. +package tool + +import ( + "slices" +) + +// ToolCategory classifies a tool by its runtime shape. +type ToolCategory string + +const ( + // ToolCategoryCLI is a standalone command-line binary (e.g. az, copilot). + ToolCategoryCLI ToolCategory = "cli" + // ToolCategoryExtension is an IDE extension (e.g. VS Code extensions). + ToolCategoryExtension ToolCategory = "extension" + // ToolCategoryServer is a long-running background process or server (e.g. MCP server). + ToolCategoryServer ToolCategory = "server" + // ToolCategoryLibrary is an azd extension or plugin library. + ToolCategoryLibrary ToolCategory = "library" +) + +// ToolPriority indicates how strongly a tool is recommended. +type ToolPriority string + +const ( + // ToolPriorityRecommended marks a tool that most azd users should install. + ToolPriorityRecommended ToolPriority = "recommended" + // ToolPriorityOptional marks a tool that is useful but not essential. + ToolPriorityOptional ToolPriority = "optional" +) + +// InstallStrategy describes how to install a tool on a specific platform. +type InstallStrategy struct { + // PackageManager is the package manager name (e.g. "winget", "brew", "apt", "npm", "code"). + PackageManager string + // PackageId is the identifier within the package manager (e.g. "Microsoft.AzureCLI"). + PackageId string + // InstallCommand is the full shell command when a simple package-manager install + // does not apply (e.g. "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"). + InstallCommand string + // FallbackUrl points to manual installation instructions. + FallbackUrl string +} + +// ToolDefinition is the complete metadata for a single tool in the registry. +type ToolDefinition struct { + // Id is the unique, kebab-case identifier for the tool (e.g. "az-cli"). + Id string + // Name is the human-readable display name. + Name string + // Description summarizes what the tool does in one sentence. + Description string + // Category classifies the tool (CLI, extension, server, or library). + Category ToolCategory + // Priority indicates whether the tool is recommended or optional. + Priority ToolPriority + // Website is the canonical documentation URL. + Website string + // DetectCommand is the binary name used to verify the tool is installed (e.g. "az"). + DetectCommand string + // VersionArgs are the CLI arguments that print a version string (e.g. ["--version"]). + VersionArgs []string + // VersionRegex is a Go regular expression with a capture group for the semver portion + // of the version output (e.g. `azure-cli\s+(\d+\.\d+\.\d+)`). + VersionRegex string + // InstallStrategies maps a GOOS value ("windows", "darwin", "linux") to the + // platform-specific installation strategy. + InstallStrategies map[string]InstallStrategy + // Dependencies lists the IDs of tools that must be installed before this one. + Dependencies []string +} + +// BuiltInTools returns the full set of tools that ship with the azd tool registry. +// The returned slice is a fresh copy; callers may safely append or modify it. +func BuiltInTools() []*ToolDefinition { + return slices.Clone(builtInTools) +} + +// FindTool returns the built-in tool with the given id, or nil if none matches. +func FindTool(id string) *ToolDefinition { + for _, t := range builtInTools { + if t.Id == id { + return t + } + } + return nil +} + +// FindToolsByCategory returns every built-in tool whose category matches. +// The returned slice is a fresh copy. +func FindToolsByCategory(category ToolCategory) []*ToolDefinition { + var result []*ToolDefinition + for _, t := range builtInTools { + if t.Category == category { + result = append(result, t) + } + } + return result +} + +// builtInTools is the canonical, read-only manifest of tools known to azd. +// Use [BuiltInTools] to obtain a safe copy. +var builtInTools = []*ToolDefinition{ + azCLI(), + githubCopilotCLI(), + vscodeAzureTools(), + vscodeBicep(), + vscodeGitHubCopilot(), + azureMCPServer(), + azdAIExtensions(), +} + +// --------------------------------------------------------------------------- +// Individual tool constructors – one function per tool keeps the manifest +// readable without one huge composite literal. +// --------------------------------------------------------------------------- + +func azCLI() *ToolDefinition { + return &ToolDefinition{ + Id: "az-cli", + Name: "Azure CLI", + Description: "The Azure command-line interface for managing Azure resources.", + Category: ToolCategoryCLI, + Priority: ToolPriorityRecommended, + Website: "https://learn.microsoft.com/cli/azure/", + DetectCommand: "az", + VersionArgs: []string{"--version"}, + VersionRegex: `azure-cli\s+(\d+\.\d+\.\d+)`, + InstallStrategies: map[string]InstallStrategy{ + "windows": { + PackageManager: "winget", + PackageId: "Microsoft.AzureCLI", + }, + "darwin": { + PackageManager: "brew", + PackageId: "azure-cli", + }, + "linux": { + InstallCommand: "curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash", + FallbackUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli", + }, + }, + } +} + +func githubCopilotCLI() *ToolDefinition { + return &ToolDefinition{ + Id: "github-copilot-cli", + Name: "GitHub Copilot CLI", + Description: "AI-powered CLI assistant from GitHub Copilot.", + Category: ToolCategoryCLI, + Priority: ToolPriorityRecommended, + Website: "https://docs.github.com/copilot/github-copilot-in-the-cli", + DetectCommand: "copilot", + VersionArgs: []string{"--version"}, + VersionRegex: `(\d+\.\d+\.\d+)`, + InstallStrategies: map[string]InstallStrategy{ + "windows": { + PackageManager: "winget", + PackageId: "GitHub.Copilot", + }, + "darwin": { + PackageManager: "brew", + PackageId: "copilot-cli", + }, + "linux": { + PackageManager: "npm", + PackageId: "@github/copilot", + InstallCommand: "npm install -g @github/copilot", + }, + }, + } +} + +func vscodeAzureTools() *ToolDefinition { + return &ToolDefinition{ + Id: "vscode-azure-tools", + Name: "Azure Tools VS Code Extension", + Description: "VS Code extension for browsing and managing " + + "Azure resources.", + Category: ToolCategoryExtension, + Priority: ToolPriorityRecommended, + Website: "https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azureresourcegroups", + DetectCommand: "code", + VersionArgs: []string{"--list-extensions", "--show-versions"}, + VersionRegex: `ms-azuretools\.vscode-azureresourcegroups@(\d+\.\d+\.\d+)`, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "code", + InstallCommand: "code --install-extension ms-azuretools.vscode-azureresourcegroups", + }), + } +} + +func vscodeBicep() *ToolDefinition { + return &ToolDefinition{ + Id: "vscode-bicep", + Name: "Bicep VS Code Extension", + Description: "VS Code extension providing language support for Azure Bicep.", + Category: ToolCategoryExtension, + Priority: ToolPriorityRecommended, + Website: "https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-bicep", + DetectCommand: "code", + VersionArgs: []string{"--list-extensions", "--show-versions"}, + VersionRegex: `ms-azuretools\.vscode-bicep@(\d+\.\d+\.\d+)`, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "code", + InstallCommand: "code --install-extension ms-azuretools.vscode-bicep", + }), + } +} + +func vscodeGitHubCopilot() *ToolDefinition { + return &ToolDefinition{ + Id: "vscode-github-copilot", + Name: "GitHub Copilot VS Code Extension", + Description: "VS Code extension for AI-powered code completions.", + Category: ToolCategoryExtension, + Priority: ToolPriorityOptional, + Website: "https://marketplace.visualstudio.com/items?itemName=GitHub.copilot", + DetectCommand: "code", + VersionArgs: []string{"--list-extensions", "--show-versions"}, + VersionRegex: `GitHub\.copilot@(\d+\.\d+\.\d+)`, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "code", + InstallCommand: "code --install-extension GitHub.copilot", + }), + } +} + +func azureMCPServer() *ToolDefinition { + return &ToolDefinition{ + Id: "azure-mcp-server", + Name: "Azure MCP Server", + Description: "Model Context Protocol server for Azure resource interaction.", + Category: ToolCategoryServer, + Priority: ToolPriorityOptional, + Website: "https://github.com/Azure/azure-mcp", + DetectCommand: "npm", + VersionArgs: []string{"list", "-g", "@azure/mcp", "--json"}, + VersionRegex: `"@azure/mcp":\s*\{\s*"version":\s*"(\d+\.\d+\.\d+)"`, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "npm", + PackageId: "@azure/mcp", + InstallCommand: "npm install -g @azure/mcp", + }), + } +} + +func azdAIExtensions() *ToolDefinition { + return &ToolDefinition{ + Id: "azd-ai-extensions", + Name: "azd AI Extensions", + Description: "Azure Developer CLI extensions for AI agent workflows.", + Category: ToolCategoryLibrary, + Priority: ToolPriorityOptional, + Website: "https://learn.microsoft.com/azure/developer/azure-developer-cli/", + DetectCommand: "azd", + VersionArgs: []string{"extension", "list"}, + VersionRegex: `azure\.ai\.agents\s+(\d+\.\d+\.\d+)`, + InstallStrategies: allPlatforms(InstallStrategy{ + InstallCommand: "azd extension install azure.ai.agents", + }), + Dependencies: []string{"az-cli"}, + } +} + +// allPlatforms returns an [InstallStrategies] map that uses the same strategy +// for Windows, macOS and Linux. +func allPlatforms(s InstallStrategy) map[string]InstallStrategy { + return map[string]InstallStrategy{ + "windows": s, + "darwin": s, + "linux": s, + } +} diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go new file mode 100644 index 00000000000..79d23cf5fde --- /dev/null +++ b/cli/azd/pkg/tool/manifest_test.go @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuiltInTools(t *testing.T) { + t.Parallel() + + t.Run("ReturnsExpectedCount", func(t *testing.T) { + t.Parallel() + + tools := BuiltInTools() + require.Len(t, tools, 7, "expected 7 built-in tools") + }) + + t.Run("ContainsAllExpectedToolIDs", func(t *testing.T) { + t.Parallel() + + expectedIDs := []string{ + "az-cli", + "github-copilot-cli", + "vscode-azure-tools", + "vscode-bicep", + "vscode-github-copilot", + "azure-mcp-server", + "azd-ai-extensions", + } + + tools := BuiltInTools() + actualIDs := make([]string, len(tools)) + for i, tool := range tools { + actualIDs[i] = tool.Id + } + + for _, id := range expectedIDs { + assert.Contains(t, actualIDs, id, + "missing expected tool %q", id) + } + }) + + t.Run("NoDuplicateIDs", func(t *testing.T) { + t.Parallel() + + tools := BuiltInTools() + seen := make(map[string]bool, len(tools)) + for _, tool := range tools { + require.False(t, seen[tool.Id], + "duplicate tool ID %q", tool.Id) + seen[tool.Id] = true + } + }) + + t.Run("AllToolsHaveRequiredFields", func(t *testing.T) { + t.Parallel() + + tools := BuiltInTools() + for _, tool := range tools { + assert.NotEmpty(t, tool.Id, + "tool must have an Id") + assert.NotEmpty(t, tool.Name, + "tool %q must have a Name", tool.Id) + assert.NotEmpty(t, tool.Description, + "tool %q must have a Description", tool.Id) + assert.NotEmpty(t, tool.Category, + "tool %q must have a Category", tool.Id) + assert.NotEmpty(t, tool.DetectCommand, + "tool %q must have a DetectCommand", tool.Id) + } + }) + + t.Run("AllToolsHaveValidCategory", func(t *testing.T) { + t.Parallel() + + validCategories := map[ToolCategory]bool{ + ToolCategoryCLI: true, + ToolCategoryExtension: true, + ToolCategoryServer: true, + ToolCategoryLibrary: true, + } + + tools := BuiltInTools() + for _, tool := range tools { + assert.True(t, validCategories[tool.Category], + "tool %q has invalid category %q", + tool.Id, tool.Category) + } + }) + + t.Run("AllToolsHaveValidPriority", func(t *testing.T) { + t.Parallel() + + validPriorities := map[ToolPriority]bool{ + ToolPriorityRecommended: true, + ToolPriorityOptional: true, + } + + tools := BuiltInTools() + for _, tool := range tools { + assert.True(t, validPriorities[tool.Priority], + "tool %q has invalid priority %q", + tool.Id, tool.Priority) + } + }) + + t.Run("AllToolsHaveInstallStrategies", func(t *testing.T) { + t.Parallel() + + tools := BuiltInTools() + for _, tool := range tools { + assert.NotEmpty(t, tool.InstallStrategies, + "tool %q must have InstallStrategies", + tool.Id) + } + }) + + t.Run("ReturnsFreshCopy", func(t *testing.T) { + t.Parallel() + + first := BuiltInTools() + second := BuiltInTools() + + require.Equal(t, len(first), len(second)) + + // Mutating the first slice should not affect the second. + first[0] = &ToolDefinition{Id: "mutated-tool"} + assert.NotEqual(t, first[0].Id, second[0].Id, + "BuiltInTools should return independent copies") + }) +} + +func TestFindTool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + expectNil bool + expectId string + }{ + { + name: "FindsAzCLI", + id: "az-cli", + expectId: "az-cli", + }, + { + name: "FindsGitHubCopilotCLI", + id: "github-copilot-cli", + expectId: "github-copilot-cli", + }, + { + name: "FindsVSCodeExtension", + id: "vscode-azure-tools", + expectId: "vscode-azure-tools", + }, + { + name: "FindsMCPServer", + id: "azure-mcp-server", + expectId: "azure-mcp-server", + }, + { + name: "FindsAzdAIExtensions", + id: "azd-ai-extensions", + expectId: "azd-ai-extensions", + }, + { + name: "ReturnsNilForUnknownID", + id: "nonexistent-tool", + expectNil: true, + }, + { + name: "ReturnsNilForEmptyID", + id: "", + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := FindTool(tt.id) + if tt.expectNil { + require.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, tt.expectId, result.Id) + } + }) + } +} + +func TestFindToolsByCategory(t *testing.T) { + t.Parallel() + + t.Run("ReturnsCLITools", func(t *testing.T) { + t.Parallel() + + tools := FindToolsByCategory(ToolCategoryCLI) + require.NotEmpty(t, tools) + + for _, tool := range tools { + assert.Equal(t, ToolCategoryCLI, tool.Category) + } + + // Known CLI tools: az-cli, github-copilot-cli + ids := make([]string, len(tools)) + for i, tool := range tools { + ids[i] = tool.Id + } + assert.Contains(t, ids, "az-cli") + assert.Contains(t, ids, "github-copilot-cli") + }) + + t.Run("ReturnsExtensionTools", func(t *testing.T) { + t.Parallel() + + tools := FindToolsByCategory(ToolCategoryExtension) + require.NotEmpty(t, tools) + + for _, tool := range tools { + assert.Equal(t, ToolCategoryExtension, tool.Category) + } + }) + + t.Run("ReturnsServerTools", func(t *testing.T) { + t.Parallel() + + tools := FindToolsByCategory(ToolCategoryServer) + require.NotEmpty(t, tools) + + for _, tool := range tools { + assert.Equal(t, ToolCategoryServer, tool.Category) + } + }) + + t.Run("ReturnsLibraryTools", func(t *testing.T) { + t.Parallel() + + tools := FindToolsByCategory(ToolCategoryLibrary) + require.NotEmpty(t, tools) + + for _, tool := range tools { + assert.Equal(t, ToolCategoryLibrary, tool.Category) + } + }) + + t.Run("ReturnsEmptyForUnknownCategory", func(t *testing.T) { + t.Parallel() + + tools := FindToolsByCategory(ToolCategory("bogus")) + require.Empty(t, tools) + }) + + t.Run("CategoriesSumToTotal", func(t *testing.T) { + t.Parallel() + + allTools := BuiltInTools() + cli := FindToolsByCategory(ToolCategoryCLI) + ext := FindToolsByCategory(ToolCategoryExtension) + srv := FindToolsByCategory(ToolCategoryServer) + lib := FindToolsByCategory(ToolCategoryLibrary) + + total := len(cli) + len(ext) + len(srv) + len(lib) + assert.Equal(t, len(allTools), total, + "sum of categorised tools must equal total") + }) +} + +func TestSpecificToolDefinitions(t *testing.T) { + t.Parallel() + + t.Run("AzCLIHasCorrectFields", func(t *testing.T) { + t.Parallel() + + tool := FindTool("az-cli") + require.NotNil(t, tool) + + assert.Equal(t, "Azure CLI", tool.Name) + assert.Equal(t, ToolCategoryCLI, tool.Category) + assert.Equal(t, ToolPriorityRecommended, tool.Priority) + assert.Equal(t, "az", tool.DetectCommand) + assert.Equal(t, []string{"--version"}, tool.VersionArgs) + assert.NotEmpty(t, tool.VersionRegex) + assert.NotEmpty(t, tool.Website) + + _, hasWindows := tool.InstallStrategies["windows"] + _, hasDarwin := tool.InstallStrategies["darwin"] + _, hasLinux := tool.InstallStrategies["linux"] + assert.True(t, hasWindows, "should have windows strategy") + assert.True(t, hasDarwin, "should have darwin strategy") + assert.True(t, hasLinux, "should have linux strategy") + }) + + t.Run("AzdAIExtensionsHasDependency", func(t *testing.T) { + t.Parallel() + + tool := FindTool("azd-ai-extensions") + require.NotNil(t, tool) + + assert.Contains(t, tool.Dependencies, "az-cli") + }) + + t.Run("VSCodeExtensionsUseCodeDetectCommand", func(t *testing.T) { + t.Parallel() + + extensions := FindToolsByCategory(ToolCategoryExtension) + for _, ext := range extensions { + assert.Equal(t, "code", ext.DetectCommand, + "extension %q should detect via 'code'", ext.Id) + } + }) +} + +func TestAllPlatforms(t *testing.T) { + t.Parallel() + + strategy := InstallStrategy{ + PackageManager: "npm", + PackageId: "@test/pkg", + InstallCommand: "npm install -g @test/pkg", + } + + result := allPlatforms(strategy) + + require.Len(t, result, 3) + for _, os := range []string{"windows", "darwin", "linux"} { + got, exists := result[os] + require.True(t, exists, "missing %s", os) + assert.Equal(t, strategy, got) + } +} diff --git a/cli/azd/pkg/tool/platform.go b/cli/azd/pkg/tool/platform.go new file mode 100644 index 00000000000..0c8ab1a655e --- /dev/null +++ b/cli/azd/pkg/tool/platform.go @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "runtime" + "slices" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" +) + +// platformManagers maps each operating system to the package managers that +// should be probed during detection. +var platformManagers = map[string][]string{ + "windows": {"winget", "npm", "code"}, + "darwin": {"brew", "npm", "code"}, + "linux": {"apt", "snap", "npm", "code"}, +} + +// Platform describes the detected operating system and the package managers +// that were found on the local machine. +type Platform struct { + // OS is the operating system identifier returned by runtime.GOOS + // (e.g. "windows", "darwin", "linux"). + OS string + // AvailableManagers lists the package managers detected on the system + // (e.g. ["winget", "npm"]). + AvailableManagers []string +} + +// HasManager reports whether the named package manager was detected on this +// platform. +func (p *Platform) HasManager(name string) bool { + return slices.Contains(p.AvailableManagers, name) +} + +// PlatformDetector discovers the current operating system and probes for +// available package managers using a [exec.CommandRunner]. +type PlatformDetector struct { + commandRunner exec.CommandRunner +} + +// NewPlatformDetector creates a PlatformDetector that uses the provided +// CommandRunner to probe for package manager availability. +func NewPlatformDetector(commandRunner exec.CommandRunner) *PlatformDetector { + return &PlatformDetector{ + commandRunner: commandRunner, + } +} + +// Detect identifies the current platform by reading runtime.GOOS and checking +// each known package manager for availability. Managers that are not found are +// silently skipped; the method only returns an error for unexpected failures. +func (pd *PlatformDetector) Detect(ctx context.Context) (*Platform, error) { + osName := runtime.GOOS + + candidates := platformManagers[osName] + available := make([]string, 0, len(candidates)) + + for _, mgr := range candidates { + if pd.IsManagerAvailable(ctx, mgr) { + available = append(available, mgr) + } + } + + return &Platform{ + OS: osName, + AvailableManagers: available, + }, nil +} + +// IsManagerAvailable reports whether the named package manager can be found on +// the system PATH and responds to a --version invocation. A manager that cannot +// be located or executed is considered unavailable. +func (pd *PlatformDetector) IsManagerAvailable( + ctx context.Context, + manager string, +) bool { + // Fast path: if the tool is not on PATH there is nothing more to check. + if err := pd.commandRunner.ToolInPath(manager); err != nil { + return false + } + + // Validate the tool can actually be invoked. + _, err := pd.commandRunner.Run(ctx, exec.RunArgs{ + Cmd: manager, + Args: []string{"--version"}, + }) + + return err == nil +} + +// SelectStrategy returns the best install strategy for the given tool on the +// detected platform. It returns nil when no strategy is defined for the +// platform's OS. When the strategy names a PackageManager that is not present +// in platform.AvailableManagers the strategy is still returned — the caller +// (installer) is responsible for handling the fallback. +func (pd *PlatformDetector) SelectStrategy( + tool *ToolDefinition, + platform *Platform, +) *InstallStrategy { + if tool == nil || tool.InstallStrategies == nil { + return nil + } + + strategy, exists := tool.InstallStrategies[platform.OS] + if !exists { + return nil + } + + return &strategy +} diff --git a/cli/azd/pkg/tool/platform_test.go b/cli/azd/pkg/tool/platform_test.go new file mode 100644 index 00000000000..8265b88a710 --- /dev/null +++ b/cli/azd/pkg/tool/platform_test.go @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "errors" + osexec "os/exec" + "runtime" + "slices" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" + "github.com/stretchr/testify/require" +) + +func TestNewPlatformDetector(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + pd := NewPlatformDetector(runner) + + require.NotNil(t, pd) + require.Equal(t, runner, pd.commandRunner) +} + +func TestDetect(t *testing.T) { + t.Parallel() + + t.Run("ReturnsCurrentOS", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + // Mock all known managers as unavailable so we don't depend on + // the host environment. + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, osexec.ErrNotFound) + } + } + + pd := NewPlatformDetector(runner) + platform, err := pd.Detect(t.Context()) + + require.NoError(t, err) + require.Equal(t, runtime.GOOS, platform.OS) + }) + + t.Run("DetectsAvailableManagers", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + + // Make "npm" available (PATH + --version succeeds). + runner.MockToolInPath("npm", nil) + runner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "npm" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "10.2.0", + }) + + // Make "code" available. + runner.MockToolInPath("code", nil) + runner.When(func(args exec.RunArgs, command string) bool { + return args.Cmd == "code" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "1.85.0", + }) + + // All other managers are missing. + for _, managers := range platformManagers { + for _, mgr := range managers { + if mgr == "npm" || mgr == "code" { + continue + } + runner.MockToolInPath(mgr, osexec.ErrNotFound) + } + } + + pd := NewPlatformDetector(runner) + platform, err := pd.Detect(t.Context()) + + require.NoError(t, err) + require.Equal(t, runtime.GOOS, platform.OS) + + // npm and code should appear; others should not. + require.True(t, platform.HasManager("npm")) + require.True(t, platform.HasManager("code")) + + // Platform-specific managers should NOT be present because + // we mocked them as not found. + for _, mgr := range platformManagers[runtime.GOOS] { + if mgr == "npm" || mgr == "code" { + continue + } + require.False(t, platform.HasManager(mgr), + "expected %s to be absent", mgr) + } + }) + + t.Run("NoManagersAvailable", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, osexec.ErrNotFound) + } + } + + pd := NewPlatformDetector(runner) + platform, err := pd.Detect(t.Context()) + + require.NoError(t, err) + require.Empty(t, platform.AvailableManagers) + }) +} + +func TestIsManagerAvailable(t *testing.T) { + t.Parallel() + + t.Run("AvailableWhenInPathAndVersionSucceeds", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("winget", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "winget" && + slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: "v1.6.3133", + }) + + pd := NewPlatformDetector(runner) + require.True(t, pd.IsManagerAvailable(t.Context(), "winget")) + }) + + t.Run("UnavailableWhenNotInPath", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("brew", osexec.ErrNotFound) + + pd := NewPlatformDetector(runner) + require.False(t, pd.IsManagerAvailable(t.Context(), "brew")) + }) + + t.Run("UnavailableWhenVersionFails", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("snap", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "snap" && + slices.Contains(args.Args, "--version") + }).SetError(errors.New("exit code: 1")) + + pd := NewPlatformDetector(runner) + require.False(t, pd.IsManagerAvailable(t.Context(), "snap")) + }) + + t.Run("UnavailableWhenPathCheckErrors", func(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("unknown", + errors.New("failed searching for `unknown` on PATH")) + + pd := NewPlatformDetector(runner) + require.False(t, pd.IsManagerAvailable(t.Context(), "unknown")) + }) +} + +func TestSelectStrategy(t *testing.T) { + t.Parallel() + + // Shared detector — SelectStrategy does not use the runner. + pd := NewPlatformDetector(mockexec.NewMockCommandRunner()) + + t.Run("ReturnsNilForNilTool", func(t *testing.T) { + t.Parallel() + + platform := &Platform{ + OS: "windows", + AvailableManagers: []string{"winget"}, + } + require.Nil(t, pd.SelectStrategy(nil, platform)) + }) + + t.Run("ReturnsNilForNilStrategies", func(t *testing.T) { + t.Parallel() + + tool := &ToolDefinition{ + Name: "test-tool", + InstallStrategies: nil, + } + platform := &Platform{ + OS: "linux", + AvailableManagers: []string{"apt"}, + } + require.Nil(t, pd.SelectStrategy(tool, platform)) + }) + + t.Run("ReturnsNilWhenOSNotInStrategies", func(t *testing.T) { + t.Parallel() + + tool := &ToolDefinition{ + Name: "az", + InstallStrategies: map[string]InstallStrategy{ + "windows": { + PackageManager: "winget", + PackageId: "Microsoft.AzureCLI", + }, + }, + } + platform := &Platform{ + OS: "linux", + AvailableManagers: []string{"apt"}, + } + require.Nil(t, pd.SelectStrategy(tool, platform)) + }) + + t.Run("ReturnsStrategyForMatchingOS", func(t *testing.T) { + t.Parallel() + + tool := &ToolDefinition{ + Name: "az", + InstallStrategies: map[string]InstallStrategy{ + "darwin": { + PackageManager: "brew", + PackageId: "azure-cli", + }, + }, + } + platform := &Platform{ + OS: "darwin", + AvailableManagers: []string{"brew", "npm"}, + } + + got := pd.SelectStrategy(tool, platform) + require.NotNil(t, got) + require.Equal(t, "brew", got.PackageManager) + require.Equal(t, "azure-cli", got.PackageId) + }) + + t.Run("ReturnsStrategyEvenWhenManagerUnavailable", func(t *testing.T) { + t.Parallel() + + tool := &ToolDefinition{ + Name: "az", + InstallStrategies: map[string]InstallStrategy{ + "windows": { + PackageManager: "winget", + PackageId: "Microsoft.AzureCLI", + }, + }, + } + // Platform has no managers at all. + platform := &Platform{ + OS: "windows", + AvailableManagers: []string{}, + } + + got := pd.SelectStrategy(tool, platform) + require.NotNil(t, got) + require.Equal(t, "winget", got.PackageManager) + }) + + t.Run("ReturnsStrategyWithInstallCommand", func(t *testing.T) { + t.Parallel() + + tool := &ToolDefinition{ + Name: "az", + InstallStrategies: map[string]InstallStrategy{ + "linux": { + InstallCommand: "curl -sL https://example.com | bash", + FallbackUrl: "https://example.com/install", + }, + }, + } + platform := &Platform{ + OS: "linux", + AvailableManagers: []string{"apt"}, + } + + got := pd.SelectStrategy(tool, platform) + require.NotNil(t, got) + require.Empty(t, got.PackageManager) + require.Equal(t, + "curl -sL https://example.com | bash", + got.InstallCommand, + ) + }) + + t.Run("WorksWithBuiltInToolDefinitions", func(t *testing.T) { + t.Parallel() + + azTool := FindTool("az-cli") + require.NotNil(t, azTool) + + platform := &Platform{ + OS: "windows", + AvailableManagers: []string{"winget"}, + } + + got := pd.SelectStrategy(azTool, platform) + require.NotNil(t, got) + require.Equal(t, "winget", got.PackageManager) + require.Equal(t, "Microsoft.AzureCLI", got.PackageId) + }) +} + +func TestPlatformHasManager(t *testing.T) { + t.Parallel() + + platform := &Platform{ + OS: "windows", + AvailableManagers: []string{"winget", "npm", "code"}, + } + + t.Run("ReturnsTrueForPresentManager", func(t *testing.T) { + t.Parallel() + require.True(t, platform.HasManager("winget")) + require.True(t, platform.HasManager("npm")) + require.True(t, platform.HasManager("code")) + }) + + t.Run("ReturnsFalseForAbsentManager", func(t *testing.T) { + t.Parallel() + require.False(t, platform.HasManager("brew")) + require.False(t, platform.HasManager("apt")) + require.False(t, platform.HasManager("")) + }) + + t.Run("ReturnsFalseForEmptyManagers", func(t *testing.T) { + t.Parallel() + empty := &Platform{ + OS: "linux", + AvailableManagers: []string{}, + } + require.False(t, empty.HasManager("apt")) + }) +} + +func TestPlatformManagersTable(t *testing.T) { + t.Parallel() + + t.Run("WindowsIncludesExpectedManagers", func(t *testing.T) { + t.Parallel() + managers := platformManagers["windows"] + require.Contains(t, managers, "winget") + require.Contains(t, managers, "npm") + require.Contains(t, managers, "code") + }) + + t.Run("DarwinIncludesExpectedManagers", func(t *testing.T) { + t.Parallel() + managers := platformManagers["darwin"] + require.Contains(t, managers, "brew") + require.Contains(t, managers, "npm") + require.Contains(t, managers, "code") + }) + + t.Run("LinuxIncludesExpectedManagers", func(t *testing.T) { + t.Parallel() + managers := platformManagers["linux"] + require.Contains(t, managers, "apt") + require.Contains(t, managers, "snap") + require.Contains(t, managers, "npm") + require.Contains(t, managers, "code") + }) + + t.Run("CrossPlatformToolsOnAllOSes", func(t *testing.T) { + t.Parallel() + for os, managers := range platformManagers { + require.Contains(t, managers, "npm", + "npm missing for %s", os) + require.Contains(t, managers, "code", + "code missing for %s", os) + } + }) +} diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go new file mode 100644 index 00000000000..a7526c2932c --- /dev/null +++ b/cli/azd/pkg/tool/update_checker.go @@ -0,0 +1,465 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" +) + +const ( + // configKeyLastUpdateCheck stores the RFC3339 timestamp of the last tool + // update check. + configKeyLastUpdateCheck = "tool.lastUpdateCheck" + // configKeyCheckIntervalHours stores how many hours to wait between + // automatic update checks. + configKeyCheckIntervalHours = "tool.checkIntervalHours" + // configKeyUpdateChecks enables or disables automatic update checks. + // Valid values are "on" (default) and "off". + configKeyUpdateChecks = "tool.updateChecks" + // configKeyLastNotificationShown stores the RFC3339 timestamp of the + // last time an update notification was displayed. + configKeyLastNotificationShown = "tool.lastNotificationShown" + + // defaultCheckIntervalHours is how often (in hours) update checks run + // when the user has not overridden the interval. 168 h = 7 days. + defaultCheckIntervalHours = 168 + + // toolCheckCacheFileName is the file name used for the tool update + // check cache inside the azd config directory. + toolCheckCacheFileName = "tool-check-cache.json" +) + +// CachedToolVersion stores the latest known version of a single tool. +type CachedToolVersion struct { + // LatestVersion is the most recent version string returned by the + // remote version API (or left empty when no remote data is available). + LatestVersion string `json:"latestVersion"` +} + +// UpdateCheckCache is the on-disk representation of a tool update check +// result set. It is serialized as JSON and written to +// ~/.azd/tool-check-cache.json. +type UpdateCheckCache struct { + // CheckedAt is the time the cache was last populated. + CheckedAt time.Time `json:"checkedAt"` + // ExpiresAt is the earliest time the cache should be refreshed. + ExpiresAt time.Time `json:"expiresAt"` + // Tools maps tool IDs to their cached version information. + Tools map[string]CachedToolVersion `json:"tools"` +} + +// UpdateCheckResult pairs a tool definition with its current and latest +// version information so callers can determine whether an upgrade is +// available. +type UpdateCheckResult struct { + // Tool is the registry definition that was checked. + Tool *ToolDefinition + // CurrentVersion is the version currently installed on the local + // machine (empty when the tool is not installed). + CurrentVersion string + // LatestVersion is the newest version known to the update checker. + LatestVersion string + // UpdateAvailable is true when LatestVersion is non-empty and + // differs from CurrentVersion. + UpdateAvailable bool +} + +// UpdateChecker performs periodic update checks for registered tools, +// caching results to disk so that expensive remote lookups are amortized +// across CLI invocations. +type UpdateChecker struct { + configManager config.UserConfigManager + detector Detector + configDirFn func() (string, error) + + cachePathOnce sync.Once + cachePath string + cachePathErr error +} + +// NewUpdateChecker creates an [UpdateChecker] that resolves its cache +// file location lazily via configDirFn. This avoids blocking I/O +// (e.g. [config.GetUserConfigDir]) during IoC container registration. +func NewUpdateChecker( + configManager config.UserConfigManager, + detector Detector, + configDirFn func() (string, error), +) *UpdateChecker { + return &UpdateChecker{ + configManager: configManager, + detector: detector, + configDirFn: configDirFn, + } +} + +// getCacheFilePath returns the resolved path to the on-disk cache file, +// computing it once on first call. +func (uc *UpdateChecker) getCacheFilePath() (string, error) { + uc.cachePathOnce.Do(func() { + dir, err := uc.configDirFn() + if err != nil { + uc.cachePathErr = fmt.Errorf("resolving config directory: %w", err) + return + } + uc.cachePath = filepath.Join(dir, toolCheckCacheFileName) + }) + return uc.cachePath, uc.cachePathErr +} + +// ShouldCheck returns true when enough time has elapsed since the last +// update check and automatic checks have not been disabled by the user. +func (uc *UpdateChecker) ShouldCheck(ctx context.Context) bool { + cfg, err := uc.configManager.Load() + if err != nil { + log.Printf("update-checker: failed to load config: %v", err) + return false + } + + // Respect the kill-switch. + if mode, ok := cfg.GetString(configKeyUpdateChecks); ok { + if mode == "off" { + return false + } + } + + intervalHours := loadIntervalHours(cfg) + + lastCheckStr, ok := cfg.GetString(configKeyLastUpdateCheck) + if !ok { + // Never checked before — check now. + return true + } + + lastCheck, err := time.Parse(time.RFC3339, lastCheckStr) + if err != nil { + log.Printf( + "update-checker: invalid %s value %q: %v", + configKeyLastUpdateCheck, lastCheckStr, err, + ) + return true + } + + return time.Now().After( + lastCheck.Add(time.Duration(intervalHours) * time.Hour), + ) +} + +// Check runs the update check for the supplied tools. It detects the +// currently installed versions, compares them against cached remote +// data, persists the results, and updates the last-check timestamp. +// +// In this POC implementation there is no remote version API; the +// latest-version field comes solely from any previously cached data. +func (uc *UpdateChecker) Check( + ctx context.Context, + tools []*ToolDefinition, +) ([]*UpdateCheckResult, error) { + statuses, err := uc.detector.DetectAll(ctx, tools) + if err != nil { + return nil, fmt.Errorf("detecting installed tools: %w", err) + } + + existing, _ := uc.GetCachedResults() + + statusByID := make(map[string]*ToolStatus, len(statuses)) + for _, s := range statuses { + statusByID[s.Tool.Id] = s + } + + intervalHours := uc.loadConfiguredInterval() + now := time.Now().UTC() + cache := &UpdateCheckCache{ + CheckedAt: now, + ExpiresAt: now.Add(time.Duration(intervalHours) * time.Hour), + Tools: make(map[string]CachedToolVersion, len(tools)), + } + + results := make([]*UpdateCheckResult, 0, len(tools)) + for _, t := range tools { + status := statusByID[t.Id] + + var currentVer string + if status != nil { + currentVer = status.InstalledVersion + } + + // POC: carry forward any previously cached latest version. + var latestVer string + if existing != nil { + if cached, ok := existing.Tools[t.Id]; ok { + latestVer = cached.LatestVersion + } + } + + cache.Tools[t.Id] = CachedToolVersion{ + LatestVersion: latestVer, + } + + results = append(results, &UpdateCheckResult{ + Tool: t, + CurrentVersion: currentVer, + LatestVersion: latestVer, + UpdateAvailable: latestVer != "" && latestVer != currentVer, + }) + } + + if err := uc.SaveCache(cache); err != nil { + return results, fmt.Errorf("saving update cache: %w", err) + } + + if err := uc.recordCheckTimestamp(); err != nil { + return results, fmt.Errorf("recording check timestamp: %w", err) + } + + return results, nil +} + +// GetCachedResults reads and returns the on-disk update check cache. +// It returns (nil, nil) when the cache file does not yet exist or +// cannot be read. Corrupt cache files are removed so they can be +// regenerated on the next check cycle. +func (uc *UpdateChecker) GetCachedResults() (*UpdateCheckCache, error) { + cacheFile, err := uc.getCacheFilePath() + if err != nil { + return nil, nil // no config dir — expected in some envs + } + + data, err := os.ReadFile(cacheFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil // no cache yet — expected + } + log.Printf("update-checker: failed to read cache file: %v", err) + return nil, nil // non-fatal, will regenerate + } + + var cache UpdateCheckCache + if err := json.Unmarshal(data, &cache); err != nil { + log.Printf("update-checker: corrupt cache file, removing: %v", err) + _ = os.Remove(cacheFile) // clean up corrupt file + return nil, nil // will regenerate on next check + } + + return &cache, nil +} + +// SaveCache serializes the cache to disk, creating any intermediate +// directories as needed. +func (uc *UpdateChecker) SaveCache(cache *UpdateCheckCache) error { + cachePath, err := uc.getCacheFilePath() + if err != nil { + return err + } + + dir := filepath.Dir(cachePath) + if err := os.MkdirAll(dir, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("creating cache directory: %w", err) + } + + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("marshalling tool check cache: %w", err) + } + + if err := os.WriteFile( + cachePath, data, osutil.PermissionFile, + ); err != nil { + return fmt.Errorf("writing tool check cache: %w", err) + } + + return nil +} + +// HasUpdatesAvailable checks the cache for tools whose latest known +// version differs from the currently installed version. +// It returns whether any updates exist, how many, and any error +// encountered while reading the cache or detecting versions. +func (uc *UpdateChecker) HasUpdatesAvailable( + ctx context.Context, +) (bool, int, error) { + cache, err := uc.GetCachedResults() + if err != nil { + return false, 0, fmt.Errorf("loading cached results: %w", err) + } + + if cache == nil || len(cache.Tools) == 0 { + return false, 0, nil + } + + // Build a lookup of tool IDs to their cached latest versions, but + // only for entries that actually have a known latest version. + candidates := make(map[string]string, len(cache.Tools)) + for id, ct := range cache.Tools { + if ct.LatestVersion != "" { + candidates[id] = ct.LatestVersion + } + } + + if len(candidates) == 0 { + return false, 0, nil + } + + // Resolve tool definitions so we can detect installed versions. + var toolDefs []*ToolDefinition + for id := range candidates { + if t := FindTool(id); t != nil { + toolDefs = append(toolDefs, t) + } + } + + if len(toolDefs) == 0 { + return false, 0, nil + } + + statuses, err := uc.detector.DetectAll(ctx, toolDefs) + if err != nil { + return false, 0, fmt.Errorf("detecting tools: %w", err) + } + + count := 0 + for _, s := range statuses { + latest, ok := candidates[s.Tool.Id] + if ok && latest != s.InstalledVersion { + count++ + } + } + + return count > 0, count, nil +} + +// MarkNotificationShown records the current time as the last moment an +// update notification was displayed, preventing repeated notifications +// within the same check cycle. +func (uc *UpdateChecker) MarkNotificationShown( + ctx context.Context, +) error { + cfg, err := uc.configManager.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if err := cfg.Set( + configKeyLastNotificationShown, + time.Now().UTC().Format(time.RFC3339), + ); err != nil { + return fmt.Errorf("setting %s: %w", configKeyLastNotificationShown, err) + } + + if err := uc.configManager.Save(cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + return nil +} + +// ShouldShowNotification returns true when an update notification has +// not yet been shown for the most recent check cycle. +func (uc *UpdateChecker) ShouldShowNotification( + ctx context.Context, +) bool { + cfg, err := uc.configManager.Load() + if err != nil { + log.Printf("update-checker: failed to load config: %v", err) + return false + } + + lastCheckStr, hasCheck := cfg.GetString(configKeyLastUpdateCheck) + if !hasCheck { + // No check has been performed yet — nothing to notify about. + return false + } + + lastCheck, err := time.Parse(time.RFC3339, lastCheckStr) + if err != nil { + return false + } + + shownStr, hasShown := cfg.GetString(configKeyLastNotificationShown) + if !hasShown { + // A check exists but no notification has ever been shown. + return true + } + + lastShown, err := time.Parse(time.RFC3339, shownStr) + if err != nil { + return true + } + + // Only show once per check cycle: skip if the notification was + // already displayed after the most recent check. + return lastShown.Before(lastCheck) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// loadIntervalHours reads the check-interval configuration from a +// pre-loaded [config.Config], falling back to the default when the key +// is unset or unparsable. +func loadIntervalHours(cfg config.Config) int { + raw, ok := cfg.Get(configKeyCheckIntervalHours) + if !ok { + return defaultCheckIntervalHours + } + + switch v := raw.(type) { + case float64: + return int(v) + case int: + return v + case string: + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + + return defaultCheckIntervalHours +} + +// loadConfiguredInterval loads the interval from the user config, +// returning the default when the config is unavailable. +func (uc *UpdateChecker) loadConfiguredInterval() int { + cfg, err := uc.configManager.Load() + if err != nil { + return defaultCheckIntervalHours + } + + return loadIntervalHours(cfg) +} + +// recordCheckTimestamp persists the current UTC time as the +// last-update-check timestamp in the user config. +func (uc *UpdateChecker) recordCheckTimestamp() error { + cfg, err := uc.configManager.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if err := cfg.Set( + configKeyLastUpdateCheck, + time.Now().UTC().Format(time.RFC3339), + ); err != nil { + return fmt.Errorf("setting %s: %w", configKeyLastUpdateCheck, err) + } + + if err := uc.configManager.Save(cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + + return nil +} diff --git a/cli/azd/pkg/tool/update_checker_test.go b/cli/azd/pkg/tool/update_checker_test.go new file mode 100644 index 00000000000..9593a5fa542 --- /dev/null +++ b/cli/azd/pkg/tool/update_checker_test.go @@ -0,0 +1,516 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import ( + "context" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// mockUserConfigManager — in-package mock for config.UserConfigManager +// --------------------------------------------------------------------------- + +type mockUserConfigManager struct { + cfg config.Config +} + +func newMockUserConfigManager() *mockUserConfigManager { + return &mockUserConfigManager{cfg: config.NewEmptyConfig()} +} + +func (m *mockUserConfigManager) Load() (config.Config, error) { + return m.cfg, nil +} + +func (m *mockUserConfigManager) Save(cfg config.Config) error { + m.cfg = cfg + return nil +} + +// staticDir returns a configDirFn that always yields the given directory. +// This is a test helper for constructing [UpdateChecker] instances with +// a known, fixed directory. +func staticDir(dir string) func() (string, error) { + return func() (string, error) { return dir, nil } +} + +// --------------------------------------------------------------------------- +// ShouldCheck +// --------------------------------------------------------------------------- + +func TestShouldCheck(t *testing.T) { + t.Parallel() + + t.Run("FirstTimeReturnsTrue", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + + assert.True(t, uc.ShouldCheck(t.Context())) + }) + + t.Run("WithinIntervalReturnsFalse", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + // Set last check to 1 hour ago. + err := mgr.cfg.Set( + configKeyLastUpdateCheck, + time.Now().Add(-1*time.Hour).UTC().Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.False(t, uc.ShouldCheck(t.Context())) + }) + + t.Run("PastIntervalReturnsTrue", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + // Set last check to 200 hours ago (default interval is 168h). + err := mgr.cfg.Set( + configKeyLastUpdateCheck, + time.Now().Add(-200*time.Hour).UTC().Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.True(t, uc.ShouldCheck(t.Context())) + }) + + t.Run("UpdateChecksOffReturnsFalse", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + err := mgr.cfg.Set(configKeyUpdateChecks, "off") + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.False(t, uc.ShouldCheck(t.Context())) + }) + + t.Run("InvalidTimestampReturnsTrue", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + err := mgr.cfg.Set( + configKeyLastUpdateCheck, "not-a-timestamp", + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.True(t, uc.ShouldCheck(t.Context())) + }) + + t.Run("CustomIntervalHoursRespected", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + // Set a short 1-hour interval. + err := mgr.cfg.Set(configKeyCheckIntervalHours, float64(1)) + require.NoError(t, err) + + // Last check was 2 hours ago. + err = mgr.cfg.Set( + configKeyLastUpdateCheck, + time.Now().Add(-2*time.Hour).UTC().Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.True(t, uc.ShouldCheck(t.Context())) + }) +} + +// --------------------------------------------------------------------------- +// SaveCache / GetCachedResults round-trip +// --------------------------------------------------------------------------- + +func TestSaveCacheAndGetCachedResults(t *testing.T) { + t.Parallel() + + t.Run("RoundTrip", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(tmpDir)) + + now := time.Now().UTC().Truncate(time.Second) + cache := &UpdateCheckCache{ + CheckedAt: now, + ExpiresAt: now.Add(168 * time.Hour), + Tools: map[string]CachedToolVersion{ + "az-cli": {LatestVersion: "2.65.0"}, + "vscode-bicep": { + LatestVersion: "0.30.0", + }, + }, + } + + err := uc.SaveCache(cache) + require.NoError(t, err) + + loaded, err := uc.GetCachedResults() + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, + cache.CheckedAt.Unix(), + loaded.CheckedAt.Unix(), + ) + assert.Len(t, loaded.Tools, 2) + assert.Equal(t, "2.65.0", + loaded.Tools["az-cli"].LatestVersion) + assert.Equal(t, "0.30.0", + loaded.Tools["vscode-bicep"].LatestVersion) + }) + + t.Run("NoCacheFileReturnsNilNil", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(tmpDir)) + + cache, err := uc.GetCachedResults() + assert.NoError(t, err) + assert.Nil(t, cache) + }) + + t.Run("EmptyToolsMap", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(tmpDir)) + + cache := &UpdateCheckCache{ + CheckedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + Tools: map[string]CachedToolVersion{}, + } + + require.NoError(t, uc.SaveCache(cache)) + + loaded, err := uc.GetCachedResults() + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Empty(t, loaded.Tools) + }) +} + +// --------------------------------------------------------------------------- +// ShouldShowNotification +// --------------------------------------------------------------------------- + +func TestShouldShowNotification(t *testing.T) { + t.Parallel() + + t.Run("NoCheckPerformedReturnsFalse", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + + assert.False(t, uc.ShouldShowNotification(t.Context())) + }) + + t.Run("CheckExistsNoNotificationReturnsTrue", func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + err := mgr.cfg.Set( + configKeyLastUpdateCheck, + time.Now().UTC().Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.True(t, uc.ShouldShowNotification(t.Context())) + }) + + t.Run("NotificationAlreadyShownAfterCheckReturnsFalse", + func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + checkTime := time.Now().Add(-1 * time.Hour).UTC() + shownTime := time.Now().UTC() + + err := mgr.cfg.Set( + configKeyLastUpdateCheck, + checkTime.Format(time.RFC3339), + ) + require.NoError(t, err) + err = mgr.cfg.Set( + configKeyLastNotificationShown, + shownTime.Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.False(t, + uc.ShouldShowNotification(t.Context())) + }, + ) + + t.Run("NotificationShownBeforeCheckReturnsTrue", + func(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + shownTime := time.Now().Add(-2 * time.Hour).UTC() + checkTime := time.Now().Add(-1 * time.Hour).UTC() + + err := mgr.cfg.Set( + configKeyLastUpdateCheck, + checkTime.Format(time.RFC3339), + ) + require.NoError(t, err) + err = mgr.cfg.Set( + configKeyLastNotificationShown, + shownTime.Format(time.RFC3339), + ) + require.NoError(t, err) + + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + assert.True(t, + uc.ShouldShowNotification(t.Context())) + }, + ) +} + +// --------------------------------------------------------------------------- +// MarkNotificationShown +// --------------------------------------------------------------------------- + +func TestMarkNotificationShown(t *testing.T) { + t.Parallel() + + mgr := newMockUserConfigManager() + uc := NewUpdateChecker(mgr, nil, staticDir(t.TempDir())) + + err := uc.MarkNotificationShown(t.Context()) + require.NoError(t, err) + + // Verify the timestamp was persisted. + val, ok := mgr.cfg.GetString(configKeyLastNotificationShown) + require.True(t, ok, "expected lastNotificationShown to be set") + + ts, parseErr := time.Parse(time.RFC3339, val) + require.NoError(t, parseErr) + assert.WithinDuration(t, time.Now().UTC(), ts, 5*time.Second) +} + +// --------------------------------------------------------------------------- +// loadIntervalHours helper +// --------------------------------------------------------------------------- + +func TestLoadIntervalHours(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value any + expect int + }{ + { + name: "DefaultWhenUnset", + value: nil, + expect: defaultCheckIntervalHours, + }, + { + name: "Float64Value", + value: float64(24), + expect: 24, + }, + { + name: "IntValue", + value: 48, + expect: 48, + }, + { + name: "StringValue", + value: "72", + expect: 72, + }, + { + name: "InvalidStringFallsBackToDefault", + value: "not-a-number", + expect: defaultCheckIntervalHours, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := config.NewEmptyConfig() + if tt.value != nil { + err := cfg.Set(configKeyCheckIntervalHours, tt.value) + require.NoError(t, err) + } + + result := loadIntervalHours(cfg) + assert.Equal(t, tt.expect, result) + }) + } +} + +// --------------------------------------------------------------------------- +// Check (integration-like test with mocks) +// --------------------------------------------------------------------------- + +func TestCheck(t *testing.T) { + t.Parallel() + + t.Run("DetectsToolsAndSavesCache", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + + det := &mockDetector{ + detectAllFn: func( + _ context.Context, tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + for i, tool := range tools { + results[i] = &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "1.0.0", + } + } + return results, nil + }, + } + + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir)) + + tools := []*ToolDefinition{ + {Id: "tool-a", Name: "Tool A"}, + {Id: "tool-b", Name: "Tool B"}, + } + + results, err := uc.Check(t.Context(), tools) + require.NoError(t, err) + require.Len(t, results, 2) + + for _, r := range results { + assert.Equal(t, "1.0.0", r.CurrentVersion) + } + + // Verify cache was persisted. + cache, cacheErr := uc.GetCachedResults() + require.NoError(t, cacheErr) + require.NotNil(t, cache) + assert.Len(t, cache.Tools, 2) + + // Verify timestamp was recorded. + val, ok := mgr.cfg.GetString(configKeyLastUpdateCheck) + require.True(t, ok) + _, parseErr := time.Parse(time.RFC3339, val) + assert.NoError(t, parseErr) + }) + + t.Run("CarriesForwardCachedLatestVersion", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + + det := &mockDetector{ + detectAllFn: func( + _ context.Context, tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + results := make([]*ToolStatus, len(tools)) + for i, tool := range tools { + results[i] = &ToolStatus{ + Tool: tool, + Installed: true, + InstalledVersion: "1.0.0", + } + } + return results, nil + }, + } + + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir)) + + // Seed a cache with a known latest version. + seedCache := &UpdateCheckCache{ + CheckedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + Tools: map[string]CachedToolVersion{ + "tool-a": {LatestVersion: "2.0.0"}, + }, + } + require.NoError(t, uc.SaveCache(seedCache)) + + tools := []*ToolDefinition{ + {Id: "tool-a", Name: "Tool A"}, + } + + results, err := uc.Check(t.Context(), tools) + require.NoError(t, err) + require.Len(t, results, 1) + + assert.Equal(t, "1.0.0", results[0].CurrentVersion) + assert.Equal(t, "2.0.0", results[0].LatestVersion) + assert.True(t, results[0].UpdateAvailable) + }) +} + +// --------------------------------------------------------------------------- +// HasUpdatesAvailable +// --------------------------------------------------------------------------- + +func TestHasUpdatesAvailable(t *testing.T) { + t.Parallel() + + t.Run("NoCacheReturnsNoUpdates", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + det := &mockDetector{} + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir)) + + hasUpdates, count, err := uc.HasUpdatesAvailable(t.Context()) + require.NoError(t, err) + assert.False(t, hasUpdates) + assert.Equal(t, 0, count) + }) + + t.Run("EmptyCacheToolsReturnsNoUpdates", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + det := &mockDetector{} + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir)) + + cache := &UpdateCheckCache{ + CheckedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + Tools: map[string]CachedToolVersion{}, + } + require.NoError(t, uc.SaveCache(cache)) + + hasUpdates, count, err := uc.HasUpdatesAvailable(t.Context()) + require.NoError(t, err) + assert.False(t, hasUpdates) + assert.Equal(t, 0, count) + }) +}