diff --git a/.github-pr-body.md b/.github-pr-body.md deleted file mode 100644 index 22e1e8134a0..00000000000 --- a/.github-pr-body.md +++ /dev/null @@ -1,47 +0,0 @@ -## Summary - -Adds the `azd tool` command group for managing external tool dependencies (CLI tools, VS Code extensions, npm packages, and azd extensions). This gives users a unified way to discover, install, upgrade, and check the status of tools that integrate with `azd`. - -### Commands - -| Command | Description | -|---|---| -| `azd tool list` | Show available tools and their installation status | -| `azd tool install [tools...]` | Install tools with dependency resolution and retry verification | -| `azd tool upgrade [tools...]` | Upgrade installed tools to latest versions | -| `azd tool check` | Check for available tool updates | -| `azd tool show ` | Show detailed information about a specific tool | - -## Changes - -### Command Layer (`cli/azd/cmd/`) - -- **`cmd/tool.go`** — Registers the `azd tool` command group with subcommands: `list`, `install`, `upgrade`, `check`, `show` - -### Core Package (`cli/azd/pkg/tool/`) - -- **`pkg/tool/detector.go`** — Concurrent tool detection using `sync.WaitGroup` with spinner UX -- **`pkg/tool/installer.go`** — Installation orchestration with dependency resolution, retry with exponential backoff, and post-install verification -- **`pkg/tool/manager.go`** — Central manager coordinating detection, installation, and upgrade workflows -- **`pkg/tool/manifest.go`** — Tool manifest definitions and parsing; supports CLI tools, VS Code extensions, npm packages, and azd extensions -- **`pkg/tool/platform.go`** — Cross-platform support (Windows, macOS, Linux) with platform-specific install strategies and checksum validation for direct downloads -- **`pkg/tool/update_checker.go`** — Background update check logic - -### Middleware (`cli/azd/cmd/middleware/`) - -- **`middleware/tool_first_run.go`** — First-run middleware that prompts new users to install recommended tools on initial `azd` usage -- **`middleware/tool_update_check.go`** — Background middleware that notifies users when tool updates are available - -## Testing - -- Comprehensive unit tests across all packages in `pkg/tool/` covering detection, installation, upgrade, and manifest parsing -- Middleware tests for first-run and update-check flows -- CI/non-interactive environment detection ensures no interactive prompts in automated pipelines - -## Notes - -- Replaces #7450 (reopened from fork due to branch protection) -- Tool IDs use real underlying identifiers (e.g., `GitHub.copilot`, `@azure/mcp`, `azure.ai.agents`) -- JSON-based detection for azd extensions -- JSON output support (`--output json`) for machine-readable consumption -- 31 files changed, ~8,700 additions across 23 commits diff --git a/cli/azd/cmd/figspec_test.go b/cli/azd/cmd/figspec_test.go index 5677d6e9c28..35e73786d18 100644 --- a/cli/azd/cmd/figspec_test.go +++ b/cli/azd/cmd/figspec_test.go @@ -28,6 +28,7 @@ func TestFigSpec(t *testing.T) { t.Setenv("AZD_CONFIG_DIR", configDir) t.Setenv("AZURE_DEV_COLLECT_TELEMETRY", "no") t.Setenv("AZD_SKIP_FIRST_RUN", "true") + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") cli := azdcli.NewCLI(t) diff --git a/cli/azd/cmd/middleware/tool_first_run.go b/cli/azd/cmd/middleware/tool_first_run.go index 258d1c83ba2..dcdb059b144 100644 --- a/cli/azd/cmd/middleware/tool_first_run.go +++ b/cli/azd/cmd/middleware/tool_first_run.go @@ -15,6 +15,7 @@ import ( "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/alpha" "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" @@ -35,6 +36,7 @@ const envKeySkipFirstRun = "AZD_SKIP_FIRST_RUN" // installed Azure development tools and optionally offers to // install any missing recommended tools. type ToolFirstRunMiddleware struct { + alphaManager *alpha.FeatureManager configManager config.UserConfigManager console input.Console manager *tool.Manager @@ -43,12 +45,14 @@ type ToolFirstRunMiddleware struct { // NewToolFirstRunMiddleware creates a new [ToolFirstRunMiddleware]. func NewToolFirstRunMiddleware( + alphaManager *alpha.FeatureManager, configManager config.UserConfigManager, console input.Console, manager *tool.Manager, options *internal.GlobalCommandOptions, ) Middleware { return &ToolFirstRunMiddleware{ + alphaManager: alphaManager, configManager: configManager, console: console, manager: manager, @@ -61,6 +65,11 @@ func NewToolFirstRunMiddleware( // 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 when the tool alpha feature is not enabled. + if !m.alphaManager.IsEnabled(tool.FeatureAlphaTool) { + return nextFn(ctx) + } + // Skip for child actions (e.g. workflow steps). if IsChildAction(ctx) { return nextFn(ctx) diff --git a/cli/azd/cmd/middleware/tool_first_run_test.go b/cli/azd/cmd/middleware/tool_first_run_test.go index 7bd4cd2ac34..f97d4199a64 100644 --- a/cli/azd/cmd/middleware/tool_first_run_test.go +++ b/cli/azd/cmd/middleware/tool_first_run_test.go @@ -10,6 +10,7 @@ import ( "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/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" "github.com/stretchr/testify/assert" @@ -58,6 +59,7 @@ func passthroughNext(called *bool) NextFn { func TestToolFirstRunMiddleware_SkipConditions(t *testing.T) { // These tests modify env vars via t.Setenv so they cannot be // parallel with each other or with tests that read the same vars. + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") tests := []struct { name string @@ -141,6 +143,7 @@ func TestToolFirstRunMiddleware_SkipConditions(t *testing.T) { ucm := &mockUserConfigManager{cfg: cfg} m := &ToolFirstRunMiddleware{ + alphaManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), configManager: ucm, console: console, manager: nil, // not needed for skip paths @@ -177,6 +180,7 @@ func TestToolFirstRunMiddleware_TriggersWhenNoSkip(t *testing.T) { clearCIVars(t) t.Setenv(envKeySkipFirstRun, "") os.Unsetenv(envKeySkipFirstRun) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() cfg := config.NewEmptyConfig() @@ -184,6 +188,7 @@ func TestToolFirstRunMiddleware_TriggersWhenNoSkip(t *testing.T) { ucm := &mockUserConfigManager{cfg: cfg} m := &ToolFirstRunMiddleware{ + alphaManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), configManager: ucm, console: console, manager: nil, // runFirstRunExperience will fail at prompt @@ -205,3 +210,35 @@ func TestToolFirstRunMiddleware_TriggersWhenNoSkip(t *testing.T) { assert.NotEmpty(t, console.Output(), "triggered first-run experience should produce console output") } + +// TestToolFirstRunMiddleware_SkipsWhenAlphaDisabled verifies that the +// middleware is a no-op when the tool alpha feature is not enabled. +func TestToolFirstRunMiddleware_SkipsWhenAlphaDisabled(t *testing.T) { + clearCIVars(t) + t.Setenv(envKeySkipFirstRun, "") + os.Unsetenv(envKeySkipFirstRun) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "false") + + console := mockinput.NewMockConsole() + cfg := config.NewEmptyConfig() + opts := &internal.GlobalCommandOptions{} + ucm := &mockUserConfigManager{cfg: cfg} + + m := &ToolFirstRunMiddleware{ + alphaManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), + configManager: ucm, + console: console, + manager: nil, + options: opts, + } + + nextCalled := false + result, err := m.Run(t.Context(), passthroughNext(&nextCalled)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, nextCalled, + "nextFn must always be called even when alpha is disabled") + assert.Empty(t, console.Output(), + "alpha-disabled should skip the first-run experience entirely") +} diff --git a/cli/azd/cmd/middleware/tool_update_check.go b/cli/azd/cmd/middleware/tool_update_check.go index 8e7dff4177e..a6eb04968de 100644 --- a/cli/azd/cmd/middleware/tool_update_check.go +++ b/cli/azd/cmd/middleware/tool_update_check.go @@ -14,6 +14,7 @@ import ( "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/alpha" "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" @@ -25,6 +26,7 @@ import ( // interactive and the current command is not a tool-management // subcommand. type ToolUpdateCheckMiddleware struct { + alphaManager *alpha.FeatureManager manager *tool.Manager console input.Console options *Options @@ -34,12 +36,14 @@ type ToolUpdateCheckMiddleware struct { // NewToolUpdateCheckMiddleware creates a new [ToolUpdateCheckMiddleware]. // All dependencies are resolved by the IoC container. func NewToolUpdateCheckMiddleware( + alphaManager *alpha.FeatureManager, manager *tool.Manager, console input.Console, options *Options, globalOptions *internal.GlobalCommandOptions, ) Middleware { return &ToolUpdateCheckMiddleware{ + alphaManager: alphaManager, manager: manager, console: console, options: options, @@ -52,6 +56,11 @@ func NewToolUpdateCheckMiddleware( // 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 when the tool alpha feature is not enabled. + if !m.alphaManager.IsEnabled(tool.FeatureAlphaTool) { + return nextFn(ctx) + } + // Skip all notification and background-check logic for child // actions (e.g. workflow steps invoked by a parent command). if !IsChildAction(ctx) { diff --git a/cli/azd/cmd/middleware/tool_update_check_test.go b/cli/azd/cmd/middleware/tool_update_check_test.go index 376065c47ac..04abb5ebe1c 100644 --- a/cli/azd/cmd/middleware/tool_update_check_test.go +++ b/cli/azd/cmd/middleware/tool_update_check_test.go @@ -11,6 +11,7 @@ import ( "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/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/tool" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" @@ -39,6 +40,7 @@ func newUpdateCheckMiddleware( } return &ToolUpdateCheckMiddleware{ + alphaManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), manager: manager, console: console, options: opts, @@ -51,6 +53,8 @@ func newUpdateCheckMiddleware( // --------------------------------------------------------------------------- func TestToolUpdateCheckMiddleware_SkipNotification(t *testing.T) { + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") + tests := []struct { name string setup func( @@ -147,6 +151,7 @@ func TestToolUpdateCheckMiddleware_SkipNotification(t *testing.T) { func TestToolUpdateCheckMiddleware_ChildAction(t *testing.T) { clearCIVars(t) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() m := newUpdateCheckMiddleware( @@ -175,6 +180,7 @@ func TestToolUpdateCheckMiddleware_NotificationGating(t *testing.T) { clearCIVars(t) t.Setenv(envKeySkipFirstRun, "") os.Unsetenv(envKeySkipFirstRun) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() cfg := config.NewEmptyConfig() @@ -229,6 +235,7 @@ func TestToolUpdateCheckMiddleware_BackgroundCheckSkippedWhenCI( // early and never calls manager.ShouldCheckForUpdates. clearCIVars(t) t.Setenv("CI", "1") + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() @@ -259,6 +266,7 @@ func TestToolUpdateCheckMiddleware_BackgroundCheckSkippedByEnvVar( ) { clearCIVars(t) t.Setenv(envKeySkipFirstRun, "true") + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() @@ -312,6 +320,32 @@ func TestToolUpdateCheckMiddleware_IsToolCommand(t *testing.T) { } } +// TestToolUpdateCheckMiddleware_SkipsWhenAlphaDisabled verifies that +// the middleware is a no-op when the tool alpha feature is not enabled. +func TestToolUpdateCheckMiddleware_SkipsWhenAlphaDisabled(t *testing.T) { + clearCIVars(t) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "false") + t.Setenv(envKeySkipFirstRun, "") + os.Unsetenv(envKeySkipFirstRun) + + console := mockinput.NewMockConsole() + m := newUpdateCheckMiddleware( + nil, console, + &Options{CommandPath: "azd provision"}, + &internal.GlobalCommandOptions{}, + ) + + nextCalled := false + result, err := m.Run(t.Context(), passthroughNext(&nextCalled)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, nextCalled, + "nextFn must always be called even when alpha is disabled") + assert.Empty(t, console.Output(), + "alpha-disabled should skip update check entirely") +} + // --------------------------------------------------------------------------- // TestToolUpdateCheckMiddleware_AlwaysCallsNext // --------------------------------------------------------------------------- @@ -320,6 +354,7 @@ func TestToolUpdateCheckMiddleware_AlwaysCallsNext(t *testing.T) { clearCIVars(t) // Skip background check so the nil manager is not accessed. t.Setenv(envKeySkipFirstRun, "true") + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") console := mockinput.NewMockConsole() console.SetNoPromptMode(true) // triggers notification skip diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 85f5f7f774a..2c84b24ca95 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -17,10 +17,13 @@ import ( // Importing for infrastructure provider plugin registrations + "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/azd" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/platform" + "github.com/azure/azure-dev/cli/azd/pkg/tool" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/cmd" @@ -196,7 +199,13 @@ func newRootCmd( hooksActions(root) mcpActions(root) copilotActions(root) - toolActions(root) + + // Create a FeatureManager for command-tree gating. + // User config is loaded best-effort; env vars (AZD_ALPHA_ENABLE_TOOL) always work. + alphaManager := newAlphaManagerForCommandTree() + if alphaManager.IsEnabled(tool.FeatureAlphaTool) { + toolActions(root) + } root.Add("version", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ @@ -563,6 +572,20 @@ var workflowCommands = map[string]struct{}{ "restore": {}, } +// newAlphaManagerForCommandTree creates a FeatureManager for use during +// command-tree construction (before DI is available). It loads the +// user config best-effort so that `azd config set alpha.tool on` works; +// env-var overrides (AZD_ALPHA_ENABLE_TOOL) always work regardless. +func newAlphaManagerForCommandTree() *alpha.FeatureManager { + ucm := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) + cfg, err := ucm.Load() + if err != nil { + log.Printf("warning: failed to load user config for alpha feature gating: %v", err) + cfg = config.NewEmptyConfig() + } + return alpha.NewFeaturesManagerWithConfig(cfg) +} + // isWorkflowCommand reports whether the command is a primary workflow // command where a first-run tool check adds value. func isWorkflowCommand(descriptor *actions.ActionDescriptor) bool { diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index c9df7ccc037..2bdac8042ab 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -23,6 +23,7 @@ import ( ) // toolActions registers the "azd tool" command group and all of its subcommands. +// The caller is responsible for gating on the "tool" alpha feature flag. func toolActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { toolCmd := &cobra.Command{ Use: "tool", diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go new file mode 100644 index 00000000000..1b79432ab92 --- /dev/null +++ b/cli/azd/cmd/tool_test.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToolCommandGating(t *testing.T) { + t.Run("hidden when alpha feature disabled", func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", configDir) + // Ensure the tool alpha feature is NOT enabled. + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "false") + + root := NewRootCmd(true, nil, nil) + found := false + for _, c := range root.Commands() { + if c.Name() == "tool" { + found = true + break + } + } + require.False(t, found, "expected 'tool' subcommand to be absent when alpha feature is disabled") + }) + + t.Run("present when alpha feature enabled", func(t *testing.T) { + configDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", configDir) + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") + + root := NewRootCmd(true, nil, nil) + found := false + for _, c := range root.Commands() { + if c.Name() == "tool" { + found = true + break + } + } + require.True(t, found, "expected 'tool' subcommand to be present when alpha feature is enabled") + }) +} diff --git a/cli/azd/cmd/usage_test.go b/cli/azd/cmd/usage_test.go index d20396b1e83..7a07809b3e7 100644 --- a/cli/azd/cmd/usage_test.go +++ b/cli/azd/cmd/usage_test.go @@ -31,6 +31,7 @@ func TestUsage(t *testing.T) { t.Setenv("AZD_CONFIG_DIR", configDir) t.Setenv("AZURE_DEV_COLLECT_TELEMETRY", "no") t.Setenv("AZD_SKIP_FIRST_RUN", "true") + t.Setenv("AZD_ALPHA_ENABLE_TOOL", "true") cli := azdcli.NewCLI(t) diff --git a/cli/azd/pkg/tool/feature.go b/cli/azd/pkg/tool/feature.go new file mode 100644 index 00000000000..06913d6cd64 --- /dev/null +++ b/cli/azd/pkg/tool/feature.go @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tool + +import "github.com/azure/azure-dev/cli/azd/pkg/alpha" + +// FeatureAlphaTool is the alpha feature key for the azd tool command group. +var FeatureAlphaTool = alpha.MustFeatureKey("tool") diff --git a/cli/azd/resources/alpha_features.yaml b/cli/azd/resources/alpha_features.yaml index d872e7efa3e..7a53716c45e 100644 --- a/cli/azd/resources/alpha_features.yaml +++ b/cli/azd/resources/alpha_features.yaml @@ -14,3 +14,5 @@ description: "Enables the use of LLMs in the CLI with support for intelligent azd init assistance and error handling workflows." - id: language.custom description: "Enables support for services to use custom language." +- id: tool + description: "Enables the 'azd tool' command group for discovering, installing, and upgrading Azure development tools."