diff --git a/.github/agents/changelog-core.agent.md b/.github/agents/changelog-core.agent.md new file mode 100644 index 00000000000..c058c5b398a --- /dev/null +++ b/.github/agents/changelog-core.agent.md @@ -0,0 +1,79 @@ +--- +name: Generate changelog (azd core) +description: Update azd core release changelog and version based on merged PRs. +infer: true +--- + +# Changelog (azd core) + +You maintain the Azure Developer CLI (azd) release changelog. + +## Scope + +- Update **only**: + - `cli/azd/CHANGELOG.md` + - `cli/version.txt` + - `cli/azd/.vscode/cspell*` (if needed for spell checking) +- Repository: `Azure/azure-dev` + +## Goal + +Prepare a high-quality release entry that is accurate, user-facing, and consistent with the existing changelog style. + +## Process + +### 1. Prepare the version header + +1. In `cli/azd/CHANGELOG.md`, find the top-most unreleased section (typically `## X.Y.Z-beta.1 (Unreleased)` if present). +2. Convert it to a release entry: + - Remove `-beta.*` and `(Unreleased)`. + - Add the release date in `YYYY-MM-DD` format, matching existing entries (e.g., `## 1.22.1 (2025-12-10)`). +3. Update `cli/version.txt` to the same released version. + +### 2. Identify commits to include + +1. Find the cutoff commit by inspecting recent changelog edits: + ```bash + git --no-pager log -n 3 --follow -p -- cli/azd/CHANGELOG.md + ``` + In the diff output, identify the commit SHA that **added the previous released version’s notes** (the last non-empty release section). + + Note: ignore automation/bot commits that only add a placeholder unreleased section like: + `## 1.x.y-beta.1 (Unreleased)` with empty categories. +2. List commits newer than the cutoff (increase `-20` as needed): + ```bash + git --no-pager log --oneline --pretty=format:"%h (%ad)%d %s" --date=short -20 origin/main + ``` + +### 3. Process changes one PR at a time (no batching) + +For **each** commit newer than the cutoff, do this workflow fully (steps 1-6) before moving to the next commit. **DO NOT** batch process multiple commits/PRs, skip PRs, or cut the process short due to time constraints. + +1. Extract PR number from the commit subject (`(#1234)`). If missing, find the PR another way (e.g., search by commit SHA). +2. Fetch PR details (owner: `Azure`, repo: `azure-dev`) using GitHub MCP. +3. Determine whether the PR author is an external contributor: + - Get the PR author handle. + - Consider them "core" if their handle appears in `.github/CODEOWNERS`; otherwise treat as external. +4. Identify linked issues from the PR description/body and fetch issue details using GitHub MCP when needed to understand user impact. +5. Decide if it belongs in the changelog. Exclude changes that are primarily: + - Tests or test infrastructure + - Documentation-only changes (`*.md`, `CODEOWNERS`, etc.) + - Pure refactors/cleanup/renames with no user impact + - CI/build/release infrastructure changes + - Automated dependency bumps that are purely dependency maintenance (updates to tools like Bicep CLI, GitHub CLI can remain in the changelog) + - Extension-only changes under `cli/azd/extensions/` (e.g. azure.ai.agent, microsoft.azd.demo, etc.) +6. Write the changelog entry to `CHANGELOG.md`: + - Categorize into one of: `### Features Added`, `### Bugs Fixed`, `### Other Changes` + - Add a bullet using the exact format: `- [[#PR]](https://github.com/Azure/azure-dev/pull/PR) User-facing description.` + - Guidelines: start with a verb (**Add**, **Fix**, **Update**, **Improve**); describe user impact; keep it short; prefer bug phrasing like "Fix …". + - Attribution: if the PR is from an external contributor, append: ` Thanks @handle for the contribution!` + +### 4. Finalize + +1. Remove any empty categories in the new release section. +2. Ensure formatting matches existing releases. +3. Spell check: + ```bash + cspell lint "cli/azd/CHANGELOG.md" --relative --config cli/azd/.vscode/cspell.yaml --no-progress + ``` + If new names/handles trip cspell, update `cli/azd/.vscode/cspell-github-user-aliases.txt`. diff --git a/.github/agents/changelog-extension.agent.md b/.github/agents/changelog-extension.agent.md new file mode 100644 index 00000000000..d049e510492 --- /dev/null +++ b/.github/agents/changelog-extension.agent.md @@ -0,0 +1,95 @@ +--- +name: Generate changelog (extensions) +description: Update an azd extension changelog and bump its version. +infer: true +--- + +# Changelog (extensions) + +You maintain release notes for **azd extensions** under `cli/azd/extensions/`. + +## Scope + +For the **target extension folder** (for example `cli/azd/extensions/microsoft.azd.demo`), update **only**: + +- `/CHANGELOG.md` +- `/version.txt` +- `/extension.yaml` (the `version:` field) + +**DO NOT** update the core CLI changelog (`cli/azd/CHANGELOG.md`) or `registry.json`. + +## Goal + +Produce a user-facing release entry for the extension and ensure the extension's version is consistent across files. + +## Process + +### 1. Identify the target extension + +1. Determine which extension is being released (folder under `cli/azd/extensions/`). +2. Confirm it contains: + - `CHANGELOG.md` + - `version.txt` + - `extension.yaml` + +### 2. Bump the version + +1. Choose the new version according to the extension's existing conventions (SemVer, and optional suffix like `-preview`). +2. Update **both**: + - `/version.txt` + - `/extension.yaml` (`version:`) + +Keep them exactly in sync. + +### 3. Prepare the changelog header + +In `/CHANGELOG.md`, add a new top entry for the new version with today's date in `YYYY-MM-DD`, matching the file's existing formatting. + +- If the changelog uses category headings (e.g., `### Features Added`), follow that style. +- If it uses simple bullet lists (no categories), keep it consistent. + +### 4. Gather commits affecting the extension + +1. Find the cutoff commit for the previous release entry, use the extension changelog history: + + ```bash + git --no-pager log -n 2 --follow -p -- /CHANGELOG.md + ``` + + Identify the commit that added the previous version section, then only consider commits newer than that cutoff. + +2. List commits newer than the cutoff: + ```bash + git --no-pager log --oneline --pretty=format:"%h (%ad)%d %s" --date=short -10 origin/main -- / + ``` + +### 5. Process changes one PR at a time (no batching) + +For each commit/PR in scope, do the full workflow (steps 1-6) before moving to the next: + +1. Extract PR number from the commit subject (`(#1234)`), or locate the PR by commit SHA. +2. Fetch PR details (owner: `Azure`, repo: `azure-dev`) using GitHub MCP. +3. Identify linked issues and fetch issue details if needed to understand user impact. +4. Decide if it belongs in the extension changelog. Exclude changes that are primarily: + - Tests or test infrastructure + - Documentation-only changes + - Pure refactors/cleanup/renames with no user impact + - CI/build/release infra changes +6. Write the changelog entry to `CHANGELOG.md`: + + Add concise, user-facing bullets under the new version section. + + - Start with a verb (**Add**, **Fix**, **Update**, **Improve**). + - Describe user impact (what changes for someone using the extension). + - Include PR link when available, using the format: + - `- [[#PR]](https://github.com/Azure/azure-dev/pull/PR) Description.` + +### 6. Finalize + +1. Ensure the new changelog entry matches the extension's existing style. +2. Ensure `/version.txt` and `/extension.yaml` versions match exactly. +3. Run spellcheck on the extension changelog if it's in scope for release: + +```bash +cspell lint "/CHANGELOG.md" --relative --config cli/azd/.vscode/cspell.yaml --no-progress +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7f80144aa74..75628aff4f3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -195,48 +195,6 @@ func TestMyFunction(t *testing.T) { ## Changelog updates for releases -When preparing a new release changelog, update `cli/azd/CHANGELOG.md` and `cli/version.txt`: - -### Step 1: Prepare version header -Rename any existing `## 1.x.x-beta.1 (Unreleased)` section to the version being released, without the `-beta.1` and `Unreleased` parts. Do the same for `cli/version.txt`. - -### Step 2: Gather commits -**Find cutoff commit**: -```bash -git --no-pager log --grep="Increment CLI version" --invert-grep -n 3 --follow -p -- cli/azd/CHANGELOG.md -``` -Review the diff output to find the most recent commit that added the previous version's changelog - this is the cutoff commit. Focus on the actual changelog changes in the diff instead of the commit messages themselves. - -**Get commits to process**: -```bash -git --no-pager log --oneline --pretty=format:"%h (%ad) %s" --date=short -20 origin/main -``` -Increase `-20` if needed to find the cutoff commit. `git log` shows commits in reverse chronological order (newest first). You must identify the cutoff commit and only take commits newer than (above) it. - -### Step 3: Gather context and write changelog entry -**CRITICAL INSTRUCTION: Process each commit individually and sequentially. Complete the full workflow (extract PR, fetch details, categorize, write entry, save) one entry at a time. DO NOT batch process multiple commits/PRs, skip PRs, or cut the process short due to time constraints.** - -1. **Extract PR number**: Look for `(#XXXX)` pattern in commit message -2. **Fetch PR details** using GitHub tools: owner: `Azure`, repo: `azure-dev`, pullNumber: `PR#` - - Get the GitHub handle of the PR owner, and determine whether the owner is outside the core team (handle not in `.github/CODEOWNERS`) -3. **Identify linked issues**: Scan PR details for GitHub issue references -4. **Fetch linked issue details** using GitHub tools: owner: `Azure`, repo: `azure-dev`, issue_number: `XXXX` -5. **Categorize change**: Features Added, Bugs Fixed, Other Changes -6. **Add changelog entry to CHANGELOG.md**: - - **Format**: `- [[PR#]](https://github.com/Azure/azure-dev/pull/PR#) User-friendly description.` - - **Process**: Read PR description and linked issue carefully to understand the user impact - - **Guidelines**: - - Be brief. Start with action verbs (Add, Fix, Update, etc.) and describe user impact. Follow existing changelog entries for style. - - For bugs, phrase the changelog entry in terms of the issue that was fixed when possible. Example: "Fix PowerShell 7 suggestion text not showing for service-level hooks." - - **Attribution**: For PRs from contributors outside the core team, append: " Thanks @handle for the contribution!" -7. **Exclude the following types of changes** from the changelog: - - Test-related changes and test infrastructure updates - - Documentation updates (README.md, .md files, CODEOWNERS) - - Automated dependency bumps and CVE fixes that are purely dependency updates (updates to tools like Bicep CLI, GitHub CLI should remain in the changelog) - - Internal refactoring, code cleanup, and variable renames without user impact - - Build/release infrastructure and CI/CD pipeline changes - - Changes exclusively under `cli/azd/extensions/` directory (extension-specific updates) - -### Step 4: Organize and finalize -1. **Remove empty categories** and **validate formatting** -2. **Spell check**: Run `cspell lint "cli/azd/CHANGELOG.md" --relative --config cli/azd/.vscode/cspell.yaml --no-progress` and update `.vscode/cspell-github-user-aliases.txt` if needed +When asked to prepare a release changelog, use the appropriate custom agent instructions: +- `.github/agents/changelog-core.agent.md` for core CLI releases +- `.github/agents/changelog-extension.agent.md` for extension releases diff --git a/.github/policies/resourceManagement.yml b/.github/policies/resourceManagement.yml index ce043e344bf..3667297f9df 100644 --- a/.github/policies/resourceManagement.yml +++ b/.github/policies/resourceManagement.yml @@ -73,15 +73,14 @@ configuration: reply: "Hi @${issueAuthor}. Thank you for your contribution. Since there hasn't been recent engagement, we're going to close this out. Feel free to respond with a comment containing \"/reopen\" if you'd like to continue working on these changes. Please be sure to use the command to reopen or remove the \"no-recent-activity\" label; otherwise, this is likely to be closed again with the next cleanup pass. " - description: frequencies: - - weekday: - day: Friday - time: 5:0 + - hourly: + hour: 6 filters: - isOpen - isNotLabeledWith: label: no-recent-activity - noActivitySince: - days: 60 + days: 7 - isPullRequest actions: - addLabel: diff --git a/.github/workflows/ext-registry-ci.yml b/.github/workflows/ext-registry-ci.yml new file mode 100644 index 00000000000..08eb665ddfc --- /dev/null +++ b/.github/workflows/ext-registry-ci.yml @@ -0,0 +1,50 @@ +name: ext-registry-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/registry.json" + - ".github/workflows/ext-registry-ci.yml" + branches: [main] + +# If two events are triggered within a short time in the same PR, cancel the run of the oldest event +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + snapshot-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli/azd + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "^1.25" + cache-dependency-path: | + cli/azd/go.sum + + - name: Build azd + run: go build . + + - name: Run FigSpec snapshot test + run: go test ./cmd -v -run TestFigSpec + + - name: Run Usage snapshot test + run: go test ./cmd -v -run TestUsage + + - name: Check snapshot test results + if: failure() + run: | + echo "::error::Snapshots may be out of date. Run the following locally to update them:" + echo "" + echo " cd cli/azd" + echo " UPDATE_SNAPSHOTS=true go test ./cmd -run 'TestFigSpec|TestUsage'" + exit 1 diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index f4a852d454d..f4993098fe7 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -33,4 +33,5 @@ jobs: # Exclude PRs from closing or being marked as stale days-before-pr-stale: -1 days-before-pr-close: -1 - \ No newline at end of file + operations-per-run: 500 + diff --git a/.gitignore b/.gitignore index 660ecdae526..a7905e501a2 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ cli/azd/extensions/microsoft.azd.ai.builder/microsoft.azd.ai.builder cli/azd/extensions/microsoft.azd.ai.builder/microsoft.azd.ai.builder.exe cli/azd/extensions/microsoft.azd.demo/microsoft.azd.demo cli/azd/extensions/microsoft.azd.demo/microsoft.azd.demo.exe +cli/azd/extensions/microsoft.azd.concurx/concurx +cli/azd/extensions/microsoft.azd.concurx/concurx.exe +cli/azd/azd-test diff --git a/.vscode/cspell-github-user-aliases.txt b/.vscode/cspell-github-user-aliases.txt index f1184fcc7d4..51ef55d059b 100644 --- a/.vscode/cspell-github-user-aliases.txt +++ b/.vscode/cspell-github-user-aliases.txt @@ -48,3 +48,4 @@ vivazqu weilim Yionse Saipriya +Menghua1 diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index cffa9fb44bb..185cd456b19 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -73,6 +73,7 @@ BUILDID BUILDNUMBER buildpacks byoi +callstack cflags charmbracelet circleci @@ -80,6 +81,7 @@ cmdrecord cmdsubst Cobo cognitiveservices +concurx conditionalize consolesize containeragent @@ -150,6 +152,7 @@ jmes jquery keychain kubelogin +langchain langchaingo LASTEXITCODE ldflags @@ -205,6 +208,7 @@ preinit protogen proxying psanford +pseudonymized psycopg psycopgbinary pulumi diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index e63e5a8e145..f369c560619 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -237,6 +237,18 @@ overrides: - filename: pkg/project/service_target_dotnet_containerapp.go words: - IMAGENAME + - filename: extensions/microsoft.azd.extensions/internal/resources/languages/**/.gitignore + words: + - rsuser + - userosscache + - docstates + - dylib + - filename: docs/recording-functional-tests-guide.md + words: + - httptest + - Logf + - Getenv + - httptest ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 4a8ac058620..9c393354504 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 1.22.0-beta.1 (Unreleased) +## 1.23.0-beta.1 (Unreleased) ### Features Added @@ -10,6 +10,93 @@ ### Other Changes +## 1.22.5 (2025-12-18) + +### Bugs Fixed + +- [[#6398]](https://github.com/Azure/azure-dev/pull/6398) Fix `azd provision --preview` incorrectly prompting to install extensions for custom service targets when the extension is already installed. +- [[#6408]](https://github.com/Azure/azure-dev/pull/6408) Fix panic during provisioning by reverting custom configuration properties feature introduced in v1.22.4. + +## 1.22.4 (2025-12-17) + +### Features Added + +- [[#6196]](https://github.com/Azure/azure-dev/pull/6196) Add support for custom configuration properties in project and service configuration for extensions. +- [[#6367]](https://github.com/Azure/azure-dev/pull/6367) Add interactive mode support for extensions to enable TUI applications. + +### Bugs Fixed + +- [[#6356]](https://github.com/Azure/azure-dev/pull/6356) Fix resource discovery logic to correctly identify resources associated with deployments during deletion. +- [[#6385]](https://github.com/Azure/azure-dev/pull/6385) Fix display names for Foundry and Foundry project resources in output. + +### Other Changes + +- [[#6389]](https://github.com/Azure/azure-dev/pull/6389) Improve error telemetry with specific error type classification. + +## 1.22.3 (2025-12-15) + +### Features Added + +- [[#6347]](https://github.com/Azure/azure-dev/pull/6347) Add interactive environment selector to `azd env select` when no environment is specified. + +### Bugs Fixed + +- [[#6373]](https://github.com/Azure/azure-dev/pull/6373) Fix error handling to display proper error messages when loading invalid `azure.yaml` files instead of panicking. + +## 1.22.2 (2025-12-12) + +### Features Added + +- [[#6321]](https://github.com/Azure/azure-dev/pull/6321) Add distributed tracing and structured error handling for extensions. + +### Bugs Fixed + +- [[#6360]](https://github.com/Azure/azure-dev/pull/6360) Fix Container App deployment to correctly resolve infrastructure path from defaults when using layered provisioning with service module settings. +- [[#6351]](https://github.com/Azure/azure-dev/pull/6351) Fix GitHub URL parsing to support branch names containing slashes. + +### Other Changes + +- [[#6357]](https://github.com/Azure/azure-dev/pull/6357) Add usage tracking telemetry for layered provisioning and revision-based ACA deployments. + +## 1.22.1 (2025-12-10) + +### Features Added + +- [[#6285]](https://github.com/Azure/azure-dev/pull/6285) Support `azure.yml` as an alternative to `azure.yaml` for project configuration files. +- [[#6266]](https://github.com/Azure/azure-dev/pull/6266) Prompt to create directory when using `-C`/`--cwd` with non-existent path. +- [[#6300]](https://github.com/Azure/azure-dev/pull/6300) Add template gallery links in `azd init` and `azd template list` commands. +- [[#6313]](https://github.com/Azure/azure-dev/pull/6313) Improve error handling flow with three-solution prompt and upgraded langchain dependency. + +### Bugs Fixed + +- [[#6353]](https://github.com/Azure/azure-dev/pull/6353) Fix unclear error message when deploying to improperly-tagged Container App in non-revision mode. +- [[#6346]](https://github.com/Azure/azure-dev/pull/6346) Fix Container App revision deployment to respect service `module` setting in `azure.yaml`. +- [[#6345]](https://github.com/Azure/azure-dev/pull/6345) Improve `azd down` deletion behavior for resource-group-scoped deployments. +- [[#6341]](https://github.com/Azure/azure-dev/pull/6341) Fix Static Web App deployment by ensuring framework service requires restore and produces correct artifacts. +- [[#5568]](https://github.com/Azure/azure-dev/pull/5568) Fix JSON-escaped environment variables being lost when syncing to remote CI/CD pipelines. Thanks @Menghua1 for the contribution! + +### Other Changes + +## 1.22.0 (2025-12-02) + +### Features Added + +- [[#6256]](https://github.com/Azure/azure-dev/pull/6256) Reduce provisioning progress display polling interval from 10s to 3s for more responsive status updates. +- [[#6232]](https://github.com/Azure/azure-dev/pull/6232) Add language-specific `.gitignore` templates to extension scaffolding for `azd x init`. + +### Bugs Fixed + +- [[#6277]](https://github.com/Azure/azure-dev/pull/6277) Fix hooks not running in CI/CD scenarios when `.azure` directory does not exist. +- [[#6282]](https://github.com/Azure/azure-dev/pull/6282) Fix panic in `azd provision --preview` when ARM returns nil After field during resource deletion. +- [[#6281]](https://github.com/Azure/azure-dev/pull/6281) Fix `azd provision` skipping deployment when resource groups were deleted outside of azd. +- [[#6180]](https://github.com/Azure/azure-dev/pull/6180) Relax Aspire binding validation for non-HTTP protocols as simple TCP. +- [[#6255]](https://github.com/Azure/azure-dev/pull/6255) Fix Container Apps deployment error when using revision-based deployments. + +### Other Changes + +- [[#6247]](https://github.com/Azure/azure-dev/pull/6247) Update Bicep CLI to v0.39.26. +- [[#6270]](https://github.com/Azure/azure-dev/pull/6270) Rename Azure AI Foundry to Microsoft Foundry. + ## 1.21.3 (2025-11-14) ### Bugs Fixed diff --git a/cli/azd/cmd/auth_login.go b/cli/azd/cmd/auth_login.go index 69d5b2e4648..753b9f69439 100644 --- a/cli/azd/cmd/auth_login.go +++ b/cli/azd/cmd/auth_login.go @@ -196,13 +196,6 @@ func (lf *loginFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandO lf.global = global } -func newLoginFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *loginFlags { - flags := &loginFlags{} - flags.Bind(cmd.Flags(), global) - - return flags -} - func newLoginCmd(parent string) *cobra.Command { return &cobra.Command{ Use: "login", @@ -237,9 +230,6 @@ type loginAction struct { commandRunner exec.CommandRunner } -// it is important to update both newAuthLoginAction and newLoginAction at the same time -// newAuthLoginAction is the action that is bound to `azd auth login`, -// and newLoginAction is the action that is bound to `azd login` func newAuthLoginAction( formatter output.Formatter, writer io.Writer, @@ -262,31 +252,6 @@ func newAuthLoginAction( } } -// it is important to update both newAuthLoginAction and newLoginAction at the same time -// newAuthLoginAction is the action that is bound to `azd auth login`, -// and newLoginAction is the action that is bound to `azd login` -func newLoginAction( - formatter output.Formatter, - writer io.Writer, - authManager *auth.Manager, - accountSubManager *account.SubscriptionsManager, - flags *loginFlags, - console input.Console, - annotations CmdAnnotations, - commandRunner exec.CommandRunner, -) actions.Action { - return &loginAction{ - formatter: formatter, - writer: writer, - console: console, - authManager: authManager, - accountSubManager: accountSubManager, - flags: flags, - annotations: annotations, - commandRunner: commandRunner, - } -} - func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) { if len(la.flags.scopes) == 0 { la.flags.scopes = la.authManager.LoginScopes() diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 23a88980725..b9da1b850c3 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -385,6 +385,18 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai log.Panic("failed to resolve console for unknown flags error:", err) } + // Check for deprecated commands and provide helpful redirection messages + if unknownCommand == "login" { + console.Message(ctx, "Error: The 'azd login' command has been removed.") + console.Message(ctx, "Please use 'azd auth login' instead.") + return fmt.Errorf("unknown command 'login'") + } + if unknownCommand == "logout" { + console.Message(ctx, "Error: The 'azd logout' command has been removed.") + console.Message(ctx, "Please use 'azd auth logout' instead.") + return fmt.Errorf("unknown command 'logout'") + } + // If unknown flags were found before a non-built-in command, return an error with helpful guidance if len(unknownFlags) > 0 { flagsList := strings.Join(unknownFlags, ", ") diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 40e2375c3c6..093c57b20ee 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -637,7 +637,6 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(containerregistry.NewRemoteBuildManager) container.MustRegisterSingleton(keyvault.NewKeyVaultService) container.MustRegisterSingleton(storage.NewFileShareService) - container.MustRegisterSingleton(azapi.NewSpringService) container.MustRegisterScoped(project.NewContainerHelper) container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*project.ContainerHelper] { diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index c93a7ee8d72..6f63832b4fb 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -136,6 +136,7 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { a.console.Message(ctx, "") } + layer.Mode = provisioning.ModeDestroy if err := a.provisionManager.Initialize(ctx, a.projectConfig.Path, layer); err != nil { return nil, fmt.Errorf("initializing provisioning manager: %w", err) } diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 20918413453..49e15ea9d48 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -108,6 +108,38 @@ func envActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { ActionResolver: newEnvGetValueAction, }) + // Add env config sub-command group + configGroup := group.Add("config", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "config", + Short: "Manage environment configuration (ex: stored in .azure//config.json).", + }, + HelpOptions: actions.ActionHelpOptions{ + Description: getCmdEnvConfigHelpDescription, + Footer: getCmdEnvConfigHelpFooter, + }, + }) + + configGroup.Add("get", &actions.ActionDescriptorOptions{ + Command: newEnvConfigGetCmd(), + FlagsResolver: newEnvConfigGetFlags, + ActionResolver: newEnvConfigGetAction, + OutputFormats: []output.Format{output.JsonFormat}, + DefaultFormat: output.JsonFormat, + }) + + configGroup.Add("set", &actions.ActionDescriptorOptions{ + Command: newEnvConfigSetCmd(), + FlagsResolver: newEnvConfigSetFlags, + ActionResolver: newEnvConfigSetAction, + }) + + configGroup.Add("unset", &actions.ActionDescriptorOptions{ + Command: newEnvConfigUnsetCmd(), + FlagsResolver: newEnvConfigUnsetFlags, + ActionResolver: newEnvConfigUnsetAction, + }) + return group } @@ -697,39 +729,78 @@ func newEnvSetSecretAction( func newEnvSelectCmd() *cobra.Command { return &cobra.Command{ - Use: "select ", + Use: "select []", Short: "Set the default environment.", - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), } } type envSelectAction struct { azdCtx *azdcontext.AzdContext envManager environment.Manager + console input.Console args []string } -func newEnvSelectAction(azdCtx *azdcontext.AzdContext, envManager environment.Manager, args []string) actions.Action { +func newEnvSelectAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + console input.Console, + args []string, +) actions.Action { return &envSelectAction{ azdCtx: azdCtx, envManager: envManager, + console: console, args: args, } } func (e *envSelectAction) Run(ctx context.Context) (*actions.ActionResult, error) { - _, err := e.envManager.Get(ctx, e.args[0]) + var environmentName string + + // If no argument provided, prompt the user to select an environment + if len(e.args) == 0 { + envs, err := e.envManager.List(ctx) + if err != nil { + return nil, fmt.Errorf("listing environments: %w", err) + } + + if len(envs) == 0 { + return nil, fmt.Errorf("no environments found. You can create one with \"azd env new \"") + } + + // Build list of environment names + envNames := make([]string, len(envs)) + for i, env := range envs { + envNames[i] = env.Name + } + + selection, err := e.console.Select(ctx, input.ConsoleOptions{ + Message: "Select an environment:", + Options: envNames, + }) + if err != nil { + return nil, fmt.Errorf("selecting environment: %w", err) + } + + environmentName = envNames[selection] + } else { + environmentName = e.args[0] + } + + _, err := e.envManager.Get(ctx, environmentName) if errors.Is(err, environment.ErrNotFound) { return nil, fmt.Errorf( `environment '%s' does not exist. You can create it with "azd env new %s"`, - e.args[0], - e.args[0], + environmentName, + environmentName, ) } else if err != nil { return nil, fmt.Errorf("ensuring environment exists: %w", err) } - if err := e.azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: e.args[0]}); err != nil { + if err := e.azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: environmentName}); err != nil { return nil, fmt.Errorf("setting default environment: %w", err) } @@ -1311,6 +1382,294 @@ func (eg *envGetValueAction) Run(ctx context.Context) (*actions.ActionResult, er return nil, nil } +// azd env config get + +func newEnvConfigGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Gets a configuration value from the environment.", + Long: "Gets a configuration value from the environment's config.json file.", + Args: cobra.ExactArgs(1), + } +} + +type envConfigGetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigGetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigGetFlags { + flags := &envConfigGetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigGetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigGetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + formatter output.Formatter + writer io.Writer + flags *envConfigGetFlags + args []string +} + +func newEnvConfigGetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + formatter output.Formatter, + writer io.Writer, + flags *envConfigGetFlags, + args []string, +) actions.Action { + return &envConfigGetAction{ + azdCtx: azdCtx, + envManager: envManager, + formatter: formatter, + writer: writer, + flags: flags, + args: args, + } +} + +func (a *envConfigGetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + key := a.args[0] + value, ok := env.Config.Get(key) + + if !ok { + return nil, fmt.Errorf("no value stored at path '%s'", key) + } + + if a.formatter.Kind() == output.JsonFormat { + err := a.formatter.Format(value, a.writer, nil) + if err != nil { + return nil, fmt.Errorf("failing formatting config values: %w", err) + } + } + + return nil, nil +} + +// azd env config set + +func newEnvConfigSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Sets a configuration value in the environment.", + Long: "Sets a configuration value in the environment's config.json file.", + Args: cobra.ExactArgs(2), + Example: `$ azd env config set myapp.endpoint https://example.com +$ azd env config set myapp.debug true`, + } +} + +type envConfigSetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigSetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigSetFlags { + flags := &envConfigSetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigSetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigSetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + flags *envConfigSetFlags + args []string +} + +func newEnvConfigSetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + flags *envConfigSetFlags, + args []string, +) actions.Action { + return &envConfigSetAction{ + azdCtx: azdCtx, + envManager: envManager, + flags: flags, + args: args, + } +} + +func (a *envConfigSetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + path := a.args[0] + value := a.args[1] + + err = env.Config.Set(path, value) + if err != nil { + return nil, fmt.Errorf("failed setting configuration value '%s' to '%s'. %w", path, value, err) + } + + if err := a.envManager.Save(ctx, env); err != nil { + return nil, fmt.Errorf("saving environment: %w", err) + } + + return nil, nil +} + +// azd env config unset + +func newEnvConfigUnsetCmd() *cobra.Command { + return &cobra.Command{ + Use: "unset ", + Short: "Unsets a configuration value in the environment.", + Long: "Removes a configuration value from the environment's config.json file.", + Example: `$ azd env config unset myapp.endpoint`, + Args: cobra.ExactArgs(1), + } +} + +type envConfigUnsetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigUnsetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigUnsetFlags { + flags := &envConfigUnsetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigUnsetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigUnsetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + flags *envConfigUnsetFlags + args []string +} + +func newEnvConfigUnsetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + flags *envConfigUnsetFlags, + args []string, +) actions.Action { + return &envConfigUnsetAction{ + azdCtx: azdCtx, + envManager: envManager, + flags: flags, + args: args, + } +} + +func (a *envConfigUnsetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + path := a.args[0] + + err = env.Config.Unset(path) + if err != nil { + return nil, fmt.Errorf("failed removing configuration with path '%s'. %w", path, err) + } + + if err := a.envManager.Save(ctx, env); err != nil { + return nil, fmt.Errorf("saving environment: %w", err) + } + + return nil, nil +} + +// Help functions for env config commands + +func getCmdEnvConfigHelpDescription(*cobra.Command) string { + return generateCmdHelpDescription( + "Manage environment-specific configuration stored in .azure//config.json.", + []string{ + formatHelpNote("Configuration values set with these commands are specific to the environment."), + formatHelpNote("These values are separate from environment variables (.env file)."), + formatHelpNote( + "Environment configuration is stored in .azure//config.json.", + ), + }) +} + +func getCmdEnvConfigHelpFooter(c *cobra.Command) string { + return generateCmdHelpSamplesBlock(map[string]string{ + "Get a configuration value": fmt.Sprintf("%s %s", + output.WithHighLightFormat("azd env config get"), + output.WithWarningFormat("myapp.endpoint")), + "Set a configuration value": fmt.Sprintf("%s %s %s", + output.WithHighLightFormat("azd env config set"), + output.WithWarningFormat("myapp.endpoint"), + output.WithWarningFormat("https://example.com")), + "Unset a configuration value": fmt.Sprintf("%s %s", + output.WithHighLightFormat("azd env config unset"), + output.WithWarningFormat("myapp.endpoint")), + }) +} + func getCmdEnvHelpDescription(*cobra.Command) string { return generateCmdHelpDescription( "Manage your application environments. With this command group, you can create a new environment or get, set,"+ diff --git a/cli/azd/cmd/env_config_test.go b/cli/azd/cmd/env_config_test.go new file mode 100644 index 00000000000..c259c110c27 --- /dev/null +++ b/cli/azd/cmd/env_config_test.go @@ -0,0 +1,588 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/state" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +// setupTestEnvironment creates a test environment with config data +func setupTestEnvironment(t *testing.T, envName string, configData map[string]any) ( + *azdcontext.AzdContext, + environment.Manager, + string, +) { + tempDir := t.TempDir() + envDir := filepath.Join(tempDir, "project") + require.NoError(t, os.MkdirAll(filepath.Join(envDir, ".azure", envName), 0755)) + + azdCtx := azdcontext.NewAzdContextWithDirectory(envDir) + env := environment.New(envName) + env.Config = config.NewConfig(configData) + + // Create config manager + configManager := config.NewFileConfigManager(config.NewManager()) + localDataStore := environment.NewLocalFileDataStore(azdCtx, configManager) + + // Save environment + err := localDataStore.Save(context.Background(), env, &environment.SaveOptions{IsNew: true}) + require.NoError(t, err) + + // Create mock context and register environment manager + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Container.MustRegisterSingleton(func() *azdcontext.AzdContext { + return azdCtx + }) + mockContext.Container.MustRegisterSingleton(func() environment.LocalDataStore { + return localDataStore + }) + mockContext.Container.MustRegisterSingleton(func() *state.RemoteConfig { + return nil + }) + mockContext.Container.MustRegisterSingleton(environment.NewManager) + + // Get environment manager from container + var envManager environment.Manager + err = mockContext.Container.Resolve(&envManager) + require.NoError(t, err) + + return azdCtx, envManager, envDir +} + +// TestEnvConfigGet tests the azd env config get command +func TestEnvConfigGet(t *testing.T) { + tests := []struct { + name string + configData map[string]any + path string + expectedValue any + expectError bool + errorContains string + }{ + { + name: "GetSimpleValue", + configData: map[string]any{ + "key": "value", + }, + path: "key", + expectedValue: "value", + expectError: false, + }, + { + name: "GetNestedValue", + configData: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + path: "app.endpoint", + expectedValue: "https://example.com", + expectError: false, + }, + { + name: "GetNestedObject", + configData: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + path: "app", + expectedValue: map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + expectError: false, + }, + { + name: "GetNonExistentKey", + configData: map[string]any{ + "key": "value", + }, + path: "nonexistent", + expectError: true, + errorContains: "no value stored at path", + }, + { + name: "GetDeeplyNestedValue", + configData: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + }, + }, + }, + path: "level1.level2.level3", + expectedValue: "deep-value", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.configData) + + // Setup action + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{tt.path}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + + var result any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, tt.expectedValue, result) + } + }) + } +} + +// TestEnvConfigSet tests the azd env config set command +func TestEnvConfigSet(t *testing.T) { + tests := []struct { + name string + initialConfig map[string]any + path string + value string + expectedConfig map[string]any + expectError bool + }{ + { + name: "SetSimpleValue", + initialConfig: map[string]any{}, + path: "key", + value: "value", + expectedConfig: map[string]any{ + "key": "value", + }, + expectError: false, + }, + { + name: "SetNestedValue", + initialConfig: map[string]any{}, + path: "app.endpoint", + value: "https://example.com", + expectedConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + expectError: false, + }, + { + name: "UpdateExistingValue", + initialConfig: map[string]any{ + "key": "old-value", + }, + path: "key", + value: "new-value", + expectedConfig: map[string]any{ + "key": "new-value", + }, + expectError: false, + }, + { + name: "AddToExistingObject", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + path: "app.port", + value: "8080", + expectedConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + expectError: false, + }, + { + name: "SetDeeplyNestedValue", + initialConfig: map[string]any{}, + path: "level1.level2.level3", + value: "deep-value", + expectedConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.initialConfig) + + // Setup action + flags := &envConfigSetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigSetAction( + azdCtx, + envManager, + flags, + []string{tt.path, tt.value}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // Reload environment and verify config + reloadedEnv, err := envManager.Get(context.Background(), envName) + require.NoError(t, err) + + require.Equal(t, tt.expectedConfig, reloadedEnv.Config.Raw()) + } + }) + } +} + +// TestEnvConfigUnset tests the azd env config unset command +func TestEnvConfigUnset(t *testing.T) { + tests := []struct { + name string + initialConfig map[string]any + path string + expectedConfig map[string]any + expectError bool + }{ + { + name: "UnsetSimpleValue", + initialConfig: map[string]any{ + "key": "value", + }, + path: "key", + expectedConfig: map[string]any{}, + expectError: false, + }, + { + name: "UnsetNestedValue", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + path: "app.endpoint", + expectedConfig: map[string]any{ + "app": map[string]any{ + "port": "8080", + }, + }, + expectError: false, + }, + { + name: "UnsetEntireObject", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + "other": "value", + }, + path: "app", + expectedConfig: map[string]any{ + "other": "value", + }, + expectError: false, + }, + { + name: "UnsetNonExistentKey", + initialConfig: map[string]any{ + "key": "value", + }, + path: "nonexistent", + expectedConfig: map[string]any{ + "key": "value", + }, + expectError: false, // Unset is idempotent + }, + { + name: "UnsetDeeplyNestedValue", + initialConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + "other": "keep", + }, + }, + }, + path: "level1.level2.level3", + expectedConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "other": "keep", + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.initialConfig) + + // Setup action + flags := &envConfigUnsetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigUnsetAction( + azdCtx, + envManager, + flags, + []string{tt.path}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // Reload environment and verify config + reloadedEnv, err := envManager.Get(context.Background(), envName) + require.NoError(t, err) + + require.Equal(t, tt.expectedConfig, reloadedEnv.Config.Raw()) + } + }) + } +} + +// TestEnvConfigNonExistentEnvironment tests error handling when environment doesn't exist +func TestEnvConfigNonExistentEnvironment(t *testing.T) { + tempDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(tempDir) + + configManager := config.NewFileConfigManager(config.NewManager()) + localDataStore := environment.NewLocalFileDataStore(azdCtx, configManager) + + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Container.MustRegisterSingleton(func() *azdcontext.AzdContext { + return azdCtx + }) + mockContext.Container.MustRegisterSingleton(func() environment.LocalDataStore { + return localDataStore + }) + mockContext.Container.MustRegisterSingleton(func() *state.RemoteConfig { + return nil + }) + mockContext.Container.MustRegisterSingleton(environment.NewManager) + + var envManager environment.Manager + err := mockContext.Container.Resolve(&envManager) + require.NoError(t, err) + + t.Run("GetWithNonExistentEnv", func(t *testing.T) { + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{"key"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) + + t.Run("SetWithNonExistentEnv", func(t *testing.T) { + flags := &envConfigSetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigSetAction( + azdCtx, + envManager, + flags, + []string{"key", "value"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) + + t.Run("UnsetWithNonExistentEnv", func(t *testing.T) { + flags := &envConfigUnsetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigUnsetAction( + azdCtx, + envManager, + flags, + []string{"key"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) +} + +// TestEnvConfigWithDefaultEnvironment tests commands work with default environment +func TestEnvConfigWithDefaultEnvironment(t *testing.T) { + envName := "default-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, map[string]any{ + "test": "value", + }) + + // Set default environment + err := azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: envName}) + require.NoError(t, err) + + t.Run("GetWithDefaultEnv", func(t *testing.T) { + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = "" // Use default + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{"test"}, + ) + + _, err := action.Run(context.Background()) + require.NoError(t, err) + + var result any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, "value", result) + }) +} + +// TestEnvConfigMultipleOperations tests multiple operations on the same environment +func TestEnvConfigMultipleOperations(t *testing.T) { + envName := "multi-op-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, map[string]any{}) + + // Set multiple values + setFlags1 := &envConfigSetFlags{} + setFlags1.EnvironmentName = envName + setAction1 := newEnvConfigSetAction( + azdCtx, + envManager, + setFlags1, + []string{"app.endpoint", "https://example.com"}, + ) + _, err := setAction1.Run(context.Background()) + require.NoError(t, err) + + setFlags2 := &envConfigSetFlags{} + setFlags2.EnvironmentName = envName + setAction2 := newEnvConfigSetAction( + azdCtx, + envManager, + setFlags2, + []string{"app.port", "8080"}, + ) + _, err = setAction2.Run(context.Background()) + require.NoError(t, err) + + // Verify both values exist + buf := &bytes.Buffer{} + getFlags1 := &envConfigGetFlags{} + getFlags1.EnvironmentName = envName + getAction := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + getFlags1, + []string{"app"}, + ) + _, err = getAction.Run(context.Background()) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, "https://example.com", result["endpoint"]) + require.Equal(t, "8080", result["port"]) + + // Unset one value + unsetFlags := &envConfigUnsetFlags{} + unsetFlags.EnvironmentName = envName + unsetAction := newEnvConfigUnsetAction( + azdCtx, + envManager, + unsetFlags, + []string{"app.endpoint"}, + ) + _, err = unsetAction.Run(context.Background()) + require.NoError(t, err) + + // Verify only port remains + buf = &bytes.Buffer{} + getFlags2 := &envConfigGetFlags{} + getFlags2.EnvironmentName = envName + getAction2 := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + getFlags2, + []string{"app"}, + ) + _, err = getAction2.Run(context.Background()) + require.NoError(t, err) + + var result2 map[string]any + err = json.Unmarshal(buf.Bytes(), &result2) + require.NoError(t, err) + require.Equal(t, map[string]any{"port": "8080"}, result2) +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 7c32e0bebde..ca5cced22c7 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -326,6 +326,7 @@ func newExtensionShowAction( type extensionShowItem struct { Id string + Name string Source string Namespace string Description string @@ -380,9 +381,10 @@ func (t *extensionShowItem) Display(writer io.Writer) error { // Extension Information section extensionInfo := [][]string{ {"Id", ":", t.Id}, + {"Name", ":", t.Name}, + {"Description", ":", t.Description}, {"Source", ":", t.Source}, {"Namespace", ":", t.Namespace}, - {"Description", ":", t.Description}, } if err := writeSection("Extension Information", extensionInfo); err != nil { return err @@ -484,9 +486,10 @@ func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, e extensionDetails := extensionShowItem{ Id: registryExtension.Id, + Name: registryExtension.DisplayName, Source: registryExtension.Source, Namespace: registryExtension.Namespace, - Description: registryExtension.DisplayName, + Description: registryExtension.Description, Tags: registryExtension.Tags, LatestVersion: latestVersion.Version, AvailableVersions: otherVersions, diff --git a/cli/azd/cmd/extension_helpers_test.go b/cli/azd/cmd/extension_helpers_test.go new file mode 100644 index 00000000000..995da4ef70b --- /dev/null +++ b/cli/azd/cmd/extension_helpers_test.go @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/test/azdcli" + "github.com/stretchr/testify/require" +) + +const ( + localSourceName = "local" +) + +// extensionListEntry represents an extension entry returned from the extension list command. +type extensionListEntry struct { + ID string `json:"id"` + Version string `json:"version"` + Source string `json:"source"` +} + +func installAllExtensions(ctx context.Context, t *testing.T, cli *azdcli.CLI, sourceName string) { + t.Helper() + + result, err := cli.RunCommand(ctx, "extension", "list", "--source", sourceName, "--output", "json") + require.NoError(t, err, "failed to list extensions from source %s", sourceName) + + var extensions []extensionListEntry + err = json.Unmarshal([]byte(result.Stdout), &extensions) + require.NoError(t, err, "failed to unmarshal extension list") + + if len(extensions) == 0 { + t.Logf("No extensions found in source %s to install", sourceName) + return + } + + for _, ext := range extensions { + args := []string{"extension", "install", ext.ID, "--source", sourceName} + if ext.Version != "" { + args = append(args, "--version", ext.Version) + } + + t.Logf("Installing extension %s@%s", ext.ID, ext.Version) + _, err := cli.RunCommand(ctx, args...) + require.NoErrorf(t, err, "failed to install extension %s from source %s", ext.ID, sourceName) + } +} + +func uninstallAllExtensions(ctx context.Context, t *testing.T, cli *azdcli.CLI) { + t.Helper() + + t.Log("Uninstalling all extensions") + if _, err := cli.RunCommand(ctx, "extension", "uninstall", "--all"); err != nil { + t.Logf("warning: failed to uninstall extensions: %v", err) + } +} + +func addLocalRegistrySource(ctx context.Context, t *testing.T, cli *azdcli.CLI) string { + t.Helper() + + registryPath := filepath.Join(azdcli.GetSourcePath(), "extensions", "registry.json") + t.Logf("Adding local registry source '%s' from %s", localSourceName, registryPath) + _, err := cli.RunCommand( + ctx, + "extension", "source", "add", + "-n", localSourceName, + "-t", "file", + "-l", registryPath, + ) + require.NoError(t, err, "failed to add local registry source") + return localSourceName +} + +func removeLocalExtensionSource(ctx context.Context, t *testing.T, cli *azdcli.CLI) { + t.Helper() + + if _, err := cli.RunCommand(ctx, "extension", "source", "remove", localSourceName); err != nil { + t.Logf("warning: failed to remove extension source %s: %v", localSourceName, err) + } +} diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 2944b516788..0b2a58ac3a2 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -176,12 +176,16 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error fmt.Sprintf("AZD_ACCESS_TOKEN=%s", jwtToken), ) + // Propagate trace context to the extension process + if traceEnv := tracing.Environ(ctx); len(traceEnv) > 0 { + allEnv = append(allEnv, traceEnv...) + } + options := &extensions.InvokeOptions{ - Args: a.args, - Env: allEnv, - StdIn: a.console.Handles().Stdin, - StdOut: a.console.Handles().Stdout, - StdErr: a.console.Handles().Stderr, + Args: a.args, + Env: allEnv, + // cmd extensions are always interactive (connected to terminal) + Interactive: true, } _, err = a.extensionRunner.Invoke(ctx, extension, options) diff --git a/cli/azd/cmd/figspec_test.go b/cli/azd/cmd/figspec_test.go index 0a613bd9e37..127b80d7718 100644 --- a/cli/azd/cmd/figspec_test.go +++ b/cli/azd/cmd/figspec_test.go @@ -4,9 +4,12 @@ package cmd import ( + "context" "testing" + "time" "github.com/azure/azure-dev/cli/azd/internal/figspec" + "github.com/azure/azure-dev/cli/azd/test/azdcli" "github.com/azure/azure-dev/cli/azd/test/snapshot" "github.com/stretchr/testify/require" ) @@ -22,6 +25,26 @@ import ( // For Pwsh, // $env:UPDATE_SNAPSHOTS='true'; go test ./cmd -run TestFigSpec; $env:UPDATE_SNAPSHOTS=$null func TestFigSpec(t *testing.T) { + configDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", configDir) + t.Setenv("AZURE_DEV_COLLECT_TELEMETRY", "no") + + cli := azdcli.NewCLI(t) + + sourceName := addLocalRegistrySource(t.Context(), t, cli) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + removeLocalExtensionSource(ctx, t, cli) + }) + + installAllExtensions(t.Context(), t, cli, sourceName) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + uninstallAllExtensions(ctx, t, cli) + }) + root := NewRootCmd(false, nil, nil) builder := figspec.NewSpecBuilder(false) diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index a32399dc415..827a7064555 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -5,6 +5,7 @@ package middleware import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -12,7 +13,6 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/agent" - "github.com/azure/azure-dev/cli/azd/internal/agent/feedback" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/events" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" @@ -53,19 +53,35 @@ func NewErrorMiddleware( } } -func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { - actionResult, err := next(ctx) +func (e *ErrorMiddleware) displayAgentResponse(ctx context.Context, response string, disclaimer string) { + if response != "" { + e.console.Message(ctx, disclaimer) + e.console.Message(ctx, "") + e.console.Message(ctx, fmt.Sprintf("%s:", output.AzdAgentLabel())) + e.console.Message(ctx, output.WithMarkdown(response)) + e.console.Message(ctx, "") + } +} +func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { // Short-circuit agentic error handling in non-interactive scenarios: // - LLM feature is disabled // - User specified --no-prompt (non-interactive mode) // - Running in CI/CD environment where user interaction is not possible if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() { - return actionResult, err + return next(ctx) } + // Preserve a non-cancellable parent context BEFORE making the first attempt + // This ensures that if the context gets cancelled during the first attempt, + // retries can use a fresh context + parentCtx := context.WithoutCancel(ctx) + + actionResult, err := next(parentCtx) + // Stop the spinner always to un-hide cursor e.console.StopSpinner(ctx, "", input.Step) + if err == nil || e.options.IsChildAction(ctx) { return actionResult, err } @@ -92,6 +108,7 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action // Warn user that this is an alpha feature e.console.WarnForFeature(ctx, llm.FeatureLlm) + ctx, span := tracing.Start(ctx, events.AgentTroubleshootEvent) defer span.End() @@ -160,31 +177,25 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action `Steps to follow: 1. Use available tool including azd_error_troubleshooting tool to identify and explain the error. Diagnose its root cause when running azd command. - 2. Provide actionable troubleshooting steps. Do not perform any file changes. + 2. Provide actionable troubleshooting steps in natural language format with clear sections. + DO NOT return JSON. Use readable narrative text with markdown formatting. + Do not perform any file changes. Error details: %s`, errorInput)) if err != nil { - if agentOutput != "" { - e.console.Message(ctx, AIDisclaimer) - e.console.Message(ctx, output.WithMarkdown(agentOutput)) - } - + e.displayAgentResponse(ctx, agentOutput, AIDisclaimer) span.SetStatus(codes.Error, "agent.send_message.failed") return nil, err } - e.console.Message(ctx, AIDisclaimer) - e.console.Message(ctx, "") - e.console.Message(ctx, fmt.Sprintf("%s:", output.AzdAgentLabel())) - e.console.Message(ctx, output.WithMarkdown(agentOutput)) - e.console.Message(ctx, "") + e.displayAgentResponse(ctx, agentOutput, AIDisclaimer) } - // Ask user if they want to let AI fix the + // Ask user if they want to let AI fix the error confirmFix, err := e.checkErrorHandlingConsent( ctx, "mcp.errorHandling.fix", - fmt.Sprintf("Fix this error using %s?", agentName), + fmt.Sprintf("Brainstorm solutions using %s?", agentName), fmt.Sprintf("This action will run AI tools to help fix the error."+ " Edit permissions for AI tools anytime by running %s.", output.WithHighLightFormat("azd mcp consent")), @@ -207,55 +218,90 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action previousError = originalError agentOutput, err := azdAgent.SendMessage(ctx, fmt.Sprintf( `Steps to follow: - 1. Use available tool to identify, explain and diagnose this error when running azd command and its root cause. - 2. Resolve the error by making the minimal, targeted change required to the code or configuration. - Avoid unnecessary modifications and focus only on what is essential to restore correct functionality. - 3. Remove any changes that were created solely for validation and are not part of the actual error fix. - Error details: %s`, errorInput)) + 1. Use available tools to identify, explain and diagnose this error when running azd command and its root cause. + 2. Only return a JSON object in the following format: + { + "analysis": "Brief explanation of the error and its root cause", + "solutions": [ + "Solution 1 Short description (one sentence)", + "Solution 2 Short description (one sentence)", + "Solution 3 Short description (one sentence)" + ] + } + Provide 1-3 solutions. Each solution must be concise (one sentence). + Error details: %s`, errorInput)) + + // Extract solutions from agent output even if there's a parsing error + // The agent may return valid content + solutions := extractSuggestedSolutions(agentOutput) + + // Only fail if we got an error AND couldn't extract any solutions + if err != nil && len(solutions) == 0 { + e.displayAgentResponse(ctx, agentOutput, AIDisclaimer) + span.SetStatus(codes.Error, "agent.send_message.failed") + return nil, fmt.Errorf("failed to generate solutions: %w", err) + } + e.console.Message(ctx, "") + selectedSolution, continueWithFix, err := promptUserForSolution(ctx, solutions, agentName) if err != nil { - if agentOutput != "" { - e.console.Message(ctx, AIDisclaimer) - e.console.Message(ctx, output.WithMarkdown(agentOutput)) - } - - span.SetStatus(codes.Error, "agent.send_message.failed") - return nil, err + return nil, fmt.Errorf("prompting for solution selection: %w", err) } - // Ask the user to add feedback - if err := e.collectAndApplyFeedback(ctx, azdAgent, AIDisclaimer); err != nil { - span.SetStatus(codes.Error, "agent.collect_feedback.failed") - return nil, err + if continueWithFix { + agentOutput, err := azdAgent.SendMessage(ctx, fmt.Sprintf( + `Steps to follow: + 1. Use available tools to identify, explain and diagnose this error when running azd command and its root cause. + 2. Resolve the error by making the minimal, targeted change required to the code or configuration. + Avoid unnecessary modifications and focus only on what is essential to restore correct functionality. + 3. Remove any changes that were created solely for validation and are not part of the actual error fix. + 4. You are currently in the middle of executing '%s'. Never run this command. + Error details: %s`, e.options.CommandPath, errorInput)) + + if err != nil { + e.displayAgentResponse(ctx, agentOutput, AIDisclaimer) + span.SetStatus(codes.Error, "agent.send_message.failed") + return nil, err + } + + span.SetStatus(codes.Ok, "agent.fix.agent") + } else { + if selectedSolution != "" { + // User selected a solution + agentOutput, err = azdAgent.SendMessage(ctx, fmt.Sprintf( + `Steps to follow: + 1. Perform the following actions to resolve the error: %s. + During this, make minimal changes and avoid unnecessary modifications. + 2. Remove any changes that were created solely for validation and + are not part of the actual error fix. + 3. You are currently in the middle of executing '%s'. Never run this command. + Error details: %s`, selectedSolution, e.options.CommandPath, errorInput)) + + if err != nil { + e.displayAgentResponse(ctx, agentOutput, AIDisclaimer) + span.SetStatus(codes.Error, "agent.send_message.failed") + return nil, err + } + span.SetStatus(codes.Ok, "agent.fix.solution") + } else { + // User selected cancel + span.SetStatus(codes.Error, "agent.fix.cancelled") + return actionResult, originalError + } } + // Use a fresh child context per retry to avoid reusing a canceled ctx + attemptCtx, cancel := context.WithCancel(parentCtx) // Clear check cache to prevent skip of tool related error - ctx = tools.WithInstalledCheckCache(ctx) - - actionResult, err = next(ctx) + attemptCtx = tools.WithInstalledCheckCache(attemptCtx) + actionResult, err = next(attemptCtx) + cancel() originalError = err } return actionResult, err } -// collectAndApplyFeedback prompts for user feedback and applies it using the agent -func (e *ErrorMiddleware) collectAndApplyFeedback( - ctx context.Context, - azdAgent agent.Agent, - AIDisclaimer string, -) error { - collector := feedback.NewFeedbackCollector(e.console, feedback.FeedbackCollectorOptions{ - EnableLoop: false, - FeedbackPrompt: "Any changes you'd like to make?", - FeedbackHint: "Describe your changes or press enter to skip.", - RequireFeedback: false, - AIDisclaimer: AIDisclaimer, - }) - - return collector.CollectFeedbackAndApply(ctx, azdAgent, AIDisclaimer) -} - func (e *ErrorMiddleware) checkErrorHandlingConsent( ctx context.Context, promptName string, @@ -326,7 +372,7 @@ func promptForErrorHandlingConsent( HelpMessage: helpMessage, Choices: choices, EnableFiltering: uxlib.Ptr(false), - DisplayCount: 5, + DisplayCount: len(choices), }) choiceIndex, err := selector.Ask(ctx) @@ -340,3 +386,76 @@ func promptForErrorHandlingConsent( return choices[*choiceIndex].Value, nil } + +// AgentResponse represents the structured JSON response from the LLM agent +type AgentResponse struct { + Analysis string `json:"analysis"` + Solutions []string `json:"solutions"` +} + +// extractSuggestedSolutions extracts solutions from the LLM response. +// It expects a JSON response with the structure: {"analysis": "...", "solutions": ["...", "...", "..."]} +// If JSON parsing fails, it returns an empty slice. +func extractSuggestedSolutions(llmResponse string) []string { + var response AgentResponse + if err := json.Unmarshal([]byte(llmResponse), &response); err != nil { + return []string{} + } + + return response.Solutions +} + +// promptUserForSolution displays extracted solutions to the user and prompts them to select which solution to try. +// Returns the selected solution text, a flag indicating if user wants to continue with AI fix, and error if any. +func promptUserForSolution(ctx context.Context, solutions []string, agentName string) (string, bool, error) { + choices := make([]*uxlib.SelectChoice, len(solutions)+2) + + if len(solutions) > 0 { + // Add the three solutions + for i, solution := range solutions { + choices[i] = &uxlib.SelectChoice{ + Value: solution, + Label: "Yes. " + solution, + } + } + } + + choices[len(solutions)] = &uxlib.SelectChoice{ + Value: "continue", + Label: fmt.Sprintf("Yes, let %s choose the best approach", agentName), + } + + choices[len(solutions)+1] = &uxlib.SelectChoice{ + Value: "cancel", + Label: "No, cancel", + } + + selector := uxlib.NewSelect(&uxlib.SelectOptions{ + Message: fmt.Sprintf("Allow %s to fix the error?", agentName), + HelpMessage: "Select a suggested fix, or let AI decide", + Choices: choices, + EnableFiltering: uxlib.Ptr(false), + DisplayCount: len(choices), + }) + + choiceIndex, err := selector.Ask(ctx) + if err != nil { + return "", false, err + } + + if choiceIndex == nil || *choiceIndex < 0 || *choiceIndex >= len(choices) { + return "", false, fmt.Errorf("invalid choice selected") + } + + selectedValue := choices[*choiceIndex].Value + + // Handle different selections + switch selectedValue { + case "continue": + return "", true, nil // Continue to AI fix + case "cancel": + return "", false, nil // Cancel and return error + default: + return selectedValue, false, nil // User selected a solution + } +} diff --git a/cli/azd/cmd/middleware/error_test.go b/cli/azd/cmd/middleware/error_test.go new file mode 100644 index 00000000000..4c3bbd75c4c --- /dev/null +++ b/cli/azd/cmd/middleware/error_test.go @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package middleware + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/llm" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +func Test_ErrorMiddleware_SuccessNoError(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewConfig(map[string]any{ + "alpha": map[string]any{ + string(llm.FeatureLlm): "on", + }, + }) + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: false, + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, // agentFactory not needed for success case + global, + featureManager, + userConfigManager, + ) + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: "Success", + }, + }, nil + } + + result, err := middleware.Run(*mockContext.Context, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "Success", result.Message.Header) +} + +func Test_ErrorMiddleware_LLMAlphaFeatureDisabled(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewEmptyConfig() + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: false, + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, + global, + featureManager, + userConfigManager, + ) + + testError := errors.New("test error") + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return nil, testError + } + + result, err := middleware.Run(*mockContext.Context, nextFn) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, testError, err) +} + +func Test_ErrorMiddleware_NoPromptMode(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewConfig(map[string]any{ + "alpha": map[string]any{ + string(llm.FeatureLlm): "on", + }, + }) + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: true, // Non-interactive mode + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, + global, + featureManager, + userConfigManager, + ) + + testError := errors.New("test error") + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return nil, testError + } + + result, err := middleware.Run(*mockContext.Context, nextFn) + + // Should return error without AI intervention in no-prompt mode + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, testError, err) +} + +func Test_ErrorMiddleware_ChildAction(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewConfig(map[string]any{ + "alpha": map[string]any{ + string(llm.FeatureLlm): "on", + }, + }) + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: false, + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, + global, + featureManager, + userConfigManager, + ) + testError := errors.New("test error") + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return nil, testError + } + + // Mark context as child action + ctx := WithChildAction(*mockContext.Context) + result, err := middleware.Run(ctx, nextFn) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, testError, err) +} + +func Test_ErrorMiddleware_ErrorWithSuggestion(t *testing.T) { + if os.Getenv("TF_BUILD") != "" || os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("CI") != "" { + t.Skip("Skipping test in CI/CD environment") + } + + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewConfig(map[string]any{ + "alpha": map[string]any{ + string(llm.FeatureLlm): "on", + }, + }) + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: false, + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, + global, + featureManager, + userConfigManager, + ) + + // Create error with suggestion + testErr := errors.New("test error") + suggestionErr := &internal.ErrorWithSuggestion{ + Err: testErr, + Suggestion: "Suggested fix", + } + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return nil, suggestionErr + } + + result, err := middleware.Run(*mockContext.Context, nextFn) + + require.Error(t, err) + require.Nil(t, result) + + // Check that suggestion was displayed + consoleOutput := mockContext.Console.Output() + foundSuggestion := false + for _, message := range consoleOutput { + if message == "Suggested fix" { + foundSuggestion = true + break + } + } + require.True(t, foundSuggestion, "No suggestion displayed for ErrorWithSuggestion") +} + +func Test_ExtractSuggestedSolutions(t *testing.T) { + tests := []struct { + name string + llmResponse string + expectedCount int + expectedFirst string + }{ + { + name: "Valid JSON with Three Solutions", + llmResponse: `{ + "analysis": "Brief explanation of the error", + "solutions": [ + "Log out and log in again with Azure Developer CLI", + "Check and fix your network environment", + "Retry after reboot or from a clean terminal" + ] + }`, + expectedCount: 3, + expectedFirst: "Log out and log in again with Azure Developer CLI", + }, + { + name: "Valid JSON with One Solution", + llmResponse: `{ + "analysis": "Error analysis", + "solutions": [ + "Only one solution" + ] + }`, + expectedCount: 1, + expectedFirst: "Only one solution", + }, + { + name: "Valid JSON with Two Solutions", + llmResponse: `{ + "analysis": "Error analysis", + "solutions": [ + "First solution", + "Second solution" + ] + }`, + expectedCount: 2, + expectedFirst: "First solution", + }, + { + name: "Invalid JSON", + llmResponse: `This is not valid JSON at all`, + expectedCount: 0, + }, + { + name: "JSON with Empty Solutions Array", + llmResponse: `{ + "analysis": "Error analysis", + "solutions": [] + }`, + expectedCount: 0, + }, + { + name: "JSON Missing Solutions Field", + llmResponse: `{ + "analysis": "Error analysis only" + }`, + expectedCount: 0, + }, + { + name: "JSON with Extra Whitespace", + llmResponse: ` { + "analysis": "Error analysis", + "solutions": [ + " First solution with spaces ", + " Second solution " + ] + } `, + expectedCount: 2, + expectedFirst: " First solution with spaces ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + solutions := extractSuggestedSolutions(tt.llmResponse) + require.Equal(t, tt.expectedCount, len(solutions)) + + if tt.expectedCount > 0 { + require.Equal(t, tt.expectedFirst, solutions[0]) + } + }) + } +} diff --git a/cli/azd/cmd/middleware/extensions.go b/cli/azd/cmd/middleware/extensions.go index 85768d9f828..40149f7613b 100644 --- a/cli/azd/cmd/middleware/extensions.go +++ b/cli/azd/cmd/middleware/extensions.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal/grpcserver" + "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" @@ -132,8 +133,18 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A allEnv = append(allEnv, "FORCE_COLOR=1") } + // Propagate trace context to the extension process + if traceEnv := tracing.Environ(ctx); len(traceEnv) > 0 { + allEnv = append(allEnv, traceEnv...) + } + + args := []string{"listen"} + if debugEnabled, _ := m.options.Flags.GetBool("debug"); debugEnabled { + args = append(args, "--debug") + } + options := &extensions.InvokeOptions{ - Args: []string{"listen"}, + Args: args, Env: allEnv, StdIn: ext.StdIn(), StdOut: ext.StdOut(), diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index 1d2a657c815..fdcaa9257d1 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -20,8 +20,8 @@ import ( ) type HooksMiddleware struct { - lazyEnvManager *lazy.Lazy[environment.Manager] - lazyEnv *lazy.Lazy[*environment.Environment] + envManager environment.Manager + env *environment.Environment lazyProjectConfig *lazy.Lazy[*project.ProjectConfig] importManager *project.ImportManager commandRunner exec.CommandRunner @@ -32,8 +32,8 @@ type HooksMiddleware struct { // Creates a new instance of the Hooks middleware func NewHooksMiddleware( - lazyEnvManager *lazy.Lazy[environment.Manager], - lazyEnv *lazy.Lazy[*environment.Environment], + envManager environment.Manager, + env *environment.Environment, lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], importManager *project.ImportManager, commandRunner exec.CommandRunner, @@ -42,8 +42,8 @@ func NewHooksMiddleware( serviceLocator ioc.ServiceLocator, ) Middleware { return &HooksMiddleware{ - lazyEnvManager: lazyEnvManager, - lazyEnv: lazyEnv, + envManager: envManager, + env: env, lazyProjectConfig: lazyProjectConfig, importManager: importManager, commandRunner: commandRunner, @@ -55,12 +55,6 @@ func NewHooksMiddleware( // Runs the Hooks middleware func (m *HooksMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { - env, err := m.lazyEnv.GetValue() - if err != nil { - log.Println("azd environment is not available, skipping all hook registrations.") - return next(ctx) - } - projectConfig, err := m.lazyProjectConfig.GetValue() if err != nil || projectConfig == nil { log.Println("azd project is not available, skipping all hook registrations.") @@ -74,42 +68,37 @@ func (m *HooksMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action } } - if err := m.registerServiceHooks(ctx, env, projectConfig); err != nil { + if err := m.registerServiceHooks(ctx, projectConfig); err != nil { return nil, fmt.Errorf("failed registering service hooks, %w", err) } - return m.registerCommandHooks(ctx, env, projectConfig, next) + return m.registerCommandHooks(ctx, projectConfig, next) } // Register command level hooks for the executing cobra command & action // Invokes the middleware next function + func (m *HooksMiddleware) registerCommandHooks( ctx context.Context, - env *environment.Environment, projectConfig *project.ProjectConfig, next NextFn, ) (*actions.ActionResult, error) { if len(projectConfig.Hooks) == 0 { log.Println( - "azd project is not available or does not contain any command hooks, skipping command hook registrations.", + "azd project does not contain any command hooks, skipping command hook registrations.", ) return next(ctx) } - envManager, err := m.lazyEnvManager.GetValue() - if err != nil { - return nil, fmt.Errorf("failed getting environment manager, %w", err) - } - hooksManager := ext.NewHooksManager(projectConfig.Path, m.commandRunner) hooksRunner := ext.NewHooksRunner( hooksManager, m.commandRunner, - envManager, + m.envManager, m.console, projectConfig.Path, projectConfig.Hooks, - env, + m.env, m.serviceLocator, ) @@ -118,7 +107,7 @@ func (m *HooksMiddleware) registerCommandHooks( commandNames := []string{m.options.CommandPath} commandNames = append(commandNames, m.options.Aliases...) - err = hooksRunner.Invoke(ctx, commandNames, func() error { + err := hooksRunner.Invoke(ctx, commandNames, func() error { result, err := next(ctx) if err != nil { return err @@ -139,14 +128,8 @@ func (m *HooksMiddleware) registerCommandHooks( // Runs hooks for each matching event handler func (m *HooksMiddleware) registerServiceHooks( ctx context.Context, - env *environment.Environment, projectConfig *project.ProjectConfig, ) error { - envManager, err := m.lazyEnvManager.GetValue() - if err != nil { - return fmt.Errorf("failed getting environment manager, %w", err) - } - stableServices, err := m.importManager.ServiceStable(ctx, projectConfig) if err != nil { return fmt.Errorf("failed getting services: %w", err) @@ -164,11 +147,11 @@ func (m *HooksMiddleware) registerServiceHooks( serviceHooksRunner := ext.NewHooksRunner( serviceHooksManager, m.commandRunner, - envManager, + m.envManager, m.console, service.Path(), service.Hooks, - env, + m.env, m.serviceLocator, ) diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index b1c350f4b35..5849ac039ab 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -289,6 +289,34 @@ func Test_ServiceHooks_Registered(t *testing.T) { require.Equal(t, 1, preDeployCount) } +func Test_HooksMiddleware_SkipsWhenProjectUnavailable(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + envManager := &mockenv.MockEnvManager{} + env := environment.New("test") + + lazyProjectConfig := lazy.NewLazy(func() (*project.ProjectConfig, error) { + return nil, errors.New("failed to load project") + }) + + middleware := NewHooksMiddleware( + envManager, + env, + lazyProjectConfig, + project.NewImportManager(nil), + mockContext.CommandRunner, + mockContext.Console, + &Options{CommandPath: "command"}, + mockContext.Container, + ) + + nextFn, actionRan := createNextFn() + result, err := middleware.Run(*mockContext.Context, nextFn) + + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, *actionRan) +} + func createAzdContext(t *testing.T) *azdcontext.AzdContext { tempDir := t.TempDir() ostest.Chdir(t, tempDir) @@ -344,21 +372,11 @@ func runMiddleware( envManager.On("Save", mock.Anything, mock.Anything).Return(nil) envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) - lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { - return envManager, nil - }) - - lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { - return env, nil - }) - - lazyProjectConfig := lazy.NewLazy(func() (*project.ProjectConfig, error) { - return projectConfig, nil - }) + lazyProjectConfig := lazy.From(projectConfig) middleware := NewHooksMiddleware( - lazyEnvManager, - lazyEnv, + envManager, + env, lazyProjectConfig, project.NewImportManager(nil), mockContext.CommandRunner, diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index c317f7f99ba..2c214b6fbc3 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -26,6 +26,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/cmd/show" "github.com/azure/azure-dev/cli/azd/internal/telemetry" "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/ux" "github.com/spf13/cobra" ) @@ -80,6 +81,44 @@ func NewRootCmd( prevDir = current + // Check if the directory exists + if _, err := os.Stat(opts.Cwd); os.IsNotExist(err) { + // Directory doesn't exist, prompt user to create it + shouldCreate := true // Default to Yes + + if !opts.NoPrompt { + // Prompt the user + defaultValue := true + confirm := ux.NewConfirm(&ux.ConfirmOptions{ + Message: fmt.Sprintf( + "Directory '%s' does not exist. Would you like to create it?", + opts.Cwd, + ), + DefaultValue: &defaultValue, + }) + + result, err := confirm.Ask(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to prompt for directory creation: %w", err) + } + + if result == nil { + return fmt.Errorf("no response received for directory creation prompt") + } + + shouldCreate = *result + } + + if !shouldCreate { + return fmt.Errorf("directory '%s' does not exist and creation was declined", opts.Cwd) + } + + // Create the directory + if err := os.MkdirAll(opts.Cwd, 0755); err != nil { + return fmt.Errorf("failed to create directory '%s': %w", opts.Cwd, err) + } + } + if err := os.Chdir(opts.Cwd); err != nil { return fmt.Errorf("failed to change directory to %s: %w", opts.Cwd, err) } @@ -164,25 +203,6 @@ func NewRootCmd( }). UseMiddleware("extensions", middleware.NewExtensionsMiddleware) - //deprecate:cmd hide login - login := newLoginCmd("") - login.Hidden = true - root.Add("login", &actions.ActionDescriptorOptions{ - Command: login, - FlagsResolver: newLoginFlags, - ActionResolver: newLoginAction, - OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, - DefaultFormat: output.NoneFormat, - }) - - //deprecate:cmd hide logout - logout := newLogoutCmd("") - logout.Hidden = true - root.Add("logout", &actions.ActionDescriptorOptions{ - Command: logout, - ActionResolver: newLogoutAction, - }) - root.Add("init", &actions.ActionDescriptorOptions{ Command: newInitCmd(), FlagsResolver: newInitFlags, @@ -248,13 +268,7 @@ func NewRootCmd( } return true }). - UseMiddlewareWhen("extensions", middleware.NewExtensionsMiddleware, func(descriptor *actions.ActionDescriptor) bool { - if onPreview, _ := descriptor.Options.Command.Flags().GetBool("preview"); onPreview { - log.Println("Skipping provision hooks due to preview flag.") - return false - } - return true - }) + UseMiddleware("extensions", middleware.NewExtensionsMiddleware) root. Add("package", &actions.ActionDescriptorOptions{ diff --git a/cli/azd/cmd/templates.go b/cli/azd/cmd/templates.go index 90a253627cb..3a3e66b198f 100644 --- a/cli/azd/cmd/templates.go +++ b/cli/azd/cmd/templates.go @@ -131,6 +131,12 @@ func (tl *templateListAction) Run(ctx context.Context) (*actions.ActionResult, e err = tl.formatter.Format(listedTemplates, tl.writer, output.TableFormatterOptions{ Columns: columns, }) + + if err == nil { + templates.PrintGalleryLinks(tl.writer) + fmt.Fprintf(tl.writer, "Select a template from the gallery, then run %s\n", + output.WithHighLightFormat("azd init -t