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