Skip to content
4 changes: 2 additions & 2 deletions cli/azd/cmd/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -1238,7 +1238,7 @@ func runToolOperation(
Action: action,
Success: false,
})
return uxlib.Warning, fmt.Errorf(
return uxlib.Error, fmt.Errorf(
"%s did not succeed", action,
)
}
Expand Down Expand Up @@ -1293,7 +1293,7 @@ func runToolOperation(
})

if !depResult.Success {
return uxlib.Warning, fmt.Errorf("%s did not succeed", action)
return uxlib.Error, fmt.Errorf("%s did not succeed", action)
}
return uxlib.Success, nil
},
Expand Down
71 changes: 71 additions & 0 deletions cli/azd/cmd/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@
package cmd

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/azure/azure-dev/cli/azd/pkg/tool"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockinput"
)

func TestToolCommandGating(t *testing.T) {
Expand Down Expand Up @@ -43,3 +48,69 @@ func TestToolCommandGating(t *testing.T) {
require.True(t, found, "expected 'tool' subcommand to be present when alpha feature is enabled")
})
}

func TestRunToolOperationUnsuccessfulResultReturnsError(t *testing.T) {
toolDef := &tool.ToolDefinition{
Id: "az-cli",
Name: "Azure CLI",
}
console := mockinput.NewMockConsole()

results, err := runToolOperation(
t.Context(),
[]*tool.ToolDefinition{toolDef},
func(ctx context.Context, ids []string) ([]*tool.InstallResult, error) {
return []*tool.InstallResult{
{
Tool: toolDef,
Success: false,
},
}, nil
},
"Installing",
"install",
console,
)

require.Error(t, err)
require.Len(t, results, 1)
assert.False(t, results[0].Success)
require.NotEmpty(t, console.Output())
assert.Contains(t, console.Output()[0], "Some tools could not be")
}

func TestRunToolOperationSuccessfulResultReturnsNoError(t *testing.T) {
toolDef := &tool.ToolDefinition{
Id: "az-cli",
Name: "Azure CLI",
}
console := mockinput.NewMockConsole()

results, err := runToolOperation(
t.Context(),
[]*tool.ToolDefinition{toolDef},
func(ctx context.Context, ids []string) ([]*tool.InstallResult, error) {
assert.Equal(t, []string{"az-cli"}, ids)

return []*tool.InstallResult{
{
Tool: toolDef,
Success: true,
InstalledVersion: "2.73.0",
},
}, nil
},
"Installing",
"install",
console,
)

require.NoError(t, err)
require.Len(t, results, 1)
assert.True(t, results[0].Success)
assert.Equal(t, "az-cli", results[0].Id)
assert.Equal(t, "Azure CLI", results[0].Name)
assert.Equal(t, "install", results[0].Action)
assert.Equal(t, "2.73.0", results[0].InstalledVersion)
assert.Empty(t, console.Output())
}
11 changes: 11 additions & 0 deletions cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Release History

## 0.11.0 (2026-05-19)

