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) + + + + + + + + + + + + + + +