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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+