Skip to content
122 changes: 72 additions & 50 deletions cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,66 +119,20 @@ func runBuildAction(ctx context.Context, flags *buildFlags) error {
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
if len(validationErrors) > 0 {
aggregatedError := fmt.Errorf(
"Extension contains validation failures: %s",
strings.Join(errors, "; "),
strings.Join(validationErrors, "; "),
)
return ux.Error, common.NewDetailedError("Validation failed", aggregatedError)
}

// 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)
return ux.Warning, errors.New(validationWarningsMessage(warnings))
}

return ux.Success, nil
Expand Down Expand Up @@ -319,6 +273,74 @@ func copyBinaryFiles(extensionId, sourcePath, destPath string) error {
})
}

// validateExtensionMetadata returns validation warnings and required-field errors
// for the given extension schema. Errors indicate missing required fields. Warnings
// flag recommended but optional fields that improve the extension experience.
func validateExtensionMetadata(schema *models.ExtensionSchema) (warnings, errs []string) {
// 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 {
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 == "" {
warnings = append(warnings,
"Missing 'usage' field in extension.yaml - shown to users as a usage hint in 'azd <namespace> --help'.",
)
}

return warnings, errs
}

// validationWarningsMessage formats validation warnings into a multi-line message with bullet points.
func validationWarningsMessage(warnings []string) string {
var message strings.Builder
message.WriteString("validation warnings:")
for _, warning := range warnings {
message.WriteString("\n - ")
message.WriteString(warning)
}

return message.String()
}

// escapePowerShellSingleQuotes escapes single quotes for use in PowerShell single-quoted strings.
func escapePowerShellSingleQuotes(s string) string {
return strings.ReplaceAll(s, "'", "''")
Expand Down
129 changes: 104 additions & 25 deletions cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ type initFlags struct {
namespace string
}

// extensionSchemaHeader is prepended to generated extension.yaml files so editor
// tooling (VS Code YAML extension) can resolve and validate against the schema.
const extensionSchemaHeader = "# yaml-language-server: $schema=" +
"https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json\n"

func newInitCommand(noPrompt *bool) *cobra.Command {
flags := &initFlags{}

Expand Down Expand Up @@ -175,8 +180,8 @@ func runInitAction(ctx context.Context, flags *initFlags) error {
Confirm(ctx, &azdext.ConfirmRequest{
Options: &azdext.ConfirmOptions{
Message: fmt.Sprintf("Continue creating the extension at %s?", extensionMetadata.Id),
DefaultValue: new(false),
Placeholder: "no",
DefaultValue: new(true),
Placeholder: "yes",
Comment thread
JeffreyCA marked this conversation as resolved.
HelpMessage: "Confirm if you want to continue creating the extension.",
},
})
Expand Down Expand Up @@ -218,11 +223,8 @@ func runInitAction(ctx context.Context, flags *initFlags) error {
return fmt.Errorf("failed to check extension directory: %w", err)
}

localRegistryExists := false

createLocalExtensionSourceAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) {
if has, err := internal.HasLocalRegistry(); err == nil && has {
localRegistryExists = true
return ux.Skipped, nil
}

Expand All @@ -247,11 +249,39 @@ func runInitAction(ctx context.Context, flags *initFlags) error {
return ux.Success, nil
}

var buildWarnings []string
validateExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) {
warnings, validationErrors := validateExtensionMetadata(extensionMetadata)
if len(validationErrors) > 0 {
return ux.Error, common.NewDetailedError(
"Validation failed",
fmt.Errorf(
"extension contains validation failures: %s",
strings.Join(validationErrors, "; "),
),
)
}

if len(warnings) > 0 {
buildWarnings = warnings
noun := "warning"
if len(warnings) != 1 {
noun = "warnings"
}
return ux.Warning, fmt.Errorf("%d %s — see details below", len(warnings), noun)
}

return ux.Success, nil
}

var buildCommandOutput []byte
buildExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) {
cmd := exec.Command("azd", "x", "build", "--skip-install")
cmd.Dir = extensionMetadata.Path

if err := cmd.Run(); err != nil {
result, err := cmd.CombinedOutput()
if err != nil {
buildCommandOutput = result
return ux.Error, common.NewDetailedError(
"Build failed",
fmt.Errorf("failed to build extension: %w", err),
Expand Down Expand Up @@ -318,6 +348,10 @@ func runInitAction(ctx context.Context, flags *initFlags) error {
Title: fmt.Sprintf("Creating extension directory %s", output.WithHighLightFormat(extensionMetadata.Id)),
Action: createExtensionDirectoryAction,
}).
AddTask(ux.TaskOptions{
Title: "Validate extension metadata",
Action: validateExtensionAction,
}).
AddTask(ux.TaskOptions{
Title: "Build extension",
Action: buildExtensionAction,
Expand All @@ -337,19 +371,19 @@ func runInitAction(ctx context.Context, flags *initFlags) error {
}

if err := taskList.Run(); err != nil {
if len(buildCommandOutput) > 0 {
writeFailedCommandOutput(buildCommandOutput)
}
return fmt.Errorf("failed running init tasks: %w", err)
}

if localRegistryExists {
fmt.Println(output.WithWarningFormat("Local extension source already exists."))
fmt.Println()
}
writeCollectedWarnings(buildWarnings)

if !flags.createRegistry {
fmt.Println(output.WithBold("Try out the extension"))
fmt.Printf(
"- Run %s to try your extension now.\n",
output.WithHighLightFormat("azd %s -h", extensionMetadata.Namespace),
output.WithHighLightFormat("azd %s -h", namespaceCommandPath(extensionMetadata.Namespace)),
)
fmt.Println()
fmt.Println(output.WithBold("Next Steps"))
Expand Down Expand Up @@ -434,7 +468,7 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem
Capabilities: capabilities,
Language: flags.language,
Tags: tags,
Usage: fmt.Sprintf("azd %s <command> [options]", namespace),
Usage: formatUsage(namespace),
Version: "0.0.1",
Path: absExtensionPath,
}, nil
Expand Down Expand Up @@ -490,12 +524,10 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient)

tagsPrompt, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{
Options: &azdext.PromptOptions{
Message: "Enter tags for your extension (comma-separated)",
Placeholder: "tag1, tag2",
RequiredMessage: "Tags are required",
Required: true,
Message: "Enter tags for your extension (comma-separated, optional)",
Placeholder: "tag1, tag2",
HelpMessage: "Tags are used to categorize your extension. " +
"You can enter multiple tags separated by commas.",
"You can enter multiple tags separated by commas, or leave empty to skip.",
},
})
if err != nil {
Expand All @@ -508,7 +540,8 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient)
RequiredMessage: "Namespace is required",
Required: true,
HelpMessage: "Namespace is used to group custom commands into a single command " +
"group used for executing the extension.",
"group used for executing the extension. " +
"Use dots to create nested command groups (e.g. 'ai.project' becomes 'azd ai project').",
},
})
if err != nil {
Expand Down Expand Up @@ -567,9 +600,8 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient)
capabilities[i] = extensions.CapabilityType(capability.Value)
}

