diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 9320f925f5c..fe7e6faaf81 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -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, ) } @@ -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 }, diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 1b79432ab92..3e43c24290b 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -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) { @@ -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()) +} diff --git a/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md b/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md index b05682a5713..bbf41d8ec44 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md +++ b/cli/azd/extensions/microsoft.azd.extensions/CHANGELOG.md @@ -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. diff --git a/cli/azd/extensions/microsoft.azd.extensions/extension.yaml b/cli/azd/extensions/microsoft.azd.extensions/extension.yaml index 51317a17e2a..fac0e4c3322 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/extension.yaml +++ b/cli/azd/extensions/microsoft.azd.extensions/extension.yaml @@ -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 [options] -version: 0.10.0 +version: 0.11.0 capabilities: - custom-commands - metadata diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go index 14286c08bb1..a242847e7d9 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/build.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "os" "os/exec" "path/filepath" @@ -14,6 +15,8 @@ import ( "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" @@ -21,7 +24,6 @@ import ( "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 { @@ -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)", @@ -110,8 +114,19 @@ 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")) + } 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{ @@ -119,66 +134,17 @@ 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 - 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 @@ -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 { @@ -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 } @@ -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 ').", + ) + } + + // 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 --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, "'", "''") diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go index d4091f1a36e..a2d53f1cddb 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init.go @@ -8,14 +8,21 @@ import ( "context" "errors" "fmt" + "io" "io/fs" "os" "os/exec" "path" "path/filepath" + "regexp" "slices" + "strconv" "strings" "text/template" + "unicode" + + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" "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" @@ -25,8 +32,6 @@ import ( "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" - "go.yaml.in/yaml/v3" ) type initFlags struct { @@ -37,14 +42,30 @@ type initFlags struct { capabilities []string language string namespace string + tags []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" + +const ( + maxExtensionTags = 10 + maxExtensionTagLength = 64 +) + +var extensionNamespacePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$`) + func newInitCommand(noPrompt *bool) *cobra.Command { flags := &initFlags{} initCmd := &cobra.Command{ Use: "init", Short: "Initialize a new azd extension project", + Long: "Initializes a new azd extension project from a template.\n\n" + + "When creating an extension project, the build step invokes the azd binary found on PATH. " + + "Validation warning behavior during that nested build depends on the installed azd version.", RunE: func(cmd *cobra.Command, args []string) error { internal.WriteCommandHeader( "Initialize a new azd extension project (azd x init)", @@ -128,10 +149,20 @@ func newInitCommand(noPrompt *bool) *cobra.Command { "The namespace for the extension commands.", ) + initCmd.Flags().StringSliceVar( + &flags.tags, + "tags", []string{}, + fmt.Sprintf( + "Optional tags for the extension, comma-separated or repeatable (max %d tags, %d characters each).", + maxExtensionTags, + maxExtensionTagLength, + ), + ) + return initCmd } -func runInitAction(ctx context.Context, flags *initFlags) error { +func runInitAction(ctx context.Context, flags *initFlags) (err error) { // Create a new context that includes the azd access token ctx = azdext.WithAccessToken(ctx) @@ -175,8 +206,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", HelpMessage: "Confirm if you want to continue creating the extension.", }, }) @@ -197,13 +228,31 @@ func runInitAction(ctx context.Context, flags *initFlags) error { // Skip confirmation prompt in headless mode if !flags.noPrompt { + nonEmpty, err := isDirNonEmpty(extensionPath) + if err != nil { + return fmt.Errorf("failed to inspect existing extension directory: %w", err) + } + + message := fmt.Sprintf( + "The extension directory '%s' already exists. Continue?", + extensionMetadata.Id, + ) + helpMessage := "" + if nonEmpty { + message = fmt.Sprintf( + "The extension directory '%s' already exists and is not empty. "+ + "Existing files may be overwritten. Continue?", + extensionMetadata.Id, + ) + helpMessage = "Scaffolded files will overwrite any existing files at the same paths " + + "(e.g. extension.yaml, main.go, README.md). Other files will be left untouched." + } + confirmResponse, err := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ - Message: fmt.Sprintf( - "The extension directory '%s' already exists. Do you want to continue?", - extensionMetadata.Id, - ), + Message: message, DefaultValue: new(false), + HelpMessage: helpMessage, }, }) if err != nil { @@ -218,11 +267,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 } @@ -247,58 +293,61 @@ func runInitAction(ctx context.Context, flags *initFlags) error { return ux.Success, nil } - buildExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { - cmd := exec.Command("azd", "x", "build", "--skip-install") - cmd.Dir = extensionMetadata.Path + var buildWarnings []string + // Ensure validation warnings are flushed after the live TaskList canvas + // completes, regardless of whether a later task fails. + defer func() { writeCollectedWarnings(os.Stdout, buildWarnings) }() - if err := cmd.Run(); err != nil { - return ux.Error, common.NewDetailedError( - "Build failed", - fmt.Errorf("failed to build extension: %w", err), - ) + validateExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { + warnings, validationErrors := validateExtensionMetadata(extensionMetadata) + if len(validationErrors) > 0 { + return ux.Error, validationFailureError(validationErrors) + } + + if len(warnings) > 0 { + buildWarnings = warnings + return ux.Warning, fmt.Errorf("%s; see details below", validationWarningSummary(warnings)) } return ux.Success, nil } - packageExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { - cmd := exec.Command("azd", "x", "pack") + runSubprocess := func(failureDescription string, args ...string) (ux.TaskState, error) { + /* #nosec G204 - Subprocess launched with a potential tainted input or cmd arguments */ + cmd := exec.Command("azd", args...) cmd.Dir = extensionMetadata.Path - if err := cmd.Run(); err != nil { + // Capture combined output so we can surface the child's own error message + // inline in the wrapped error instead of letting the child's TaskList canvas + // stream into our terminal alongside ours. + result, err := cmd.CombinedOutput() + if err != nil { return ux.Error, common.NewDetailedError( - "Package failed", - fmt.Errorf("failed to package extension: %w", err), + failureDescription, + fmt.Errorf("%w%s", err, subprocessErrorTail(result)), ) } + return ux.Success, nil } - publishExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { - cmd := exec.Command("azd", "x", "publish") - cmd.Dir = extensionMetadata.Path + buildExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { + return runSubprocess("Build failed", "x", "build", "--skip-install") + } - if err := cmd.Run(); err != nil { - return ux.Error, common.NewDetailedError( - "Publish failed", - fmt.Errorf("failed to package extension: %w", err), - ) - } - return ux.Success, nil + packageExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { + return runSubprocess("Package failed", "x", "pack") } - installExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { - /* #nosec G204 - Subprocess launched with a potential tainted input or cmd arguments */ - cmd := exec.Command("azd", "ext", "install", extensionMetadata.Id, "--source", "local") - cmd.Dir = extensionMetadata.Path + publishExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { + return runSubprocess("Publish failed", "x", "publish") + } - if err := cmd.Run(); err != nil { - return ux.Error, common.NewDetailedError( - "Install failed", - fmt.Errorf("failed to install extension: %w", err), - ) - } - return ux.Success, nil + installExtensionAction := func(spf ux.SetProgressFunc) (ux.TaskState, error) { + return runSubprocess( + "Install failed", + "ext", "install", extensionMetadata.Id, "--source", "local", + ) } taskList := ux.NewTaskList(nil) @@ -310,6 +359,10 @@ func runInitAction(ctx context.Context, flags *initFlags) error { }) } else { taskList. + AddTask(ux.TaskOptions{ + Title: "Validate extension metadata", + Action: validateExtensionAction, + }). AddTask(ux.TaskOptions{ Title: "Create local azd extension source", Action: createLocalExtensionSourceAction, @@ -336,20 +389,16 @@ func runInitAction(ctx context.Context, flags *initFlags) error { }) } - if err := taskList.Run(); err != nil { - return fmt.Errorf("failed running init tasks: %w", err) - } - - if localRegistryExists { - fmt.Println(output.WithWarningFormat("Local extension source already exists.")) - fmt.Println() + if runErr := taskList.Run(); runErr != nil { + err = fmt.Errorf("failed running init tasks: %w", runErr) + return err } 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")) @@ -409,8 +458,12 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem capabilities[i] = extensions.CapabilityType(cap) } - // Use default empty tags - tags := []string{} + // StringSlice accepts comma-separated values and repeated flags; normalize + // both forms through the same parser used by the interactive flow. + tags, err := parseTags(strings.Join(flags.tags, ",")) + if err != nil { + return nil, err + } // Set a default description description := "An azd extension" @@ -420,6 +473,9 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem if flags.namespace != "" { namespace = flags.namespace } + if err := validateExtensionNamespace(namespace); err != nil { + return nil, err + } absExtensionPath, err := filepath.Abs(flags.id) if err != nil { @@ -434,7 +490,7 @@ func collectExtensionMetadataFromFlags(flags *initFlags) (*models.ExtensionSchem Capabilities: capabilities, Language: flags.language, Tags: tags, - Usage: fmt.Sprintf("azd %s [options]", namespace), + Usage: formatUsage(namespace), Version: "0.0.1", Path: absExtensionPath, }, nil @@ -490,27 +546,17 @@ 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 { return nil, fmt.Errorf("failed to prompt for tags: %w", err) } - namespacePrompt, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: "Enter a namespace for your extension", - RequiredMessage: "Namespace is required", - Required: true, - HelpMessage: "Namespace is used to group custom commands into a single command " + - "group used for executing the extension.", - }, - }) + namespace, err := promptExtensionNamespace(ctx, azdClient) if err != nil { return nil, fmt.Errorf("failed to prompt for namespace: %w", err) } @@ -567,13 +613,9 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) capabilities[i] = extensions.CapabilityType(capability.Value) } - tags := []string{} - strings.Split(tagsPrompt.Value, ",") - for _, tag := range tags { - tag = strings.TrimSpace(tag) - if tag != "" { - tags = append(tags, tag) - } + tags, err := parseTags(tagsPrompt.Value) + if err != nil { + return nil, err } absExtensionPath, err := filepath.Abs(idPrompt.Value) @@ -585,11 +627,11 @@ func collectExtensionMetadata(ctx context.Context, azdClient *azdext.AzdClient) Id: idPrompt.Value, DisplayName: displayNamePrompt.Value, Description: descriptionPrompt.Value, - Namespace: namespacePrompt.Value, + Namespace: namespace, Capabilities: capabilities, Language: languageChoices[*programmingLanguagePrompt.Value].Value, Tags: tags, - Usage: fmt.Sprintf("azd %s [options]", namespacePrompt.Value), + Usage: formatUsage(namespace), Version: "0.0.1", Path: absExtensionPath, }, nil @@ -604,6 +646,82 @@ 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 [options]", namespaceCommandPath(namespace)) +} + +func validateExtensionNamespace(namespace string) error { + if !extensionNamespacePattern.MatchString(namespace) { + return fmt.Errorf( + "invalid namespace '%s': use lowercase letters, numbers, and hyphens separated by single dots "+ + "(for example, 'foo.bar' or 'coding-agent')", + namespace, + ) + } + + return nil +} + +func promptExtensionNamespace(ctx context.Context, azdClient *azdext.AzdClient) (string, error) { + for { + namespacePrompt, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a namespace for your extension", + Placeholder: "foo.bar", + RequiredMessage: "Namespace is required", + Required: true, + HelpMessage: "Namespace is used to group custom commands into a single command " + + "group used for executing the extension. " + + "Use dots to create nested command groups (e.g. 'foo.bar' becomes 'azd foo bar'). " + + "Use only lowercase letters, numbers, and hyphens separated by single dots; spaces are not allowed.", + }, + }) + if err != nil { + return "", err + } + + if err := validateExtensionNamespace(namespacePrompt.Value); err != nil { + fmt.Println(output.WithErrorFormat(err.Error())) + continue + } + + return namespacePrompt.Value, nil + } +} + +func parseTags(rawTags string) ([]string, error) { + var tags []string + for tag := range strings.SplitSeq(rawTags, ",") { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + + if len(tags) == maxExtensionTags { + return nil, fmt.Errorf("too many tags: maximum is %d", maxExtensionTags) + } + if len(tag) > maxExtensionTagLength { + return nil, fmt.Errorf("tag '%s' is too long: maximum length is %d", tag, maxExtensionTagLength) + } + if strings.ContainsFunc(tag, unicode.IsControl) { + return nil, fmt.Errorf("tag '%s' contains control characters", tag) + } + + tags = append(tags, tag) + } + + return tags, nil +} + func capabilityPromptChoices() []*azdext.MultiSelectChoice { choices := make([]*azdext.MultiSelectChoice, len(extensions.ValidCapabilities)) for i, cap := range extensions.ValidCapabilities { @@ -634,6 +752,24 @@ func capabilityLabel(cap extensions.CapabilityType) string { return strings.Join(words, " ") } +// isDirNonEmpty reports whether dir contains at least one entry. It returns +// (false, nil) for an empty directory and propagates the underlying error +// otherwise. Implemented via Readdirnames(1) to avoid reading the entire +// directory listing into memory. +func isDirNonEmpty(dir string) (bool, error) { + f, err := os.Open(dir) + if err != nil { + return false, err + } + defer f.Close() + + names, err := f.Readdirnames(1) + if err != nil && !errors.Is(err, io.EOF) { + return false, err + } + return len(names) > 0, nil +} + func createExtensionDirectory( ctx context.Context, azdClient *azdext.AzdClient, @@ -657,8 +793,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(), @@ -688,7 +826,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) } @@ -721,7 +860,7 @@ func copyAndProcessTemplates(srcFS fs.FS, srcDir, destDir string, data any) erro } if strings.HasSuffix(path, ".tmpl") { - tmpl, err := template.New(filepath.Base(path)).Parse(string(fileBytes)) + tmpl, err := template.New(filepath.Base(path)).Funcs(templateFuncs).Parse(string(fileBytes)) if err != nil { return fmt.Errorf("failed to parse template %s: %w", path, err) } @@ -743,9 +882,80 @@ 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(writer io.Writer, warnings []string) { + if len(warnings) == 0 { + return + } + + fmt.Fprintln(writer, output.WithWarningFormat("Validation warnings:")) + for _, warning := range warnings { + fmt.Fprintf(writer, " - %s\n", warning) + } + fmt.Fprintln(writer) +} + +// ansiEscapeRegex matches ANSI CSI escape sequences and OSC hyperlinks commonly +// emitted by child azd processes. +var ansiEscapeRegex = regexp.MustCompile(`(?:\x1b\[[0-9;]*[A-Za-z])|(?:\x1b\][^\x07\x1b]*(?:\x07|\x1b\\))`) + +// subprocessErrorTail extracts a short, human-friendly summary line from captured +// subprocess output to inline into a wrapped error message. It prefers the first +// line beginning with "ERROR:"/"Error:" and falls back to the last non-empty line. +// The returned string is prefixed with ": " when non-empty, or empty otherwise. +func subprocessErrorTail(output []byte) string { + if len(output) == 0 { + return "" + } + + cleaned := ansiEscapeRegex.ReplaceAllString(string(output), "") + + var fallback string + for line := range strings.SplitSeq(cleaned, "\n") { + line = strings.TrimRight(line, "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "ERROR:") || strings.HasPrefix(trimmed, "Error:") { + errorLine := strings.TrimSpace( + strings.TrimPrefix(strings.TrimPrefix(trimmed, "ERROR:"), "Error:"), + ) + if errorLine == "" { + continue + } + + return ": " + errorLine + } + fallback = trimmed + } + + if fallback == "" { + return "" + } + return ": " + fallback +} + +// ExtensionTemplate contains values used when rendering extension project templates. 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 +} + +// templateFuncs are template helpers exposed to .tmpl files when rendering +// extension scaffolds. They allow user-supplied strings (e.g. extension +// description) to be safely embedded in generated source code. +var templateFuncs = template.FuncMap{ + // strconvQuote quotes a string as a Go double-quoted literal, escaping any + // characters that would otherwise produce invalid Go source (quotes, + // backslashes, newlines, control characters, etc.). The returned value + // includes the surrounding quotes. + "strconvQuote": strconv.Quote, } type DotNetTemplate struct { diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go index 4f674dc14d4..502f6eff76f 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/init_test.go @@ -4,10 +4,20 @@ package cmd import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" "testing" + "text/template" - "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.extensions/internal/models" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/ux" ) func TestCapabilityPromptChoicesMatchValidCapabilities(t *testing.T) { @@ -25,3 +35,563 @@ func TestCapabilityLabel(t *testing.T) { require.Equal(t, "MCP Server", capabilityLabel(extensions.McpServerCapability)) require.Equal(t, "Provisioning Provider", capabilityLabel(extensions.ProvisioningProviderCapability)) } + +func TestNamespaceCommandPath(t *testing.T) { + tests := []struct { + namespace string + want string + }{ + {namespace: "demo", want: "demo"}, + {namespace: "ai.project", want: "ai project"}, + {namespace: "company.team.tool", want: "company team tool"}, + {namespace: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.namespace, func(t *testing.T) { + assert.Equal(t, tt.want, namespaceCommandPath(tt.namespace)) + }) + } +} + +func TestFormatUsage(t *testing.T) { + assert.Equal(t, "azd demo [options]", formatUsage("demo")) + assert.Equal(t, "azd ai project [options]", formatUsage("ai.project")) +} + +func TestValidateExtensionMetadata(t *testing.T) { + tests := []struct { + name string + schema *models.ExtensionSchema + wantWarningCount int + wantWarningContains []string + wantErrorCount int + wantErrorContains []string + }{ + { + name: "complete schema produces no warnings or errors", + schema: &models.ExtensionSchema{ + Id: "test.extension", + Version: "0.0.1", + DisplayName: "Test Extension", + Description: "A test extension", + Namespace: "test", + Usage: "azd test ", + Capabilities: []extensions.CapabilityType{extensions.CustomCommandCapability}, + }, + }, + { + name: "empty schema reports all required-field errors", + schema: &models.ExtensionSchema{}, + wantWarningCount: 1, + wantWarningContains: []string{ + "Missing 'usage' field in extension.yaml", + }, + wantErrorCount: 5, + wantErrorContains: []string{ + "Missing required field: id", + "Missing required field: version", + "Missing required field: capabilities", + "Missing required field: displayName", + "Missing required field: description", + }, + }, + { + name: "service target provider without providers emits warning", + schema: &models.ExtensionSchema{ + Id: "test.extension", + Version: "0.0.1", + DisplayName: "Test Extension", + Description: "A test extension", + Namespace: "test", + Usage: "azd test ", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + }, + wantWarningCount: 1, + wantWarningContains: []string{ + "Missing 'providers' field in extension.yaml", + "service-target-provider", + }, + }, + { + name: "custom commands without namespace is a fatal error", + schema: &models.ExtensionSchema{ + Id: "test.extension", + Version: "0.0.1", + DisplayName: "Test Extension", + Description: "A test extension", + Usage: "azd test ", + Capabilities: []extensions.CapabilityType{extensions.CustomCommandCapability}, + }, + wantErrorCount: 1, + wantErrorContains: []string{ + "Missing 'namespace' field in extension.yaml", + "custom-commands", + }, + }, + { + name: "extension pack without capabilities or usage is valid", + schema: &models.ExtensionSchema{ + Id: "test.pack", + Version: "0.0.1", + DisplayName: "Test Pack", + Description: "A test extension pack", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.extension", Version: "latest"}, + }, + }, + }, + { + name: "extension with dependencies and executable metadata still requires capabilities", + schema: &models.ExtensionSchema{ + Id: "test.extension", + Version: "0.0.1", + DisplayName: "Test Extension", + Description: "A test extension", + Namespace: "test", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.dependency", Version: "latest"}, + }, + }, + wantWarningCount: 1, + wantWarningContains: []string{ + "Missing 'usage' field in extension.yaml", + }, + wantErrorCount: 1, + wantErrorContains: []string{ + "Missing required field: capabilities", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings, errs := validateExtensionMetadata(tt.schema) + assert.Len(t, warnings, tt.wantWarningCount) + for _, want := range tt.wantWarningContains { + assert.True( + t, + slicesContainSubstring(warnings, want), + "expected warning containing %q in %v", want, warnings, + ) + } + + assert.Len(t, errs, tt.wantErrorCount) + for _, want := range tt.wantErrorContains { + assert.True( + t, + slicesContainSubstring(errs, want), + "expected error containing %q in %v", want, errs, + ) + } + }) + } +} + +func TestAddOrUpdateExtensionExtensionPack(t *testing.T) { + registry := &extensions.Registry{} + schema := &models.ExtensionSchema{ + Id: "test.pack", + Version: "0.0.1", + DisplayName: "Test Pack", + Description: "A test extension pack", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.extension", Version: "latest"}, + }, + } + + addOrUpdateExtension(registry, schema, map[string]extensions.ExtensionArtifact{}) + + require.Len(t, registry.Extensions, 1) + extension := registry.Extensions[0] + assert.Equal(t, "test.pack", extension.Id) + assert.Empty(t, extension.Namespace) + require.Len(t, extension.Versions, 1) + + version := extension.Versions[0] + assert.Equal(t, "0.0.1", version.Version) + assert.Empty(t, version.Capabilities) + assert.Empty(t, version.Artifacts) + assert.Equal(t, schema.Dependencies, version.Dependencies) +} + +func TestIsExtensionPack(t *testing.T) { + tests := []struct { + name string + in *models.ExtensionSchema + want bool + }{ + { + name: "dependency-only manifest is pack", + in: &models.ExtensionSchema{ + Id: "test.pack", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.extension", Version: "latest"}, + }, + }, + want: true, + }, + { + name: "dependencies with capabilities is executable extension", + in: &models.ExtensionSchema{ + Id: "test.extension", + Capabilities: []extensions.CapabilityType{extensions.CustomCommandCapability}, + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.dependency", Version: "latest"}, + }, + }, + }, + { + name: "dependencies with namespace is executable extension", + in: &models.ExtensionSchema{ + Id: "test.extension", + Namespace: "test", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.dependency", Version: "latest"}, + }, + }, + }, + { + name: "dependencies with language is executable extension", + in: &models.ExtensionSchema{ + Id: "test.extension", + Language: "go", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.dependency", Version: "latest"}, + }, + }, + }, + { + name: "dependencies with entry point is executable extension", + in: &models.ExtensionSchema{ + Id: "test.extension", + EntryPoint: "test-extension", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.dependency", Version: "latest"}, + }, + }, + }, + { + name: "no dependencies is not pack", + in: &models.ExtensionSchema{Id: "test.extension"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isExtensionPack(tt.in)) + }) + } +} + +func TestValidatePublishOptionsExtensionPack(t *testing.T) { + tests := []struct { + name string + extensionPack bool + flags *publishFlags + wantErr string + }{ + { + name: "extension pack without artifact flags is valid", + extensionPack: true, + flags: &publishFlags{}, + }, + { + name: "extension pack rejects repository", + extensionPack: true, + flags: &publishFlags{repository: "owner/repo"}, + wantErr: "omit --repo", + }, + { + name: "extension pack rejects artifacts", + extensionPack: true, + flags: &publishFlags{artifacts: []string{"./artifacts/*.zip"}}, + wantErr: "omit --artifacts", + }, + { + name: "executable extension allows repository and artifacts", + extensionPack: false, + flags: &publishFlags{ + repository: "owner/repo", + artifacts: []string{"./artifacts/*.zip"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePublishOptions(tt.extensionPack, tt.flags) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestValidatePublishAssets(t *testing.T) { + tests := []struct { + name string + extensionPack bool + assetCount int + wantState ux.TaskState + wantErr string + }{ + { + name: "executable extension with assets succeeds", + assetCount: 1, + wantState: ux.Success, + }, + { + name: "extension pack without assets skips", + extensionPack: true, + wantState: ux.Skipped, + }, + { + name: "executable extension without assets errors", + wantState: ux.Error, + wantErr: "no artifacts found for this extension version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state, err := validatePublishAssets(tt.extensionPack, tt.assetCount) + assert.Equal(t, tt.wantState, state) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestCollectExtensionMetadataFromFlagsTags(t *testing.T) { + metadata, err := collectExtensionMetadataFromFlags(&initFlags{ + id: "test.extension", + name: "Test Extension", + capabilities: []string{string(extensions.CustomCommandCapability)}, + language: "go", + tags: []string{"alpha, beta", "gamma"}, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta", "gamma"}, metadata.Tags) +} + +func TestCollectExtensionMetadataFromFlagsInvalidTags(t *testing.T) { + _, err := collectExtensionMetadataFromFlags(&initFlags{ + id: "test.extension", + name: "Test Extension", + capabilities: []string{string(extensions.CustomCommandCapability)}, + language: "go", + tags: []string{"valid", "ba\nd"}, + }) + + require.ErrorContains(t, err, "control characters") +} + +func TestValidateExtensionNamespace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + namespace string + wantErr bool + }{ + {name: "single segment", namespace: "demo"}, + {name: "two segments", namespace: "ai.project"}, + {name: "three segments with digits", namespace: "company1.team2.tool3"}, + {name: "hyphenated segment", namespace: "coding-agent"}, + {name: "hyphenated nested segment", namespace: "azure.coding-agent"}, + {name: "empty", namespace: "", wantErr: true}, + {name: "consecutive dots", namespace: "a..b", wantErr: true}, + {name: "leading dot", namespace: ".demo", wantErr: true}, + {name: "trailing dot", namespace: "demo.", wantErr: true}, + {name: "uppercase", namespace: "Demo", wantErr: true}, + {name: "underscore", namespace: "demo.tool_name", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateExtensionNamespace(tt.namespace) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestParseTags(t *testing.T) { + tags, err := parseTags("alpha, beta,,gamma") + require.NoError(t, err) + assert.Equal(t, []string{"alpha", "beta", "gamma"}, tags) + + // Boundary: exactly maxExtensionTags must succeed. + boundary := make([]string, maxExtensionTags) + for i := range maxExtensionTags { + boundary[i] = fmt.Sprintf("tag%d", i) + } + tags, err = parseTags(strings.Join(boundary, ",")) + require.NoError(t, err) + assert.Len(t, tags, maxExtensionTags) + + _, err = parseTags(strings.Join(append(boundary, "overflow"), ",")) + require.ErrorContains(t, err, "too many tags") + + _, err = parseTags(strings.Repeat("a", maxExtensionTagLength+1)) + require.ErrorContains(t, err, "too long") + + _, err = parseTags("valid,ba\nd") + require.ErrorContains(t, err, "control characters") +} + +func TestWriteCollectedWarnings(t *testing.T) { + var buf bytes.Buffer + writeCollectedWarnings(&buf, []string{"first warning", "second warning"}) + + output := buf.String() + assert.Contains(t, output, "Validation warnings:") + assert.NotContains(t, output, "(!) Warning") + assert.Contains(t, output, " - first warning") + assert.Contains(t, output, " - second warning") +} + +func TestValidationWarningSummary(t *testing.T) { + assert.Equal(t, "1 validation warning", validationWarningSummary([]string{"first"})) + assert.Equal(t, "2 validation warnings", validationWarningSummary([]string{"first", "second"})) +} + +func TestSubprocessErrorTail(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: ""}, + {name: "whitespace only", in: " \n\n\t\n", want: ""}, + { + name: "ERROR line wins over later content", + in: "Installing extension\n" + + "ERROR: namespace 'test.ext' conflicts with 'ext.agent'\n" + + "Suggestion: uninstall first", + want: ": namespace 'test.ext' conflicts with 'ext.agent'", + }, + { + name: "empty ERROR line falls back to later content", + in: "ERROR:\nSuggestion: retry", + want: ": Suggestion: retry", + }, + { + name: "falls back to last non-empty line", + in: "first\nsecond\n\n", + want: ": second", + }, + { + name: "strips ANSI escapes", + in: "\x1b[31mERROR:\x1b[0m boom", + want: ": boom", + }, + { + name: "strips OSC hyperlinks", + in: "\x1b]8;;file:///tmp/out\x07/tmp/out\x1b]8;;\x07", + want: ": /tmp/out", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, subprocessErrorTail([]byte(tt.in))) + }) + } +} + +func TestIsDirNonEmpty(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + nonEmpty, err := isDirNonEmpty(dir) + require.NoError(t, err) + assert.False(t, nonEmpty) + }) + + t.Run("non-empty", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "file.txt"), []byte("x"), 0o600)) + + nonEmpty, err := isDirNonEmpty(dir) + require.NoError(t, err) + assert.True(t, nonEmpty) + }) + + t.Run("missing path", func(t *testing.T) { + t.Parallel() + _, err := isDirNonEmpty(filepath.Join(t.TempDir(), "does-not-exist")) + assert.Error(t, err) + }) +} + +func TestTemplateGoStringQuotesDescription(t *testing.T) { + t.Parallel() + + const tmplSrc = `Short: {{strconvQuote .Description}},` + tmpl, err := template.New("test").Funcs(templateFuncs).Parse(tmplSrc) + require.NoError(t, err) + + tests := []struct { + name string + description string + want string + }{ + {name: "plain", description: "A test extension", want: `Short: "A test extension",`}, + { + name: "embedded double quotes", + description: `says "hi"`, + want: `Short: "says \"hi\"",`, + }, + { + name: "backslash", + description: `a\b`, + want: `Short: "a\\b",`, + }, + { + name: "newline and tab", + description: "line1\nline2\t!", + want: `Short: "line1\nline2\t!",`, + }, + { + name: "trailing backslash injection attempt", + description: `boom",}{`, + want: `Short: "boom\",}{",`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + require.NoError(t, tmpl.Execute(&buf, struct{ Description string }{tt.description})) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func slicesContainSubstring(values []string, substring string) bool { + for _, value := range values { + if strings.Contains(value, substring) { + return true + } + } + + return false +} diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/pack.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/pack.go index d554eaffbde..99fdc84079e 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/pack.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/pack.go @@ -37,7 +37,7 @@ func newPackCommand(outputPath *string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { internal.WriteCommandHeader( "Package azd extension (azd x pack)", - "Packages the azd extension project and updates the registry", + "Prepares the azd extension project for publishing", ) // For pack, an empty output path means "use the local registry artifacts path". @@ -46,12 +46,17 @@ func newPackCommand(outputPath *string) *cobra.Command { flags.outputPath = *outputPath } defaultPackageFlags(flags) - err := runPackageAction(cmd.Context(), flags) + extensionPack, err := runPackageAction(cmd.Context(), flags) if err != nil { return err } - internal.WriteCommandSuccess("Extension packaged successfully") + if extensionPack { + internal.WriteCommandSuccess("Extension pack contains no artifacts to package") + } else { + internal.WriteCommandSuccess("Extension packaged successfully") + } + return nil }, } @@ -77,60 +82,72 @@ func newPackCommand(outputPath *string) *cobra.Command { return packageCmd } -func runPackageAction(ctx context.Context, flags *packageFlags) error { +func runPackageAction(ctx context.Context, flags *packageFlags) (bool, error) { // Create a new context that includes the AZD access token ctx = azdext.WithAccessToken(ctx) // Create a new AZD client azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return false, fmt.Errorf("failed to create azd client: %w", err) } defer azdClient.Close() if err := azdext.WaitForDebugger(ctx, azdClient); err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, azdext.ErrDebuggerAborted) { - return nil + return false, nil } - return fmt.Errorf("failed waiting for debugger: %w", err) + return false, fmt.Errorf("failed waiting for debugger: %w", err) } absExtensionPath, err := os.Getwd() if err != nil { - return fmt.Errorf("failed to get absolute path for extension directory: %w", err) + return false, fmt.Errorf("failed to get absolute path for extension directory: %w", err) } extensionMetadata, err := models.LoadExtension(absExtensionPath) if err != nil { - return fmt.Errorf("failed to load extension metadata: %w", err) + return false, fmt.Errorf("failed to load extension metadata: %w", err) } - if flags.outputPath == "" { + extensionPack := isExtensionPack(extensionMetadata) + + if flags.outputPath == "" && !extensionPack { localRegistryArtifactsPath, err := internal.LocalRegistryArtifactsPath() if err != nil { - return err + return false, err } flags.outputPath = filepath.Join(localRegistryArtifactsPath, extensionMetadata.Id, extensionMetadata.Version) } - absInputPath := filepath.Join(extensionMetadata.Path, flags.inputPath) - absOutputPath, err := filepath.Abs(flags.outputPath) - if err != nil { - return fmt.Errorf("failed to get absolute path for output directory: %w", err) - } - fmt.Println() - fmt.Printf("%s: %s\n", output.WithBold("Input Path"), output.WithHyperlink(absInputPath, absInputPath)) - 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")) + } else { + absInputPath := filepath.Join(extensionMetadata.Path, flags.inputPath) + absOutputPath, err := filepath.Abs(flags.outputPath) + if err != nil { + return false, fmt.Errorf("failed to get absolute path for output directory: %w", err) + } + + fmt.Printf("%s: %s\n", output.WithBold("Input Path"), output.WithHyperlink(absInputPath, absInputPath)) + fmt.Printf("%s: %s\n", output.WithBold("Output Path"), output.WithHyperlink(absOutputPath, absOutputPath)) + } taskList := ux.NewTaskList(nil). AddTask(ux.TaskOptions{ Title: "Building extension", Action: func(spf ux.SetProgressFunc) (ux.TaskState, error) { + if extensionPack { + spf("Extension packs do not contain build artifacts") + return ux.Skipped, nil + } + // Verify if we have any existing binaries if !flags.rebuild { + absInputPath := filepath.Join(extensionMetadata.Path, flags.inputPath) entires, err := os.ReadDir(absInputPath) if err == nil { binaries := []string{} @@ -177,6 +194,11 @@ func runPackageAction(ctx context.Context, flags *packageFlags) error { AddTask(ux.TaskOptions{ Title: "Packaging extension", Action: func(spf ux.SetProgressFunc) (ux.TaskState, error) { + if extensionPack { + spf("Extension packs contain no artifacts; nothing to package") + return ux.Skipped, nil + } + if err := packExtensionBinaries(extensionMetadata, flags.outputPath); err != nil { return ux.Error, common.NewDetailedError( "Packaging failed", @@ -188,7 +210,7 @@ func runPackageAction(ctx context.Context, flags *packageFlags) error { }, }) - return taskList.Run() + return extensionPack, taskList.Run() } func packExtensionBinaries( diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go index c9e9b93a060..988b1edbcb8 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/cmd/publish.go @@ -117,11 +117,16 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU extensionMetadata.Version = flags.version } + extensionPack := isExtensionPack(extensionMetadata) + if err := validatePublishOptions(extensionPack, flags); err != nil { + return err + } + // Use artifacts patterns from flag artifactPatterns := flags.artifacts // Setting remote repository overrides local artifacts - if flags.repository != "" { + if flags.repository != "" || extensionPack { artifactPatterns = nil } @@ -151,7 +156,7 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU } } - if flags.repository != "" { + if flags.repository != "" && !extensionPack { repo, err := ghCli.ViewRepository(absExtensionPath, flags.repository) if err != nil { return err @@ -192,6 +197,8 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU release.TagName, output.WithHyperlink(release.Url, "View Release"), ) + } else if extensionPack { + fmt.Printf("%s: Extension pack\n", output.WithBold("Extension Type")) } else { // Show what artifacts will be processed if len(artifactPatterns) > 0 { @@ -212,7 +219,7 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU AddTask(ux.TaskOptions{ Title: "Fetching local artifacts", Action: func(spf ux.SetProgressFunc) (ux.TaskState, error) { - if flags.repository != "" { + if flags.repository != "" || extensionPack { return ux.Skipped, nil } @@ -265,7 +272,7 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU AddTask(ux.TaskOptions{ Title: "Fetching GitHub release assets", Action: func(spf ux.SetProgressFunc) (ux.TaskState, error) { - if flags.repository == "" { + if flags.repository == "" || extensionPack { return ux.Skipped, nil } @@ -289,6 +296,13 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU AddTask(ux.TaskOptions{ Title: "Generating extension metadata", Action: func(spf ux.SetProgressFunc) (ux.TaskState, error) { + if state, err := validatePublishAssets(extensionPack, len(assets)); err != nil { + return state, err + } else if state == ux.Skipped { + spf("Extension packs do not contain artifacts") + return state, nil + } + for _, asset := range assets { spf(fmt.Sprintf("Processing %s", asset.Name)) @@ -387,6 +401,36 @@ func runPublishAction(ctx context.Context, flags *publishFlags, defaultRegistryU return taskList.Run() } +func validatePublishOptions(extensionPack bool, flags *publishFlags) error { + if !extensionPack { + return nil + } + + if flags.repository != "" { + return fmt.Errorf("extension packs do not have release artifacts; omit --repo and publish directly to the registry") + } + if len(flags.artifacts) > 0 { + return fmt.Errorf("extension packs do not have artifacts; omit --artifacts and publish directly to the registry") + } + + return nil +} + +func validatePublishAssets(extensionPack bool, assetCount int) (ux.TaskState, error) { + if assetCount > 0 { + return ux.Success, nil + } + + if extensionPack { + return ux.Skipped, nil + } + + return ux.Error, common.NewDetailedError( + "Artifacts not found", + errors.New("no artifacts found for this extension version"), + ) +} + func addOrUpdateExtension( registry *extensions.Registry, extensionMetadata *models.ExtensionSchema, diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/go/internal/cmd/root.go.tmpl b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/go/internal/cmd/root.go.tmpl index 57bab41c295..dbb9e9a44b0 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/go/internal/cmd/root.go.tmpl +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/resources/languages/go/internal/cmd/root.go.tmpl @@ -10,9 +10,9 @@ import ( func NewRootCommand() *cobra.Command { rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ - Name: "{{.Metadata.Namespace}}", - Use: "azd {{.Metadata.Namespace}} [options]", - Short: "{{.Metadata.Description}}", + Name: "{{.LeafNamespace}}", + Use: "{{.LeafNamespace}} [options]", + Short: {{strconvQuote .Metadata.Description}}, }) rootCmd.SilenceUsage = true diff --git a/cli/azd/extensions/microsoft.azd.extensions/version.txt b/cli/azd/extensions/microsoft.azd.extensions/version.txt index 2774f8587f4..d9df1bbc0c7 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/version.txt +++ b/cli/azd/extensions/microsoft.azd.extensions/version.txt @@ -1 +1 @@ -0.10.0 \ No newline at end of file +0.11.0 diff --git a/cli/azd/pkg/ux/render_test.go b/cli/azd/pkg/ux/render_test.go index fa24d71bead..cc16bfa45c9 100644 --- a/cli/azd/pkg/ux/render_test.go +++ b/cli/azd/pkg/ux/render_test.go @@ -9,8 +9,11 @@ import ( "testing" "time" + "github.com/fatih/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + outputpkg "github.com/azure/azure-dev/cli/azd/pkg/output" ) // --- TaskList tests --- @@ -171,6 +174,10 @@ func TestTaskList_Render_skipped_with_and_without_error(t *testing.T) { } func TestTaskList_Render_warning(t *testing.T) { + originalNoColor := color.NoColor + color.NoColor = false + t.Cleanup(func() { color.NoColor = originalNoColor }) + var buf bytes.Buffer printer := NewPrinter(&buf) @@ -192,6 +199,63 @@ func TestTaskList_Render_warning(t *testing.T) { output := buf.String() assert.Contains(t, output, "Warn task") assert.Contains(t, output, "partial failure") + assert.Contains(t, output, outputpkg.WithWarningFormat("(partial failure)")) + assert.NotContains(t, output, outputpkg.WithErrorFormat("(partial failure)")) +} + +func TestTaskList_Run_warningContinues(t *testing.T) { + var buf bytes.Buffer + ranNextTask := false + + tl := NewTaskList(&TaskListOptions{Writer: &buf}) + tl.AddTask(TaskOptions{ + Title: "Warn task", + Action: func(sp SetProgressFunc) (TaskState, error) { + return Warning, errors.New("validation warning") + }, + }).AddTask(TaskOptions{ + Title: "Next task", + Action: func(sp SetProgressFunc) (TaskState, error) { + ranNextTask = true + return Success, nil + }, + }) + + err := tl.Run() + require.NoError(t, err) + assert.True(t, ranNextTask) + assert.Equal(t, Warning, tl.allTasks[0].State) + assert.Equal(t, Success, tl.allTasks[1].State) + assert.Empty(t, tl.errors) + + output := buf.String() + assert.Contains(t, output, "Warn task") + assert.Contains(t, output, "validation warning") + assert.Contains(t, output, "Next task") +} + +func TestShouldCollectTaskError(t *testing.T) { + taskErr := errors.New("task failed") + tests := []struct { + name string + state TaskState + err error + want bool + }{ + {name: "nil error", state: Error, err: nil, want: false}, + {name: "pending error", state: Pending, err: taskErr, want: true}, + {name: "running error", state: Running, err: taskErr, want: true}, + {name: "skipped error", state: Skipped, err: taskErr, want: true}, + {name: "warning error", state: Warning, err: taskErr, want: false}, + {name: "error error", state: Error, err: taskErr, want: true}, + {name: "success error", state: Success, err: taskErr, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, shouldCollectTaskError(tt.err, tt.state)) + }) + } } func TestTaskList_Render_ordering(t *testing.T) { diff --git a/cli/azd/pkg/ux/task_list.go b/cli/azd/pkg/ux/task_list.go index 9d53a2e9bb5..73fc6ba93d3 100644 --- a/cli/azd/pkg/ux/task_list.go +++ b/cli/azd/pkg/ux/task_list.go @@ -86,11 +86,18 @@ type Task struct { type TaskState int const ( + // Pending indicates a task has not started yet. Pending TaskState = iota + // Running indicates a task is currently executing. Running + // Skipped indicates a task was not executed. Skipped + // Warning indicates a task completed with a non-fatal issue. If the task + // returns an error with this state, it is displayed but not returned by Run. Warning + // Error indicates a task failed. Error + // Success indicates a task completed successfully. Success ) @@ -227,12 +234,12 @@ func (t *TaskList) Render(printer Printer) error { elapsedText = output.WithGrayFormat("(%s)", durationAsText(elapsed)) } - var errorDescription string + var statusDescription string if task.Error != nil { if detailedErr, ok := errors.AsType[*common.DetailedError](task.Error); ok { - errorDescription = detailedErr.Description() + statusDescription = detailedErr.Description() } else { - errorDescription = task.Error.Error() + statusDescription = task.Error.Error() } } @@ -258,7 +265,7 @@ func (t *TaskList) Render(printer Printer) error { output.WithWarningFormat(t.options.WarningStyle), task.Title, elapsedText, - output.WithErrorFormat("(%s)", errorDescription), + output.WithWarningFormat("(%s)", statusDescription), ) case Error: printer.Fprintf( @@ -266,12 +273,12 @@ func (t *TaskList) Render(printer Printer) error { output.WithErrorFormat(t.options.ErrorStyle), task.Title, elapsedText, - output.WithErrorFormat("(%s)", errorDescription), + output.WithErrorFormat("(%s)", statusDescription), ) case Success: printer.Fprintf("%s %s %s\n", output.WithSuccessFormat(t.options.SuccessStyle), task.Title, elapsedText) case Skipped: - if errorDescription == "" { + if statusDescription == "" { printer.Fprintf( "%s %s\n", output.WithGrayFormat(t.options.SkippedStyle), @@ -282,7 +289,7 @@ func (t *TaskList) Render(printer Printer) error { "%s %s %s\n", output.WithGrayFormat(t.options.SkippedStyle), task.Title, - output.WithErrorFormat("(%s)", errorDescription), + output.WithErrorFormat("(%s)", statusDescription), ) } } @@ -318,7 +325,7 @@ func (t *TaskList) runSyncTasks() { } state, err := task.Action(setProgress) - if err != nil { + if shouldCollectTaskError(err, state) { t.errorMutex.Lock() t.errors = append(t.errors, err) t.errorMutex.Unlock() @@ -348,7 +355,7 @@ func (t *TaskList) addAsyncTask(task *Task) { } state, err := task.Action(setProgress) - if err != nil { + if shouldCollectTaskError(err, state) { t.errorMutex.Lock() t.errors = append(t.errors, err) t.errorMutex.Unlock() @@ -370,6 +377,12 @@ func (t *TaskList) addSyncTask(task *Task) { t.syncTasks = append(t.syncTasks, task) } +// shouldCollectTaskError determines whether a task error should be added to the error collection. +// Warning-state tasks with errors are excluded to allow following tasks to continue. +func shouldCollectTaskError(err error, state TaskState) bool { + return err != nil && state != Warning +} + // DurationAsText provides a slightly nicer string representation of a duration // when compared to default formatting in go, by spelling out the words hour, // minute and second and providing some spacing and eliding the fractional component