- [[#8197]](https://github.com/Azure/azure-dev/pull/8197) Treat extension metadata warnings as non-fatal during `azd x build`, while keeping required fields and unusable metadata as validation errors.
- [[#8197]](https://github.com/Azure/azure-dev/pull/8197) Improve `azd x init` validation ordering, warning output, namespace/tag handling including `--tags` for `--no-prompt`, and child process error messages.
- [[#8197]](https://github.com/Azure/azure-dev/pull/8197) Improve `azd x init` overwrite prompts and generated Go scaffolding, including command metadata and string-escaped extension descriptions.
- [[#8197]](https://github.com/Azure/azure-dev/pull/8197) Support dependency-only extension packs in `azd x build`, `azd x pack`, and `azd x publish`, including direct dependency-only registry metadata publishing and clear no-artifact messaging.
- [[#8197]](https://github.com/Azure/azure-dev/pull/8197) Fail `azd x publish` when executable extensions have no matching artifacts instead of publishing an empty artifact map.
- [[#7956]](https://github.com/Azure/azure-dev/pull/7956) Migrate the extension developer kit to the `azdext` runtime and refresh generated Go extension scaffolding.
- [[#7982]](https://github.com/Azure/azure-dev/pull/7982) Add `secret` prompt option support to the scaffolded extension gRPC prompt contract.
- [[#7697]](https://github.com/Azure/azure-dev/pull/7697) Add `.azdxignore` and `.gitignore` support to `azd x watch`.

## 0.10.0 (2026-03-04)

- [[#6826]](https://github.com/Azure/azure-dev/pull/6826) Handle locked files on Windows during `azd x build` by terminating stale extension processes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ language: go
displayName: azd extensions Developer Kit
description: This extension provides a set of tools for azd extension developers to test and debug their extensions.
usage: azd x <command> [options]
version: 0.10.0
version: 0.11.0
capabilities:
- custom-commands
- metadata
Expand Down
182 changes: 125 additions & 57 deletions cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@ import (
"context"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"

"github.com/spf13/cobra"

"github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.extensions/internal"
"github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.extensions/internal/models"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/common"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/azure/azure-dev/cli/azd/pkg/ux"
"github.com/spf13/cobra"
)

type buildFlags struct {
Expand All @@ -36,6 +38,8 @@ func newBuildCommand(outputPath *string) *cobra.Command {
buildCmd := &cobra.Command{
Use: "build",
Short: "Build the azd extension project",
Long: "Builds the azd extension project for one or more platforms.\n\n" +
"Extension metadata validation warnings are non-fatal and are printed after the build completes.",
RunE: func(cmd *cobra.Command, args []string) error {
internal.WriteCommandHeader(
"Build and azd extension (azd x build)",
Expand Down Expand Up @@ -110,75 +114,37 @@ func runBuildAction(ctx context.Context, flags *buildFlags) error {
return fmt.Errorf("failed to load extension metadata: %w", err)
}

extensionPack := isExtensionPack(schema)

fmt.Println()
fmt.Printf("%s: %s\n", output.WithBold("Output Path"), output.WithHyperlink(absOutputPath, absOutputPath))
if extensionPack {
fmt.Printf("%s: Extension pack\n", output.WithBold("Extension Type"))
Comment thread
JeffreyCA marked this conversation as resolved.
} else {
fmt.Printf("%s: %s\n", output.WithBold("Output Path"), output.WithHyperlink(absOutputPath, absOutputPath))
}

var buildWarnings []string
// Flush collected validation warnings after the live TaskList canvas completes,
// regardless of whether a later task fails.
defer func() { writeCollectedWarnings(os.Stdout, buildWarnings) }()

taskList := ux.NewTaskList(nil).
AddTask(ux.TaskOptions{
Title: "Validating extension metadata",
Action: func(progress ux.SetProgressFunc) (ux.TaskState, error) {
progress("Checking required fields...")

var errors []string
var warnings []string

// Check required fields per schema - these are errors
if schema.Id == "" {
errors = append(errors, "Missing required field: id")
}
if schema.Version == "" {
errors = append(errors, "Missing required field: version")
}
if len(schema.Capabilities) == 0 {
errors = append(errors, "Missing required field: capabilities")
}
if schema.DisplayName == "" {
errors = append(errors, "Missing required field: displayName")
}
if schema.Description == "" {
errors = append(errors, "Missing required field: description")
}

progress("Validating capability-specific requirements...")

// Capability-specific validations - these are warnings
hasCustomCommands := slices.Contains(schema.Capabilities, extensions.CustomCommandCapability)
hasServiceTarget := slices.Contains(schema.Capabilities, extensions.ServiceTargetProviderCapability)

// Only validate namespace if custom-commands capability is defined
if hasCustomCommands && schema.Namespace == "" {
warnings = append(warnings, "Missing namespace - recommended when using custom-commands capability")
}

// Only validate providers if service-target-provider capability is defined
if hasServiceTarget && len(schema.Providers) == 0 {
warnings = append(warnings, "Missing providers - recommended when using custom providers capability")
}

// Check for missing optional but generally recommended fields
if schema.Usage == "" {
warnings = append(warnings, "Missing usage information")
}
warnings, validationErrors := validateExtensionMetadata(schema)

progress("Validation complete")

// If we have errors, this is a failure
if len(errors) > 0 {
// Create aggregated error
aggregatedError := fmt.Errorf(
"Extension contains validation failures: %s",
strings.Join(errors, "; "),
)
return ux.Error, common.NewDetailedError("Validation failed", aggregatedError)
if len(validationErrors) > 0 {
return ux.Error, validationFailureError(validationErrors)
}

// If we have warnings, return warning state but no error
if len(warnings) > 0 {
aggregatedWarning := fmt.Errorf(
"Extension contains validation warnings: %s",
strings.Join(warnings, "\n - "),
)
return ux.Warning, common.NewDetailedError("Validation warnings", aggregatedWarning)
buildWarnings = warnings
return ux.Warning, fmt.Errorf("%s; see details below", validationWarningSummary(warnings))
}

return ux.Success, nil
Expand All @@ -187,6 +153,11 @@ func runBuildAction(ctx context.Context, flags *buildFlags) error {
AddTask(ux.TaskOptions{
Title: "Building extension artifacts",
Action: func(progress ux.SetProgressFunc) (ux.TaskState, error) {
if extensionPack {
progress("Extension packs do not contain build artifacts")
return ux.Skipped, nil
}

// Create output directory if it doesn't exist
if _, err := os.Stat(absOutputPath); os.IsNotExist(err) {
if err := os.MkdirAll(absOutputPath, os.ModePerm); err != nil {
Expand Down Expand Up @@ -246,7 +217,7 @@ func runBuildAction(ctx context.Context, flags *buildFlags) error {
AddTask(ux.TaskOptions{
Title: "Installing extension",
Action: func(progress ux.SetProgressFunc) (ux.TaskState, error) {
if flags.skipInstall {
if flags.skipInstall || extensionPack {
return ux.Skipped, nil
}

Expand Down Expand Up @@ -319,6 +290,103 @@ func copyBinaryFiles(extensionId, sourcePath, destPath string) error {
})
}

// validateExtensionMetadata returns validation warnings and errors for the given extension schema.
// Errors include missing required fields and capability-specific metadata that would create
// unusable extensions. Warnings flag recommended metadata that improves the extension experience.
func validateExtensionMetadata(schema *models.ExtensionSchema) (warnings, errs []string) {
extensionPack := isExtensionPack(schema)

// Required fields - missing values are errors.
if schema.Id == "" {
errs = append(errs, "Missing required field: id")
}
if schema.Version == "" {
errs = append(errs, "Missing required field: version")
}
if len(schema.Capabilities) == 0 && !extensionPack {
errs = append(errs, "Missing required field: capabilities")
}
if schema.DisplayName == "" {
errs = append(errs, "Missing required field: displayName")
}
if schema.Description == "" {
errs = append(errs, "Missing required field: description")
}

// Capability-specific recommendations.
hasCustomCommands := slices.Contains(schema.Capabilities, extensions.CustomCommandCapability)
hasServiceTarget := slices.Contains(schema.Capabilities, extensions.ServiceTargetProviderCapability)

// Missing namespace is fatal for custom-commands extensions: bindExtension
// uses the last '.'-segment of Namespace as the cobra command name, so an
// empty namespace silently installs an unreachable command. The init wizard
// always populates namespace, so this only triggers on hand-edited files.
if hasCustomCommands && schema.Namespace == "" {
errs = append(errs,
"Missing 'namespace' field in extension.yaml - "+
"required by the 'custom-commands' capability. "+
"Set it to the prefix users will type after 'azd' (e.g. 'demo' to expose 'azd demo <command>').",
)
Comment thread
JeffreyCA marked this conversation as resolved.
}

// Kept as a warning: the init wizard doesn't yet prompt for providers, so
// promoting this to an error would block every service-target-provider scaffold.
if hasServiceTarget && len(schema.Providers) == 0 {
warnings = append(warnings,
"Missing 'providers' field in extension.yaml - "+
"required by the 'service-target-provider' capability. "+
"List the providers your extension contributes (each entry needs a name, type, and description).",
)
}

if schema.Usage == "" && !extensionPack {
warnings = append(warnings,
"Missing 'usage' field in extension.yaml - shown to users as a usage hint in 'azd <namespace> --help'.",
)
}

return warnings, errs
}

// isExtensionPack detects dependency-only extension packs. The extension registry
// treats versions with dependencies and no artifacts as packs; local extension.yaml
// manifests do not have an explicit pack discriminator, so this intentionally
// infers pack mode only when dependencies are present and executable metadata is absent.
func isExtensionPack(schema *models.ExtensionSchema) bool {
isPack := len(schema.Dependencies) > 0 &&
len(schema.Capabilities) == 0 &&
schema.Namespace == "" &&
schema.Language == "" &&
schema.EntryPoint == ""
if isPack {
log.Printf(
"debug: detected extension pack manifest for %s because it has dependencies and no executable metadata",
schema.Id,
)
}

return isPack
}

func validationFailureError(validationErrors []string) error {
return common.NewDetailedError(
"Validation failed",
fmt.Errorf(
"extension contains validation failures: %s",
strings.Join(validationErrors, "; "),
),
)
}

func validationWarningSummary(warnings []string) string {
noun := "warning"
if len(warnings) != 1 {
noun = "warnings"
}

return fmt.Sprintf("%d validation %s", len(warnings), noun)
}

// escapePowerShellSingleQuotes escapes single quotes for use in PowerShell single-quoted strings.
func escapePowerShellSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "''")
Expand Down
Loading
Loading