tags := []string{}
strings.Split(tagsPrompt.Value, ",")
for _, tag := range tags {
var tags []string
for tag := range strings.SplitSeq(tagsPrompt.Value, ",") {
tag = strings.TrimSpace(tag)
if tag != "" {
tags = append(tags, tag)
Expand All @@ -589,7 +621,7 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient)
Capabilities: capabilities,
Language: languageChoices[*programmingLanguagePrompt.Value].Value,
Tags: tags,
Usage: fmt.Sprintf("azd %s <command> [options]", namespacePrompt.Value),
Usage: formatUsage(namespacePrompt.Value),
Version: "0.0.1",
Path: absExtensionPath,
}, nil
Expand All @@ -604,6 +636,19 @@ func validCapabilityNames() []string {
return names
}

// namespaceCommandPath converts an extension namespace (e.g. "ai.project") into the
// command path used to invoke it from azd (e.g. "ai project"). Dots in a namespace
// represent nested command groups; see bindExtension in cli/azd/cmd/extensions.go.
func namespaceCommandPath(namespace string) string {
return strings.ReplaceAll(namespace, ".", " ")
}

// formatUsage returns the usage hint string for an extension with the given namespace,
// translating dotted namespaces into the equivalent nested-command form.
func formatUsage(namespace string) string {
return fmt.Sprintf("azd %s <command> [options]", namespaceCommandPath(namespace))
}

func capabilityPromptChoices() []*azdext.MultiSelectChoice {
choices := make([]*azdext.MultiSelectChoice, len(extensions.ValidCapabilities))
for i, cap := range extensions.ValidCapabilities {
Expand Down Expand Up @@ -657,8 +702,10 @@ func createExtensionDirectory(
// If directory already exists (err == nil), continue to create/update files

// Create project from template.
namespaceParts := strings.Split(extensionMetadata.Namespace, ".")
templateMetadata := &ExtensionTemplate{
Metadata: extensionMetadata,
Metadata: extensionMetadata,
LeafNamespace: namespaceParts[len(namespaceParts)-1],
DotNet: &DotNetTemplate{
Namespace: internal.ToPascalCase(extensionMetadata.Id),
ExeName: extensionMetadata.SafeDashId(),
Expand Down Expand Up @@ -688,7 +735,8 @@ func createExtensionDirectory(
}

extensionFilePath := filepath.Join(extensionPath, "extension.yaml")
if err := os.WriteFile(extensionFilePath, yamlBytes, internal.PermissionFile); err != nil {
yamlContents := append([]byte(extensionSchemaHeader), yamlBytes...)
if err := os.WriteFile(extensionFilePath, yamlContents, internal.PermissionFile); err != nil {
return fmt.Errorf("failed to create extension.yaml file: %w", err)
}

Expand Down Expand Up @@ -743,9 +791,40 @@ func copyAndProcessTemplates(srcFS fs.FS, srcDir, destDir string, data any) erro
})
}

// writeCollectedWarnings prints collected validation warnings after the task list canvas is complete.
func writeCollectedWarnings(warnings []string) {
if len(warnings) == 0 {
return
}

fmt.Println(output.WithWarningFormat("WARNING: Extension contains validation warnings:"))
for _, warning := range warnings {
fmt.Printf(" - %s\n", warning)
}
fmt.Println()
}

// writeFailedCommandOutput prints captured subprocess output after the task list canvas is complete.
// It is only intended for failure diagnostics; the contents are echoed verbatim.
func writeFailedCommandOutput(result []byte) {
if len(result) == 0 {
return
}

fmt.Print(string(result))
if !bytes.HasSuffix(result, []byte("\n")) {
fmt.Println()
}
}

type ExtensionTemplate struct {
Metadata *models.ExtensionSchema
DotNet *DotNetTemplate
// LeafNamespace is the final dot-separated segment of Metadata.Namespace, used as the
// cobra Use/Name for the extension's root command. For nested namespaces like
// "ai.agents", users invoke the extension via "azd ai agents" (azd splits on '.'),
// so the extension's own root command name is the leaf ("agents").
LeafNamespace string
DotNet *DotNetTemplate
}

type DotNetTemplate struct {
Expand Down
Loading
Loading