Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) {
language.HookKindPython: language.NewPythonExecutor,
language.HookKindJavaScript: language.NewJavaScriptExecutor,
language.HookKindTypeScript: language.NewTypeScriptExecutor,
language.HookKindDotNet: language.NewDotNetExecutor,
}

for kind, constructor := range hookExecutorMap {
Expand Down
52 changes: 49 additions & 3 deletions cli/azd/docs/language-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ unified lifecycle regardless of its executor: **Prepare → Execute → Cleanup*
| Python | `python` | `.py` | ✅ Phase 1 |
| JavaScript | `js` | `.js` | ✅ Phase 2 |
| TypeScript | `ts` | `.ts` | ✅ Phase 3 |
| .NET (C#) | `dotnet` | `.cs` | 🔜 Planned |
| .NET (C#) | `dotnet` | `.cs` | ✅ Phase 4 |

## Configuration

Expand Down Expand Up @@ -224,6 +224,49 @@ hooks:
shell: sh
```

### .NET hook with project — auto-detected from .cs extension

When a `.csproj` (or `.fsproj`/`.vbproj`) is found near the script, azd
automatically runs `dotnet restore` and `dotnet build` during preparation,
then executes via `dotnet run --project`.

```yaml
hooks:
postprovision:
run: ./hooks/seed-database.cs
# .csproj in ./hooks/ → restore + build run automatically
```

### .NET single-file hook (.NET 10+)

On .NET 10 or later, single `.cs` files can run without a project file.
azd detects the SDK version and runs `dotnet run script.cs` directly.

```yaml
hooks:
postprovision:
run: ./hooks/seed-database.cs
# No .csproj nearby + .NET 10+ SDK → single-file execution
```

### .NET hook — explicit kind

```yaml
hooks:
postprovision:
run: ./hooks/setup
kind: dotnet
```

### .NET hook with working directory override

```yaml
hooks:
postprovision:
run: ./tools/scripts/seed.cs
dir: ./tools # .csproj is in ./tools, not ./tools/scripts
```

## How It Works

Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle:
Expand Down Expand Up @@ -254,11 +297,14 @@ Every hook follows the unified **Prepare → Execute → Cleanup** lifecycle:
- **Inline scripts** are only supported for Bash and PowerShell hooks.
All other executor types must reference a file path.
- **Phase 1** supports Python as a non-shell executor.
**Phase 2** adds JavaScript and **Phase 3** adds TypeScript.
.NET support is planned for a future phase.
**Phase 2** adds JavaScript, **Phase 3** adds TypeScript,
and **Phase 4** adds .NET (C#).
- **Virtual environments** (Python) are created in the project directory alongside
the dependency file, following the naming convention `{dirName}_env`.
- **TypeScript** hooks require Node.js 18+ and use `npx tsx` for execution.
If `tsx` is not installed locally, `npx` will download it automatically.
- **Package manager** for JS/TS hooks currently uses npm for dependency
installation. Support for pnpm and yarn may be added in a future release.
- **.NET single-file** execution (`.cs` without a `.csproj`) requires .NET SDK
10.0.0 or later. On older SDKs, create a `.csproj` project file alongside
the script.
14 changes: 14 additions & 0 deletions cli/azd/pkg/tools/dotnet/dotnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ func (cli *Cli) CheckInstalled(ctx context.Context) error {
return nil
}

// SdkVersion returns the installed .NET SDK version by running
// `dotnet --version` and parsing the output as semver.
func (cli *Cli) SdkVersion(ctx context.Context) (semver.Version, error) {
res, err := cli.commandRunner.Run(ctx, newDotNetRunArgs("--version"))
if err != nil {
return semver.Version{}, fmt.Errorf("checking %s version: %w", cli.Name(), err)
}
ver, err := tools.ExtractVersion(res.Stdout)
if err != nil {
return semver.Version{}, fmt.Errorf("parsing .NET SDK version from %q: %w", res.Stdout, err)
}
return ver, nil
}

func (cli *Cli) Restore(ctx context.Context, project string, env []string) error {
runArgs := newDotNetRunArgs("restore", project)
// Append user env vars to preserve base env set by newDotNetRunArgs (DOTNET_NOLOGO, etc.)
Expand Down
236 changes: 236 additions & 0 deletions cli/azd/pkg/tools/language/dotnet_executor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package language

import (
"context"
"fmt"
"path/filepath"

"github.com/azure/azure-dev/cli/azd/pkg/errorhandler"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
"github.com/blang/semver/v4"
)

// dotnetTools abstracts the .NET CLI operations needed by
// dotnetExecutor, decoupling it from the concrete [dotnet.Cli]
// for testability. [dotnet.Cli] satisfies this interface.
type dotnetTools interface {
CheckInstalled(ctx context.Context) error
SdkVersion(ctx context.Context) (semver.Version, error)
Restore(ctx context.Context, project string, env []string) error
Build(
ctx context.Context,
project string, configuration string,
output string, env []string,
) error
}

// minSingleFileVersion is the minimum .NET SDK version that
// supports running single .cs files without a project file.
var minSingleFileVersion = semver.Version{
Major: 10, Minor: 0, Patch: 0,
}

// dotnetExecutor implements [tools.HookExecutor] for .NET (C#)
// scripts. It supports two execution modes:
// - Project mode: when a .csproj/.fsproj/.vbproj is discovered
// near the script, runs dotnet restore → build → run --project.
// - Single-file mode: when no project file is found and the SDK
// is .NET 10+, runs dotnet run script.cs directly.
type dotnetExecutor struct {
commandRunner exec.CommandRunner
dotnetCli dotnetTools

// projectPath is set by Prepare when a .NET project file is
// discovered. Empty means single-file mode.
projectPath string
}

// NewDotNetExecutor creates a .NET HookExecutor.
// Takes only IoC-injectable deps.
func NewDotNetExecutor(
commandRunner exec.CommandRunner,
dotnetCli *dotnet.Cli,
) tools.HookExecutor {
return newDotNetExecutorInternal(commandRunner, dotnetCli)
}

// newDotNetExecutorInternal creates a dotnetExecutor using the
// dotnetTools interface. This allows tests to inject mocks.
func newDotNetExecutorInternal(
commandRunner exec.CommandRunner,
dotnetCli dotnetTools,
) *dotnetExecutor {
return &dotnetExecutor{
commandRunner: commandRunner,
dotnetCli: dotnetCli,
}
}

// Prepare verifies that the .NET SDK is installed and, when a
// project file (.csproj/.fsproj/.vbproj) is found near the script,
// runs dotnet restore and dotnet build.
//
// For single-file .cs scripts (no project file), Prepare validates
// that the installed SDK supports single-file execution (.NET 10+).
func (e *dotnetExecutor) Prepare(
ctx context.Context,
scriptPath string,
execCtx tools.ExecutionContext,
) error {
// 1. Verify .NET SDK is installed.
if err := e.dotnetCli.CheckInstalled(ctx); err != nil {
return &errorhandler.ErrorWithSuggestion{
Err: err,
Message: ".NET SDK is required to run " +
".NET hooks.",
Suggestion: "Install the .NET SDK from " +
"https://dotnet.microsoft.com/download",
Links: []errorhandler.ErrorLink{{
Title: "Download .NET SDK",
URL: "https://dotnet.microsoft.com/download",
}},
}
}

// 2. Discover .NET project context (.csproj/.fsproj/.vbproj).
// Uses DiscoverDotNetProject instead of the generic
// DiscoverProjectFile to avoid Python/Node.js project files
// shadowing the .NET project file in mixed-language directories.
projCtx, err := DiscoverDotNetProject(
scriptPath, execCtx.BoundaryDir,
)
if err != nil {
return fmt.Errorf(
"discovering .NET project file: %w", err,
)
}

// 3a. Project mode: restore and build.
if projCtx != nil {
if err := e.dotnetCli.Restore(
ctx, projCtx.DependencyFile, execCtx.EnvVars,
); err != nil {
return fmt.Errorf(
"dotnet restore failed for %q: %w",
projCtx.DependencyFile, err,
)
}

if err := e.dotnetCli.Build(
ctx, projCtx.DependencyFile, "", "",
execCtx.EnvVars,
); err != nil {
return fmt.Errorf(
"dotnet build failed for %q: %w",
projCtx.DependencyFile, err,
)
}

e.projectPath = projCtx.DependencyFile
return nil
}

// 3b. Single-file mode: validate SDK version >= 10.
sdkVer, err := e.dotnetCli.SdkVersion(ctx)
if err != nil {
return fmt.Errorf(
"detecting .NET SDK version: %w", err,
)
}

if sdkVer.LT(minSingleFileVersion) {
return &errorhandler.ErrorWithSuggestion{
Err: fmt.Errorf(
".NET SDK %s does not support single-file "+
"C# execution (requires .NET 10+)",
sdkVer,
),
Message: fmt.Sprintf(
"Single-file .cs hooks require .NET SDK "+
"10.0.0 or later (installed: %s).",
sdkVer,
),
Suggestion: "Create a .csproj project file " +
"alongside your script, or upgrade to " +
".NET 10 or later.",
Links: []errorhandler.ErrorLink{{
Title: "Download .NET 10",
URL: "https://dotnet.microsoft.com/" +
"download/dotnet/10.0",
}},
}
}

return nil
}

// Execute runs the .NET hook at the given path.
//
// In project mode (Prepare found a project file):
//
// dotnet run --project <project_path>
//
// In single-file mode:
//
// dotnet run <script.cs>
func (e *dotnetExecutor) Execute(
ctx context.Context,
scriptPath string,
execCtx tools.ExecutionContext,
) (exec.RunResult, error) {
var runArgs exec.RunArgs

if e.projectPath != "" {
// Project mode — skip restore/build since Prepare
// already ran them.
runArgs = exec.NewRunArgs(
"dotnet", "run",
"--project", e.projectPath,
"--no-build",
)
} else {
// Single-file mode.
runArgs = exec.NewRunArgs(
"dotnet", "run", scriptPath,
)
}

// Set standard dotnet env vars to suppress noisy output,
// then append user-provided env vars.
runArgs = runArgs.WithEnv(append(
[]string{
"DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE=1",
"DOTNET_NOLOGO=1",
},
execCtx.EnvVars...,
))

// Prefer configured cwd; fall back to script's directory.
cwd := execCtx.Cwd
if cwd == "" {
cwd = filepath.Dir(scriptPath)
}
runArgs = runArgs.WithCwd(cwd)

if execCtx.Interactive != nil {
runArgs = runArgs.WithInteractive(
*execCtx.Interactive,
)
}
if execCtx.StdOut != nil {
runArgs = runArgs.WithStdOut(execCtx.StdOut)
}

return e.commandRunner.Run(ctx, runArgs)
}

// Cleanup is a no-op for the .NET executor — no temporary
// resources are created during Prepare.
func (e *dotnetExecutor) Cleanup(_ context.Context) error {
return nil
}
Loading
Loading