From 5ec4c5d0be539f58da7c017e9ac2591000c1eefa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 24 Nov 2025 19:54:58 +0000
Subject: [PATCH 01/32] Initial plan
From 257a01e68d50603cd587466f643f9e690c08f1c8 Mon Sep 17 00:00:00 2001
From: Alex Crome <289860+afscrome@users.noreply.github.com>
Date: Mon, 24 Nov 2025 18:21:50 +0000
Subject: [PATCH 02/32] Initial playground using `dotnet tool isntall` and then
running the tool.
---
Aspire.slnx | 3 +
Directory.Packages.props | 1 +
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 55 +++++
.../DotNetToolAnnotation.cs | 12 ++
.../DotNetToolExtensions.cs | 192 ++++++++++++++++++
.../DotnetTool.AppHost.csproj | 23 +++
.../DotnetTool.AppHost/DotnetToolInstaller.cs | 7 +
.../DotnetTool.AppHost/DotnetToolResource.cs | 32 +++
.../Properties/launchSettings.json | 44 ++++
.../appsettings.Development.json | 8 +
.../DotnetTool.AppHost/appsettings.json | 9 +
11 files changed, 386 insertions(+)
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json
create mode 100644 playground/DotnetTool/DotnetTool.AppHost/appsettings.json
diff --git a/Aspire.slnx b/Aspire.slnx
index a1ea50410f8..13e5a42f743 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -171,6 +171,9 @@
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 48bf97791f5..d724292507c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -120,6 +120,7 @@
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
new file mode 100644
index 00000000000..c51522d3126
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -0,0 +1,55 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+using DotnetTool.AppHost;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+builder.AddDotnetTool("ef", "dotnet-ef", "dotnet-ef");
+
+// Multiple versions
+builder.AddDotnetTool("dump1", "dotnet-dump", "dotnet-dump")
+ .WithArgs("--version")
+ .WithPackageVersion("9.0.652701");
+builder.AddDotnetTool("dump2", "dotnet-dump", "dotnet-dump")
+ .WithPackageVersion("9.0.621003")
+ .WithArgs("--version");
+
+// Concurrency
+for (int i = 0; i < 5; i++)
+{
+ builder.AddDotnetTool($"trace-{i}", "dotnet-trace", "dotnet-trace")
+ .WithArgs("--version");
+}
+
+foreach(var resource in builder.Resources.OfType())
+{
+ builder.CreateResourceBuilder(resource)
+ .WithCommand("reset", "Reset", ctx =>
+ {
+ try
+ {
+ var path = Path.GetDirectoryName(resource.Command);
+ Directory.Delete(path!, true);
+ return Task.FromResult(CommandResults.Success());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(CommandResults.Failure(ex));
+ }
+ }, new CommandOptions
+ {
+ IconName = "ArrowReset"
+ });
+}
+
+#if !SKIP_DASHBOARD_REFERENCE
+// This project is only added in playground projects to support development/debugging
+// of the dashboard. It is not required in end developer code. Comment out this code
+// or build with `/p:SkipDashboardReference=true`, to test end developer
+// dashboard launch experience, Refer to Directory.Build.props for the path to
+// the dashboard binary (defaults to the Aspire.Dashboard bin output in the
+// artifacts dir).
+builder.AddProject(KnownResourceNames.AspireDashboard);
+#endif
+
+builder.Build().Run();
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
new file mode 100644
index 00000000000..b316ffa7ed1
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
@@ -0,0 +1,12 @@
+namespace DotnetTool.AppHost;
+
+public class DotNetToolAnnotation : IResourceAnnotation
+{
+ public required string PackageId { get; set; }
+ public string? Version { get; set; }
+ public bool Prerelease { get; set; }
+ public List Sources { get; } = [];
+ public bool IgnoreExistingFeeds { get; set; }
+ public bool IgnoreFailedSources { get; set; }
+ public bool AllowDowngrade { get; set; }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
new file mode 100644
index 00000000000..ac6876f8fba
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -0,0 +1,192 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DotnetTool.AppHost;
+
+///
+/// Provides extension methods for adding Dotnet Tool resources to the application model.
+///
+public static class DotNetToolExtensions
+{
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string command, string packageId, Action> configure)
+ {
+ var tool = builder.AddDotnetTool(name, packageId, command);
+ configure(tool);
+ return tool;
+ }
+
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string command, string packageId)
+ => builder.AddDotnetTool(new DotnetToolResource(name, packageId, command));
+
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource)
+ where T: DotnetToolResource
+ {
+ var tool = builder.AddResource(resource)
+ .WithIconName("Toolbox");
+
+ var installer = BuildInstaller();
+ RewriteToolCommand();
+
+ return tool.WaitForCompletion(installer);
+
+ void RewriteToolCommand()
+ {
+ // To avoid excess redownloading, want to set the tool path to a
+ // .Net 10's `dotnet tool exec` would handle a lot of that natively
+ // Although https://github.com/dotnet/sdk/issues/50579 is a complication
+ // In the meantime, download tool to a path based on IAspireStore
+ //
+ // Using BeforeStartEvent rather than BeforeResoruceStart as the latter event
+ // gets called multiple times, and prepending would break the path
+ builder.Eventing.Subscribe((evt, ct) =>
+ {
+ if (Path.IsPathFullyQualified(resource.Command))
+ {
+ throw new ArgumentException("Executable must not have an absolute path to run as a tool", nameof(builder));
+ }
+
+ var toolDirectory = GetToolDirectory(evt.Services, tool);
+ tool.WithCommand(Path.Combine(toolDirectory, resource.Command));
+
+ return Task.CompletedTask;
+ });
+ }
+
+ IResourceBuilder BuildInstaller()
+ {
+ var installerResource = new DotnetToolInstaller($"{tool.Resource.Name}-installer", "dotnet") { Parent = tool.Resource };
+
+ return builder
+ .AddResource(installerResource)
+ .WithArgs(x =>
+ {
+ var toolDirectory = GetToolDirectory(x.ExecutionContext.ServiceProvider, tool);
+ var toolConfig = tool.Resource.ToolConfiguration;
+
+ x.Args.Add("tool");
+ x.Args.Add("install");
+ x.Args.Add(toolConfig.PackageId);
+ x.Args.Add("--tool-path");
+ x.Args.Add(toolDirectory);
+
+ var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
+
+ foreach (var source in toolConfig.Sources)
+ {
+ x.Args.Add(sourceArg);
+ x.Args.Add(source);
+ }
+
+ if (toolConfig.IgnoreFailedSources)
+ {
+ x.Args.Add("--ignore-failed-sources");
+ }
+
+ if (toolConfig.Version is not null)
+ {
+ x.Args.Add("--version");
+ x.Args.Add(toolConfig.Version);
+ }
+ else if (toolConfig.Prerelease)
+ {
+ x.Args.Add("--prerelease");
+ }
+
+ if (toolConfig.AllowDowngrade)
+ {
+ x.Args.Add("--allow-downgrade");
+ }
+
+ x.Args.Add("--verbosity");
+ x.Args.Add("detailed");
+ })
+ .WithIconName("ArrowDownload")
+ .WithParentRelationship(tool)
+ .WithOfflineFallback();
+ }
+
+ string GetToolDirectory(IServiceProvider serviceProvider, IResourceBuilder tool)
+ {
+ var builder = tool.ApplicationBuilder;
+ var explicitPath = builder.Configuration["ASPIRE_TOOLBASEPATH"];
+
+ if (!string.IsNullOrEmpty(explicitPath))
+ {
+ return Path.Combine(explicitPath, builder.Environment.ApplicationName, tool.Resource.Name);
+ }
+ else
+ {
+ var store = serviceProvider.GetRequiredService();
+ return Path.Combine(store.BasePath, "tools", tool.Resource.Name);
+ }
+ }
+ }
+
+ public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.PackageId = packageId;
+ return builder;
+ }
+
+ ///
+ /// Set the package version for a tool to use
+ ///
+ /// The Dotnet Tool resource type
+ /// The .
+ /// The package version to use
+ /// The for chaining.
+ public static IResourceBuilder WithPackageVersion(this IResourceBuilder builder, string version)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.Version = version;
+ return builder;
+ }
+
+ public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder builder)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.Prerelease = true;
+ return builder;
+ }
+
+ public static IResourceBuilder WithPackageSource(this IResourceBuilder builder, string source)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.Sources.Add(source);
+ return builder;
+ }
+
+ public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResourceBuilder builder)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.IgnoreExistingFeeds = true;
+ return builder;
+ }
+
+ public static IResourceBuilder WithPackageIgnoreFailedSources(this IResourceBuilder builder)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.IgnoreFailedSources = true;
+ return builder;
+ }
+
+ public static IResourceBuilder WithPackageAllowDowngrade(this IResourceBuilder builder)
+ where T : DotnetToolResource
+ {
+ builder.Resource.ToolConfiguration.AllowDowngrade = true;
+ return builder;
+ }
+
+ private static IResourceBuilder WithOfflineFallback(this IResourceBuilder builder)
+ {
+ return builder.WithArgs(x =>
+ {
+ var settings = NuGet.Configuration.Settings.LoadDefaultSettings(root: builder.Resource.WorkingDirectory);
+ var packagesPath = NuGet.Configuration.SettingsUtility.GetGlobalPackagesFolder(settings);
+
+ builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent)
+ .WithPackageSource(packagesPath)
+ .WithPackageIgnoreFailedSources();
+ });
+ }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
new file mode 100644
index 00000000000..9ef0172c69b
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+ Exe
+ $(DefaultTargetFramework)
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
new file mode 100644
index 00000000000..0530b0975a8
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
@@ -0,0 +1,7 @@
+namespace DotnetTool.AppHost;
+
+internal sealed class DotnetToolInstaller(string name, string command) :
+ ExecutableResource(name, command, string.Empty), IResourceWithParent
+{
+ public required DotnetToolResource Parent { get; init; }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
new file mode 100644
index 00000000000..183d11e6846
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
@@ -0,0 +1,32 @@
+namespace DotnetTool.AppHost;
+
+///
+/// Represents a .NET tool resource that encapsulates metadata about a .NET CLI tool, including its name, package ID,
+/// and command.
+///
+/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and
+/// command with its package ID, and ensures that the required metadata is properly annotated.
+public class DotnetToolResource : ExecutableResource
+{
+ /// The name of the resource.
+ /// The package id of the tool
+ /// The command to execute.
+ public DotnetToolResource(string name, string packageId, string command)
+ : base(name, command, string.Empty)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
+ Annotations.Add(new DotNetToolAnnotation { PackageId = packageId });
+ }
+
+ internal DotNetToolAnnotation ToolConfiguration
+ {
+ get
+ {
+ if (!this.TryGetLastAnnotation(out var toolConfig))
+ {
+ throw new InvalidOperationException("DotNetToolAnnotation is missing");
+ }
+ return toolConfig;
+ }
+ }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000000..eefe510114d
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:18001;http://localhost:18002",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18003",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:18004",
+ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:18002",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18003",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:18005",
+ "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
+ "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
+ }
+ },
+ "generate-manifest": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "dotnetRunMessages": true,
+ "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json",
+ "applicationUrl": "http://localhost:18002",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18003"
+ }
+ }
+ }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json
new file mode 100644
index 00000000000..0c208ae9181
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/appsettings.json b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json
new file mode 100644
index 00000000000..31c092aa450
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
From 2c67f06b1f48e78b228488a0f738ed233d674110 Mon Sep 17 00:00:00 2001
From: Alex Crome <289860+afscrome@users.noreply.github.com>
Date: Mon, 24 Nov 2025 18:53:51 +0000
Subject: [PATCH 03/32] Rework to use `dotnet tool exec`
---
Directory.Packages.props | 1 -
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 107 ++++++++---
.../DotNetToolExtensions.cs | 177 +++++-------------
.../DotnetTool.AppHost/DotnetToolResource.cs | 5 +-
4 files changed, 133 insertions(+), 157 deletions(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d724292507c..48bf97791f5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -120,7 +120,6 @@
-
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index c51522d3126..035e32b2b80 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -1,46 +1,108 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using DotnetTool.AppHost;
+using Microsoft.Extensions.DependencyInjection;
var builder = DistributedApplication.CreateBuilder(args);
-builder.AddDotnetTool("ef", "dotnet-ef", "dotnet-ef");
+var simpleUsage = builder.AddDotnetTool("simpleUsage", "dotnet-ef");
+
+var wildcardVersion = builder.AddDotnetTool("wildcard", "dotnet-ef")
+ .WithPackageVersion("10.0.*")
+ .WithParentRelationship(simpleUsage);
+
+var preRelease = builder.AddDotnetTool("prerelease", "dotnet-ef")
+ .WithPackagePrerelease()
+ .WithParentRelationship(simpleUsage);
// Multiple versions
-builder.AddDotnetTool("dump1", "dotnet-dump", "dotnet-dump")
+var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump")
.WithArgs("--version")
.WithPackageVersion("9.0.652701");
-builder.AddDotnetTool("dump2", "dotnet-dump", "dotnet-dump")
+builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump")
.WithPackageVersion("9.0.621003")
- .WithArgs("--version");
+ .WithArgs("--version")
+ .WithParentRelationship(differentVersion);
// Concurrency
+IResourceBuilder? concurrencyParent = null;
for (int i = 0; i < 5; i++)
{
- builder.AddDotnetTool($"trace-{i}", "dotnet-trace", "dotnet-trace")
+ var concurrency = builder.AddDotnetTool($"sametoolconcurrency-{i}", "dotnet-trace")
.WithArgs("--version");
+
+ if (concurrencyParent == null)
+ {
+ concurrencyParent = concurrency;
+ }
+ else
+ {
+ concurrency.WithParentRelationship(concurrencyParent);
+ }
}
-foreach(var resource in builder.Resources.OfType())
+// Substitution
+var substituted = builder.AddDotnetTool("substituted", "dotnet-ef")
+ .WithCommand("calc")
+ .WithIconName("Calculator")
+ .WithExplicitStart();
+foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList())
{
- builder.CreateResourceBuilder(resource)
- .WithCommand("reset", "Reset", ctx =>
- {
- try
+ substituted.Resource.Annotations.Remove(toolAnnotation);
+}
+
+// Fake Offline by using "empty" package feeds
+var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString());
+var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef")
+ .WaitForCompletion(simpleUsage)
+ .WithPackageSource(fakeSourcesPath)
+ .WithPackageIgnoreExistingFeeds()
+ .WithPackageIgnoreFailedSources()
+ ;
+
+builder.AddDotnetTool("offlineWildcard", "dotnet-ef")
+ .WithPackageVersion("10.0.*")
+ .WaitForCompletion(wildcardVersion)
+ .WithParentRelationship(offline)
+ .WithPackageSource(fakeSourcesPath)
+ .WithPackageIgnoreExistingFeeds()
+ .WithPackageIgnoreFailedSources();
+
+builder.AddDotnetTool("offlinePrerelease", "dotnet-ef")
+ .WithPackagePrerelease()
+ .WaitForCompletion(preRelease)
+ .WithParentRelationship(offline)
+ .WithPackageSource(fakeSourcesPath)
+ .WithPackageIgnoreExistingFeeds()
+ .WithPackageIgnoreFailedSources();
+
+// Some issues only show up when installing for first time, rather than using existing downloaded versions
+// Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them
+builder.Eventing.Subscribe(async (evt, _) =>
+{
+ var nugetPackagesPath = Path.Join(evt.Services.GetRequiredService().BasePath, "nuget");
+
+ foreach (var resource in builder.Resources.OfType())
+ {
+ builder.CreateResourceBuilder(resource)
+ .WithEnvironment("NUGET_PACKAGES", nugetPackagesPath)
+ .WithCommand("reset", "Reset Packages", ctx =>
{
- var path = Path.GetDirectoryName(resource.Command);
- Directory.Delete(path!, true);
- return Task.FromResult(CommandResults.Success());
- }
- catch (Exception ex)
+ try
+ {
+ Directory.Delete(nugetPackagesPath, true);
+ return Task.FromResult(CommandResults.Success());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(CommandResults.Failure(ex));
+ }
+ }, new CommandOptions
{
- return Task.FromResult(CommandResults.Failure(ex));
- }
- }, new CommandOptions
- {
- IconName = "ArrowReset"
- });
-}
+ IconName = "Delete"
+ });
+ }
+});
#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
@@ -53,3 +115,4 @@
#endif
builder.Build().Run();
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
index ac6876f8fba..20c99a6bb22 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -1,5 +1,3 @@
-using Microsoft.Extensions.DependencyInjection;
-
namespace DotnetTool.AppHost;
///
@@ -7,118 +5,55 @@ namespace DotnetTool.AppHost;
///
public static class DotNetToolExtensions
{
- public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string command, string packageId, Action> configure)
- {
- var tool = builder.AddDotnetTool(name, packageId, command);
- configure(tool);
- return tool;
- }
-
- public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string command, string packageId)
- => builder.AddDotnetTool(new DotnetToolResource(name, packageId, command));
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string packageId)
+ => builder.AddDotnetTool(new DotnetToolResource(name, packageId));
public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource)
- where T: DotnetToolResource
+ where T : DotnetToolResource
{
- var tool = builder.AddResource(resource)
- .WithIconName("Toolbox");
-
- var installer = BuildInstaller();
- RewriteToolCommand();
-
- return tool.WaitForCompletion(installer);
-
- void RewriteToolCommand()
- {
- // To avoid excess redownloading, want to set the tool path to a
- // .Net 10's `dotnet tool exec` would handle a lot of that natively
- // Although https://github.com/dotnet/sdk/issues/50579 is a complication
- // In the meantime, download tool to a path based on IAspireStore
- //
- // Using BeforeStartEvent rather than BeforeResoruceStart as the latter event
- // gets called multiple times, and prepending would break the path
- builder.Eventing.Subscribe((evt, ct) =>
- {
- if (Path.IsPathFullyQualified(resource.Command))
- {
- throw new ArgumentException("Executable must not have an absolute path to run as a tool", nameof(builder));
- }
-
- var toolDirectory = GetToolDirectory(evt.Services, tool);
- tool.WithCommand(Path.Combine(toolDirectory, resource.Command));
-
- return Task.CompletedTask;
- });
- }
-
- IResourceBuilder BuildInstaller()
- {
- var installerResource = new DotnetToolInstaller($"{tool.Resource.Name}-installer", "dotnet") { Parent = tool.Resource };
-
- return builder
- .AddResource(installerResource)
- .WithArgs(x =>
- {
- var toolDirectory = GetToolDirectory(x.ExecutionContext.ServiceProvider, tool);
- var toolConfig = tool.Resource.ToolConfiguration;
-
- x.Args.Add("tool");
- x.Args.Add("install");
- x.Args.Add(toolConfig.PackageId);
- x.Args.Add("--tool-path");
- x.Args.Add(toolDirectory);
-
- var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
-
- foreach (var source in toolConfig.Sources)
- {
- x.Args.Add(sourceArg);
- x.Args.Add(source);
- }
-
- if (toolConfig.IgnoreFailedSources)
- {
- x.Args.Add("--ignore-failed-sources");
- }
-
- if (toolConfig.Version is not null)
- {
- x.Args.Add("--version");
- x.Args.Add(toolConfig.Version);
- }
- else if (toolConfig.Prerelease)
- {
- x.Args.Add("--prerelease");
- }
-
- if (toolConfig.AllowDowngrade)
- {
- x.Args.Add("--allow-downgrade");
- }
-
- x.Args.Add("--verbosity");
- x.Args.Add("detailed");
- })
- .WithIconName("ArrowDownload")
- .WithParentRelationship(tool)
- .WithOfflineFallback();
- }
-
- string GetToolDirectory(IServiceProvider serviceProvider, IResourceBuilder tool)
- {
- var builder = tool.ApplicationBuilder;
- var explicitPath = builder.Configuration["ASPIRE_TOOLBASEPATH"];
-
- if (!string.IsNullOrEmpty(explicitPath))
- {
- return Path.Combine(explicitPath, builder.Environment.ApplicationName, tool.Resource.Name);
- }
- else
- {
- var store = serviceProvider.GetRequiredService();
- return Path.Combine(store.BasePath, "tools", tool.Resource.Name);
- }
- }
+ return builder.AddResource(resource)
+ .WithIconName("Toolbox")
+ .WithCommand("dotnet")
+ .WithArgs(x =>
+ {
+ if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
+ {
+ // If the annotation has been removed, don't add any dotnet tool arguments.
+ return;
+ }
+
+ x.Args.Add("tool");
+ x.Args.Add("exec");
+ x.Args.Add(toolConfig.PackageId);
+
+ var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
+
+ foreach (var source in toolConfig.Sources)
+ {
+ x.Args.Add(sourceArg);
+ x.Args.Add(source);
+ }
+
+ if (toolConfig.IgnoreFailedSources)
+ {
+ x.Args.Add("--ignore-failed-sources");
+ }
+
+ if (toolConfig.Version is not null)
+ {
+ x.Args.Add("--version");
+ x.Args.Add(toolConfig.Version);
+ }
+ else if (toolConfig.Prerelease)
+ {
+ x.Args.Add("--prerelease");
+ }
+
+ x.Args.Add("--verbosity");
+ x.Args.Add("detailed");
+ x.Args.Add("--yes");
+ x.Args.Add("--");
+ });
}
public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
@@ -169,24 +104,4 @@ public static IResourceBuilder WithPackageIgnoreFailedSources(this IResour
builder.Resource.ToolConfiguration.IgnoreFailedSources = true;
return builder;
}
-
- public static IResourceBuilder WithPackageAllowDowngrade(this IResourceBuilder builder)
- where T : DotnetToolResource
- {
- builder.Resource.ToolConfiguration.AllowDowngrade = true;
- return builder;
- }
-
- private static IResourceBuilder WithOfflineFallback(this IResourceBuilder builder)
- {
- return builder.WithArgs(x =>
- {
- var settings = NuGet.Configuration.Settings.LoadDefaultSettings(root: builder.Resource.WorkingDirectory);
- var packagesPath = NuGet.Configuration.SettingsUtility.GetGlobalPackagesFolder(settings);
-
- builder.ApplicationBuilder.CreateResourceBuilder(builder.Resource.Parent)
- .WithPackageSource(packagesPath)
- .WithPackageIgnoreFailedSources();
- });
- }
}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
index 183d11e6846..62d0ff7f1b4 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
@@ -10,9 +10,8 @@ public class DotnetToolResource : ExecutableResource
{
/// The name of the resource.
/// The package id of the tool
- /// The command to execute.
- public DotnetToolResource(string name, string packageId, string command)
- : base(name, command, string.Empty)
+ public DotnetToolResource(string name, string packageId)
+ : base(name, "dotnet", string.Empty)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
Annotations.Add(new DotNetToolAnnotation { PackageId = packageId });
From f60451be12626516c765eb60f60ba5730746bd14 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 24 Nov 2025 20:03:51 +0000
Subject: [PATCH 04/32] Port .NET Tool Integration from PR #13168 and fix
package reference
Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com>
---
Directory.Packages.props | 1 +
1 file changed, 1 insertion(+)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 48bf97791f5..1b82e9adf47 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -79,6 +79,7 @@
+
From d2a0efe949839b355c7fa5367807cdad985c9fc6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 24 Nov 2025 20:39:47 +0000
Subject: [PATCH 05/32] Update DotnetTool launchSettings.json to align with PR
#13155 port allocation
- Changed app URLs from 18001/18002 to 16179/16180 (standard app port range)
- Updated OTLP endpoints from 18003/18005 to 17119/17120 (standard OTLP range)
- Added MCP endpoints 18036/18037 (standard MCP range)
- Updated Resource Service endpoints to match OTLP endpoints
- Removed launchBrowser from generate-manifest profile for consistency
Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com>
---
.../Properties/launchSettings.json | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
index eefe510114d..be00d6140cb 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
+++ b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
@@ -5,12 +5,13 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "https://localhost:18001;http://localhost:18002",
+ "applicationUrl": "https://localhost:16179;http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
- "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:18003",
- "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:18004",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18036",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
}
},
@@ -18,26 +19,26 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "applicationUrl": "http://localhost:18002",
+ "applicationUrl": "http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
- "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18003",
- "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:18005",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18037",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
},
"generate-manifest": {
"commandName": "Project",
- "launchBrowser": true,
"dotnetRunMessages": true,
"commandLineArgs": "--publisher manifest --output-path aspire-manifest.json",
- "applicationUrl": "http://localhost:18002",
+ "applicationUrl": "http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
- "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:18003"
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120"
}
}
}
From dbcb13e9f2cff5fe47db3c1fc54b516a0d22c1f5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 1 Dec 2025 22:12:06 +0000
Subject: [PATCH 06/32] Remove DotnetToolInstaller and add comprehensive tests
- Removed DotnetToolInstaller.cs (leftover from dotnet tool install approach)
- Created new test project Aspire.Hosting.DotnetTool.Tests with 22 tests
- All tests pass and validate AddDotnetTool extension methods
- Tests cover: basic usage, version specs, prerelease, custom sources, args, and manifest generation
- Added using statements to playground files (needed for test linking)
- Fixed DotnetToolResource to use "." as working directory (fixes manifest generation)
- Added test project to solution file in alphabetical order
Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com>
---
Aspire.slnx | 1 +
.../DotNetToolAnnotation.cs | 4 +
.../DotNetToolExtensions.cs | 5 +
.../DotnetTool.AppHost/DotnetToolInstaller.cs | 7 -
.../DotnetTool.AppHost/DotnetToolResource.cs | 6 +-
.../AddDotnetToolTests.cs | 400 ++++++++++++++++++
.../Aspire.Hosting.DotnetTool.Tests.csproj | 19 +
7 files changed, 434 insertions(+), 8 deletions(-)
delete mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
create mode 100644 tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
create mode 100644 tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
diff --git a/Aspire.slnx b/Aspire.slnx
index 13e5a42f743..cc0eb9037b9 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -417,6 +417,7 @@
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
index b316ffa7ed1..a46f4cba47d 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
@@ -1,3 +1,7 @@
+#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
+using Aspire.Hosting.ApplicationModel;
+#pragma warning restore IDE0005
+
namespace DotnetTool.AppHost;
public class DotNetToolAnnotation : IResourceAnnotation
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
index 20c99a6bb22..d43d3f9e69f 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -1,3 +1,8 @@
+#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+#pragma warning restore IDE0005
+
namespace DotnetTool.AppHost;
///
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
deleted file mode 100644
index 0530b0975a8..00000000000
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolInstaller.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace DotnetTool.AppHost;
-
-internal sealed class DotnetToolInstaller(string name, string command) :
- ExecutableResource(name, command, string.Empty), IResourceWithParent
-{
- public required DotnetToolResource Parent { get; init; }
-}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
index 62d0ff7f1b4..4fc5b3a22ec 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
@@ -1,3 +1,7 @@
+#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
+using Aspire.Hosting.ApplicationModel;
+#pragma warning restore IDE0005
+
namespace DotnetTool.AppHost;
///
@@ -11,7 +15,7 @@ public class DotnetToolResource : ExecutableResource
/// The name of the resource.
/// The package id of the tool
public DotnetToolResource(string name, string packageId)
- : base(name, "dotnet", string.Empty)
+ : base(name, "dotnet", ".")
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
Annotations.Add(new DotNetToolAnnotation { PackageId = packageId });
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
new file mode 100644
index 00000000000..59767ec4e81
--- /dev/null
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -0,0 +1,400 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Tests.Utils;
+using Aspire.Hosting.Utils;
+using DotnetTool.AppHost;
+using Microsoft.AspNetCore.InternalTesting;
+
+namespace Aspire.Hosting.DotnetTool.Tests;
+
+public class AddDotnetToolTests
+{
+ [Fact]
+ public void AddDotnetToolAddsResourceWithCorrectName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
+
+ Assert.Equal("mytool", tool.Resource.Name);
+ Assert.IsType(tool.Resource);
+ }
+
+ [Fact]
+ public void AddDotnetToolAddsToolAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
+
+ var annotation = Assert.Single(tool.Resource.Annotations.OfType());
+ Assert.Equal("dotnet-ef", annotation.PackageId);
+ }
+
+ [Fact]
+ public void AddDotnetToolThrowsWhenPackageIdIsNull()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ Assert.Throws(() => builder.AddDotnetTool("mytool", null!));
+ }
+
+ [Fact]
+ public void AddDotnetToolThrowsWhenPackageIdIsEmpty()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ Assert.Throws(() => builder.AddDotnetTool("mytool", ""));
+ }
+
+ [Fact]
+ public void AddDotnetToolThrowsWhenPackageIdIsWhitespace()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+
+ Assert.Throws(() => builder.AddDotnetTool("mytool", " "));
+ }
+
+ [Fact]
+ public void AddDotnetToolSetsIconName()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
+
+ var annotation = Assert.Single(tool.Resource.Annotations.OfType());
+ Assert.Equal("Toolbox", annotation.IconName);
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithDefaultSettingsGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithVersionGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageVersion("10.0.0");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--version", arg),
+ arg => Assert.Equal("10.0.0", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithPrereleaseGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackagePrerelease();
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--prerelease", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithCustomSourceGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageSource("https://custom.nuget.org/v3/index.json");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--add-source", arg),
+ arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithMultipleSourcesGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageSource("https://source1.nuget.org/v3/index.json")
+ .WithPackageSource("https://source2.nuget.org/v3/index.json");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--add-source", arg),
+ arg => Assert.Equal("https://source1.nuget.org/v3/index.json", arg),
+ arg => Assert.Equal("--add-source", arg),
+ arg => Assert.Equal("https://source2.nuget.org/v3/index.json", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithIgnoreExistingFeedsUsesSourceInsteadOfAddSource()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageSource("https://custom.nuget.org/v3/index.json")
+ .WithPackageIgnoreExistingFeeds();
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--source", arg),
+ arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithIgnoreFailedSourcesGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageIgnoreFailedSources();
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--ignore-failed-sources", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithAdditionalArgsPassedThrough()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithArgs("database", "update");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg),
+ arg => Assert.Equal("database", arg),
+ arg => Assert.Equal("update", arg)
+ );
+ }
+
+ [Fact]
+ public async Task AddDotnetToolWithComplexConfigurationGeneratesCorrectArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageVersion("9.0.1")
+ .WithPackageSource("https://custom.nuget.org/v3/index.json")
+ .WithPackageIgnoreFailedSources()
+ .WithArgs("database", "update");
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ Assert.Collection(args,
+ arg => Assert.Equal("tool", arg),
+ arg => Assert.Equal("exec", arg),
+ arg => Assert.Equal("dotnet-ef", arg),
+ arg => Assert.Equal("--add-source", arg),
+ arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg),
+ arg => Assert.Equal("--ignore-failed-sources", arg),
+ arg => Assert.Equal("--version", arg),
+ arg => Assert.Equal("9.0.1", arg),
+ arg => Assert.Equal("--verbosity", arg),
+ arg => Assert.Equal("detailed", arg),
+ arg => Assert.Equal("--yes", arg),
+ arg => Assert.Equal("--", arg),
+ arg => Assert.Equal("database", arg),
+ arg => Assert.Equal("update", arg)
+ );
+ }
+
+ [Fact]
+ public void WithPackageVersionSetsVersionInAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageVersion("10.0.*");
+
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ Assert.Equal("10.0.*", annotation.Version);
+ }
+
+ [Fact]
+ public void WithPackagePrereleaseSetsPreReleaseInAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackagePrerelease();
+
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ Assert.True(annotation.Prerelease);
+ }
+
+ [Fact]
+ public void WithPackageSourceAddsSourceToAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageSource("https://custom.nuget.org/v3/index.json");
+
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ Assert.Single(annotation.Sources);
+ Assert.Equal("https://custom.nuget.org/v3/index.json", annotation.Sources[0]);
+ }
+
+ [Fact]
+ public void WithPackageIgnoreExistingFeedsSetsIgnoreExistingFeedsInAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageIgnoreExistingFeeds();
+
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ Assert.True(annotation.IgnoreExistingFeeds);
+ }
+
+ [Fact]
+ public void WithPackageIgnoreFailedSourcesSetsIgnoreFailedSourcesInAnnotation()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
+ .WithPackageIgnoreFailedSources();
+
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ Assert.True(annotation.IgnoreFailedSources);
+ }
+
+ [Fact]
+ public async Task RemovingToolAnnotationResultsInNoArgs()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
+
+ // Remove the annotation to simulate it being removed
+ var annotation = tool.Resource.Annotations.OfType().Single();
+ tool.Resource.Annotations.Remove(annotation);
+
+ using var app = builder.Build();
+
+ var args = await ArgumentEvaluator.GetArgumentListAsync(tool.Resource).DefaultTimeout();
+
+ // Should be empty since annotation was removed
+ Assert.Empty(args);
+ }
+
+ [Fact]
+ public async Task AddDotnetToolGeneratesCorrectManifest()
+ {
+ var builder = DistributedApplication.CreateBuilder();
+ var tool = builder.AddDotnetTool("ef-tool", "dotnet-ef")
+ .WithPackageVersion("10.0.0")
+ .WithArgs("--version");
+
+ using var app = builder.Build();
+
+ var manifest = await ManifestUtils.GetManifest(tool.Resource).DefaultTimeout();
+
+ var expectedManifest =
+ """
+ {
+ "type": "executable.v0",
+ "workingDirectory": ".",
+ "command": "dotnet",
+ "args": [
+ "tool",
+ "exec",
+ "dotnet-ef",
+ "--version",
+ "10.0.0",
+ "--verbosity",
+ "detailed",
+ "--yes",
+ "--",
+ "--version"
+ ]
+ }
+ """;
+
+ Assert.Equal(expectedManifest, manifest.ToString());
+ }
+}
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
new file mode 100644
index 00000000000..409b5fc5188
--- /dev/null
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(DefaultTargetFramework)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 926c38cb10a3d308e4a74b625115c5608d71cf59 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 16:07:15 +0000
Subject: [PATCH 07/32] Give tools their own resource type.
Updated `ResourceSnapshotBuilder` to not override any custom resource types a consumer may have set.
Updated `ApplicationOrchestrator` to also not set the resource type - we don't need two places in Aspire core setting the ResourceType
---
.../DotNetToolExtensions.cs | 79 ++++++++++---------
.../Dcp/ResourceSnapshotBuilder.cs | 8 +-
.../Orchestrator/ApplicationOrchestrator.cs | 2 -
3 files changed, 47 insertions(+), 42 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
index d43d3f9e69f..c33e154f27d 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -1,6 +1,8 @@
#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
+using System.Collections.Immutable;
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.DependencyInjection;
#pragma warning restore IDE0005
namespace DotnetTool.AppHost;
@@ -17,48 +19,53 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
where T : DotnetToolResource
{
return builder.AddResource(resource)
- .WithIconName("Toolbox")
- .WithCommand("dotnet")
- .WithArgs(x =>
- {
- if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
- {
- // If the annotation has been removed, don't add any dotnet tool arguments.
- return;
- }
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Tool",
+ Properties = []
+ })
+ .WithIconName("Toolbox")
+ .WithCommand("dotnet")
+ .WithArgs(x =>
+ {
+ if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
+ {
+ // If the annotation has been removed, don't add any dotnet tool arguments.
+ return;
+ }
- x.Args.Add("tool");
- x.Args.Add("exec");
- x.Args.Add(toolConfig.PackageId);
+ x.Args.Add("tool");
+ x.Args.Add("exec");
+ x.Args.Add(toolConfig.PackageId);
- var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
+ var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
- foreach (var source in toolConfig.Sources)
- {
- x.Args.Add(sourceArg);
- x.Args.Add(source);
- }
+ foreach (var source in toolConfig.Sources)
+ {
+ x.Args.Add(sourceArg);
+ x.Args.Add(source);
+ }
- if (toolConfig.IgnoreFailedSources)
- {
- x.Args.Add("--ignore-failed-sources");
- }
+ if (toolConfig.IgnoreFailedSources)
+ {
+ x.Args.Add("--ignore-failed-sources");
+ }
- if (toolConfig.Version is not null)
- {
- x.Args.Add("--version");
- x.Args.Add(toolConfig.Version);
- }
- else if (toolConfig.Prerelease)
- {
- x.Args.Add("--prerelease");
- }
+ if (toolConfig.Version is not null)
+ {
+ x.Args.Add("--version");
+ x.Args.Add(toolConfig.Version);
+ }
+ else if (toolConfig.Prerelease)
+ {
+ x.Args.Add("--prerelease");
+ }
- x.Args.Add("--verbosity");
- x.Args.Add("detailed");
- x.Args.Add("--yes");
- x.Args.Add("--");
- });
+ x.Args.Add("--verbosity");
+ x.Args.Add("detailed");
+ x.Args.Add("--yes");
+ x.Args.Add("--");
+ });
}
public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
diff --git a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
index e4ed4b6b4c8..e7864c331a3 100644
--- a/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
+++ b/src/Aspire.Hosting/Dcp/ResourceSnapshotBuilder.cs
@@ -44,7 +44,7 @@ public CustomResourceSnapshot ToSnapshot(Container container, CustomResourceSnap
return previous with
{
- ResourceType = KnownResourceTypes.Container,
+ ResourceType = previous.ResourceType ?? KnownResourceTypes.Container,
State = state,
// Map a container exit code of -1 (unknown) to null
ExitCode = container.Status?.ExitCode is null or Conventions.UnknownExitCode ? null : container.Status.ExitCode,
@@ -108,7 +108,7 @@ public CustomResourceSnapshot ToSnapshot(ContainerExec executable, CustomResourc
return previous with
{
- ResourceType = KnownResourceTypes.Executable,
+ ResourceType = previous.ResourceType ?? KnownResourceTypes.Executable,
State = state,
ExitCode = executable.Status?.ExitCode,
Properties = previous.Properties.SetResourcePropertyRange([
@@ -159,7 +159,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn
{
return previous with
{
- ResourceType = KnownResourceTypes.Project,
+ ResourceType = previous.ResourceType ?? KnownResourceTypes.Project,
State = state,
ExitCode = executable.Status?.ExitCode,
Properties = previous.Properties.SetResourcePropertyRange([
@@ -183,7 +183,7 @@ public CustomResourceSnapshot ToSnapshot(Executable executable, CustomResourceSn
return previous with
{
- ResourceType = KnownResourceTypes.Executable,
+ ResourceType = previous.ResourceType ?? KnownResourceTypes.Executable,
State = state,
ExitCode = executable.Status?.ExitCode,
Properties = previous.Properties.SetResourcePropertyRange([
diff --git a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
index 18559c5299c..f062dfeaee0 100644
--- a/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
+++ b/src/Aspire.Hosting/Orchestrator/ApplicationOrchestrator.cs
@@ -168,7 +168,6 @@ private async Task OnResourceStarting(OnResourceStartingContext context)
await PublishUpdateAsync(_notificationService, context.Resource, context.DcpResourceName, s => s with
{
State = KnownResourceStates.Starting,
- ResourceType = context.ResourceType,
HealthReports = GetInitialHealthReports(context.Resource)
})
.ConfigureAwait(false);
@@ -179,7 +178,6 @@ await PublishUpdateAsync(_notificationService, context.Resource, context.DcpReso
{
State = KnownResourceStates.Starting,
Properties = s.Properties.SetResourceProperty(KnownProperties.Container.Image, context.Resource.TryGetContainerImageName(out var imageName) ? imageName : ""),
- ResourceType = KnownResourceTypes.Container,
HealthReports = GetInitialHealthReports(context.Resource)
})
.ConfigureAwait(false);
From 434d1d7e0c9cc2a8bb86a0ebd89f3a55e57d8d43 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 18:06:51 +0000
Subject: [PATCH 08/32] Format dotnet tools nicely in dashboard "source" column
The implementation works, but isnt' great as it fights with DCP a lot.
---
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 7 ++
.../DotNetToolExtensions.cs | 83 +++++++++++++++++--
.../DotnetTool.AppHost.csproj | 3 +-
.../DotnetTool.AppHost/DotnetToolResource.cs | 7 +-
.../Aspire.Hosting.DotnetTool.Tests.csproj | 4 +-
5 files changed, 89 insertions(+), 15 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index 035e32b2b80..7b8d57ce9d8 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -76,6 +76,13 @@
.WithPackageIgnoreExistingFeeds()
.WithPackageIgnoreFailedSources();
+var secret = builder.AddParameter("secret", "Shhhhhhh", secret: true);
+
+// Secrets
+builder.AddDotnetTool("secretArg", "dotnet-ef")
+ .WithArgs("--help")
+ .WithArgs(secret);
+
// Some issues only show up when installing for first time, rather than using existing downloaded versions
// Use a specific NUGET_PACKAGES path for these playground tools, so we can easily reset them
builder.Eventing.Subscribe(async (evt, _) =>
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
index c33e154f27d..53a62496edd 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -3,6 +3,7 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
+using Aspire.Dashboard.Model;
#pragma warning restore IDE0005
namespace DotnetTool.AppHost;
@@ -12,6 +13,8 @@ namespace DotnetTool.AppHost;
///
public static class DotNetToolExtensions
{
+ private const string ArgumentSeperator = "--";
+
public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string packageId)
=> builder.AddDotnetTool(new DotnetToolResource(name, packageId));
@@ -65,13 +68,81 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
x.Args.Add("detailed");
x.Args.Add("--yes");
x.Args.Add("--");
+ })
+ .OnInitializeResource(async (resource, evt, ct) =>
+ {
+ var rns = evt.Services.GetRequiredService();
+ _ = Task.Run(async () =>
+ {
+ await foreach (var x in rns.WatchAsync(ct))
+ {
+ if (x.Resource != resource)
+ {
+ continue;
+ }
+
+ var toolConfig = resource.ToolConfiguration;
+ if (toolConfig == null)
+ {
+ continue;
+ }
+
+ var expectedPath = toolConfig.PackageId;
+
+ var existingPathProp = x.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
+ if (existingPathProp != null && existingPathProp.Value as string != expectedPath)
+ {
+ await rns.PublishUpdateAsync(resource, x =>
+ {
+ // Existing Path could have changed in the meantime, so make sure to get the updated version
+ var existingPathProp = x.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
+ if (existingPathProp == null)
+ {
+ return x;
+ }
+
+ //TODO: `Use KnownProperties.Executable.Path` & `KnownProperties.Resource.AppArgsSensitivity`
+ var argsProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
+ var argsSensitivityProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
+
+ if (argsProperty?.Value is not ImmutableArray originalArgs
+ || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
+ {
+ return x; ;
+ }
+
+ var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
+ if (argSeperatorPosition == 0)
+ {
+ return x;
+ }
+
+ var firstArgToDisplay = argSeperatorPosition + 1;
+ var trimmedArgs = originalArgs[firstArgToDisplay..];
+ var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
+
+ return x with
+ {
+ Properties = x.Properties
+ .Replace(existingPathProp, existingPathProp with { Value = expectedPath })
+ .Replace(argsSensitivityProperty, argsSensitivityProperty with { Value = trimmedSensitivity })
+ //TODO: This could be overly sensitive if any of the `dotnet tool exec` args are sensitive
+ // But I don't see how else to get se
+ // I also don't see how you could mark a sens
+ .Replace(argsProperty, argsProperty with { Value = trimmedArgs })
+ };
+ });
+ }
+ }
+
+ }, ct);
});
}
public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.PackageId = packageId;
+ builder.Resource.ToolConfiguration?.PackageId = packageId;
return builder;
}
@@ -85,35 +156,35 @@ public static IResourceBuilder WithPackageId(this IResourceBuilder buil
public static IResourceBuilder WithPackageVersion(this IResourceBuilder builder, string version)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.Version = version;
+ builder.Resource.ToolConfiguration?.Version = version;
return builder;
}
public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder builder)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.Prerelease = true;
+ builder.Resource.ToolConfiguration?.Prerelease = true;
return builder;
}
public static IResourceBuilder WithPackageSource(this IResourceBuilder builder, string source)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.Sources.Add(source);
+ builder.Resource.ToolConfiguration?.Sources.Add(source);
return builder;
}
public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResourceBuilder builder)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.IgnoreExistingFeeds = true;
+ builder.Resource.ToolConfiguration?.IgnoreExistingFeeds = true;
return builder;
}
public static IResourceBuilder WithPackageIgnoreFailedSources(this IResourceBuilder builder)
where T : DotnetToolResource
{
- builder.Resource.ToolConfiguration.IgnoreFailedSources = true;
+ builder.Resource.ToolConfiguration?.IgnoreFailedSources = true;
return builder;
}
}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
index 9ef0172c69b..0ea803aeda7 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
@@ -9,7 +9,8 @@
-
+
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
index 4fc5b3a22ec..4a586fd9740 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
@@ -21,14 +21,11 @@ public DotnetToolResource(string name, string packageId)
Annotations.Add(new DotNetToolAnnotation { PackageId = packageId });
}
- internal DotNetToolAnnotation ToolConfiguration
+ internal DotNetToolAnnotation? ToolConfiguration
{
get
{
- if (!this.TryGetLastAnnotation(out var toolConfig))
- {
- throw new InvalidOperationException("DotNetToolAnnotation is missing");
- }
+ this.TryGetLastAnnotation(out var toolConfig);
return toolConfig;
}
}
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
index 409b5fc5188..64fcee94e42 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj
@@ -5,14 +5,12 @@
+
-
-
-
From 52a95bad4bb92fc0467655b7a3aad83a19ebd126 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 18:14:54 +0000
Subject: [PATCH 09/32] Remove verbosity argument.
---
.../DotNetToolExtensions.cs | 2 --
.../AddDotnetToolTests.cs | 20 -------------------
2 files changed, 22 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
index 53a62496edd..5972a079e81 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -64,8 +64,6 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
x.Args.Add("--prerelease");
}
- x.Args.Add("--verbosity");
- x.Args.Add("detailed");
x.Args.Add("--yes");
x.Args.Add("--");
})
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
index 59767ec4e81..72ea6bc77c8 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -79,8 +79,6 @@ public async Task AddDotnetToolWithDefaultSettingsGeneratesCorrectArgs()
arg => Assert.Equal("tool", arg),
arg => Assert.Equal("exec", arg),
arg => Assert.Equal("dotnet-ef", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -103,8 +101,6 @@ public async Task AddDotnetToolWithVersionGeneratesCorrectArgs()
arg => Assert.Equal("dotnet-ef", arg),
arg => Assert.Equal("--version", arg),
arg => Assert.Equal("10.0.0", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -126,8 +122,6 @@ public async Task AddDotnetToolWithPrereleaseGeneratesCorrectArgs()
arg => Assert.Equal("exec", arg),
arg => Assert.Equal("dotnet-ef", arg),
arg => Assert.Equal("--prerelease", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -150,8 +144,6 @@ public async Task AddDotnetToolWithCustomSourceGeneratesCorrectArgs()
arg => Assert.Equal("dotnet-ef", arg),
arg => Assert.Equal("--add-source", arg),
arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -177,8 +169,6 @@ public async Task AddDotnetToolWithMultipleSourcesGeneratesCorrectArgs()
arg => Assert.Equal("https://source1.nuget.org/v3/index.json", arg),
arg => Assert.Equal("--add-source", arg),
arg => Assert.Equal("https://source2.nuget.org/v3/index.json", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -202,8 +192,6 @@ public async Task AddDotnetToolWithIgnoreExistingFeedsUsesSourceInsteadOfAddSour
arg => Assert.Equal("dotnet-ef", arg),
arg => Assert.Equal("--source", arg),
arg => Assert.Equal("https://custom.nuget.org/v3/index.json", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -225,8 +213,6 @@ public async Task AddDotnetToolWithIgnoreFailedSourcesGeneratesCorrectArgs()
arg => Assert.Equal("exec", arg),
arg => Assert.Equal("dotnet-ef", arg),
arg => Assert.Equal("--ignore-failed-sources", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg)
);
@@ -247,8 +233,6 @@ public async Task AddDotnetToolWithAdditionalArgsPassedThrough()
arg => Assert.Equal("tool", arg),
arg => Assert.Equal("exec", arg),
arg => Assert.Equal("dotnet-ef", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg),
arg => Assert.Equal("database", arg),
@@ -279,8 +263,6 @@ public async Task AddDotnetToolWithComplexConfigurationGeneratesCorrectArgs()
arg => Assert.Equal("--ignore-failed-sources", arg),
arg => Assert.Equal("--version", arg),
arg => Assert.Equal("9.0.1", arg),
- arg => Assert.Equal("--verbosity", arg),
- arg => Assert.Equal("detailed", arg),
arg => Assert.Equal("--yes", arg),
arg => Assert.Equal("--", arg),
arg => Assert.Equal("database", arg),
@@ -386,8 +368,6 @@ public async Task AddDotnetToolGeneratesCorrectManifest()
"dotnet-ef",
"--version",
"10.0.0",
- "--verbosity",
- "detailed",
"--yes",
"--",
"--version"
From 5673ec92e2c75f147869a325150c2e457c2f31ed Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 18:45:25 +0000
Subject: [PATCH 10/32] Move dotnet tool implementation to `Aspire.Hosting`
---
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 3 +-
.../DotNetToolAnnotation.cs | 16 -----
.../ApplicationModel/DotnetToolAnnotation.cs | 42 ++++++++++++
.../ApplicationModel}/DotnetToolResource.cs | 20 +++---
.../Aspire.Hosting/DotnetToolExtensions.cs | 64 ++++++++++++++++---
.../AddDotnetToolTests.cs | 15 ++---
6 files changed, 116 insertions(+), 44 deletions(-)
delete mode 100644 playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
create mode 100644 src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
rename {playground/DotnetTool/DotnetTool.AppHost => src/Aspire.Hosting/ApplicationModel}/DotnetToolResource.cs (54%)
rename playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs => src/Aspire.Hosting/DotnetToolExtensions.cs (69%)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index 7b8d57ce9d8..f530f15819f 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -1,6 +1,5 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using DotnetTool.AppHost;
using Microsoft.Extensions.DependencyInjection;
var builder = DistributedApplication.CreateBuilder(args);
@@ -46,7 +45,7 @@
.WithCommand("calc")
.WithIconName("Calculator")
.WithExplicitStart();
-foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList())
+foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList())
{
substituted.Resource.Annotations.Remove(toolAnnotation);
}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
deleted file mode 100644
index a46f4cba47d..00000000000
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
-using Aspire.Hosting.ApplicationModel;
-#pragma warning restore IDE0005
-
-namespace DotnetTool.AppHost;
-
-public class DotNetToolAnnotation : IResourceAnnotation
-{
- public required string PackageId { get; set; }
- public string? Version { get; set; }
- public bool Prerelease { get; set; }
- public List Sources { get; } = [];
- public bool IgnoreExistingFeeds { get; set; }
- public bool IgnoreFailedSources { get; set; }
- public bool AllowDowngrade { get; set; }
-}
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
new file mode 100644
index 00000000000..3edc6a32e29
--- /dev/null
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting.ApplicationModel;
+///
+/// Represents an annotation for a dotnet tool resources.
+///
+public class DotnetToolAnnotation : IResourceAnnotation
+{
+ ///
+ /// he NuGet package ID of the .NET tool to execute. You can optionally specify a version using the @ syntax, for example dotnetsay@2.1.
+ ///
+ public required string PackageId { get; set; }
+
+ ///
+ /// The version of the tool package to install.
+ ///
+ public string? Version { get; set; }
+
+ ///
+ /// Allows prerelease packages to be selected when resolving the version to install.
+ ///
+ public bool Prerelease { get; set; }
+
+ ///
+ /// NuGet package sources to use during installation
+ ///
+ public List Sources { get; } = [];
+
+ ///
+ /// Are custom sources used in addition or intead of existing feeds.
+ ///
+ ///
+ /// This value has no impact if is empty.
+ ///
+ public bool IgnoreExistingFeeds { get; set; }
+
+ ///
+ /// Treats package source failures as warnings.
+ ///
+ public bool IgnoreFailedSources { get; set; }
+}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
similarity index 54%
rename from playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
rename to src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index 4a586fd9740..cca62554f15 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -1,8 +1,7 @@
-#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
-using Aspire.Hosting.ApplicationModel;
-#pragma warning restore IDE0005
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
-namespace DotnetTool.AppHost;
+namespace Aspire.Hosting.ApplicationModel;
///
/// Represents a .NET tool resource that encapsulates metadata about a .NET CLI tool, including its name, package ID,
@@ -12,20 +11,23 @@ namespace DotnetTool.AppHost;
/// command with its package ID, and ensures that the required metadata is properly annotated.
public class DotnetToolResource : ExecutableResource
{
+ ///
+ /// Initializes a new instance of the class.
+ ///
/// The name of the resource.
- /// The package id of the tool
- public DotnetToolResource(string name, string packageId)
+ /// The package id of the tool.
+ public DotnetToolResource(string name, string packageId)
: base(name, "dotnet", ".")
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
- Annotations.Add(new DotNetToolAnnotation { PackageId = packageId });
+ Annotations.Add(new DotnetToolAnnotation { PackageId = packageId });
}
- internal DotNetToolAnnotation? ToolConfiguration
+ internal DotnetToolAnnotation? ToolConfiguration
{
get
{
- this.TryGetLastAnnotation(out var toolConfig);
+ this.TryGetLastAnnotation(out var toolConfig);
return toolConfig;
}
}
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
similarity index 69%
rename from playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
rename to src/Aspire.Hosting/DotnetToolExtensions.cs
index 5972a079e81..17b0ca093d3 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -1,12 +1,12 @@
-#pragma warning disable IDE0005 // Using directive is unnecessary (needed when file is linked to test project)
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
using System.Collections.Immutable;
-using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
using Aspire.Dashboard.Model;
-#pragma warning restore IDE0005
-namespace DotnetTool.AppHost;
+namespace Aspire.Hosting;
///
/// Provides extension methods for adding Dotnet Tool resources to the application model.
@@ -15,9 +15,24 @@ public static class DotNetToolExtensions
{
private const string ArgumentSeperator = "--";
- public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, string name, string packageId)
+ ///
+ /// Adds a .NET tool resource to the distributed application builder.
+ ///
+ /// The .
+ /// The name of the resource.
+ /// The package id of the tool.
+ /// The .
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, [ResourceName] string name, string packageId)
=> builder.AddDotnetTool(new DotnetToolResource(name, packageId));
+ ///
+ /// Adds a .NET tool resource to the distributed application builder, configuring it for execution via the 'dotnet
+ /// tool exec' command.
+ ///
+ /// The type of the .NET tool resource to add. Must inherit from .
+ /// The distributed application builder to which the .NET tool resource will be added.
+ /// The .NET tool resource instance to add and configure.
+ /// The .
public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource)
where T : DotnetToolResource
{
@@ -31,7 +46,7 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
.WithCommand("dotnet")
.WithArgs(x =>
{
- if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
+ if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
{
// If the annotation has been removed, don't add any dotnet tool arguments.
return;
@@ -72,7 +87,7 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
var rns = evt.Services.GetRequiredService();
_ = Task.Run(async () =>
{
- await foreach (var x in rns.WatchAsync(ct))
+ await foreach (var x in rns.WatchAsync(ct).ConfigureAwait(false))
{
if (x.Resource != resource)
{
@@ -99,7 +114,6 @@ await rns.PublishUpdateAsync(resource, x =>
return x;
}
- //TODO: `Use KnownProperties.Executable.Path` & `KnownProperties.Resource.AppArgsSensitivity`
var argsProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
var argsSensitivityProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
@@ -129,7 +143,7 @@ await rns.PublishUpdateAsync(resource, x =>
// I also don't see how you could mark a sens
.Replace(argsProperty, argsProperty with { Value = trimmedArgs })
};
- });
+ }).ConfigureAwait(false);
}
}
@@ -137,6 +151,13 @@ await rns.PublishUpdateAsync(resource, x =>
});
}
+ ///
+ /// Sets the package identifier for the tool configuration associated with the resource builder.
+ ///
+ /// The Dotnet Tool resource type
+ /// The .
+ /// The package identifier to assign to the tool configuration. Cannot be null.
+ /// The for chaining.
public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
where T : DotnetToolResource
{
@@ -158,6 +179,12 @@ public static IResourceBuilder WithPackageVersion(this IResourceBuilder
return builder;
}
+ ///
+ /// Configures the resource builder to mark the associated .NET tool package as a prerelease version.
+ ///
+ /// The type of resource being built. Must inherit from DotnetToolResource.
+ /// The .
+ /// The for chaining.
public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder builder)
where T : DotnetToolResource
{
@@ -165,6 +192,13 @@ public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder
return builder;
}
+ ///
+ /// Adds a package source to
+ ///
+ /// The Dotnet Tool resource type
+ /// The .
+ ///
+ /// The for chaining.
public static IResourceBuilder WithPackageSource(this IResourceBuilder builder, string source)
where T : DotnetToolResource
{
@@ -172,6 +206,12 @@ public static IResourceBuilder WithPackageSource(this IResourceBuilder
return builder;
}
+ ///
+ ///
+ ///
+ /// The Dotnet Tool resource type
+ /// The .
+ /// The for chaining.
public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResourceBuilder builder)
where T : DotnetToolResource
{
@@ -179,6 +219,12 @@ public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResour
return builder;
}
+ ///
+ ///
+ ///
+ /// The Dotnet Tool resource type
+ /// The .
+ /// The for chaining.
public static IResourceBuilder WithPackageIgnoreFailedSources(this IResourceBuilder builder)
where T : DotnetToolResource
{
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
index 72ea6bc77c8..dd2259a6695 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -4,7 +4,6 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
-using DotnetTool.AppHost;
using Microsoft.AspNetCore.InternalTesting;
namespace Aspire.Hosting.DotnetTool.Tests;
@@ -27,7 +26,7 @@ public void AddDotnetToolAddsToolAnnotation()
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
- var annotation = Assert.Single(tool.Resource.Annotations.OfType());
+ var annotation = Assert.Single(tool.Resource.Annotations.OfType());
Assert.Equal("dotnet-ef", annotation.PackageId);
}
@@ -277,7 +276,7 @@ public void WithPackageVersionSetsVersionInAnnotation()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
.WithPackageVersion("10.0.*");
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
Assert.Equal("10.0.*", annotation.Version);
}
@@ -288,7 +287,7 @@ public void WithPackagePrereleaseSetsPreReleaseInAnnotation()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
.WithPackagePrerelease();
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.Prerelease);
}
@@ -299,7 +298,7 @@ public void WithPackageSourceAddsSourceToAnnotation()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
.WithPackageSource("https://custom.nuget.org/v3/index.json");
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
Assert.Single(annotation.Sources);
Assert.Equal("https://custom.nuget.org/v3/index.json", annotation.Sources[0]);
}
@@ -311,7 +310,7 @@ public void WithPackageIgnoreExistingFeedsSetsIgnoreExistingFeedsInAnnotation()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
.WithPackageIgnoreExistingFeeds();
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.IgnoreExistingFeeds);
}
@@ -322,7 +321,7 @@ public void WithPackageIgnoreFailedSourcesSetsIgnoreFailedSourcesInAnnotation()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
.WithPackageIgnoreFailedSources();
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.IgnoreFailedSources);
}
@@ -333,7 +332,7 @@ public async Task RemovingToolAnnotationResultsInNoArgs()
var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
// Remove the annotation to simulate it being removed
- var annotation = tool.Resource.Annotations.OfType().Single();
+ var annotation = tool.Resource.Annotations.OfType().Single();
tool.Resource.Annotations.Remove(annotation);
using var app = builder.Build();
From 79ff8f858d31f9c967fa63891c8bce238f16a5b6 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:09:57 +0000
Subject: [PATCH 11/32] Make dotnet tool apis experimental.
---
src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs | 3 +++
src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs | 3 +++
src/Aspire.Hosting/DotnetToolExtensions.cs | 4 +++-
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
index 3edc6a32e29..cfd1f72551e 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
@@ -1,10 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
+
namespace Aspire.Hosting.ApplicationModel;
///
/// Represents an annotation for a dotnet tool resources.
///
+[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class DotnetToolAnnotation : IResourceAnnotation
{
///
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index cca62554f15..eebcb33ca35 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics.CodeAnalysis;
+
namespace Aspire.Hosting.ApplicationModel;
///
@@ -9,6 +11,7 @@ namespace Aspire.Hosting.ApplicationModel;
///
/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and
/// command with its package ID, and ensures that the required metadata is properly annotated.
+[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class DotnetToolResource : ExecutableResource
{
///
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 17b0ca093d3..9b00af6949a 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -2,15 +2,17 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.DependencyInjection;
-using Aspire.Dashboard.Model;
namespace Aspire.Hosting;
///
/// Provides extension methods for adding Dotnet Tool resources to the application model.
///
+[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public static class DotNetToolExtensions
{
private const string ArgumentSeperator = "--";
From e2e7a1d425a9a7a7973faa230f829af5b9784267 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:29:20 +0000
Subject: [PATCH 12/32] Mark dotnet tool apis experimental.
---
playground/DotnetTool/DotnetTool.AppHost/AppHost.cs | 1 +
.../DotnetTool.AppHost/DotnetTool.AppHost.csproj | 7 +------
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index f530f15819f..0dd1cf8b529 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Extensions.DependencyInjection;
+#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var builder = DistributedApplication.CreateBuilder(args);
diff --git a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
index 0ea803aeda7..e0f1b2bf1e9 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
@@ -1,4 +1,4 @@
-
+
Exe
@@ -10,15 +10,10 @@
-
-
-
-
-
From fb569c9f0fc347f6c82606a3298d105e3cfcc7bb Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:29:36 +0000
Subject: [PATCH 13/32] Make secret tool example not fail.
---
playground/DotnetTool/DotnetTool.AppHost/AppHost.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index 0dd1cf8b529..88475c8f946 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -80,7 +80,7 @@
// Secrets
builder.AddDotnetTool("secretArg", "dotnet-ef")
- .WithArgs("--help")
+ .WithArgs("--version")
.WithArgs(secret);
// Some issues only show up when installing for first time, rather than using existing downloaded versions
From 6efa25096a2c97faf742f2eafcfb590b63d66a6f Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:35:40 +0000
Subject: [PATCH 14/32] Refactor to local functions to make slightly cleaner.
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 182 +++++++++++----------
1 file changed, 94 insertions(+), 88 deletions(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 9b00af6949a..b072833088f 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -46,111 +46,117 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
})
.WithIconName("Toolbox")
.WithCommand("dotnet")
- .WithArgs(x =>
+ .WithArgs(BuildToolExecArguments)
+ .OnInitializeResource(StartSourceFixer);
+
+ void BuildToolExecArguments(CommandLineArgsCallbackContext x)
+ {
+ var toolConfig = resource.ToolConfiguration;
+ if (toolConfig == null)
{
- if (!x.Resource.TryGetLastAnnotation(out var toolConfig))
- {
- // If the annotation has been removed, don't add any dotnet tool arguments.
- return;
- }
+ // If the annotation has been removed, don't add any dotnet tool arguments.
+ return;
+ }
- x.Args.Add("tool");
- x.Args.Add("exec");
- x.Args.Add(toolConfig.PackageId);
+ x.Args.Add("tool");
+ x.Args.Add("exec");
+ x.Args.Add(toolConfig.PackageId);
- var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
+ var sourceArg = toolConfig.IgnoreExistingFeeds ? "--source" : "--add-source";
- foreach (var source in toolConfig.Sources)
- {
- x.Args.Add(sourceArg);
- x.Args.Add(source);
- }
+ foreach (var source in toolConfig.Sources)
+ {
+ x.Args.Add(sourceArg);
+ x.Args.Add(source);
+ }
- if (toolConfig.IgnoreFailedSources)
- {
- x.Args.Add("--ignore-failed-sources");
- }
+ if (toolConfig.IgnoreFailedSources)
+ {
+ x.Args.Add("--ignore-failed-sources");
+ }
- if (toolConfig.Version is not null)
- {
- x.Args.Add("--version");
- x.Args.Add(toolConfig.Version);
- }
- else if (toolConfig.Prerelease)
- {
- x.Args.Add("--prerelease");
- }
+ if (toolConfig.Version is not null)
+ {
+ x.Args.Add("--version");
+ x.Args.Add(toolConfig.Version);
+ }
+ else if (toolConfig.Prerelease)
+ {
+ x.Args.Add("--prerelease");
+ }
- x.Args.Add("--yes");
- x.Args.Add("--");
- })
- .OnInitializeResource(async (resource, evt, ct) =>
+ x.Args.Add("--yes");
+ x.Args.Add("--");
+ }
+
+ static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, CancellationToken ct)
+ {
+ var rns = evt.Services.GetRequiredService();
+ _ = Task.Run(async () =>
{
- var rns = evt.Services.GetRequiredService();
- _ = Task.Run(async () =>
+ await foreach (var x in rns.WatchAsync(ct).ConfigureAwait(false))
{
- await foreach (var x in rns.WatchAsync(ct).ConfigureAwait(false))
+ if (x.Resource != resource)
{
- if (x.Resource != resource)
- {
- continue;
- }
+ continue;
+ }
- var toolConfig = resource.ToolConfiguration;
- if (toolConfig == null)
- {
- continue;
- }
+ var toolConfig = resource.ToolConfiguration;
+ if (toolConfig == null)
+ {
+ continue;
+ }
- var expectedPath = toolConfig.PackageId;
+ var expectedPath = toolConfig.PackageId;
- var existingPathProp = x.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
- if (existingPathProp != null && existingPathProp.Value as string != expectedPath)
+ var existingPathProp = x.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
+ if (existingPathProp != null && existingPathProp.Value as string != expectedPath)
+ {
+ await rns.PublishUpdateAsync(resource, x =>
{
- await rns.PublishUpdateAsync(resource, x =>
+ // Existing Path could have changed in the meantime, so make sure to get the updated version
+ var existingPathProp = x.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
+ if (existingPathProp == null)
+ {
+ return x;
+ }
+
+ var argsProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
+ var argsSensitivityProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
+
+ if (argsProperty?.Value is not ImmutableArray originalArgs
+ || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
+ {
+ return x; ;
+ }
+
+ var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
+ if (argSeperatorPosition == 0)
+ {
+ return x;
+ }
+
+ var firstArgToDisplay = argSeperatorPosition + 1;
+ var trimmedArgs = originalArgs[firstArgToDisplay..];
+ var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
+
+ return x with
{
- // Existing Path could have changed in the meantime, so make sure to get the updated version
- var existingPathProp = x.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
- if (existingPathProp == null)
- {
- return x;
- }
-
- var argsProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
- var argsSensitivityProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
-
- if (argsProperty?.Value is not ImmutableArray originalArgs
- || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
- {
- return x; ;
- }
-
- var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
- if (argSeperatorPosition == 0)
- {
- return x;
- }
-
- var firstArgToDisplay = argSeperatorPosition + 1;
- var trimmedArgs = originalArgs[firstArgToDisplay..];
- var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
-
- return x with
- {
- Properties = x.Properties
- .Replace(existingPathProp, existingPathProp with { Value = expectedPath })
- .Replace(argsSensitivityProperty, argsSensitivityProperty with { Value = trimmedSensitivity })
- //TODO: This could be overly sensitive if any of the `dotnet tool exec` args are sensitive
- // But I don't see how else to get se
- // I also don't see how you could mark a sens
- .Replace(argsProperty, argsProperty with { Value = trimmedArgs })
- };
- }).ConfigureAwait(false);
- }
+ Properties = x.Properties
+ .Replace(existingPathProp, existingPathProp with { Value = expectedPath })
+ .Replace(argsSensitivityProperty, argsSensitivityProperty with { Value = trimmedSensitivity })
+ //TODO: This could be overly sensitive if any of the `dotnet tool exec` args are sensitive
+ // But I don't see how else to get se
+ // I also don't see how you could mark a sens
+ .Replace(argsProperty, argsProperty with { Value = trimmedArgs })
+ };
+ }).ConfigureAwait(false);
}
+ }
+
+ }, ct);
+ }
- }, ct);
- });
}
///
From 6247973048667713ee3da1126948b3645d133c94 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:55:44 +0000
Subject: [PATCH 15/32] Add properties to tool resources.
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 22 +++++++++++++++++++++-
src/Shared/Model/KnownProperties.cs | 6 ++++++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index b072833088f..7cd3cc59d49 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -47,7 +47,8 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
.WithIconName("Toolbox")
.WithCommand("dotnet")
.WithArgs(BuildToolExecArguments)
- .OnInitializeResource(StartSourceFixer);
+ .OnInitializeResource(StartSourceFixer)
+ .OnBeforeResourceStarted(BuildToolProperties);
void BuildToolExecArguments(CommandLineArgsCallbackContext x)
{
@@ -157,6 +158,25 @@ await rns.PublishUpdateAsync(resource, x =>
}, ct);
}
+ //TODO: Move to WithConfigurationFinalizer once merged - https://github.com/dotnet/aspire/pull/13200
+ async Task BuildToolProperties(T resource, BeforeResourceStartedEvent evt, CancellationToken ct)
+ {
+ var rns = evt.Services.GetRequiredService();
+ var toolConfig = resource.ToolConfiguration;
+ if (toolConfig == null)
+ {
+ return;
+ }
+
+ await rns.PublishUpdateAsync(resource, x => x with
+ {
+ Properties = [
+ ..x.Properties,
+ new ResourcePropertySnapshot(KnownProperties.Tool.Package, toolConfig.PackageId),
+ new ResourcePropertySnapshot(KnownProperties.Tool.Version, toolConfig.Version)
+ ]
+ }).ConfigureAwait(false);
+ }
}
///
diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs
index 769cef8f6c0..e94c496a6d2 100644
--- a/src/Shared/Model/KnownProperties.cs
+++ b/src/Shared/Model/KnownProperties.cs
@@ -60,4 +60,10 @@ public static class Parameter
{
public const string Value = "Value";
}
+
+ public static class Tool
+ {
+ public const string Package = "tool.package";
+ public const string Version = "tool.version";
+ }
}
From ebc8846dce03cad051d13f0d63fc39e82aa4aabd Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:58:05 +0000
Subject: [PATCH 16/32] Add missing experimental supression in tests.
---
tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
index dd2259a6695..5b7f8ead4ca 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -8,6 +8,7 @@
namespace Aspire.Hosting.DotnetTool.Tests;
+#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
public class AddDotnetToolTests
{
[Fact]
From cedafb2cc8981e016e0dc3425bdde038d9083549 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 19:58:23 +0000
Subject: [PATCH 17/32] Fix formatting in tests
---
.../AddDotnetToolTests.cs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
index 5b7f8ead4ca..4c11d4bd811 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -16,7 +16,7 @@ public void AddDotnetToolAddsResourceWithCorrectName()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
-
+
Assert.Equal("mytool", tool.Resource.Name);
Assert.IsType(tool.Resource);
}
@@ -26,7 +26,7 @@ public void AddDotnetToolAddsToolAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
-
+
var annotation = Assert.Single(tool.Resource.Annotations.OfType());
Assert.Equal("dotnet-ef", annotation.PackageId);
}
@@ -35,7 +35,7 @@ public void AddDotnetToolAddsToolAnnotation()
public void AddDotnetToolThrowsWhenPackageIdIsNull()
{
var builder = DistributedApplication.CreateBuilder();
-
+
Assert.Throws(() => builder.AddDotnetTool("mytool", null!));
}
@@ -43,7 +43,7 @@ public void AddDotnetToolThrowsWhenPackageIdIsNull()
public void AddDotnetToolThrowsWhenPackageIdIsEmpty()
{
var builder = DistributedApplication.CreateBuilder();
-
+
Assert.Throws(() => builder.AddDotnetTool("mytool", ""));
}
@@ -51,7 +51,7 @@ public void AddDotnetToolThrowsWhenPackageIdIsEmpty()
public void AddDotnetToolThrowsWhenPackageIdIsWhitespace()
{
var builder = DistributedApplication.CreateBuilder();
-
+
Assert.Throws(() => builder.AddDotnetTool("mytool", " "));
}
@@ -60,7 +60,7 @@ public void AddDotnetToolSetsIconName()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef");
-
+
var annotation = Assert.Single(tool.Resource.Annotations.OfType());
Assert.Equal("Toolbox", annotation.IconName);
}
From 0f3b1fbca48be4887fdc77e8760e29de51457f57 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 21:22:48 +0000
Subject: [PATCH 18/32] Missing docs.
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 7cd3cc59d49..9a1f329ce01 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -146,9 +146,9 @@ await rns.PublishUpdateAsync(resource, x =>
Properties = x.Properties
.Replace(existingPathProp, existingPathProp with { Value = expectedPath })
.Replace(argsSensitivityProperty, argsSensitivityProperty with { Value = trimmedSensitivity })
- //TODO: This could be overly sensitive if any of the `dotnet tool exec` args are sensitive
- // But I don't see how else to get se
- // I also don't see how you could mark a sens
+ //TODO: This could be overly agressive if any of the `dotnet tool exec` args are sensitive but no others are
+ // But I don't see how else to get argument sensitivity.
+ // I also don't see how you could end up with a sensitive argument specifically to `dotnet tool exec`, and not the tool itself
.Replace(argsProperty, argsProperty with { Value = trimmedArgs })
};
}).ConfigureAwait(false);
@@ -172,8 +172,9 @@ await rns.PublishUpdateAsync(resource, x => x with
{
Properties = [
..x.Properties,
- new ResourcePropertySnapshot(KnownProperties.Tool.Package, toolConfig.PackageId),
- new ResourcePropertySnapshot(KnownProperties.Tool.Version, toolConfig.Version)
+ new (KnownProperties.Executable.Path, toolConfig.PackageId),
+ new (KnownProperties.Tool.Package, toolConfig.PackageId),
+ new (KnownProperties.Tool.Version, toolConfig.Version)
]
}).ConfigureAwait(false);
}
@@ -221,7 +222,7 @@ public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder
}
///
- /// Adds a package source to
+ /// Adds a package source to get a tool from
///
/// The Dotnet Tool resource type
/// The .
@@ -235,7 +236,7 @@ public static IResourceBuilder WithPackageSource(this IResourceBuilder
}
///
- ///
+ /// Only use the specified package sources, rather than using them in addition to the existing sources.
///
/// The Dotnet Tool resource type
/// The .
@@ -248,7 +249,7 @@ public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResour
}
///
- ///
+ /// Treat package source failures as warnings.
///
/// The Dotnet Tool resource type
/// The .
From ca77ba14c3fa7752d522f988188eb662ee5f187c Mon Sep 17 00:00:00 2001
From: afscrome
Date: Wed, 3 Dec 2025 21:41:12 +0000
Subject: [PATCH 19/32] Fix typos.
---
src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs | 2 +-
src/Aspire.Hosting/DotnetToolExtensions.cs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
index cfd1f72551e..fc7377e50b8 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
@@ -11,7 +11,7 @@ namespace Aspire.Hosting.ApplicationModel;
public class DotnetToolAnnotation : IResourceAnnotation
{
///
- /// he NuGet package ID of the .NET tool to execute. You can optionally specify a version using the @ syntax, for example dotnetsay@2.1.
+ /// The NuGet package ID of the .NET tool to execute. You can optionally specify a version using the @ syntax, for example dotnetsay@2.1.
///
public required string PackageId { get; set; }
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 9a1f329ce01..bbc59090127 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -13,7 +13,7 @@ namespace Aspire.Hosting;
/// Provides extension methods for adding Dotnet Tool resources to the application model.
///
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
-public static class DotNetToolExtensions
+public static class DotnetToolExtensions
{
private const string ArgumentSeperator = "--";
From 6dab8e6a88a0b52ccd14c0c9a4fff8ba78d268c8 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 4 Dec 2025 16:23:10 +0000
Subject: [PATCH 20/32] Add functional tests.
---
.../DotnetToolFunctionalTests.cs.cs | 54 +++++++++++++++++++
1 file changed, 54 insertions(+)
create mode 100644 tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
new file mode 100644
index 00000000000..79bafff5445
--- /dev/null
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
@@ -0,0 +1,54 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+
+namespace Aspire.Hosting.DotnetTool.Tests;
+
+public class DotnetToolFunctionalTests(ITestOutputHelper testOutputHelper)
+{
+ [Fact]
+ public async Task VerifyDotnetToolResource()
+ {
+ var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
+ var resource = builder
+ .AddDotnetTool("tool", "dotnet-ef")
+ .WithArgs("--help");
+
+ using var app = builder.Build();
+ await app.StartAsync(cts.Token);
+
+ await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token);
+ var terminalState = await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.TerminalStates, cts.Token);
+
+ Assert.Equal(KnownResourceStates.Finished, terminalState);
+
+ Assert.True(app.ResourceNotifications.TryGetCurrentState(resource.Resource.Name, out var resourceState));
+ Assert.Equal(resourceState.Snapshot.ExitCode, 0);
+ }
+
+ [Fact]
+ public async Task VerifyNonExistantDotnetToolResource()
+ {
+ var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
+ var resource = builder
+ .AddDotnetTool("tool", "dotnet-ef")
+ .WithArgs("--help")
+ .WithPackageSource("./fake-package-feed")
+ .WithPackageIgnoreExistingFeeds();
+
+ using var app = builder.Build();
+ await app.StartAsync(cts.Token);
+
+ await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token);
+ var terminalState = await app.ResourceNotifications.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.TerminalStates, cts.Token);
+ Assert.Equal(KnownResourceStates.Finished, terminalState);
+
+ Assert.True(app.ResourceNotifications.TryGetCurrentState(resource.Resource.Name, out var resourceState));
+ Assert.NotEqual(resourceState.Snapshot.ExitCode, 0);
+ }
+
+}
From 82559b5945a0995fafb9329be93815dcb09a019a Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 4 Dec 2025 16:59:08 +0000
Subject: [PATCH 21/32] Refactored `StartSourceFixer` to minimise risk of
calling `PublishUpdate`, without actually updating anything.
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 67 ++++++++++------------
1 file changed, 29 insertions(+), 38 deletions(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index bbc59090127..80454d1f3e6 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -108,53 +108,44 @@ static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, Canc
continue;
}
+ var properties = x.Snapshot.Properties;
var expectedPath = toolConfig.PackageId;
+ var existingPath = properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path)?.Value;
- var existingPathProp = x.Snapshot.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
- if (existingPathProp != null && existingPathProp.Value as string != expectedPath)
+ if (existingPath as string == expectedPath)
{
- await rns.PublishUpdateAsync(resource, x =>
- {
- // Existing Path could have changed in the meantime, so make sure to get the updated version
- var existingPathProp = x.Properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path);
- if (existingPathProp == null)
- {
- return x;
- }
+ continue;
+ }
- var argsProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
- var argsSensitivityProperty = x.Properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
+ var argsProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
+ var argsSensitivityProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
- if (argsProperty?.Value is not ImmutableArray originalArgs
- || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
- {
- return x; ;
- }
+ if (argsProperty?.Value is not ImmutableArray originalArgs
+ || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
+ {
+ continue;
+ }
- var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
- if (argSeperatorPosition == 0)
- {
- return x;
- }
+ var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
+ if (argSeperatorPosition == 0)
+ {
+ return;
+ }
- var firstArgToDisplay = argSeperatorPosition + 1;
- var trimmedArgs = originalArgs[firstArgToDisplay..];
- var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
+ var firstArgToDisplay = argSeperatorPosition + 1;
+ var trimmedArgs = originalArgs[firstArgToDisplay..];
+ var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
- return x with
- {
- Properties = x.Properties
- .Replace(existingPathProp, existingPathProp with { Value = expectedPath })
- .Replace(argsSensitivityProperty, argsSensitivityProperty with { Value = trimmedSensitivity })
- //TODO: This could be overly agressive if any of the `dotnet tool exec` args are sensitive but no others are
- // But I don't see how else to get argument sensitivity.
- // I also don't see how you could end up with a sensitive argument specifically to `dotnet tool exec`, and not the tool itself
- .Replace(argsProperty, argsProperty with { Value = trimmedArgs })
- };
- }).ConfigureAwait(false);
- }
+ await rns.PublishUpdateAsync(resource, x => x with
+ {
+ Properties= [
+ ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Executable.Path or KnownProperties.Resource.AppArgs or KnownProperties.Resource.AppArgsSensitivity),
+ new(KnownProperties.Executable.Path, expectedPath),
+ new(KnownProperties.Resource.AppArgs, trimmedArgs),
+ new(KnownProperties.Resource.AppArgsSensitivity, trimmedSensitivity)
+ ]
+ }).ConfigureAwait(false);
}
-
}, ct);
}
From 7834d5ebc8f1e235dc903c93e79fdaf6d6204131 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 4 Dec 2025 17:24:54 +0000
Subject: [PATCH 22/32] Consistently use `ArgumentSeperator`
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 80454d1f3e6..85f115c9a4c 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -87,7 +87,7 @@ void BuildToolExecArguments(CommandLineArgsCallbackContext x)
}
x.Args.Add("--yes");
- x.Args.Add("--");
+ x.Args.Add(ArgumentSeperator);
}
static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, CancellationToken ct)
From a58e9acf684f69bce24d0c0b28f7fd79739b0c35 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 4 Dec 2025 17:36:23 +0000
Subject: [PATCH 23/32] Renamed `WithPackage*` to `WithTool*`
---
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 30 ++++++++--------
src/Aspire.Hosting/DotnetToolExtensions.cs | 12 +++----
.../AddDotnetToolTests.cs | 34 +++++++++----------
.../DotnetToolFunctionalTests.cs.cs | 4 +--
4 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index 88475c8f946..4e85fca5e6e 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -8,19 +8,19 @@
var simpleUsage = builder.AddDotnetTool("simpleUsage", "dotnet-ef");
var wildcardVersion = builder.AddDotnetTool("wildcard", "dotnet-ef")
- .WithPackageVersion("10.0.*")
+ .WithToolVersion("10.0.*")
.WithParentRelationship(simpleUsage);
var preRelease = builder.AddDotnetTool("prerelease", "dotnet-ef")
- .WithPackagePrerelease()
+ .WithToolPrerelease()
.WithParentRelationship(simpleUsage);
// Multiple versions
var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump")
.WithArgs("--version")
- .WithPackageVersion("9.0.652701");
+ .WithToolVersion("9.0.652701");
builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump")
- .WithPackageVersion("9.0.621003")
+ .WithToolVersion("9.0.621003")
.WithArgs("--version")
.WithParentRelationship(differentVersion);
@@ -55,26 +55,26 @@
var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString());
var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef")
.WaitForCompletion(simpleUsage)
- .WithPackageSource(fakeSourcesPath)
- .WithPackageIgnoreExistingFeeds()
- .WithPackageIgnoreFailedSources()
+ .WithToolSource(fakeSourcesPath)
+ .WithToolIgnoreExistingFeeds()
+ .WithToolIgnoreFailedSources()
;
builder.AddDotnetTool("offlineWildcard", "dotnet-ef")
- .WithPackageVersion("10.0.*")
+ .WithToolVersion("10.0.*")
.WaitForCompletion(wildcardVersion)
.WithParentRelationship(offline)
- .WithPackageSource(fakeSourcesPath)
- .WithPackageIgnoreExistingFeeds()
- .WithPackageIgnoreFailedSources();
+ .WithToolSource(fakeSourcesPath)
+ .WithToolIgnoreExistingFeeds()
+ .WithToolIgnoreFailedSources();
builder.AddDotnetTool("offlinePrerelease", "dotnet-ef")
- .WithPackagePrerelease()
+ .WithToolPrerelease()
.WaitForCompletion(preRelease)
.WithParentRelationship(offline)
- .WithPackageSource(fakeSourcesPath)
- .WithPackageIgnoreExistingFeeds()
- .WithPackageIgnoreFailedSources();
+ .WithToolSource(fakeSourcesPath)
+ .WithToolIgnoreExistingFeeds()
+ .WithToolIgnoreFailedSources();
var secret = builder.AddParameter("secret", "Shhhhhhh", secret: true);
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index 85f115c9a4c..bfd7849c0d8 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -178,7 +178,7 @@ await rns.PublishUpdateAsync(resource, x => x with
/// The .
/// The package identifier to assign to the tool configuration. Cannot be null.
/// The for chaining.
- public static IResourceBuilder WithPackageId(this IResourceBuilder builder, string packageId)
+ public static IResourceBuilder WithToolPackage(this IResourceBuilder builder, string packageId)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.PackageId = packageId;
@@ -192,7 +192,7 @@ public static IResourceBuilder WithPackageId(this IResourceBuilder buil
/// The .
/// The package version to use
/// The for chaining.
- public static IResourceBuilder WithPackageVersion(this IResourceBuilder builder, string version)
+ public static IResourceBuilder WithToolVersion(this IResourceBuilder builder, string version)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.Version = version;
@@ -205,7 +205,7 @@ public static IResourceBuilder WithPackageVersion(this IResourceBuilder
/// The type of resource being built. Must inherit from DotnetToolResource.
/// The .
/// The for chaining.
- public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder builder)
+ public static IResourceBuilder WithToolPrerelease(this IResourceBuilder builder)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.Prerelease = true;
@@ -219,7 +219,7 @@ public static IResourceBuilder WithPackagePrerelease(this IResourceBuilder
/// The .
///
/// The for chaining.
- public static IResourceBuilder WithPackageSource(this IResourceBuilder builder, string source)
+ public static IResourceBuilder WithToolSource(this IResourceBuilder builder, string source)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.Sources.Add(source);
@@ -232,7 +232,7 @@ public static IResourceBuilder WithPackageSource(this IResourceBuilder
/// The Dotnet Tool resource type
/// The .
/// The for chaining.
- public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResourceBuilder builder)
+ public static IResourceBuilder WithToolIgnoreExistingFeeds(this IResourceBuilder builder)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.IgnoreExistingFeeds = true;
@@ -245,7 +245,7 @@ public static IResourceBuilder WithPackageIgnoreExistingFeeds(this IResour
/// The Dotnet Tool resource type
/// The .
/// The for chaining.
- public static IResourceBuilder WithPackageIgnoreFailedSources(this IResourceBuilder builder)
+ public static IResourceBuilder WithToolIgnoreFailedSources(this IResourceBuilder builder)
where T : DotnetToolResource
{
builder.Resource.ToolConfiguration?.IgnoreFailedSources = true;
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
index 4c11d4bd811..37f4d51d389 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/AddDotnetToolTests.cs
@@ -89,7 +89,7 @@ public async Task AddDotnetToolWithVersionGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageVersion("10.0.0");
+ .WithToolVersion("10.0.0");
using var app = builder.Build();
@@ -111,7 +111,7 @@ public async Task AddDotnetToolWithPrereleaseGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackagePrerelease();
+ .WithToolPrerelease();
using var app = builder.Build();
@@ -132,7 +132,7 @@ public async Task AddDotnetToolWithCustomSourceGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageSource("https://custom.nuget.org/v3/index.json");
+ .WithToolSource("https://custom.nuget.org/v3/index.json");
using var app = builder.Build();
@@ -154,8 +154,8 @@ public async Task AddDotnetToolWithMultipleSourcesGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageSource("https://source1.nuget.org/v3/index.json")
- .WithPackageSource("https://source2.nuget.org/v3/index.json");
+ .WithToolSource("https://source1.nuget.org/v3/index.json")
+ .WithToolSource("https://source2.nuget.org/v3/index.json");
using var app = builder.Build();
@@ -179,8 +179,8 @@ public async Task AddDotnetToolWithIgnoreExistingFeedsUsesSourceInsteadOfAddSour
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageSource("https://custom.nuget.org/v3/index.json")
- .WithPackageIgnoreExistingFeeds();
+ .WithToolSource("https://custom.nuget.org/v3/index.json")
+ .WithToolIgnoreExistingFeeds();
using var app = builder.Build();
@@ -202,7 +202,7 @@ public async Task AddDotnetToolWithIgnoreFailedSourcesGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageIgnoreFailedSources();
+ .WithToolIgnoreFailedSources();
using var app = builder.Build();
@@ -245,9 +245,9 @@ public async Task AddDotnetToolWithComplexConfigurationGeneratesCorrectArgs()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageVersion("9.0.1")
- .WithPackageSource("https://custom.nuget.org/v3/index.json")
- .WithPackageIgnoreFailedSources()
+ .WithToolVersion("9.0.1")
+ .WithToolSource("https://custom.nuget.org/v3/index.json")
+ .WithToolIgnoreFailedSources()
.WithArgs("database", "update");
using var app = builder.Build();
@@ -275,7 +275,7 @@ public void WithPackageVersionSetsVersionInAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageVersion("10.0.*");
+ .WithToolVersion("10.0.*");
var annotation = tool.Resource.Annotations.OfType().Single();
Assert.Equal("10.0.*", annotation.Version);
@@ -286,7 +286,7 @@ public void WithPackagePrereleaseSetsPreReleaseInAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackagePrerelease();
+ .WithToolPrerelease();
var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.Prerelease);
@@ -297,7 +297,7 @@ public void WithPackageSourceAddsSourceToAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageSource("https://custom.nuget.org/v3/index.json");
+ .WithToolSource("https://custom.nuget.org/v3/index.json");
var annotation = tool.Resource.Annotations.OfType().Single();
Assert.Single(annotation.Sources);
@@ -309,7 +309,7 @@ public void WithPackageIgnoreExistingFeedsSetsIgnoreExistingFeedsInAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageIgnoreExistingFeeds();
+ .WithToolIgnoreExistingFeeds();
var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.IgnoreExistingFeeds);
@@ -320,7 +320,7 @@ public void WithPackageIgnoreFailedSourcesSetsIgnoreFailedSourcesInAnnotation()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("mytool", "dotnet-ef")
- .WithPackageIgnoreFailedSources();
+ .WithToolIgnoreFailedSources();
var annotation = tool.Resource.Annotations.OfType().Single();
Assert.True(annotation.IgnoreFailedSources);
@@ -349,7 +349,7 @@ public async Task AddDotnetToolGeneratesCorrectManifest()
{
var builder = DistributedApplication.CreateBuilder();
var tool = builder.AddDotnetTool("ef-tool", "dotnet-ef")
- .WithPackageVersion("10.0.0")
+ .WithToolVersion("10.0.0")
.WithArgs("--version");
using var app = builder.Build();
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
index 79bafff5445..ceb99496538 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
@@ -37,8 +37,8 @@ public async Task VerifyNonExistantDotnetToolResource()
var resource = builder
.AddDotnetTool("tool", "dotnet-ef")
.WithArgs("--help")
- .WithPackageSource("./fake-package-feed")
- .WithPackageIgnoreExistingFeeds();
+ .WithToolSource("./fake-package-feed")
+ .WithToolIgnoreExistingFeeds();
using var app = builder.Build();
await app.StartAsync(cts.Token);
From dde6f909a2fa82b929a5a8c6af96f72892e0a306 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 4 Dec 2025 17:38:14 +0000
Subject: [PATCH 24/32] Tweaks.
---
src/Aspire.Hosting/DotnetToolExtensions.cs | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index bfd7849c0d8..c3ed19018c8 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -163,7 +163,6 @@ await rns.PublishUpdateAsync(resource, x => x with
{
Properties = [
..x.Properties,
- new (KnownProperties.Executable.Path, toolConfig.PackageId),
new (KnownProperties.Tool.Package, toolConfig.PackageId),
new (KnownProperties.Tool.Version, toolConfig.Version)
]
@@ -200,7 +199,7 @@ public static IResourceBuilder WithToolVersion(this IResourceBuilder bu
}
///
- /// Configures the resource builder to mark the associated .NET tool package as a prerelease version.
+ /// Allow prerelease versions of the tool to be used
///
/// The type of resource being built. Must inherit from DotnetToolResource.
/// The .
From d7a30ec79e27a613bba48545f65c078710b28127 Mon Sep 17 00:00:00 2001
From: Alex Crome <289860+afscrome@users.noreply.github.com>
Date: Mon, 8 Dec 2025 15:04:56 +0000
Subject: [PATCH 25/32] Remove unused package.
---
Directory.Packages.props | 1 -
1 file changed, 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1b82e9adf47..48bf97791f5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -79,7 +79,6 @@
-
From 96dd4ecfe9247c35179421d16e1abff54efc0ea5 Mon Sep 17 00:00:00 2001
From: Alex Crome <289860+afscrome@users.noreply.github.com>
Date: Mon, 8 Dec 2025 15:06:41 +0000
Subject: [PATCH 26/32] Small tweaks to tools
- Ensure tool source renders nicely before a resource starts
- Add a property including the raw args to `dotnet tool exec` to help with troubleshooting if needed.
---
.../ApplicationModel/DotnetToolResource.cs | 2 +-
src/Aspire.Hosting/DotnetToolExtensions.cs | 49 ++++++++++++++-----
src/Shared/Model/KnownProperties.cs | 1 +
3 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index eebcb33ca35..fb95181a5ad 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -20,7 +20,7 @@ public class DotnetToolResource : ExecutableResource
/// The name of the resource.
/// The package id of the tool.
public DotnetToolResource(string name, string packageId)
- : base(name, "dotnet", ".")
+ : base(name, "dotnet", string.Empty)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
Annotations.Add(new DotnetToolAnnotation { PackageId = packageId });
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolExtensions.cs
index c3ed19018c8..120c5994c62 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolExtensions.cs
@@ -47,7 +47,7 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
.WithIconName("Toolbox")
.WithCommand("dotnet")
.WithArgs(BuildToolExecArguments)
- .OnInitializeResource(StartSourceFixer)
+ .OnInitializeResource(UpdateSourceColumnForDashboard)
.OnBeforeResourceStarted(BuildToolProperties);
void BuildToolExecArguments(CommandLineArgsCallbackContext x)
@@ -90,9 +90,11 @@ void BuildToolExecArguments(CommandLineArgsCallbackContext x)
x.Args.Add(ArgumentSeperator);
}
- static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, CancellationToken ct)
+ static Task UpdateSourceColumnForDashboard(T resource, InitializeResourceEvent evt, CancellationToken ct)
{
var rns = evt.Services.GetRequiredService();
+ // `DcpExecutor` will keep on ovewriting these properties every time there is an internal update
+ // so subscribe to every Resource update so we can undo DcpExecutor's changes.
_ = Task.Run(async () =>
{
await foreach (var x in rns.WatchAsync(ct).ConfigureAwait(false))
@@ -108,15 +110,26 @@ static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, Canc
continue;
}
+ // Update the executable `path` property as this is what the dashboard uses to render the primary text in the "Source" column
+ // changing `dotnet` to `TOOL NAME`
var properties = x.Snapshot.Properties;
var expectedPath = toolConfig.PackageId;
var existingPath = properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path)?.Value;
- if (existingPath as string == expectedPath)
+ if (existingPath as string != expectedPath)
{
+ await rns.PublishUpdateAsync(resource, x => x with
+ {
+ Properties = [
+ ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Executable.Path),
+ new(KnownProperties.Executable.Path, expectedPath)
+ ]
+ }).ConfigureAwait(false);
continue;
}
+ // For resource args strip out the "tool exec ... --" portion and only show the args for the tool itself
+ // But for diagnostics, put the original properties back in the ToolExecArgs property
var argsProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
var argsSensitivityProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
@@ -126,27 +139,37 @@ static async Task StartSourceFixer(T resource, InitializeResourceEvent evt, Canc
continue;
}
+ // If the first args are not "tool" or "exec", then assume we've already removed the args
+ if (originalArgs.Length < 2 || originalArgs[0] != "tool" || originalArgs[1] != "exec")
+ {
+ continue;
+ }
+
var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
- if (argSeperatorPosition == 0)
+ if (argSeperatorPosition == -1)
{
- return;
+ continue;
}
- var firstArgToDisplay = argSeperatorPosition + 1;
- var trimmedArgs = originalArgs[firstArgToDisplay..];
- var trimmedSensitivity = originalSensitivity[firstArgToDisplay..];
+ var firstToolArg = argSeperatorPosition + 1;
+ var toolArgs = originalArgs[firstToolArg..];
+ var toolSensitivity = originalSensitivity[firstToolArg..];
+
+ var execArgs = originalArgs[..argSeperatorPosition];
await rns.PublishUpdateAsync(resource, x => x with
{
- Properties= [
- ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Executable.Path or KnownProperties.Resource.AppArgs or KnownProperties.Resource.AppArgsSensitivity),
- new(KnownProperties.Executable.Path, expectedPath),
- new(KnownProperties.Resource.AppArgs, trimmedArgs),
- new(KnownProperties.Resource.AppArgsSensitivity, trimmedSensitivity)
+ Properties = [
+ ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Resource.AppArgs or KnownProperties.Resource.AppArgsSensitivity or KnownProperties.Tool.ExecArgs),
+ new(KnownProperties.Resource.AppArgs, toolArgs),
+ new(KnownProperties.Resource.AppArgsSensitivity, toolSensitivity),
+ new(KnownProperties.Tool.ExecArgs, toolArgs){ IsSensitive = true }
]
}).ConfigureAwait(false);
}
}, ct);
+
+ return Task.CompletedTask;
}
//TODO: Move to WithConfigurationFinalizer once merged - https://github.com/dotnet/aspire/pull/13200
diff --git a/src/Shared/Model/KnownProperties.cs b/src/Shared/Model/KnownProperties.cs
index e94c496a6d2..db917b32720 100644
--- a/src/Shared/Model/KnownProperties.cs
+++ b/src/Shared/Model/KnownProperties.cs
@@ -65,5 +65,6 @@ public static class Tool
{
public const string Package = "tool.package";
public const string Version = "tool.version";
+ public const string ExecArgs = "tool.execArgs";
}
}
From b0119ecbb5fe58e3ee8c66f1220be6d752ae9f01 Mon Sep 17 00:00:00 2001
From: Alex Crome <289860+afscrome@users.noreply.github.com>
Date: Mon, 8 Dec 2025 15:54:44 +0000
Subject: [PATCH 27/32] Back to `.` as the initial working directory.
---
src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index fb95181a5ad..eebcb33ca35 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -20,7 +20,7 @@ public class DotnetToolResource : ExecutableResource
/// The name of the resource.
/// The package id of the tool.
public DotnetToolResource(string name, string packageId)
- : base(name, "dotnet", string.Empty)
+ : base(name, "dotnet", ".")
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
Annotations.Add(new DotnetToolAnnotation { PackageId = packageId });
From c626bf5aafe84f06ee63039616625790f824a676 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 1 Jan 2026 20:52:29 +0000
Subject: [PATCH 28/32] Add first-class support for .NET tool resources
Enhances Aspire to treat dotnet tool resources as first-class citizens.
- Adds KnownResourceTypes.Tool and updates DotnetToolResource to use it.
- Sets tool package ID as the resource "Source" for dashboard clarity.
- Adds IsTool and TryGetToolPackage extensions for resource model.
- Updates dashboard to display tool package as source.
- Refines argument handling: only tool-specific args are shown in dashboard, full args used for execution.
- Refactors DotnetToolResourceExtensions and removes dashboard update workaround.
- Removed unused `IResourceAnnotation` from AppLaunchArgumentAnnotation.
- Adds and updates tests for tool resource handling.
- Improves diagnostics with DebuggerDisplay and code cleanups.
---
.../DotnetTool/DotnetTool.AppHost/AppHost.cs | 5 +-
.../Model/ResourceSourceViewModel.cs | 6 +-
.../Model/ResourceViewModelExtensions.cs | 9 ++
.../AppLaunchArgumentAnnotation.cs | 2 +-
.../ApplicationModel/DotnetToolResource.cs | 2 +
src/Aspire.Hosting/Dcp/DcpExecutor.cs | 29 ++++--
...ons.cs => DotnetToolResourceExtensions.cs} | 95 ++-----------------
src/Shared/Model/KnownResourceTypes.cs | 1 +
.../Dcp/DcpExecutorTests.cs | 39 +++++++-
9 files changed, 84 insertions(+), 104 deletions(-)
rename src/Aspire.Hosting/{DotnetToolExtensions.cs => DotnetToolResourceExtensions.cs} (64%)
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
index 4e85fca5e6e..0bb329bcca5 100644
--- a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -54,7 +54,6 @@
// Fake Offline by using "empty" package feeds
var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString());
var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef")
- .WaitForCompletion(simpleUsage)
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
.WithToolIgnoreFailedSources()
@@ -62,7 +61,6 @@
builder.AddDotnetTool("offlineWildcard", "dotnet-ef")
.WithToolVersion("10.0.*")
- .WaitForCompletion(wildcardVersion)
.WithParentRelationship(offline)
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
@@ -70,8 +68,7 @@
builder.AddDotnetTool("offlinePrerelease", "dotnet-ef")
.WithToolPrerelease()
- .WaitForCompletion(preRelease)
- .WithParentRelationship(offline)
+ .WithParentRelationship(offline)
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
.WithToolIgnoreFailedSources();
diff --git a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
index 788c8e56e7d..624fc5b5d93 100644
--- a/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
+++ b/src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
@@ -16,11 +16,15 @@ public class ResourceSourceViewModel(string value, List? content
{
var commandLineInfo = GetCommandLineInfo(resource);
- // NOTE projects are also executables, so we have to check for projects first
+ // NOTE project and tools are also executables, so check for those first
if (resource.IsProject() && resource.TryGetProjectPath(out var projectPath))
{
return CreateResourceSourceViewModel(Path.GetFileName(projectPath), projectPath, commandLineInfo);
}
+ if (resource.IsTool() && resource.TryGetToolPackage(out var toolPackage))
+ {
+ return CreateResourceSourceViewModel(toolPackage, toolPackage, commandLineInfo);
+ }
if (resource.TryGetExecutablePath(out var executablePath))
{
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
index adaf39057b9..6327b31a067 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
@@ -19,6 +19,11 @@ public static bool IsProject(this ResourceViewModel resource)
return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Project);
}
+ public static bool IsTool(this ResourceViewModel resource)
+ {
+ return StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Tool);
+ }
+
public static bool IsExecutable(this ResourceViewModel resource, bool allowSubtypes)
{
if (StringComparers.ResourceType.Equals(resource.ResourceType, KnownResourceTypes.Executable))
@@ -48,6 +53,10 @@ public static bool TryGetProjectPath(this ResourceViewModel resource, [NotNullWh
{
return resource.TryGetCustomDataString(KnownProperties.Project.Path, out projectPath);
}
+ public static bool TryGetToolPackage(this ResourceViewModel resource, [NotNullWhen(returnValue: true)] out string? projectPath)
+ {
+ return resource.TryGetCustomDataString(KnownProperties.Tool.Package, out projectPath);
+ }
public static bool TryGetExecutablePath(this ResourceViewModel resource, [NotNullWhen(returnValue: true)] out string? executablePath)
{
diff --git a/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs
index ee11233189d..9c7eecf23e4 100644
--- a/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/AppLaunchArgumentAnnotation.cs
@@ -9,7 +9,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// Represents a container or project application launch argument.
///
[DebuggerDisplay("Type = {GetType().Name,nq}, Argument = {Argument}, IsSensitive = {IsSensitive}")]
-internal sealed class AppLaunchArgumentAnnotation(string argument, bool isSensitive) : IResourceAnnotation
+internal sealed class AppLaunchArgumentAnnotation(string argument, bool isSensitive)
{
///
/// The evaluated launch argument.
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index eebcb33ca35..92f92c62744 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace Aspire.Hosting.ApplicationModel;
@@ -12,6 +13,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and
/// command with its package ID, and ensures that the required metadata is properly annotated.
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Tool = {ToolConfiguration?.PackageId}")]
public class DotnetToolResource : ExecutableResource
{
///
diff --git a/src/Aspire.Hosting/Dcp/DcpExecutor.cs b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
index b0bff668cc9..f8bfd870a99 100644
--- a/src/Aspire.Hosting/Dcp/DcpExecutor.cs
+++ b/src/Aspire.Hosting/Dcp/DcpExecutor.cs
@@ -1700,14 +1700,15 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou
}
var launchArgs = BuildLaunchArgs(er, spec, configuration.Arguments);
- var executableArgs = launchArgs.Where(a => !a.AnnotationOnly).Select(a => a.Value).ToList();
+ var executableArgs = launchArgs.Where(a => a.Executable).Select(a => a.Value).ToList();
+ var displayArgs = launchArgs.Where(a => a.Display).ToList();
if (executableArgs.Count > 0)
{
spec.Args ??= [];
spec.Args.AddRange(executableArgs);
}
// Arg annotations are what is displayed in the dashboard.
- er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, launchArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive)));
+ er.DcpResource.SetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, displayArgs.Select(a => new AppLaunchArgumentAnnotation(a.Value, isSensitive: a.IsSensitive)));
spec.Env = configuration.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList();
@@ -1724,13 +1725,13 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou
}
}
- private static List<(string Value, bool IsSensitive, bool AnnotationOnly)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, IEnumerable<(string Value, bool IsSensitive)> appHostArgs)
+ private static List<(string Value, bool IsSensitive, bool Executable, bool Display)> BuildLaunchArgs(RenderedModelResource er, ExecutableSpec spec, IEnumerable<(string Value, bool IsSensitive)> appHostArgs)
{
// Launch args is the final list of args that are displayed in the UI and possibly added to the executable spec.
// They're built from app host resource model args and any args in the effective launch profile.
// Follows behavior in the IDE execution spec when in IDE execution mode:
- // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#launch-profile-processing-project-launch-configuration
- var launchArgs = new List<(string Value, bool IsSensitive, bool AnnotationOnly)>();
+ // https://github.com/dotnet/aspire/blob/main/docs/specs/IDE-execution.md#project-launch-configuration-type-project
+ var launchArgs = new List<(string Value, bool IsSensitive, bool Executable, bool Display)>();
// If the executable is a project then include any command line args from the launch profile.
if (er.ModelResource is ProjectResource project)
@@ -1742,7 +1743,7 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou
{
// When the .NET project is launched from an IDE the launch profile args are automatically added.
// We still want to display the args in the dashboard so only add them to the custom arg annotations.
- var annotationOnly = spec.ExecutionType == ExecutionType.IDE;
+ var executableArg = spec.ExecutionType != ExecutionType.IDE;
var launchProfileArgs = GetLaunchProfileArgs(project.GetEffectiveLaunchProfile()?.LaunchProfile);
if (launchProfileArgs.Count > 0 && appHostArgs.Any())
@@ -1751,12 +1752,24 @@ private async Task CreateExecutableAsync(RenderedModelResource er, ILogger resou
launchProfileArgs.Insert(0, "--");
}
- launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, annotationOnly)));
+ launchArgs.AddRange(launchProfileArgs.Select(a => (a, isSensitive: false, executableArg, true)));
}
}
+#pragma warning disable ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ else if (er.ModelResource is DotnetToolResource tool)
+ {
+ var argSeparator = appHostArgs.Select((a, i) => (index: i, value: a.Value))
+ .FirstOrDefault(x => x.value == DotnetToolResourceExtensions.ArgumentSeparator);
+
+ var args = appHostArgs.Select((a, i) => (arg : a, display : i > argSeparator.index));
+ launchArgs.AddRange(args.Select(x => (x.arg.Value, x.arg.IsSensitive, true, x.display)));
+ return launchArgs;
+
+ }
+#pragma warning restore ASPIREDOTNETTOOL // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
// In the situation where args are combined (process execution) the app host args are added after the launch profile args.
- launchArgs.AddRange(appHostArgs.Select(a => (a.Value, a.IsSensitive, annotationOnly: false)));
+ launchArgs.AddRange(appHostArgs.Select(a => (a.Value, a.IsSensitive, true, true)));
return launchArgs;
}
diff --git a/src/Aspire.Hosting/DotnetToolExtensions.cs b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs
similarity index 64%
rename from src/Aspire.Hosting/DotnetToolExtensions.cs
rename to src/Aspire.Hosting/DotnetToolResourceExtensions.cs
index 120c5994c62..3d198b0cc5f 100644
--- a/src/Aspire.Hosting/DotnetToolExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
-using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
@@ -13,9 +12,9 @@ namespace Aspire.Hosting;
/// Provides extension methods for adding Dotnet Tool resources to the application model.
///
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
-public static class DotnetToolExtensions
+public static class DotnetToolResourceExtensions
{
- private const string ArgumentSeperator = "--";
+ internal const string ArgumentSeparator = "--";
///
/// Adds a .NET tool resource to the distributed application builder.
@@ -41,13 +40,12 @@ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationB
return builder.AddResource(resource)
.WithInitialState(new CustomResourceSnapshot
{
- ResourceType = "Tool",
+ ResourceType = KnownResourceTypes.Tool,
Properties = []
})
.WithIconName("Toolbox")
.WithCommand("dotnet")
.WithArgs(BuildToolExecArguments)
- .OnInitializeResource(UpdateSourceColumnForDashboard)
.OnBeforeResourceStarted(BuildToolProperties);
void BuildToolExecArguments(CommandLineArgsCallbackContext x)
@@ -87,89 +85,7 @@ void BuildToolExecArguments(CommandLineArgsCallbackContext x)
}
x.Args.Add("--yes");
- x.Args.Add(ArgumentSeperator);
- }
-
- static Task UpdateSourceColumnForDashboard(T resource, InitializeResourceEvent evt, CancellationToken ct)
- {
- var rns = evt.Services.GetRequiredService();
- // `DcpExecutor` will keep on ovewriting these properties every time there is an internal update
- // so subscribe to every Resource update so we can undo DcpExecutor's changes.
- _ = Task.Run(async () =>
- {
- await foreach (var x in rns.WatchAsync(ct).ConfigureAwait(false))
- {
- if (x.Resource != resource)
- {
- continue;
- }
-
- var toolConfig = resource.ToolConfiguration;
- if (toolConfig == null)
- {
- continue;
- }
-
- // Update the executable `path` property as this is what the dashboard uses to render the primary text in the "Source" column
- // changing `dotnet` to `TOOL NAME`
- var properties = x.Snapshot.Properties;
- var expectedPath = toolConfig.PackageId;
- var existingPath = properties.FirstOrDefault(p => p.Name == KnownProperties.Executable.Path)?.Value;
-
- if (existingPath as string != expectedPath)
- {
- await rns.PublishUpdateAsync(resource, x => x with
- {
- Properties = [
- ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Executable.Path),
- new(KnownProperties.Executable.Path, expectedPath)
- ]
- }).ConfigureAwait(false);
- continue;
- }
-
- // For resource args strip out the "tool exec ... --" portion and only show the args for the tool itself
- // But for diagnostics, put the original properties back in the ToolExecArgs property
- var argsProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgs);
- var argsSensitivityProperty = properties.FirstOrDefault(x => x.Name == KnownProperties.Resource.AppArgsSensitivity);
-
- if (argsProperty?.Value is not ImmutableArray originalArgs
- || argsSensitivityProperty?.Value is not ImmutableArray originalSensitivity)
- {
- continue;
- }
-
- // If the first args are not "tool" or "exec", then assume we've already removed the args
- if (originalArgs.Length < 2 || originalArgs[0] != "tool" || originalArgs[1] != "exec")
- {
- continue;
- }
-
- var argSeperatorPosition = originalArgs.IndexOf(ArgumentSeperator);
- if (argSeperatorPosition == -1)
- {
- continue;
- }
-
- var firstToolArg = argSeperatorPosition + 1;
- var toolArgs = originalArgs[firstToolArg..];
- var toolSensitivity = originalSensitivity[firstToolArg..];
-
- var execArgs = originalArgs[..argSeperatorPosition];
-
- await rns.PublishUpdateAsync(resource, x => x with
- {
- Properties = [
- ..x.Properties.RemoveAll(p => p.Name is KnownProperties.Resource.AppArgs or KnownProperties.Resource.AppArgsSensitivity or KnownProperties.Tool.ExecArgs),
- new(KnownProperties.Resource.AppArgs, toolArgs),
- new(KnownProperties.Resource.AppArgsSensitivity, toolSensitivity),
- new(KnownProperties.Tool.ExecArgs, toolArgs){ IsSensitive = true }
- ]
- }).ConfigureAwait(false);
- }
- }, ct);
-
- return Task.CompletedTask;
+ x.Args.Add(ArgumentSeparator);
}
//TODO: Move to WithConfigurationFinalizer once merged - https://github.com/dotnet/aspire/pull/13200
@@ -187,7 +103,8 @@ await rns.PublishUpdateAsync(resource, x => x with
Properties = [
..x.Properties,
new (KnownProperties.Tool.Package, toolConfig.PackageId),
- new (KnownProperties.Tool.Version, toolConfig.Version)
+ new (KnownProperties.Tool.Version, toolConfig.Version),
+ new (KnownProperties.Resource.Source, resource.ToolConfiguration?.PackageId)
]
}).ConfigureAwait(false);
}
diff --git a/src/Shared/Model/KnownResourceTypes.cs b/src/Shared/Model/KnownResourceTypes.cs
index 3901f470824..dacc23be4cd 100644
--- a/src/Shared/Model/KnownResourceTypes.cs
+++ b/src/Shared/Model/KnownResourceTypes.cs
@@ -8,6 +8,7 @@ internal static class KnownResourceTypes
public const string Executable = "Executable";
public const string ContainerExec = "ContainerExec";
public const string Project = "Project";
+ public const string Tool = "Tool";
public const string Container = "Container";
public const string Parameter = "Parameter";
public const string ConnectionString = "ConnectionString";
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index c158e31d53b..1c975015862 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -109,6 +109,7 @@ public async Task ResourceStarted_ProjectHasReplicas_EventRaisedOnce()
Assert.Equal(2, resourceIds.Count);
}
+ //TODO: More tests here
[Theory]
[InlineData(ExecutionType.IDE, false, null, new string[] { "--test1", "--test2" })]
[InlineData(ExecutionType.IDE, true, new string[] { "--withargs-test" }, new string[] { "--withargs-test" })]
@@ -162,7 +163,43 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd
Assert.Equal(expectedArgs, callArgs);
Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
- Assert.Equal(expectedAnnotations, argAnnotations.Select(a => a.Argument));
+ Assert.Equal(expectedDisplayArgs, argAnnotations.Select(a => a.Argument));
+ }
+
+ [Theory]
+ [InlineData()]
+ [InlineData("--arg1", "foo")]
+ public async Task CreateExecutable_ToolHasCommandLineArgs_AnnotationsAdded(params string[] toolArgs)
+ {
+ var builder = DistributedApplication.CreateBuilder(new DistributedApplicationOptions
+ {
+ AssemblyName = typeof(DistributedApplicationTests).Assembly.FullName
+ });
+
+ var resourceBuilder = builder.AddDotnetTool("tool", "package")
+ .WithArgs(toolArgs);
+
+ var kubernetesService = new TestKubernetesService();
+ using var app = builder.Build();
+ var distributedAppModel = app.Services.GetRequiredService();
+ var dcpOptions = new DcpOptions { DashboardPath = "./dashboard", ResourceNameSuffix = "suffix" };
+
+ var events = new DcpExecutorEvents();
+ var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create();
+
+ var appExecutor = CreateAppExecutor(distributedAppModel, kubernetesService: kubernetesService, dcpOptions: dcpOptions, events: events);
+ await appExecutor.RunApplicationAsync();
+
+ var executables = kubernetesService.CreatedResources.OfType().ToList();
+ var exe = Assert.Single(executables);
+
+ string[] dotnetToolExecArgs = ["tool", "exec", "package", "--yes", "--"];
+ string[] callArgs = [..dotnetToolExecArgs, ..toolArgs];
+
+ Assert.Equal(callArgs, exe.Spec.Args);
+
+ Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
+ Assert.Equal(toolArgs, argAnnotations.Select(a => a.Argument));
}
[Fact]
From 9fb3bc5aa61365afa6450983262710c0b37e2de8 Mon Sep 17 00:00:00 2001
From: Alex Crome
Date: Thu, 1 Jan 2026 21:12:44 +0000
Subject: [PATCH 29/32] Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs | 4 ++--
.../DotnetToolFunctionalTests.cs.cs | 2 +-
tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
index fc7377e50b8..e62dbc56110 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
@@ -5,7 +5,7 @@
namespace Aspire.Hosting.ApplicationModel;
///
-/// Represents an annotation for a dotnet tool resources.
+/// Represents an annotation for dotnet tool resources.
///
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class DotnetToolAnnotation : IResourceAnnotation
@@ -31,7 +31,7 @@ public class DotnetToolAnnotation : IResourceAnnotation
public List Sources { get; } = [];
///
- /// Are custom sources used in addition or intead of existing feeds.
+ /// Are custom sources used in addition or instead of existing feeds.
///
///
/// This value has no impact if is empty.
diff --git a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
index ceb99496538..a56a28ab937 100644
--- a/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
+++ b/tests/Aspire.Hosting.DotnetTool.Tests/DotnetToolFunctionalTests.cs.cs
@@ -30,7 +30,7 @@ public async Task VerifyDotnetToolResource()
}
[Fact]
- public async Task VerifyNonExistantDotnetToolResource()
+ public async Task VerifyNonExistentDotnetToolResource()
{
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index 1c975015862..c6c17a84954 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -109,7 +109,7 @@ public async Task ResourceStarted_ProjectHasReplicas_EventRaisedOnce()
Assert.Equal(2, resourceIds.Count);
}
- //TODO: More tests here
+
[Theory]
[InlineData(ExecutionType.IDE, false, null, new string[] { "--test1", "--test2" })]
[InlineData(ExecutionType.IDE, true, new string[] { "--withargs-test" }, new string[] { "--withargs-test" })]
From 585f3a6a8ed465b5ea35e2d52be703797f8779d3 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 1 Jan 2026 21:20:34 +0000
Subject: [PATCH 30/32] More PR feedback.
---
.../DotnetToolResourceExtensions.cs | 30 +++++++++----------
.../Dcp/DcpExecutorTests.cs | 2 +-
2 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/src/Aspire.Hosting/DotnetToolResourceExtensions.cs b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs
index 3d198b0cc5f..74616db6cde 100644
--- a/src/Aspire.Hosting/DotnetToolResourceExtensions.cs
+++ b/src/Aspire.Hosting/DotnetToolResourceExtensions.cs
@@ -17,7 +17,7 @@ public static class DotnetToolResourceExtensions
internal const string ArgumentSeparator = "--";
///
- /// Adds a .NET tool resource to the distributed application builder.
+ /// Adds a .NET tool resource to the application model.
///
/// The .
/// The name of the resource.
@@ -27,8 +27,8 @@ public static IResourceBuilder AddDotnetTool(this IDistribut
=> builder.AddDotnetTool(new DotnetToolResource(name, packageId));
///
- /// Adds a .NET tool resource to the distributed application builder, configuring it for execution via the 'dotnet
- /// tool exec' command.
+ /// Adds a .NET tool resource to the distributed application model and configures it for execution via the dotnet
+ /// tool exec command.
///
/// The type of the .NET tool resource to add. Must inherit from .
/// The distributed application builder to which the .NET tool resource will be added.
@@ -114,7 +114,7 @@ await rns.PublishUpdateAsync(resource, x => x with
/// Sets the package identifier for the tool configuration associated with the resource builder.
///
/// The Dotnet Tool resource type
- /// The .
+ /// The .
/// The package identifier to assign to the tool configuration. Cannot be null.
/// The for chaining.
public static IResourceBuilder WithToolPackage(this IResourceBuilder builder, string packageId)
@@ -125,10 +125,10 @@ public static IResourceBuilder WithToolPackage(this IResourceBuilder bu
}
///
- /// Set the package version for a tool to use
+ /// Sets the package version for a tool to use.
///
/// The Dotnet Tool resource type
- /// The .
+ /// The .
/// The package version to use
/// The for chaining.
public static IResourceBuilder WithToolVersion(this IResourceBuilder builder, string version)
@@ -139,10 +139,10 @@ public static IResourceBuilder WithToolVersion(this IResourceBuilder bu
}
///
- /// Allow prerelease versions of the tool to be used
+ /// Allows prerelease versions of the tool to be used
///
/// The type of resource being built. Must inherit from DotnetToolResource.
- /// The .
+ /// The .
/// The for chaining.
public static IResourceBuilder WithToolPrerelease(this IResourceBuilder builder)
where T : DotnetToolResource
@@ -152,11 +152,11 @@ public static IResourceBuilder WithToolPrerelease(this IResourceBuilder
}
///
- /// Adds a package source to get a tool from
+ /// Adds a NuGet package source for tool acquisition.
///
/// The Dotnet Tool resource type
- /// The .
- ///
+ /// The .
+ /// The source to add.
/// The for chaining.
public static IResourceBuilder WithToolSource(this IResourceBuilder builder, string source)
where T : DotnetToolResource
@@ -166,10 +166,10 @@ public static IResourceBuilder WithToolSource(this IResourceBuilder bui
}
///
- /// Only use the specified package sources, rather than using them in addition to the existing sources.
+ /// Configures the tool to use only the specified package sources, ignoring existing NuGet configuration.
///
/// The Dotnet Tool resource type
- /// The .
+ /// The .
/// The for chaining.
public static IResourceBuilder WithToolIgnoreExistingFeeds(this IResourceBuilder builder)
where T : DotnetToolResource
@@ -179,10 +179,10 @@ public static IResourceBuilder WithToolIgnoreExistingFeeds(this IResourceB
}
///
- /// Treat package source failures as warnings.
+ /// Configures the resource to treat package source failures as warnings.
///
/// The Dotnet Tool resource type
- /// The .
+ /// The .
/// The for chaining.
public static IResourceBuilder WithToolIgnoreFailedSources(this IResourceBuilder builder)
where T : DotnetToolResource
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index c6c17a84954..d94b731c5f7 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -163,7 +163,7 @@ public async Task CreateExecutable_LaunchProfileHasCommandLineArgs_AnnotationsAd
Assert.Equal(expectedArgs, callArgs);
Assert.True(exe.TryGetAnnotationAsObjectList(CustomResource.ResourceAppArgsAnnotation, out var argAnnotations));
- Assert.Equal(expectedDisplayArgs, argAnnotations.Select(a => a.Argument));
+ Assert.Equal(expectedAnnotations, argAnnotations.Select(a => a.Argument));
}
[Theory]
From 532983892b89b523cfcd44fe722a1261d4141159 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Thu, 1 Jan 2026 22:04:17 +0000
Subject: [PATCH 31/32] Fix rogue newline.
---
tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
index d94b731c5f7..838e22d2aa0 100644
--- a/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
+++ b/tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
@@ -109,7 +109,6 @@ public async Task ResourceStarted_ProjectHasReplicas_EventRaisedOnce()
Assert.Equal(2, resourceIds.Count);
}
-
[Theory]
[InlineData(ExecutionType.IDE, false, null, new string[] { "--test1", "--test2" })]
[InlineData(ExecutionType.IDE, true, new string[] { "--withargs-test" }, new string[] { "--withargs-test" })]
From 6e7f28860aa12a6ab9311b6bb76ff08443826706 Mon Sep 17 00:00:00 2001
From: afscrome
Date: Fri, 2 Jan 2026 09:14:39 +0000
Subject: [PATCH 32/32] Self review
---
.../Model/ResourceViewModelExtensions.cs | 1 +
.../ApplicationModel/DotnetToolResource.cs | 9 +++++----
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
index 6327b31a067..5e7b62992b5 100644
--- a/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
+++ b/src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
@@ -53,6 +53,7 @@ public static bool TryGetProjectPath(this ResourceViewModel resource, [NotNullWh
{
return resource.TryGetCustomDataString(KnownProperties.Project.Path, out projectPath);
}
+
public static bool TryGetToolPackage(this ResourceViewModel resource, [NotNullWhen(returnValue: true)] out string? projectPath)
{
return resource.TryGetCustomDataString(KnownProperties.Tool.Package, out projectPath);
diff --git a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
index 92f92c62744..5d52bf12f66 100644
--- a/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
+++ b/src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
@@ -7,11 +7,12 @@
namespace Aspire.Hosting.ApplicationModel;
///
-/// Represents a .NET tool resource that encapsulates metadata about a .NET CLI tool, including its name, package ID,
-/// and command.
+/// A resource that represents a specified dotnet tool.
///
-/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and
-/// command with its package ID, and ensures that the required metadata is properly annotated.
+///
+/// This class is used to define and manage resources for .NET CLI tools. It associates a tool's name and
+/// command with its package ID, and ensures that the required metadata is properly annotated.
+///
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Tool = {ToolConfiguration?.PackageId}")]
public class DotnetToolResource : ExecutableResource