From 03774f79a937d92e05fc767150815680284b694f Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 5 Apr 2026 19:20:05 -0700 Subject: [PATCH 1/3] feat: add microsoft.azd.exec extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/lint-ext-microsoft-azd-exec.yml | 22 + cli/azd/.vscode/cspell.yaml | 7 + .../extensions/microsoft.azd.exec/.gitignore | 5 + .../microsoft.azd.exec/.golangci.yaml | 17 + .../microsoft.azd.exec/CHANGELOG.md | 9 + .../extensions/microsoft.azd.exec/README.md | 78 +++ .../extensions/microsoft.azd.exec/build.ps1 | 73 +++ .../extensions/microsoft.azd.exec/build.sh | 65 +++ .../microsoft.azd.exec/ci-build.ps1 | 86 ++++ .../microsoft.azd.exec/extension.yaml | 11 + cli/azd/extensions/microsoft.azd.exec/go.mod | 104 ++++ cli/azd/extensions/microsoft.azd.exec/go.sum | 309 ++++++++++++ .../microsoft.azd.exec/internal/cmd/root.go | 120 +++++ .../internal/cmd/root_test.go | 133 +++++ .../internal/executor/command_builder.go | 132 +++++ .../executor/command_builder_notwindows.go | 11 + .../internal/executor/command_builder_test.go | 190 +++++++ .../executor/command_builder_windows.go | 31 ++ .../internal/executor/errors.go | 55 ++ .../internal/executor/errors_test.go | 119 +++++ .../internal/executor/executor.go | 183 +++++++ .../internal/executor/executor_test.go | 477 ++++++++++++++++++ .../internal/shellutil/shellutil.go | 68 +++ .../internal/shellutil/shellutil_test.go | 95 ++++ cli/azd/extensions/microsoft.azd.exec/main.go | 25 + .../extensions/microsoft.azd.exec/version.txt | 1 + cli/azd/pkg/azdext/run.go | 21 +- cli/azd/pkg/azdext/run_coverage_test.go | 49 ++ .../release-ext-microsoft-azd-exec.yml | 33 ++ 29 files changed, 2528 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lint-ext-microsoft-azd-exec.yml create mode 100644 cli/azd/extensions/microsoft.azd.exec/.gitignore create mode 100644 cli/azd/extensions/microsoft.azd.exec/.golangci.yaml create mode 100644 cli/azd/extensions/microsoft.azd.exec/CHANGELOG.md create mode 100644 cli/azd/extensions/microsoft.azd.exec/README.md create mode 100644 cli/azd/extensions/microsoft.azd.exec/build.ps1 create mode 100644 cli/azd/extensions/microsoft.azd.exec/build.sh create mode 100644 cli/azd/extensions/microsoft.azd.exec/ci-build.ps1 create mode 100644 cli/azd/extensions/microsoft.azd.exec/extension.yaml create mode 100644 cli/azd/extensions/microsoft.azd.exec/go.mod create mode 100644 cli/azd/extensions/microsoft.azd.exec/go.sum create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/cmd/root.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/cmd/root_test.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_notwindows.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_test.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_windows.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/errors.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/errors_test.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/executor.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/executor/executor_test.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil_test.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/main.go create mode 100644 cli/azd/extensions/microsoft.azd.exec/version.txt create mode 100644 eng/pipelines/release-ext-microsoft-azd-exec.yml diff --git a/.github/workflows/lint-ext-microsoft-azd-exec.yml b/.github/workflows/lint-ext-microsoft-azd-exec.yml new file mode 100644 index 00000000000..d2fbe18c867 --- /dev/null +++ b/.github/workflows/lint-ext-microsoft-azd-exec.yml @@ -0,0 +1,22 @@ +name: ext-microsoft-azd-exec-ci + +on: + pull_request: + paths: + - "cli/azd/extensions/microsoft.azd.exec/**" + - ".github/workflows/lint-ext-microsoft-azd-exec.yml" + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + lint: + uses: ./.github/workflows/lint-go.yml + with: + working-directory: cli/azd/extensions/microsoft.azd.exec diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index ed554c08502..8a5df7bac39 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -371,6 +371,13 @@ overrides: - filename: pkg/infra/provisioning/bicep/local_preflight.go words: - actioned + - filename: docs/code-coverage-guide.md + words: + - covdata + - GOWORK + - filename: extensions/microsoft.azd.exec/**/*.go + words: + - shellutil ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/extensions/microsoft.azd.exec/.gitignore b/cli/azd/extensions/microsoft.azd.exec/.gitignore new file mode 100644 index 00000000000..e74f8c65472 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/.gitignore @@ -0,0 +1,5 @@ +bin/ +*.exe +coverage +coverage.out +cov diff --git a/cli/azd/extensions/microsoft.azd.exec/.golangci.yaml b/cli/azd/extensions/microsoft.azd.exec/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/microsoft.azd.exec/CHANGELOG.md b/cli/azd/extensions/microsoft.azd.exec/CHANGELOG.md new file mode 100644 index 00000000000..5dfe1db604d --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.5.0 + +- Initial release as `microsoft.azd.exec` in the Azure/azure-dev repository. +- Execute scripts and inline commands with full azd environment context. +- Cross-platform shell detection and execution (bash, sh, zsh, pwsh, powershell, cmd). +- Interactive mode for scripts requiring stdin. +- Child process exit code propagation for CI/CD pipelines. diff --git a/cli/azd/extensions/microsoft.azd.exec/README.md b/cli/azd/extensions/microsoft.azd.exec/README.md new file mode 100644 index 00000000000..29bc257cace --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/README.md @@ -0,0 +1,78 @@ +# microsoft.azd.exec + +Execute scripts and commands with Azure Developer CLI context and environment variables. + +## Installation + +```bash +azd extension install microsoft.azd.exec +``` + +## Usage + +```bash +# Run a command directly with azd environment (exact argv, no shell wrapping) +azd exec python script.py +azd exec npm run dev +azd exec -- python app.py --port 8000 --reload +azd exec docker compose up --build + +# Execute a script file with azd environment +azd exec ./setup.sh +azd exec ./build.sh -- --verbose + +# Inline shell command (single quoted argument uses shell) +azd exec 'echo $AZURE_ENV_NAME' +azd exec --shell pwsh "Write-Host $env:AZURE_ENV_NAME" + +# Interactive mode +azd exec -i ./interactive-setup.sh +``` + +## Execution Modes + +| Invocation | Mode | How it works | +|---|---|---| +| `azd exec python script.py` | **Direct exec** | `exec.Command("python", "script.py")` — exact argv, no shell | +| `azd exec 'echo $VAR'` | **Shell inline** | `bash -c "echo $VAR"` — shell expansion available | +| `azd exec ./setup.sh` | **Script file** | `bash ./setup.sh` — shell detected from extension | +| `azd exec --shell pwsh "cmd"` | **Shell inline** | `pwsh -Command "cmd"` — explicit shell | + +**Heuristic**: Multiple arguments without `--shell` → direct process exec. +Single quoted argument or explicit `--shell` → shell inline execution. +File path → script file execution with auto-detected or explicit shell. + +## Features + +- **Direct process execution**: Run programs with exact argv semantics (no shell wrapping) +- **Shell auto-detection**: Detects shell from file extension for script files +- **Cross-platform**: Supports bash, sh, zsh, pwsh, powershell, and cmd +- **Interactive mode**: Connect stdin for scripts requiring user input (`-i`) +- **Environment loading**: Inherits azd environment variables, including any Key Vault secrets resolved by azd core +- **Exit code propagation**: Child process exit codes forwarded for CI/CD pipelines + +## Security Considerations + +- **Environment inheritance**: Child processes receive all parent environment variables, + including Azure tokens and any Key Vault secrets resolved by azd. Be cautious when + executing untrusted scripts. +- **cmd.exe quoting**: On Windows, `cmd.exe` expands `%VAR%` patterns even inside double + quotes. This is an inherent cmd.exe behavior that cannot be fully mitigated. Prefer + PowerShell (`--shell pwsh`) for untrusted arguments on Windows. +- **Script execution**: This extension runs arbitrary scripts by design. Only execute + scripts you trust. + +## Development + +```bash +cd cli/azd/extensions/microsoft.azd.exec + +# Build +go build ./... + +# Test +go test ./... + +# Build for all platforms +EXTENSION_ID=microsoft.azd.exec EXTENSION_VERSION=0.5.0 ./build.sh +``` diff --git a/cli/azd/extensions/microsoft.azd.exec/build.ps1 b/cli/azd/extensions/microsoft.azd.exec/build.ps1 new file mode 100644 index 00000000000..e1d3f1e5fc8 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/build.ps1 @@ -0,0 +1,73 @@ +# Ensure script fails on any error +$ErrorActionPreference = 'Stop' + +# Get the directory of the script +$EXTENSION_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Change to the script directory +Set-Location -Path $EXTENSION_DIR + +# Create a safe version of EXTENSION_ID replacing dots with dashes +$EXTENSION_ID_SAFE = $env:EXTENSION_ID -replace '\.', '-' + +# Define output directory +$OUTPUT_DIR = if ($env:OUTPUT_DIR) { $env:OUTPUT_DIR } else { Join-Path $EXTENSION_DIR "bin" } + +# Create output directory if it doesn't exist +if (-not (Test-Path -Path $OUTPUT_DIR)) { + New-Item -ItemType Directory -Path $OUTPUT_DIR | Out-Null +} + +# List of OS and architecture combinations +if ($env:EXTENSION_PLATFORM) { + $PLATFORMS = @($env:EXTENSION_PLATFORM) +} +else { + $PLATFORMS = @( + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64", + "linux/amd64", + "linux/arm64" + ) +} + +$APP_PATH = "$env:EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +foreach ($PLATFORM in $PLATFORMS) { + $OS, $ARCH = $PLATFORM -split '/' + + $OUTPUT_NAME = Join-Path $OUTPUT_DIR "$EXTENSION_ID_SAFE-$OS-$ARCH" + + if ($OS -eq "windows") { + $OUTPUT_NAME += ".exe" + } + + Write-Host "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + if (Test-Path -Path $OUTPUT_NAME) { + Remove-Item -Path $OUTPUT_NAME -Force + } + + # Set environment variables for Go build + $env:GOOS = $OS + $env:GOARCH = $ARCH + + go build ` + -trimpath ` + -buildmode=pie ` + -tags=cfi,cfg,osusergo ` + -ldflags="-s -w -X '$APP_PATH.Version=$env:EXTENSION_VERSION'" ` + -o $OUTPUT_NAME + + if ($LASTEXITCODE -ne 0) { + Write-Host "An error occurred while building for $OS/$ARCH" + exit 1 + } +} + +Write-Host "Build completed successfully!" +Write-Host "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/microsoft.azd.exec/build.sh b/cli/azd/extensions/microsoft.azd.exec/build.sh new file mode 100644 index 00000000000..bcae6875b7e --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/build.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Get the directory of the script +EXTENSION_DIR="$(cd "$(dirname "$0")" && pwd)" + +# Change to the script directory +cd "$EXTENSION_DIR" || exit + +# Create a safe version of EXTENSION_ID replacing dots with dashes +EXTENSION_ID_SAFE="${EXTENSION_ID//./-}" + +# Define output directory +OUTPUT_DIR="${OUTPUT_DIR:-$EXTENSION_DIR/bin}" + +# Create output and target directories if they don't exist +mkdir -p "$OUTPUT_DIR" + +# List of OS and architecture combinations +if [ -n "$EXTENSION_PLATFORM" ]; then + PLATFORMS=("$EXTENSION_PLATFORM") +else + PLATFORMS=( + "windows/amd64" + "windows/arm64" + "darwin/amd64" + "darwin/arm64" + "linux/amd64" + "linux/arm64" + ) +fi + +APP_PATH="$EXTENSION_ID/internal/cmd" + +# Loop through platforms and build +for PLATFORM in "${PLATFORMS[@]}"; do + OS=$(echo "$PLATFORM" | cut -d'/' -f1) + ARCH=$(echo "$PLATFORM" | cut -d'/' -f2) + + OUTPUT_NAME="$OUTPUT_DIR/$EXTENSION_ID_SAFE-$OS-$ARCH" + + if [ "$OS" = "windows" ]; then + OUTPUT_NAME+='.exe' + fi + + echo "Building for $OS/$ARCH..." + + # Delete the output file if it already exists + [ -f "$OUTPUT_NAME" ] && rm -f "$OUTPUT_NAME" + + # Set environment variables for Go build + GOOS=$OS GOARCH=$ARCH go build \ + -trimpath \ + -buildmode=pie \ + -tags=cfi,cfg,osusergo \ + -ldflags="-s -w -X '$APP_PATH.Version=$EXTENSION_VERSION'" \ + -o "$OUTPUT_NAME" + + if [ $? -ne 0 ]; then + echo "An error occurred while building for $OS/$ARCH" + exit 1 + fi +done + +echo "Build completed successfully!" +echo "Binaries are located in the $OUTPUT_DIR directory." diff --git a/cli/azd/extensions/microsoft.azd.exec/ci-build.ps1 b/cli/azd/extensions/microsoft.azd.exec/ci-build.ps1 new file mode 100644 index 00000000000..9b58a6b1ab8 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/ci-build.ps1 @@ -0,0 +1,86 @@ +param( + [string] $Version = (Get-Content "$PSScriptRoot/../version.txt"), + [string] $SourceVersion = (git rev-parse HEAD), + [switch] $CodeCoverageEnabled, + [string] $OutputFileName +) +$PSNativeCommandArgumentPassing = 'Legacy' + +# Remove any previously built binaries +go clean + +if ($LASTEXITCODE) { + Write-Host "Error running go clean" + exit $LASTEXITCODE +} + +# Run `go help build` to obtain detailed information about `go build` flags. +$buildFlags = @( + # remove all file system paths from the resulting executable. + "-trimpath", + + # Use buildmode=pie (Position Independent Executable) for enhanced security. + "-buildmode=pie" +) + +if ($CodeCoverageEnabled) { + $buildFlags += "-cover" +} + +# Build constraint tags +$tagsFlag = "-tags=cfi,cfg,osusergo" + +# ld linker flags +$ldFlag = "-ldflags=-s -w -X 'microsoft.azd.exec/internal/cmd.Version=$Version'" + +if ($IsWindows) { + Write-Host "Building for Windows" +} +elseif ($IsLinux) { + Write-Host "Building for linux" +} +elseif ($IsMacOS) { + Write-Host "Building for macOS" +} + +# Add output file flag based on specified output file name +$outputFlag = "-o=$OutputFileName" + +# collect flags +$buildFlags += @( + $tagsFlag, + $ldFlag, + $outputFlag +) + +function PrintFlags() { + param( + [string] $flags + ) + + $i = 0 + foreach ($buildFlag in $buildFlags) { + $argWithValue = $buildFlag.Split('=', 2) + if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { + $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" + } + + if ($i -eq $buildFlags.Length - 1) { + Write-Host " $buildFlag" + } + else { + Write-Host " $buildFlag ``" + } + $i++ + } +} + +Write-Host "Running: go build ``" +PrintFlags -flags $buildFlags +go build @buildFlags +if ($LASTEXITCODE) { + Write-Host "Error running go build" + exit $LASTEXITCODE +} + +Write-Host "go build succeeded" diff --git a/cli/azd/extensions/microsoft.azd.exec/extension.yaml b/cli/azd/extensions/microsoft.azd.exec/extension.yaml new file mode 100644 index 00000000000..e9898fd6f60 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/extension.yaml @@ -0,0 +1,11 @@ +capabilities: + - custom-commands + - metadata +description: Execute scripts and commands with Azure Developer CLI context and environment variables. +displayName: Exec +id: microsoft.azd.exec +language: go +namespace: exec +requiredAzdVersion: ">1.23.6" +usage: azd exec [options] +version: 0.5.0 diff --git a/cli/azd/extensions/microsoft.azd.exec/go.mod b/cli/azd/extensions/microsoft.azd.exec/go.mod new file mode 100644 index 00000000000..d0f1fb15f02 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/go.mod @@ -0,0 +1,104 @@ +module microsoft.azd.exec + +go 1.26.0 + +replace github.com/azure/azure-dev/cli/azd => ../../ + +require ( + github.com/azure/azure-dev/cli/azd v0.0.0-00010101000000-000000000000 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b // indirect + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/braydonk/yaml v0.9.0 // indirect + github.com/buger/goterm v1.0.4 // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.2 // indirect + github.com/charmbracelet/glamour v0.10.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.10.2 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/drone/envsubst v1.0.3 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golobby/container/v3 v3.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/jmespath-community/go-jmespath v1.1.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mark3labs/mcp-go v0.41.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect + github.com/microsoft/go-deviceid v1.0.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/theckman/yacspin v0.13.12 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/sdk v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cli/azd/extensions/microsoft.azd.exec/go.sum b/cli/azd/extensions/microsoft.azd.exec/go.sum new file mode 100644 index 00000000000..5be6e7b8a74 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/go.sum @@ -0,0 +1,309 @@ +code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= +github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= +github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= +github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b/go.mod h1:XjvqMUpGd3Xn9Jtzk/4GEBCSoBX0eB2RyriXgne0IdM= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/braydonk/yaml v0.9.0 h1:ewGMrVmEVpsm3VwXQDR388sLg5+aQ8Yihp6/hc4m+h4= +github.com/braydonk/yaml v0.9.0/go.mod h1:hcm3h581tudlirk8XEUPDBAimBPbmnL0Y45hCRl47N4= +github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= +github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= +github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489 h1:a5q2sWiet6kgqucSGjYN1jhT2cn4bMKUwprtm2IGRto= +github.com/charmbracelet/x/exp/slice v0.0.0-20251008171431-5d3777519489/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= +github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= +github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golobby/container/v3 v3.3.2 h1:7u+RgNnsdVlhGoS8gY4EXAG601vpMMzLZlYqSp77Quw= +github.com/golobby/container/v3 v3.3.2/go.mod h1:RDdKpnKpV1Of11PFBe7Dxc2C1k2KaLE4FD47FflAmj0= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= +github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/jmespath-community/go-jmespath v1.1.1 h1:bFikPhsi/FdmlZhVgSCd2jj1e7G/rw+zyQfyg5UF+L4= +github.com/jmespath-community/go-jmespath v1.1.1/go.mod h1:4gOyFJsR/Gk+05RgTKYrifT7tBPWD8Lubtb5jRrfy9I= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA= +github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/microsoft/ApplicationInsights-Go v0.4.4 h1:G4+H9WNs6ygSCe6sUyxRc2U81TI5Es90b2t/MwX5KqY= +github.com/microsoft/ApplicationInsights-Go v0.4.4/go.mod h1:fKRUseBqkw6bDiXTs3ESTiU/4YTIHsQS4W3fP2ieF4U= +github.com/microsoft/go-deviceid v1.0.0 h1:i5AQ654Xk9kfvwJeKQm3w2+eT1+ImBDVEpAR0AjpP40= +github.com/microsoft/go-deviceid v1.0.0/go.mod h1:KY13FeVdHkzD8gy+6T8+kVmD/7RMpTaWW75K+T4uZWg= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= +github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tedsuo/ifrit v0.0.0-20180802180643-bea94bb476cc/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= +github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= +github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root.go b/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root.go new file mode 100644 index 00000000000..4529b6f64a1 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root.go @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package cmd provides the CLI commands for the azd exec extension. +package cmd + +import ( + "errors" + "fmt" + "os" + + "microsoft.azd.exec/internal/executor" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +var ( + // Populated at build time via ldflags. + Version = "dev" +) + +// NewRootCommand creates and configures the root cobra command for azd exec. +func NewRootCommand() *cobra.Command { + var ( + shell string + interactive bool + ) + + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "exec", + Version: Version, + Use: "exec [command] [args...] | [script-file] [-- script-args...]", + Short: "Exec - Execute commands/scripts with Azure Developer CLI context", + Long: `Exec is an Azure Developer CLI extension that executes commands and scripts +with full access to azd environment variables and configuration. + +Commands are run with the azd environment loaded into the child process. +Multiple arguments use direct process execution (no shell wrapping). +A single quoted argument uses shell inline execution. + +Examples: + azd exec python script.py # Direct exec (exact argv) + azd exec npm run dev # Direct exec (no shell) + azd exec -- python app.py --port 8000 # Direct exec with flags + azd exec 'echo $AZURE_ENV_NAME' # Inline via shell (Linux/macOS) + azd exec ./setup.sh # Execute script file + azd exec --shell pwsh "Write-Host 'Hello'" # Inline PowerShell + azd exec ./build.sh -- --verbose # Script with args + azd exec -i ./init.sh # Interactive mode`, + }) + + rootCmd.Args = cobra.MinimumNArgs(1) + rootCmd.RunE = func(cmd *cobra.Command, args []string) error { + scriptInput := args[0] + + var scriptArgs []string + if len(args) > 1 { + scriptArgs = args[1:] + } + + exec, err := executor.New(executor.Config{ + Shell: shell, + Interactive: interactive, + Args: scriptArgs, + }) + if err != nil { + return fmt.Errorf("invalid configuration: %w", err) + } + + // Try file execution first; fall back based on argument shape. + if err := exec.Execute(cmd.Context(), scriptInput); err != nil { + if _, ok := errors.AsType[*executor.ScriptNotFoundError](err); ok { + // Multiple args + no explicit shell → direct process exec (exact argv). + // Single arg or explicit shell → shell inline execution. + if len(scriptArgs) > 0 && shell == "" { + return exec.ExecuteDirect(cmd.Context(), scriptInput, scriptArgs) + } + return exec.ExecuteInline(cmd.Context(), scriptInput) + } + return err + } + return nil + } + + sdkPreRunE := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRunE != nil { + if err := sdkPreRunE(cmd, args); err != nil { + return err + } + } + + if extCtx.Debug { + _ = os.Setenv("AZD_DEBUG", "true") + } + if extCtx.NoPrompt { + _ = os.Setenv("AZD_NO_PROMPT", "true") + } + + return nil + } + + rootCmd.FParseErrWhitelist.UnknownFlags = true + rootCmd.Flags().SetInterspersed(false) + rootCmd.PersistentFlags().SetInterspersed(false) + + rootCmd.Flags().StringVarP(&shell, "shell", "s", "", + "Shell to use for execution (bash, sh, zsh, pwsh, powershell, cmd). Auto-detected if not specified.") + rootCmd.Flags().BoolVarP(&interactive, "interactive", "i", false, + "Run script in interactive mode") + + rootCmd.AddCommand( + azdext.NewVersionCommand("microsoft.azd.exec", Version, nil), + azdext.NewListenCommand(nil), + azdext.NewMetadataCommand("1.0", "microsoft.azd.exec", NewRootCommand), + ) + + return rootCmd +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root_test.go b/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root_test.go new file mode 100644 index 00000000000..47bd5dd244e --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/cmd/root_test.go @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestNewRootCommand(t *testing.T) { + cmd := NewRootCommand() + if cmd == nil { + t.Fatal("NewRootCommand returned nil") + } + if cmd.Use == "" { + t.Error("root command Use should not be empty") + } + + // Check that expected subcommands exist + subNames := map[string]bool{} + for _, sub := range cmd.Commands() { + subNames[sub.Name()] = true + } + + if !subNames["version"] { + t.Error("expected subcommand \"version\" not found") + } +} + +func TestRootCommand_Flags(t *testing.T) { + cmd := NewRootCommand() + + shellFlag := cmd.Flags().Lookup("shell") + if shellFlag == nil { + t.Fatal("expected --shell flag") + } + if shellFlag.Shorthand != "s" { + t.Errorf("expected -s shorthand, got %q", shellFlag.Shorthand) + } + + interactiveFlag := cmd.Flags().Lookup("interactive") + if interactiveFlag == nil { + t.Fatal("expected --interactive flag") + } + if interactiveFlag.Shorthand != "i" { + t.Errorf("expected -i shorthand, got %q", interactiveFlag.Shorthand) + } +} + +func TestRootCommand_RunE_InlineExecution(t *testing.T) { + cmd := NewRootCommand() + cmd.SetContext(t.Context()) + + if err := cmd.RunE(cmd, []string{"echo inline-test"}); err != nil { + t.Fatalf("RunE inline execution failed: %v", err) + } +} + +func TestRootCommand_RunE_FileExecution(t *testing.T) { + dir := t.TempDir() + var scriptPath string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "test.cmd") + if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho file-test"), 0o600); err != nil { + t.Fatal(err) + } + } else { + scriptPath = filepath.Join(dir, "test.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho file-test"), 0o700); err != nil { //nolint:gosec // G306 test script needs execute permission + t.Fatal(err) + } + } + + cmd := NewRootCommand() + cmd.SetContext(t.Context()) + + if err := cmd.RunE(cmd, []string{scriptPath}); err != nil { + t.Fatalf("RunE file execution failed: %v", err) + } +} + +func TestRootCommand_RunE_WithArgs(t *testing.T) { + cmd := NewRootCommand() + cmd.SetContext(t.Context()) + + // Multi-arg with no --shell routes to direct exec (exact argv). + // Use a real executable: cmd /c echo on Windows, echo on Unix. + var args []string + if runtime.GOOS == "windows" { + args = []string{"cmd", "/c", "echo", "arg1", "arg2"} + } else { + args = []string{"echo", "arg1", "arg2"} + } + + if err := cmd.RunE(cmd, args); err != nil { + t.Fatalf("RunE with args failed: %v", err) + } +} + +func TestRootCommand_PersistentPreRunE_Debug(t *testing.T) { + t.Setenv("AZD_DEBUG", "") + + cmd := NewRootCommand() + cmd.SetContext(t.Context()) + cmd.SetArgs([]string{"--debug", "version"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute with --debug version failed: %v", err) + } + + if os.Getenv("AZD_DEBUG") != "true" { + t.Error("expected AZD_DEBUG to be set to 'true'") + } +} + +func TestRootCommand_PersistentPreRunE_NoPrompt(t *testing.T) { + t.Setenv("AZD_NO_PROMPT", "") + + cmd := NewRootCommand() + cmd.SetContext(t.Context()) + cmd.SetArgs([]string{"--no-prompt", "version"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute with --no-prompt version failed: %v", err) + } + + if os.Getenv("AZD_NO_PROMPT") != "true" { + t.Error("expected AZD_NO_PROMPT to be set to 'true'") + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder.go new file mode 100644 index 00000000000..22848aabe7c --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder.go @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package executor + +import ( + "context" + "os/exec" + "strings" + + "microsoft.azd.exec/internal/shellutil" +) + +// buildCommand builds the exec.Cmd for the given shell and script, respecting +// the provided context for cancellation and deadline propagation. +func (e *Executor) buildCommand(ctx context.Context, shell, scriptOrPath string, isInline bool) *exec.Cmd { + var cmdArgs []string + skipAppendArgs := false + useCmdLineOverride := false + cmdWrapOuter := false + + shellLower := strings.ToLower(shell) + + shellBin := shell + if shellutil.ValidShells[shellLower] { + shellBin = shellLower + } + + switch shellLower { + case "bash", "sh", "zsh": + if isInline { + cmdArgs = []string{shellBin, "-c", scriptOrPath, "--"} + } else { + cmdArgs = []string{shellBin, scriptOrPath} + } + case "pwsh", "powershell": + if isInline { + cmdArgs = []string{shellBin, "-Command", e.buildPowerShellInlineCommand(scriptOrPath)} + skipAppendArgs = true + } else { + cmdArgs = []string{shellBin, "-File", scriptOrPath} + } + case "cmd": + // All cmd.exe paths use CmdLine override to bypass Go's + // CommandLineToArgvW escaping which is incompatible with cmd.exe. + useCmdLineOverride = true + if isInline { + cmdArgs = []string{shellBin, "/c", scriptOrPath} + cmdWrapOuter = false + } else { + cmdArgs = []string{shellBin, "/c", `"` + scriptOrPath + `"`} + cmdWrapOuter = true + } + default: + // All valid shells are handled above. This branch is unreachable + // when the shell has been validated by New(). Guard defensively. + if isInline { + cmdArgs = []string{shell, "-c", scriptOrPath, "--"} + } else { + cmdArgs = []string{shell, scriptOrPath} + } + } + + if !skipAppendArgs && len(e.config.Args) > 0 { + if useCmdLineOverride { + // Quote each arg individually for cmd.exe metacharacter safety + for _, arg := range e.config.Args { + cmdArgs = append(cmdArgs, quoteCmdArg(arg)) + } + } else { + cmdArgs = append(cmdArgs, e.config.Args...) + } + } + + cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...) //nolint:gosec // CLI command builder; args validated upstream + if useCmdLineOverride { + setCmdLineOverride(cmd, cmdArgs, cmdWrapOuter) + } + return cmd +} + +// controlCharReplacer strips control characters that cmd.exe interprets as +// command separators or word boundaries: +// - \n, \r, \x00: command terminators +// - \x0B (VT), \x0C (FF): treated as whitespace by cmd.exe parser +// - \x1A: Ctrl+Z / MS-DOS EOF marker +// - \x1B: ESC (ANSI sequence prefix) +var controlCharReplacer = strings.NewReplacer( + "\n", "", "\r", "", "\x00", "", + "\x0B", "", "\x0C", "", + "\x1A", "", "\x1B", "", +) + +// quoteCmdArg quotes a single argument for cmd.exe if it contains spaces, +// tabs, or metacharacters. Embedded double quotes are escaped by doubling them. +// Newline/CR/null bytes are stripped as they act as command separators. +// NOTE: cmd.exe expands %VAR% patterns even inside double quotes — this is an +// inherent limitation with no general workaround. +func quoteCmdArg(arg string) string { + if arg == "" { + return `""` + } + cleaned := controlCharReplacer.Replace(arg) + // Escape embedded double quotes by doubling (cmd.exe convention) + escaped := strings.ReplaceAll(cleaned, `"`, `""`) + if strings.ContainsAny(escaped, " \t&|<>^%\"") { + return `"` + escaped + `"` + } + return escaped +} + +// buildPowerShellInlineCommand joins the inline script with its arguments into a single -Command string. +func (e *Executor) buildPowerShellInlineCommand(scriptOrPath string) string { + if len(e.config.Args) == 0 { + return scriptOrPath + } + + quotedArgs := make([]string, len(e.config.Args)) + for i, arg := range e.config.Args { + quotedArgs[i] = quotePowerShellArg(arg) + } + + return strings.Join(append([]string{scriptOrPath}, quotedArgs...), " ") +} + +// quotePowerShellArg returns a safely single-quoted PowerShell argument. +func quotePowerShellArg(arg string) string { + if arg == "" { + return "''" + } + return "'" + strings.ReplaceAll(arg, "'", "''") + "'" +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_notwindows.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_notwindows.go new file mode 100644 index 00000000000..6eefdc0ac4f --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_notwindows.go @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build !windows + +package executor + +import "os/exec" + +// setCmdLineOverride is a no-op on non-Windows platforms. +func setCmdLineOverride(_ *exec.Cmd, _ []string, _ bool) {} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_test.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_test.go new file mode 100644 index 00000000000..9fe0b8bd976 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_test.go @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package executor + +import ( + "os/exec" + "slices" + "testing" +) + +func TestBuildCommandWithCustomShell(t *testing.T) { + tests := []struct { + name string + shell string + scriptPath string + args []string + wantFirst string + }{ + { + name: "Custom shell python", + shell: "python3", + scriptPath: "script.py", + args: []string{"arg1"}, + wantFirst: "python3", + }, + { + name: "Custom shell node", + shell: "node", + scriptPath: "script.js", + args: nil, + wantFirst: "node", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e, err := New(Config{Args: tt.args}) + if err != nil { + t.Fatalf("New() error: %v", err) + } + cmd := e.buildCommand(t.Context(), tt.shell, tt.scriptPath, false) + + if cmd == nil { + t.Fatal("buildCommand returned nil") + } + + found := slices.Contains(cmd.Args, tt.wantFirst) + + if !found { + t.Errorf("buildCommand args don't contain shell %v: %v", tt.wantFirst, cmd.Args) + } + }) + } +} + +func TestBuildCommandShellVariations(t *testing.T) { + tests := []struct { + shell string + scriptPath string + }{ + {shell: "SH", scriptPath: "test.sh"}, + {shell: "BASH", scriptPath: "test.sh"}, + } + + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + e, err := New(Config{Shell: tt.shell}) + if err != nil { + t.Fatalf("New() error: %v", err) + } + cmd := e.buildCommand(t.Context(), tt.shell, tt.scriptPath, false) + + if cmd == nil { + t.Fatal("buildCommand returned nil") + } + }) + } +} + +func TestBuildCommandLookPath(t *testing.T) { + e, err := New(Config{}) + if err != nil { + t.Fatalf("New() error: %v", err) + } + cmd := e.buildCommand(t.Context(), "cmd", "test.bat", false) + + if cmd.Path == "" { + t.Error("buildCommand created command with empty Path") + } + + _, err = exec.LookPath(cmd.Args[0]) + if err != nil { + t.Logf("Command %v not found in PATH (may be platform-specific)", cmd.Args[0]) + } +} + +func TestQuotePowerShellArg(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + {name: "empty string", arg: "", want: "''"}, + {name: "simple arg", arg: "hello", want: "'hello'"}, + {name: "arg with single quote", arg: "it's", want: "'it''s'"}, + {name: "arg with multiple quotes", arg: "a'b'c", want: "'a''b''c'"}, + {name: "arg with double dash", arg: "--skip-sync", want: "'--skip-sync'"}, + {name: "arg with spaces", arg: "hello world", want: "'hello world'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := quotePowerShellArg(tt.arg) + if got != tt.want { + t.Errorf("quotePowerShellArg(%q) = %q, want %q", tt.arg, got, tt.want) + } + }) + } +} + +func TestBuildPowerShellInlineCommand(t *testing.T) { + t.Run("no args returns script as-is", func(t *testing.T) { + e, err := New(Config{}) + if err != nil { + t.Fatalf("New() error: %v", err) + } + got := e.buildPowerShellInlineCommand("Get-Date") + if got != "Get-Date" { + t.Errorf("got %q, want %q", got, "Get-Date") + } + }) + + t.Run("with args joins and quotes", func(t *testing.T) { + e, err := New(Config{Args: []string{"arg1", "it's"}}) + if err != nil { + t.Fatalf("New() error: %v", err) + } + got := e.buildPowerShellInlineCommand("cmd") + want := "cmd 'arg1' 'it''s'" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + }) +} + +func TestQuoteCmdArg(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + {name: "empty string", arg: "", want: `""`}, + {name: "simple arg", arg: "hello", want: "hello"}, + {name: "arg with spaces", arg: "hello world", want: `"hello world"`}, + {name: "arg with ampersand", arg: "a&b", want: `"a&b"`}, + {name: "arg with pipe", arg: "a|b", want: `"a|b"`}, + {name: "arg with angle brackets", arg: "", want: `""`}, + {name: "arg with caret", arg: "a^b", want: `"a^b"`}, + {name: "arg with percent", arg: "%PATH%", want: `"%PATH%"`}, + {name: "safe path", arg: `C:\scripts\run.bat`, want: `C:\scripts\run.bat`}, + {name: "path with spaces", arg: `C:\my scripts\run.bat`, want: `"C:\my scripts\run.bat"`}, + {name: "path with ampersand", arg: `C:\a&b\run.bat`, want: `"C:\a&b\run.bat"`}, + // Embedded double-quote injection: quotes are escaped by doubling + {name: "embedded double quote", arg: `he said "hello"`, want: `"he said ""hello"""`}, + {name: "injection via embedded quotes", arg: `a" & calc & "`, want: `"a"" & calc & """`}, + // Previously-quoted input must NOT be trusted (CWE-78) + {name: "fake pre-quoted injection", arg: `"safe" & calc & "x"`, want: `"""safe"" & calc & ""x"""`}, + // Control character stripping (CWE-93) + {name: "newline stripped", arg: "a\nb", want: "ab"}, + {name: "CR stripped", arg: "a\rb", want: "ab"}, + {name: "null stripped", arg: "a\x00b", want: "ab"}, + {name: "VT stripped", arg: "a\x0Bb", want: "ab"}, + {name: "FF stripped", arg: "a\x0Cb", want: "ab"}, + {name: "Ctrl-Z stripped", arg: "a\x1Ab", want: "ab"}, + {name: "ESC stripped", arg: "a\x1Bb", want: "ab"}, + {name: "newline with metachar", arg: "a\n&b", want: `"a&b"`}, + // Tab characters are also word separators in cmd.exe + {name: "tab character", arg: "a\tb", want: "\"a\tb\""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := quoteCmdArg(tt.arg) + if got != tt.want { + t.Errorf("quoteCmdArg(%q) = %q, want %q", tt.arg, got, tt.want) + } + }) + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_windows.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_windows.go new file mode 100644 index 00000000000..88f7266b5f8 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/command_builder_windows.go @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package executor + +import ( + "os/exec" + "strings" + "syscall" +) + +// setCmdLineOverride sets the raw command line for cmd.exe on Windows to bypass +// Go's CommandLineToArgvW argument escaping which is incompatible with cmd.exe +// command-line parsing. When wrapOuter is true, the /c payload is wrapped in +// outer quotes to protect file paths with metacharacters. When false (inline +// mode), args are joined without outer wrapping so the script content is +// interpreted by cmd.exe as-is. +func setCmdLineOverride(cmd *exec.Cmd, args []string, wrapOuter bool) { + payload := strings.Join(args[2:], " ") + var cmdLine string + if wrapOuter { + cmdLine = args[0] + " " + args[1] + ` "` + payload + `"` + } else { + cmdLine = args[0] + " " + args[1] + " " + payload + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: cmdLine, + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors.go new file mode 100644 index 00000000000..9ac500fe0af --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package executor + +import "fmt" + +// ValidationError indicates that input validation failed. +type ValidationError struct { + Field string + Reason string +} + +// Error returns the formatted validation error message. +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error for %s: %s", e.Field, e.Reason) +} + +// ScriptNotFoundError indicates that a script file could not be found. +type ScriptNotFoundError struct { + Path string +} + +// Error returns the formatted script-not-found error message. +func (e *ScriptNotFoundError) Error() string { + return fmt.Sprintf("script not found: %s", e.Path) +} + +// InvalidShellError indicates that an invalid shell was specified. +type InvalidShellError struct { + Shell string +} + +// Error returns the formatted invalid-shell error message. +func (e *InvalidShellError) Error() string { + return fmt.Sprintf("invalid shell: %s (valid: bash, sh, zsh, pwsh, powershell, cmd)", e.Shell) +} + +// ExecutionError indicates that script execution failed with an exit code. +type ExecutionError struct { + ExitCode int + Shell string + IsInline bool +} + +// Error returns the formatted execution error message including exit code and shell. +func (e *ExecutionError) Error() string { + if e.Shell == "" { + return fmt.Sprintf("command exited with code %d", e.ExitCode) + } + if e.IsInline { + return fmt.Sprintf("inline script exited with code %d (shell: %s)", e.ExitCode, e.Shell) + } + return fmt.Sprintf("script exited with code %d (shell: %s)", e.ExitCode, e.Shell) +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors_test.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors_test.go new file mode 100644 index 00000000000..ad53809dc18 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/errors_test.go @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package executor + +import ( + "errors" + "strings" + "testing" +) + +func TestValidationError(t *testing.T) { + err := &ValidationError{ + Field: "scriptPath", + Reason: "cannot be empty", + } + + expected := "validation error for scriptPath: cannot be empty" + if err.Error() != expected { + t.Errorf("ValidationError.Error() = %q, want %q", err.Error(), expected) + } +} + +func TestScriptNotFoundError(t *testing.T) { + err := &ScriptNotFoundError{ + Path: "test.sh", + } + + expected := "script not found: test.sh" + if err.Error() != expected { + t.Errorf("ScriptNotFoundError.Error() = %q, want %q", err.Error(), expected) + } +} + +func TestInvalidShellError(t *testing.T) { + err := &InvalidShellError{ + Shell: "invalid", + } + + msg := err.Error() + if !strings.Contains(msg, "invalid shell: invalid") { + t.Errorf("InvalidShellError.Error() should contain 'invalid shell: invalid', got %q", msg) + } + if !strings.Contains(msg, "bash") { + t.Errorf("InvalidShellError.Error() should list valid shells, got %q", msg) + } +} + +func TestExecutionError(t *testing.T) { + tests := []struct { + name string + err *ExecutionError + wantText string + }{ + { + name: "Inline script error", + err: &ExecutionError{ + ExitCode: 1, + Shell: "bash", + IsInline: true, + }, + wantText: "inline script exited with code 1 (shell: bash)", + }, + { + name: "File script error", + err: &ExecutionError{ + ExitCode: 127, + Shell: "pwsh", + IsInline: false, + }, + wantText: "script exited with code 127 (shell: pwsh)", + }, + { + name: "Direct exec error", + err: &ExecutionError{ + ExitCode: 1, + Shell: "", + IsInline: false, + }, + wantText: "command exited with code 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err.Error() != tt.wantText { + t.Errorf("ExecutionError.Error() = %q, want %q", tt.err.Error(), tt.wantText) + } + }) + } +} + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + {name: "Valid config - empty shell", config: Config{Shell: ""}, wantErr: false}, + {name: "Valid config - bash", config: Config{Shell: "bash"}, wantErr: false}, + {name: "Valid config - pwsh", config: Config{Shell: "pwsh"}, wantErr: false}, + {name: "Invalid shell", config: Config{Shell: "invalid"}, wantErr: true}, + {name: "Valid shell with uppercase", config: Config{Shell: "BASH"}, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr && err != nil { + if _, ok := errors.AsType[*InvalidShellError](err); !ok { + t.Errorf("Config.Validate() should return *InvalidShellError, got %T", err) + } + } + }) + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor.go new file mode 100644 index 00000000000..cfc8aaa5740 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor.go @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package executor provides secure script execution with Azure context. +package executor + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "microsoft.azd.exec/internal/shellutil" +) + +// Config holds the configuration for script execution. +type Config struct { + Shell string + Interactive bool + Args []string +} + +// Validate checks if the Config has valid values. +func (c *Config) Validate() error { + if err := shellutil.ValidateShell(c.Shell); err != nil { + return &InvalidShellError{Shell: c.Shell} + } + return nil +} + +// Executor executes scripts with azd context. +type Executor struct { + config Config +} + +// New creates a new script executor with the given configuration. +func New(config Config) (*Executor, error) { + if err := config.Validate(); err != nil { + return nil, err + } + return &Executor{config: config}, nil +} + +// Execute runs a script file with azd context. +func (e *Executor) Execute(ctx context.Context, scriptPath string) error { + if scriptPath == "" { + return &ValidationError{Field: "scriptPath", Reason: "cannot be empty"} + } + + absPath, err := filepath.Abs(scriptPath) + if err != nil { + return &ValidationError{Field: "scriptPath", Reason: fmt.Sprintf("invalid path: %v", err)} + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return &ScriptNotFoundError{Path: filepath.Base(absPath)} + } + return &ValidationError{Field: "scriptPath", Reason: fmt.Sprintf("cannot access: %v", err)} + } + + if info.IsDir() { + return &ValidationError{Field: "scriptPath", Reason: "must be a file, not a directory"} + } + + shell := e.config.Shell + if shell == "" { + shell = shellutil.DetectShellFromFile(absPath) + } + + workingDir := filepath.Dir(absPath) + + return e.executeCommand(ctx, shell, workingDir, absPath, false) +} + +// ExecuteDirect runs a command directly without shell wrapping, preserving exact +// argv semantics. This is the preferred mode for "run a program with azd env" +// (e.g. `azd exec python script.py --port 8000`). Stdin is always connected +// because the caller is launching a process, not evaluating a shell expression. +func (e *Executor) ExecuteDirect(ctx context.Context, command string, args []string) error { + if command == "" { + return &ValidationError{Field: "command", Reason: "cannot be empty"} + } + + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + cmd := exec.CommandContext(ctx, command, args...) //nolint:gosec // CLI exec; command comes from user argv + cmd.Dir = workingDir + cmd.Env = os.Environ() + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if os.Getenv("AZD_DEBUG") == "true" { + quotedArgs := make([]string, len(args)) + for i, a := range args { + quotedArgs[i] = fmt.Sprintf("%q", a) + } + fmt.Fprintf(os.Stderr, "Executing (direct): %s %s\n", command, strings.Join(quotedArgs, " ")) + fmt.Fprintf(os.Stderr, "Working directory: %q\n", workingDir) + } + + return e.runCommand(cmd, command, "", false) +} + +// ExecuteInline runs an inline script command with azd context. +func (e *Executor) ExecuteInline(ctx context.Context, scriptContent string) error { + if strings.TrimSpace(scriptContent) == "" { + return &ValidationError{Field: "scriptContent", Reason: "cannot be empty or whitespace"} + } + + shell := e.config.Shell + if shell == "" { + shell = shellutil.DefaultShell() + } + + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + return e.executeCommand(ctx, shell, workingDir, scriptContent, true) +} + +func (e *Executor) executeCommand(ctx context.Context, shell, workingDir, scriptOrPath string, isInline bool) error { + cmd := e.buildCommand(ctx, shell, scriptOrPath, isInline) + cmd.Dir = workingDir + + cmd.Env = os.Environ() + + if e.config.Interactive { + cmd.Stdin = os.Stdin + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if os.Getenv("AZD_DEBUG") == "true" { + e.logDebugInfo(shell, workingDir, scriptOrPath, isInline, cmd.Args) + } + + return e.runCommand(cmd, scriptOrPath, shell, isInline) +} + +func (e *Executor) logDebugInfo(shell, workingDir, scriptOrPath string, isInline bool, cmdArgs []string) { + if isInline { + fmt.Fprintf(os.Stderr, "Executing inline: %s\n", shell) + fmt.Fprintf(os.Stderr, "Script length: %d chars\n", len(scriptOrPath)) + } else { + quotedArgs := make([]string, len(cmdArgs)-1) + for i, a := range cmdArgs[1:] { + quotedArgs[i] = fmt.Sprintf("%q", a) + } + fmt.Fprintf(os.Stderr, "Executing: %s %s\n", shell, strings.Join(quotedArgs, " ")) + } + fmt.Fprintf(os.Stderr, "Working directory: %q\n", workingDir) +} + +func (e *Executor) runCommand(cmd *exec.Cmd, scriptOrPath, shell string, isInline bool) error { + if err := cmd.Run(); err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return &ExecutionError{ + ExitCode: exitErr.ExitCode(), + Shell: shell, + IsInline: isInline, + } + } + if shell == "" { + return fmt.Errorf("failed to execute command %q: %w", scriptOrPath, err) + } + if isInline { + return fmt.Errorf("failed to execute inline script with shell %q: %w", shell, err) + } + return fmt.Errorf("failed to execute script %q with shell %q: %w", filepath.Base(scriptOrPath), shell, err) + } + return nil +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor_test.go b/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor_test.go new file mode 100644 index 00000000000..0cda286015a --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/executor/executor_test.go @@ -0,0 +1,477 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package executor + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + errType any + }{ + {"valid_empty_config", Config{}, false, nil}, + {"valid_bash", Config{Shell: "bash"}, false, nil}, + {"valid_pwsh", Config{Shell: "pwsh"}, false, nil}, + {"valid_cmd", Config{Shell: "cmd"}, false, nil}, + {"valid_sh", Config{Shell: "sh"}, false, nil}, + {"valid_zsh", Config{Shell: "zsh"}, false, nil}, + {"valid_powershell", Config{Shell: "powershell"}, false, nil}, + {"valid_uppercase", Config{Shell: "BASH"}, false, nil}, + {"invalid_shell", Config{Shell: "python"}, true, &InvalidShellError{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exec, err := New(tt.config) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + if tt.errType != nil { + if _, ok := errors.AsType[*InvalidShellError](err); !ok { + t.Fatalf("expected InvalidShellError, got %T", err) + } + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exec == nil { + t.Fatal("executor should not be nil") + } + }) + } +} + +func TestExecute_Validation(t *testing.T) { + exec, _ := New(Config{}) + + t.Run("empty_path", func(t *testing.T) { + err := exec.Execute(t.Context(), "") + if err == nil { + t.Fatal("expected error") + } + valErr, ok := errors.AsType[*ValidationError](err) + if !ok { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if valErr.Field != "scriptPath" { + t.Fatalf("expected field 'scriptPath', got %q", valErr.Field) + } + }) + + t.Run("nonexistent_file", func(t *testing.T) { + err := exec.Execute(t.Context(), filepath.Join(t.TempDir(), "no-such-file.sh")) + if err == nil { + t.Fatal("expected error") + } + if _, ok := errors.AsType[*ScriptNotFoundError](err); !ok { + t.Fatalf("expected ScriptNotFoundError, got %T: %v", err, err) + } + }) + + t.Run("directory_path", func(t *testing.T) { + err := exec.Execute(t.Context(), t.TempDir()) + if err == nil { + t.Fatal("expected error") + } + valErr, ok := errors.AsType[*ValidationError](err) + if !ok { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if !strings.Contains(valErr.Reason, "directory") { + t.Fatalf("expected reason about directory, got %q", valErr.Reason) + } + }) +} + +func TestExecute_ValidScript(t *testing.T) { + dir := t.TempDir() + var scriptPath string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "test.cmd") + if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho hello"), 0o600); err != nil { + t.Fatal(err) + } + } else { + scriptPath = filepath.Join(dir, "test.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho hello"), 0o700); err != nil { //nolint:gosec // G306 test script needs execute permission + t.Fatal(err) + } + } + exec, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + if err := exec.Execute(t.Context(), scriptPath); err != nil { + t.Fatalf("unexpected error executing script: %v", err) + } +} + +func TestExecute_WithExplicitShell(t *testing.T) { + dir := t.TempDir() + var scriptPath, shell string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "test.ps1") + if err := os.WriteFile(scriptPath, []byte("Write-Host 'hello'"), 0o600); err != nil { + t.Fatal(err) + } + shell = "powershell" + } else { + scriptPath = filepath.Join(dir, "test.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho hello"), 0o700); err != nil { //nolint:gosec // G306 test script needs execute permission + t.Fatal(err) + } + shell = "bash" + } + exec, err := New(Config{Shell: shell}) + if err != nil { + t.Fatal(err) + } + if err := exec.Execute(t.Context(), scriptPath); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecute_ScriptWithArgs(t *testing.T) { + dir := t.TempDir() + var scriptPath string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "args.cmd") + if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho %1"), 0o600); err != nil { + t.Fatal(err) + } + } else { + scriptPath = filepath.Join(dir, "args.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho $1"), 0o700); err != nil { //nolint:gosec // G306 test script needs execute permission + t.Fatal(err) + } + } + exec, err := New(Config{Args: []string{"test-arg"}}) + if err != nil { + t.Fatal(err) + } + if err := exec.Execute(t.Context(), scriptPath); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteInline_Validation(t *testing.T) { + exec, _ := New(Config{}) + + t.Run("empty_content", func(t *testing.T) { + err := exec.ExecuteInline(t.Context(), "") + if err == nil { + t.Fatal("expected error") + } + if _, ok := errors.AsType[*ValidationError](err); !ok { + t.Fatalf("expected ValidationError, got %T", err) + } + }) + + t.Run("whitespace_only", func(t *testing.T) { + err := exec.ExecuteInline(t.Context(), " \t ") + if err == nil { + t.Fatal("expected error") + } + }) +} + +func TestExecuteInline_Valid(t *testing.T) { + shell := "cmd" + if runtime.GOOS != "windows" { + shell = "bash" + } + exec, err := New(Config{Shell: shell}) + if err != nil { + t.Fatal(err) + } + if err := exec.ExecuteInline(t.Context(), "echo hello"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteInline_DefaultShell(t *testing.T) { + exec, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + if err := exec.ExecuteInline(t.Context(), "echo default-shell"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteInline_FailingCommand(t *testing.T) { + var shell, cmd string + if runtime.GOOS == "windows" { + shell = "cmd" + cmd = "exit /b 42" + } else { + shell = "bash" + cmd = "exit 42" + } + exec, err := New(Config{Shell: shell}) + if err != nil { + t.Fatal(err) + } + err = exec.ExecuteInline(t.Context(), cmd) + if err == nil { + t.Fatal("expected error for failing command") + } + execErr, ok := errors.AsType[*ExecutionError](err) + if !ok { + t.Fatalf("expected ExecutionError, got %T: %v", err, err) + } + if execErr.ExitCode != 42 { + t.Fatalf("expected exit code 42, got %d", execErr.ExitCode) + } +} + +func TestExecute_DebugLogging(t *testing.T) { + t.Setenv("AZD_DEBUG", "true") + dir := t.TempDir() + var scriptPath string + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "debug.cmd") + if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho debug"), 0o600); err != nil { + t.Fatal(err) + } + } else { + scriptPath = filepath.Join(dir, "debug.sh") + if err := os.WriteFile(scriptPath, []byte("#!/bin/bash\necho debug"), 0o700); err != nil { //nolint:gosec // G306 test script needs execute permission + t.Fatal(err) + } + } + exec, _ := New(Config{}) + if err := exec.Execute(t.Context(), scriptPath); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteInline_DebugLogging(t *testing.T) { + t.Setenv("AZD_DEBUG", "true") + shell := "cmd" + if runtime.GOOS != "windows" { + shell = "bash" + } + exec, _ := New(Config{Shell: shell}) + if err := exec.ExecuteInline(t.Context(), "echo debug-inline"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- runCommand error paths --- + +func TestRunCommand_NonExitError_Inline(t *testing.T) { + // Use a command that does not exist to get a non-ExitError failure + e := &Executor{config: Config{}} + cmd := e.buildCommand(t.Context(), "nonexistent-shell-binary-12345", "echo hi", true) + err := e.runCommand(cmd, "echo hi", "nonexistent-shell-binary-12345", true) + if err == nil { + t.Fatal("expected error from nonexistent binary") + } + if !strings.Contains(err.Error(), "failed to execute inline script") { + t.Fatalf("expected inline error message, got: %v", err) + } +} + +func TestRunCommand_NonExitError_File(t *testing.T) { + e := &Executor{config: Config{}} + cmd := e.buildCommand(t.Context(), "nonexistent-shell-binary-12345", "/tmp/script.sh", false) + err := e.runCommand(cmd, "/tmp/script.sh", "nonexistent-shell-binary-12345", false) + if err == nil { + t.Fatal("expected error from nonexistent binary") + } + if !strings.Contains(err.Error(), "failed to execute script") { + t.Fatalf("expected file error message, got: %v", err) + } +} + +func TestExecuteCommand_Interactive(t *testing.T) { + + exec, err := New(Config{Interactive: true}) + if err != nil { + t.Fatal(err) + } + + var shell string + if runtime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "bash" + } + + dir := t.TempDir() + // Execute a simple command in interactive mode — verifies stdin wiring doesn't crash + err = exec.executeCommand(t.Context(), shell, dir, "echo interactive-test", true) + if err != nil { + t.Fatalf("interactive executeCommand failed: %v", err) + } +} + +func TestExecuteCommand_DebugLogging(t *testing.T) { + t.Setenv("AZD_DEBUG", "true") + + exec, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + + var shell string + if runtime.GOOS == "windows" { + shell = "cmd" + } else { + shell = "bash" + } + + dir := t.TempDir() + err = exec.executeCommand(t.Context(), shell, dir, "echo debug-test", true) + if err != nil { + t.Fatalf("executeCommand with debug failed: %v", err) + } +} + +// --- ExecuteDirect tests --- + +func TestExecuteDirect_Validation(t *testing.T) { + exec, _ := New(Config{}) + + t.Run("empty_command", func(t *testing.T) { + err := exec.ExecuteDirect(t.Context(), "", nil) + if err == nil { + t.Fatal("expected error") + } + if _, ok := errors.AsType[*ValidationError](err); !ok { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + }) +} + +func TestExecuteDirect_SimpleCommand(t *testing.T) { + exec, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + + var command string + var args []string + if runtime.GOOS == "windows" { + command = "cmd" + args = []string{"/c", "echo", "hello"} + } else { + command = "echo" + args = []string{"hello"} + } + + if err := exec.ExecuteDirect(t.Context(), command, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteDirect_ExitCode(t *testing.T) { + exec, err := New(Config{}) + if err != nil { + t.Fatal(err) + } + + var command string + var args []string + if runtime.GOOS == "windows" { + command = "cmd" + args = []string{"/c", "exit", "42"} + } else { + command = "bash" + args = []string{"-c", "exit 42"} + } + + err = exec.ExecuteDirect(t.Context(), command, args) + if err == nil { + t.Fatal("expected error for non-zero exit") + } + execErr, ok := errors.AsType[*ExecutionError](err) + if !ok { + t.Fatalf("expected ExecutionError, got %T: %v", err, err) + } + if execErr.ExitCode != 42 { + t.Fatalf("expected exit code 42, got %d", execErr.ExitCode) + } + if execErr.Shell != "" { + t.Fatalf("expected empty shell for direct exec, got %q", execErr.Shell) + } +} + +func TestExecuteDirect_NotFound(t *testing.T) { + exec, _ := New(Config{}) + err := exec.ExecuteDirect(t.Context(), "nonexistent-binary-xyz-12345", nil) + if err == nil { + t.Fatal("expected error for nonexistent binary") + } + if strings.Contains(err.Error(), "shell") { + t.Fatalf("direct exec error should not mention shell, got: %v", err) + } +} + +func TestExecuteDirect_DebugLogging(t *testing.T) { + t.Setenv("AZD_DEBUG", "true") + exec, _ := New(Config{}) + + var command string + var args []string + if runtime.GOOS == "windows" { + command = "cmd" + args = []string{"/c", "echo", "debug-direct"} + } else { + command = "echo" + args = []string{"debug-direct"} + } + + if err := exec.ExecuteDirect(t.Context(), command, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteDirect_ArgsPassedCorrectly(t *testing.T) { + // Verify that multi-word arguments preserve exact argv. + exec, _ := New(Config{}) + + var command string + var args []string + if runtime.GOOS == "windows" { + // echo via cmd /c preserves args + command = "cmd" + args = []string{"/c", "echo", "hello world", "arg2"} + } else { + command = "echo" + args = []string{"hello world", "arg2"} + } + + if err := exec.ExecuteDirect(t.Context(), command, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- runCommand direct exec error path --- + +func TestRunCommand_NonExitError_Direct(t *testing.T) { + e := &Executor{config: Config{}} + // Build a command that will fail to start (nonexistent binary). + // Use a name without "shell" to avoid false-positive string matches. + cmd := e.buildCommand(t.Context(), "nonexistent-fake-binary-12345", "echo hi", false) + err := e.runCommand(cmd, "test-command", "", false) + if err == nil { + t.Fatal("expected error from nonexistent binary") + } + if !strings.Contains(err.Error(), "failed to execute command") { + t.Fatalf("expected direct exec error message, got: %v", err) + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil.go b/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil.go new file mode 100644 index 00000000000..efb239a25de --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package shellutil provides shell detection and validation utilities for the CLI executor. +package shellutil + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +const osWindows = "windows" + +// ValidShells is the canonical set of supported shell names (lowercase). +var ValidShells = map[string]bool{ + "bash": true, + "sh": true, + "zsh": true, + "pwsh": true, + "powershell": true, + "cmd": true, +} + +// ValidateShell checks whether shell is a known, supported shell name. +// An empty string is considered valid (auto-detect). Returns an error for +// unknown shells, listing the valid options. +func ValidateShell(shell string) error { + if shell == "" { + return nil + } + if !ValidShells[strings.ToLower(shell)] { + return fmt.Errorf("invalid shell %q: must be one of bash, sh, zsh, pwsh, powershell, cmd", shell) + } + return nil +} + +// DetectShellFromFile returns the appropriate shell for executing a script +// file based on its extension. When the extension is unrecognized it falls +// back to the platform default (powershell on Windows, bash elsewhere). +// +// Note: The SDK's [azdext.DetectShell] detects the *current* interactive shell; +// this function detects shell from a *file extension*, which is a different concern. +func DetectShellFromFile(filePath string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + switch ext { + case ".sh", ".bash": + return "bash" + case ".zsh": + return "zsh" + case ".ps1": + return "pwsh" + case ".cmd", ".bat": + return "cmd" + default: + return DefaultShell() + } +} + +// DefaultShell returns the platform-appropriate default shell +// (powershell on Windows, bash elsewhere). +func DefaultShell() string { + if runtime.GOOS == osWindows { + return "powershell" + } + return "bash" +} diff --git a/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil_test.go b/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil_test.go new file mode 100644 index 00000000000..6ba8f5f7f34 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/internal/shellutil/shellutil_test.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package shellutil + +import ( + "runtime" + "testing" +) + +func TestValidateShell(t *testing.T) { + tests := []struct { + shell string + wantErr bool + }{ + {"", false}, + {"bash", false}, + {"sh", false}, + {"zsh", false}, + {"pwsh", false}, + {"powershell", false}, + {"cmd", false}, + {"BASH", false}, + {"Pwsh", false}, + {"CMD", false}, + {"python", true}, + {"invalid", true}, + {"node", true}, + } + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + err := ValidateShell(tt.shell) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateShell(%q) error = %v, wantErr %v", tt.shell, err, tt.wantErr) + } + }) + } +} + +func TestDetectShellFromFile(t *testing.T) { + tests := []struct { + file string + want string + }{ + {"script.sh", "bash"}, + {"script.bash", "bash"}, + {"script.zsh", "zsh"}, + {"script.ps1", "pwsh"}, + {"script.cmd", "cmd"}, + {"script.bat", "cmd"}, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + got := DetectShellFromFile(tt.file) + if got != tt.want { + t.Errorf("DetectShellFromFile(%q) = %q, want %q", tt.file, got, tt.want) + } + }) + } +} + +func TestDetectShellFromFile_Default(t *testing.T) { + got := DetectShellFromFile("script.txt") + want := "bash" + if runtime.GOOS == "windows" { + want = "powershell" + } + if got != want { + t.Errorf("DetectShellFromFile(script.txt) = %q, want %q", got, want) + } +} + +func TestDetectShellFromFile_NoExtension(t *testing.T) { + got := DetectShellFromFile("Makefile") + want := "bash" + if runtime.GOOS == "windows" { + want = "powershell" + } + if got != want { + t.Errorf("DetectShellFromFile(Makefile) = %q, want %q", got, want) + } +} + +func TestDefaultShell(t *testing.T) { + got := DefaultShell() + if runtime.GOOS == "windows" { + if got != "powershell" { + t.Errorf("DefaultShell() = %q, want %q", got, "powershell") + } + } else { + if got != "bash" { + t.Errorf("DefaultShell() = %q, want %q", got, "bash") + } + } +} diff --git a/cli/azd/extensions/microsoft.azd.exec/main.go b/cli/azd/extensions/microsoft.azd.exec/main.go new file mode 100644 index 00000000000..cffe7a7f672 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/main.go @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package main + +import ( + "errors" + + "microsoft.azd.exec/internal/cmd" + "microsoft.azd.exec/internal/executor" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +func main() { + azdext.Run( + cmd.NewRootCommand(), + azdext.WithExitCode(func(err error) (int, bool) { + if execErr, ok := errors.AsType[*executor.ExecutionError](err); ok { + return execErr.ExitCode, true + } + return 0, false + }), + ) +} diff --git a/cli/azd/extensions/microsoft.azd.exec/version.txt b/cli/azd/extensions/microsoft.azd.exec/version.txt new file mode 100644 index 00000000000..8f0916f768f --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/version.txt @@ -0,0 +1 @@ +0.5.0 diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index f4509e37f67..d68c16ca795 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -19,7 +19,8 @@ import ( type RunOption func(*runConfig) type runConfig struct { - preExecute func(ctx context.Context, cmd *cobra.Command) error + preExecute func(ctx context.Context, cmd *cobra.Command) error + exitCodeFunc func(err error) (int, bool) } // WithPreExecute registers a hook that runs after context creation but before @@ -30,6 +31,18 @@ func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunO return func(c *runConfig) { c.preExecute = fn } } +// WithExitCode registers a function that extracts an exit code from an error. +// If the function returns (code, true), Run exits with that code instead of +// the default 1. This is useful for extensions that propagate child process +// exit codes (e.g., a script runner that should exit with the script's code). +// +// The returned exit code must be non-zero; returning (0, true) is treated as +// unmatched (falls through to the default exit(1)) because exit code 0 would +// mask a real error. +func WithExitCode(fn func(err error) (int, bool)) RunOption { + return func(c *runConfig) { c.exitCodeFunc = fn } +} + // Run is the standard entry point for azd extensions. It handles all lifecycle // boilerplate that every extension needs: // - FORCE_COLOR environment variable → color.NoColor @@ -78,6 +91,12 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { printError(err) } + if cfg.exitCodeFunc != nil { + if code, ok := cfg.exitCodeFunc(err); ok && code != 0 { + os.Exit(code) + } + } + os.Exit(1) } } diff --git a/cli/azd/pkg/azdext/run_coverage_test.go b/cli/azd/pkg/azdext/run_coverage_test.go index db0a46f2863..61d3a5bbbfd 100644 --- a/cli/azd/pkg/azdext/run_coverage_test.go +++ b/cli/azd/pkg/azdext/run_coverage_test.go @@ -145,6 +145,55 @@ func TestErrorMessage(t *testing.T) { } } +func TestWithExitCode(t *testing.T) { + called := false + extractor := func(err error) (int, bool) { + called = true + return 42, true + } + + opt := WithExitCode(extractor) + + var cfg runConfig + opt(&cfg) + + require.NotNil(t, cfg.exitCodeFunc) + + code, ok := cfg.exitCodeFunc(errors.New("test")) + require.True(t, called) + require.True(t, ok) + require.Equal(t, 42, code) +} + +func TestWithExitCode_NotMatched(t *testing.T) { + extractor := func(err error) (int, bool) { + return 0, false + } + + var cfg runConfig + WithExitCode(extractor)(&cfg) + + code, ok := cfg.exitCodeFunc(errors.New("test")) + require.False(t, ok) + require.Equal(t, 0, code) +} + +func TestWithExitCode_ZeroCodeTreatedAsUnmatched(t *testing.T) { + // Returning (0, true) should be treated as unmatched by Run() + // because exit code 0 would mask a real error. + extractor := func(err error) (int, bool) { + return 0, true + } + + var cfg runConfig + WithExitCode(extractor)(&cfg) + + code, ok := cfg.exitCodeFunc(errors.New("test")) + // The func itself returns (0, true), but Run() checks code != 0 + require.True(t, ok) + require.Equal(t, 0, code) +} + func TestVersion_IsSet(t *testing.T) { require.NotEmpty(t, Version) // Validate semver format (major.minor.patch with optional pre-release) diff --git a/eng/pipelines/release-ext-microsoft-azd-exec.yml b/eng/pipelines/release-ext-microsoft-azd-exec.yml new file mode 100644 index 00000000000..516123846df --- /dev/null +++ b/eng/pipelines/release-ext-microsoft-azd-exec.yml @@ -0,0 +1,33 @@ +# Continuous deployment trigger +trigger: + branches: + include: + - main + paths: + include: + - cli/azd/extensions/microsoft.azd.exec + - eng/pipelines/release-azd-extension.yml + - /eng/pipelines/templates/jobs/build-azd-extension.yml + - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml + - /eng/pipelines/templates/variables/image.yml + +pr: + paths: + include: + - cli/azd/extensions/microsoft.azd.exec + - eng/pipelines/release-ext-microsoft-azd-exec.yml + - eng/pipelines/release-azd-extension.yml + - eng/pipelines/templates/steps/publish-cli.yml + exclude: + - cli/azd/docs/** + +extends: + template: /eng/pipelines/templates/stages/1es-redirect.yml + parameters: + stages: + - template: /eng/pipelines/templates/stages/release-azd-extension.yml + parameters: + AzdExtensionId: microsoft.azd.exec + SanitizedExtensionId: microsoft-azd-exec + AzdExtensionDirectory: cli/azd/extensions/microsoft.azd.exec + SkipTests: true From c2fa5f89043d348b2530f03afb453423fa6d295f Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:31:32 -0700 Subject: [PATCH 2/3] docs: add azd exec design specification Comprehensive design spec covering execution model, environment handling, security considerations, and alternatives considered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft.azd.exec/design/design-spec.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 cli/azd/extensions/microsoft.azd.exec/design/design-spec.md diff --git a/cli/azd/extensions/microsoft.azd.exec/design/design-spec.md b/cli/azd/extensions/microsoft.azd.exec/design/design-spec.md new file mode 100644 index 00000000000..9628fee97e8 --- /dev/null +++ b/cli/azd/extensions/microsoft.azd.exec/design/design-spec.md @@ -0,0 +1,437 @@ +# azd exec — Design Specification + +## Problem Statement + +Running scripts and commands that need Azure Developer CLI environment variables +today requires manual workarounds: sourcing `.env` files, shell-specific `export` +commands, or language-specific environment loaders. There is no first-class way +to execute an arbitrary program with full azd context across platforms. + +This creates friction for common development workflows: + +- `python script.py` needs Azure credentials → manually export from `azd env get-values` +- `npm run dev` needs connection strings → source a `.env` file (shell-specific) +- `docker compose up` needs infra outputs → write a loader per language +- CI scripts need azd variables → pipe through `eval` or `source` (brittle) + +These workarounds are shell-specific, error-prone, and create inconsistency +across templates, samples, and team environments. + +**Prior art**: Issues [#391], [#1697], [#2336], [#4067], [#4384], [#7423]. + +[#391]: https://github.com/Azure/azure-dev/issues/391 +[#1697]: https://github.com/Azure/azure-dev/issues/1697 +[#2336]: https://github.com/Azure/azure-dev/issues/2336 +[#4067]: https://github.com/Azure/azure-dev/issues/4067 +[#4384]: https://github.com/Azure/azure-dev/issues/4384 +[#7423]: https://github.com/Azure/azure-dev/issues/7423 + +## Design Goals + +1. **Run any program with azd env** — `azd exec python script.py` should just work, + with the same simplicity as `env VAR=val command` on Unix. +2. **Exact argv by default** — Arguments pass through to the target program exactly + as specified. No shell interpolation, no quoting surprises, no argument swallowing. +3. **Cross-platform consistency** — The same `azd exec` invocation should behave + the same on Linux, macOS, and Windows. +4. **Shell access when needed** — When users explicitly want shell features + (expansion, pipes, globbing), there is a clear and intentional path. +5. **Child-process-only environment** — azd variables are injected into the child + process only. The caller's shell is never modified. +6. **Exit code fidelity** — The child process exit code propagates to the caller + for CI/CD pipeline integration. +7. **Zero configuration for common cases** — No flags required for the 80% use case + of "run this program with my azd env." + +## Non-Goals + +- Replacing azd hooks (hooks are project-defined; exec is ad hoc) +- Providing a REPL / interactive shell launcher (`azd shell`) +- Merging or syncing variables into app-specific config files +- Persisting environment variables in the user's current shell +- Service-scoped execution (running a specific azd service locally) + +## Execution Model + +`azd exec` supports three execution modes, selected automatically based on the +shape of the invocation. The user doesn't need to think about modes — the +heuristic does the right thing for each case. + +### Mode 1: Direct Process Execution (default for multi-arg) + +```bash +azd exec python script.py --port 8000 +azd exec npm run dev +azd exec docker compose up --build +``` + +**Semantics**: `exec.Command("python", "script.py", "--port", "8000")` + +The first argument is the program. Remaining arguments are its argv. No shell is +involved. Arguments are passed exactly as specified — no expansion, no quoting +reinterpretation, no metacharacter processing. + +This is the **default mode** when multiple arguments are provided without +`--shell`. It follows OS process semantics: the program receives exactly the +arguments the user typed. + +**Why this is the default**: The most common use case is "run my tool with azd env." +Users typing `azd exec python script.py` expect Python to receive `script.py` as +its first argument. Wrapping in a shell would change that contract — a user who +types `azd exec python script.py --port 8000` would be surprised if `--port 8000` +became shell positional parameters instead of Python arguments. + +### Mode 2: Shell Inline Execution + +```bash +azd exec 'echo $AZURE_ENV_NAME' +azd exec --shell pwsh "Write-Host $env:AZURE_STORAGE_ACCOUNT" +azd exec 'cat config.json | jq .connectionString' +``` + +**Semantics**: `bash -c "echo $AZURE_ENV_NAME"` + +A single string argument (or any invocation with `--shell`) is treated as a shell +expression. The shell processes the string — expanding variables, evaluating pipes, +interpreting glob patterns. + +This mode is selected when: +- A single argument is provided (without `--shell`), OR +- The `--shell` flag is explicitly set + +**When to use**: When you need shell features — variable expansion, pipes, +redirection, command chaining, or glob patterns. + +### Mode 3: Script File Execution + +```bash +azd exec ./setup.sh +azd exec ./build.ps1 -- --verbose +``` + +**Semantics**: `bash ./setup.sh` (shell detected from file extension) + +When the first argument is a path to an existing file, it is executed as a script. +The shell is auto-detected from the file extension (`.sh` → bash, `.ps1` → pwsh, +`.cmd` → cmd) or can be overridden with `--shell`. + +Arguments after `--` are passed to the script. + +### Selection Heuristic + +``` +Input is an existing file path? + YES → Mode 3 (script file execution) + NO → Multiple arguments AND no --shell flag? + YES → Mode 1 (direct process execution) + NO → Mode 2 (shell inline execution) +``` + +The `--shell` flag always forces shell-mediated execution (Mode 2 or Mode 3). + +## Shell Selection + +When shell-mediated execution is used (Modes 2 and 3), the shell is determined by: + +1. **Explicit `--shell` flag** — highest priority. Accepts: `bash`, `sh`, `zsh`, + `pwsh`, `powershell`, `cmd`. +2. **File extension** (Mode 3 only) — `.sh`/`.bash` → bash, `.zsh` → zsh, + `.ps1` → pwsh, `.cmd`/`.bat` → cmd. +3. **Platform default** — `bash` on Linux/macOS, `powershell` on Windows. + +The shell whitelist is fixed. Unknown shell names are rejected at startup with a +clear error listing valid options. + +**On the choice of platform defaults**: `bash` is the default on Unix because it +is the most widely available POSIX-superset shell. In environments where only +`sh` is available (e.g., Alpine containers), users can specify `--shell sh`. +The direct exec mode (Mode 1) sidesteps this entirely — no shell is needed. + +## Environment Handling + +### What the child process receives + +The child process inherits the full parent process environment (`os.Environ()`), +which includes: + +1. **System environment variables** — PATH, HOME, TERM, etc. +2. **azd environment values** — AZURE_ENV_NAME, AZURE_SUBSCRIPTION_ID, and all + user-defined values from `azd env set` or infrastructure outputs. +3. **Resolved secrets** — Any `azvs://` or `@Microsoft.KeyVault(...)` references + in the azd environment are resolved to their plaintext values. +4. **azd context flags** — `AZD_DEBUG` and `AZD_NO_PROMPT` are propagated when set. + +### How environment loading works + +The azd host (not the extension) handles environment loading: + +1. Host reads the selected azd environment (`azd env select` or `--environment`) +2. Host resolves Key Vault secret references via `KeyVaultService.SecretFromKeyVaultReference()` +3. Host passes resolved values to the extension subprocess via `InvokeOptions.Env` +4. Extension inherits these as `os.Environ()` and passes them through to child processes + +The extension itself performs no secret resolution. It receives an already-resolved +environment from the host and forwards it. This is the same model used by azd hooks. + +### Secret materialization + +Key Vault secret references (`akvs://vault/secret` and `@Microsoft.KeyVault(SecretUri=...)`) +are resolved by the azd host before the extension runs. The child process receives +plaintext secret values in its environment. + +This behavior is: + +- **Consistent with azd hooks** — hooks receive the same resolved environment +- **Not controllable by the extension** — resolution happens at the host level +- **Implicit** — there is no opt-in/opt-out flag today + +**Trade-off acknowledged**: Automatic secret materialization is convenient but +carries risk — any program launched via `azd exec` can read all resolved secrets. +This is documented in the README security considerations. If an opt-in mechanism +is added in the future, it should be at the azd host level (affecting all +extensions and hooks), not per-extension. + +### What `azd exec` does NOT do + +- Does not modify the caller's shell environment +- Does not write `.env` files +- Does not merge with app-specific configuration +- Does not filter which environment variables are passed through + +## Argument Passing + +### Direct exec (Mode 1) + +Arguments are passed as-is via `exec.Command(program, args...)`. No quoting, +escaping, or transformation. The OS process creation API handles separation. + +```bash +azd exec python script.py --port 8000 --reload +# → exec.Command("python", "script.py", "--port", "8000", "--reload") +``` + +### Shell inline (Mode 2) + +The script string is passed as a single argument to the shell's evaluation flag: + +| Shell | Invocation | +|-------|-----------| +| bash/sh/zsh | `bash -c "script" --` | +| pwsh/powershell | `pwsh -Command "script"` | +| cmd | `cmd /c "script"` | + +The `--` sentinel after `-c` for bash/sh/zsh prevents extra arguments from being +treated as shell options. + +### Script file (Mode 3) + +Script arguments (after `--`) are appended to the shell invocation: + +| Shell | Invocation | +|-------|-----------| +| bash/sh/zsh | `bash script.sh arg1 arg2` | +| pwsh/powershell | `pwsh -File script.ps1 'arg1' 'arg2'` | +| cmd | `cmd /c "script.cmd" "arg1" "arg2"` | + +PowerShell arguments are single-quote escaped. cmd.exe arguments are quoted with +metacharacter handling. bash/sh/zsh arguments pass through directly (the shell +handles tokenization). + +## Cross-Platform Behavior + +### Shell-specific quoting + +Each shell has different quoting rules. The command builder handles this per-shell: + +- **bash/sh/zsh**: Arguments appended directly (shell handles tokenization) +- **PowerShell**: Arguments wrapped in single quotes; embedded `'` escaped by doubling (`''`) +- **cmd.exe**: Arguments wrapped in double quotes when they contain spaces or + metacharacters (`&|<>^%`); embedded `"` escaped by doubling (`""`) + +### cmd.exe considerations + +cmd.exe has inherent limitations: + +- **`%VAR%` expansion in double quotes**: cmd.exe expands environment variable + references even inside double quotes. This is an OS-level behavior that cannot + be mitigated programmatically. +- **Control character stripping**: The command builder strips `\n`, `\r`, `\x00`, + `\x0B`, `\x0C`, `\x1A`, and `\x1B` from arguments before passing to cmd.exe. + These characters act as command separators or have special meaning to the cmd.exe + parser. +- **Raw command line override**: On Windows, the Go `exec.Cmd.SysProcAttr.CmdLine` + field is used to bypass Go's default `CommandLineToArgvW` escaping, which is + incompatible with cmd.exe's quoting conventions. + +**Recommendation**: Prefer `--shell pwsh` over `--shell cmd` for shell-mediated +execution on Windows. PowerShell has consistent quoting semantics. + +### Direct exec avoids all shell issues + +Mode 1 (direct exec) bypasses all shell-specific behavior. No quoting, no +expansion, no platform-specific metacharacter handling. This is why it is the +default for multi-argument invocations. + +## Error Handling + +### Structured error types + +| Type | When | Example | +|------|------|---------| +| `ValidationError` | Invalid input | Empty script path, empty command | +| `ScriptNotFoundError` | File doesn't exist | `azd exec nonexistent.sh` | +| `InvalidShellError` | Unknown shell name | `azd exec --shell fish ...` | +| `ExecutionError` | Non-zero exit code | Script returns exit code 1 | + +### Exit code propagation + +When the child process exits with a non-zero code, `azd exec` exits with the same +code. This enables CI/CD integration: + +```bash +azd exec python -m pytest || echo "Tests failed" +``` + +The `main.go` entry point intercepts `ExecutionError` and calls `os.Exit(code)` +directly, ensuring the azd process reflects the child's exit status. + +### Non-exit errors + +Errors that are not exit codes (e.g., program not found, permission denied) are +wrapped with context and returned as Go errors. The error message identifies +whether the failure was in direct exec, shell inline, or script file mode. + +## Security Considerations + +### Command injection + +- **Direct exec mode**: No shell involvement → no injection surface. Arguments + are passed via OS process creation, not parsed by a shell. +- **Shell inline mode**: The user's input IS the shell command. This is by design — + the user explicitly opted into shell execution. The extension does not attempt + to sanitize shell expressions. +- **Script file mode**: The script path is validated (must exist, must be a file), + but the script's contents are not inspected. + +### Argument sanitization (cmd.exe) + +The `quoteCmdArg` function strips control characters that cmd.exe interprets as +command separators (`\n`, `\r`, `\x00`, `\x0B`, `\x0C`, `\x1A`, `\x1B`) and +quotes arguments containing metacharacters. This prevents argument injection +through embedded newlines or metacharacters when using `--shell cmd`. + +### Debug logging + +Debug output (`AZD_DEBUG=true`) uses `%q` formatting for all paths and arguments, +ensuring control characters are rendered as escape sequences rather than +interpreted by the terminal. + +### Trust model + +`azd exec` executes arbitrary programs by design. The trust boundary is the same +as typing commands in a terminal — the user is responsible for what they execute. +The extension adds no elevation, no sandbox, and no restriction beyond what the +OS provides. + +## CLI Interface + +``` +azd exec [command] [args...] | [script-file] [-- script-args...] + +Flags: + -s, --shell string Shell to use (bash, sh, zsh, pwsh, powershell, cmd) + -i, --interactive Connect stdin for interactive scripts + --help Show help + +Global flags (inherited from azd): + --debug Enable debug logging + --no-prompt Disable interactive prompts +``` + +### Flag parsing + +- `--` separates azd exec flags from script/command arguments +- Unknown flags after the first positional argument are passed through + (`UnknownFlags = true`, `SetInterspersed(false)`) +- This means `azd exec python --version` passes `--version` to Python, + not to azd exec + +## Architecture + +``` +main.go Entry point (exit code propagation) +├── internal/cmd/root.go CLI command definition + mode heuristic +├── internal/executor/ +│ ├── executor.go Three execution modes +│ ├── command_builder.go Shell-specific command construction +│ ├── command_builder_windows.go Windows CmdLine override +│ ├── command_builder_notwindows.go No-op on non-Windows +│ └── errors.go Structured error types +└── internal/shellutil/shellutil.go Shell detection + validation +``` + +3 internal packages. No circular dependencies. No external dependencies beyond +the azd SDK (`pkg/azdext`) and cobra. + +## Alternatives Considered + +### Core command vs extension + +A core `azd env exec` command was proposed in [#7423]. The extension approach +was chosen for: + +- **Iteration speed** — Extensions ship independently from the azd release train +- **Binary size** — No impact on the core azd binary +- **Scope isolation** — Execution engine doesn't need to live in the core command tree + +Trade-off: Extensions require `azd extension install` — less discoverable than +a built-in command. The capability can be promoted to core if adoption justifies it. + +### Shell-only model + +An earlier design used shell wrapping for all invocations. This caused a subtle +but critical bug: `azd exec python script.py` would produce +`bash -c "python" -- script.py`, where `script.py` becomes `$1` in bash (a +positional parameter to bash, not an argument to Python). Python would run with +zero arguments. + +Direct exec (Mode 1) was added specifically to fix this. The current model gives +users OS process semantics by default, with shell access available when requested. + +### Separate commands (exec vs shell) + +The idea of splitting into two commands — `azd exec` for OS-level process +execution and `azd shell` for shell-mediated execution — was considered. A single +command with an automatic heuristic was chosen because: + +- Users shouldn't need to think about the distinction for common cases +- The heuristic is deterministic and predictable +- The `--shell` flag provides an explicit escape hatch + +### Opt-in secret resolution + +Making Key Vault secret resolution opt-in (via a flag) was considered. This +would need to be implemented at the azd host level, not in the extension, because +the host resolves secrets before extensions start. The current behavior is +consistent with azd hooks. This remains open for future host-level changes. + +## Testing + +94.3% statement coverage across all packages: + +| Package | Coverage | Test count | +|---------|----------|------------| +| cmd | 91.4% | 7 | +| executor | 96.5% | 11 | +| shellutil | 93.3% | 5 | + +Tests use real command execution (not mocks) for fidelity. Platform-specific +behavior is handled via build tags and runtime detection. Table-driven test +patterns throughout. + +## References + +- [PR #7400](https://github.com/Azure/azure-dev/pull/7400) — Implementation +- [Issue #7520](https://github.com/Azure/azure-dev/issues/7520) — Tracking issue +- [Issue #7423](https://github.com/Azure/azure-dev/issues/7423) — Core command proposal +- [Issue #4384](https://github.com/Azure/azure-dev/issues/4384) — Original env loading request From a263050f7eb1b097345949bb17c649ae421ac85a Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:56:41 -0700 Subject: [PATCH 3/3] fix: update extension Go version to 1.26.1 and fix cspell config - Bump microsoft.azd.exec go.mod from 1.26.0 to 1.26.1 to match repo - Add azvs, notwindows to cspell override for design-spec.md - Broaden cspell override glob to cover all extension files, not just .go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell.yaml | 4 +++- cli/azd/extensions/microsoft.azd.exec/go.mod | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 8a5df7bac39..84250024e46 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -375,8 +375,10 @@ overrides: words: - covdata - GOWORK - - filename: extensions/microsoft.azd.exec/**/*.go + - filename: extensions/microsoft.azd.exec/** words: + - azvs + - notwindows - shellutil ignorePaths: - "**/*_test.go" diff --git a/cli/azd/extensions/microsoft.azd.exec/go.mod b/cli/azd/extensions/microsoft.azd.exec/go.mod index d0f1fb15f02..01878bbdbcd 100644 --- a/cli/azd/extensions/microsoft.azd.exec/go.mod +++ b/cli/azd/extensions/microsoft.azd.exec/go.mod @@ -1,6 +1,6 @@ module microsoft.azd.exec -go 1.26.0 +go 1.26.1 replace github.com/azure/azure-dev/cli/azd => ../../