diff --git a/Aspire.slnx b/Aspire.slnx
index a1ea50410f8..cc0eb9037b9 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -171,6 +171,9 @@
+
+
+
@@ -414,6 +417,7 @@
+
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 @@
+
diff --git a/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
new file mode 100644
index 00000000000..035e32b2b80
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
@@ -0,0 +1,118 @@
+// 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);
+
+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
+var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump")
+ .WithArgs("--version")
+ .WithPackageVersion("9.0.652701");
+builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump")
+ .WithPackageVersion("9.0.621003")
+ .WithArgs("--version")
+ .WithParentRelationship(differentVersion);
+
+// Concurrency
+IResourceBuilder? concurrencyParent = null;
+for (int i = 0; i < 5; i++)
+{
+ var concurrency = builder.AddDotnetTool($"sametoolconcurrency-{i}", "dotnet-trace")
+ .WithArgs("--version");
+
+ if (concurrencyParent == null)
+ {
+ concurrencyParent = concurrency;
+ }
+ else
+ {
+ concurrency.WithParentRelationship(concurrencyParent);
+ }
+}
+
+// Substitution
+var substituted = builder.AddDotnetTool("substituted", "dotnet-ef")
+ .WithCommand("calc")
+ .WithIconName("Calculator")
+ .WithExplicitStart();
+foreach(var toolAnnotation in substituted.Resource.Annotations.OfType().ToList())
+{
+ 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 =>
+ {
+ try
+ {
+ Directory.Delete(nugetPackagesPath, true);
+ return Task.FromResult(CommandResults.Success());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(CommandResults.Failure(ex));
+ }
+ }, new CommandOptions
+ {
+ IconName = "Delete"
+ });
+ }
+});
+
+#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..a46f4cba47d
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolAnnotation.cs
@@ -0,0 +1,16 @@
+#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/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
new file mode 100644
index 00000000000..d43d3f9e69f
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotNetToolExtensions.cs
@@ -0,0 +1,112 @@
+#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;
+
+///
+/// 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 packageId)
+ => builder.AddDotnetTool(new DotnetToolResource(name, packageId));
+
+ public static IResourceBuilder AddDotnetTool(this IDistributedApplicationBuilder builder, T resource)
+ 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;
+ }
+
+ 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)
+ 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;
+ }
+}
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/DotnetToolResource.cs b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
new file mode 100644
index 00000000000..4fc5b3a22ec
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/DotnetToolResource.cs
@@ -0,0 +1,35 @@
+#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;
+
+///
+/// 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
+ public DotnetToolResource(string name, string packageId)
+ : base(name, "dotnet", ".")
+ {
+ 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..be00d6140cb
--- /dev/null
+++ b/playground/DotnetTool/DotnetTool.AppHost/Properties/launchSettings.json
@@ -0,0 +1,45 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:16179;http://localhost:16180",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "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"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:16180",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "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",
+ "dotnetRunMessages": true,
+ "commandLineArgs": "--publisher manifest --output-path aspire-manifest.json",
+ "applicationUrl": "http://localhost:16180",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120"
+ }
+ }
+ }
+}
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"
+ }
+ }
+}
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)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+