Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5ec4c5d
Initial plan
Copilot Nov 24, 2025
257a01e
Initial playground using `dotnet tool isntall` and then running the t…
afscrome Nov 24, 2025
2c67f06
Rework to use `dotnet tool exec`
afscrome Nov 24, 2025
f60451b
Port .NET Tool Integration from PR #13168 and fix package reference
Copilot Nov 24, 2025
d2a0efe
Update DotnetTool launchSettings.json to align with PR #13155 port al…
Copilot Nov 24, 2025
dbcb13e
Remove DotnetToolInstaller and add comprehensive tests
Copilot Dec 1, 2025
926c38c
Give tools their own resource type.
afscrome Dec 3, 2025
434d1d7
Format dotnet tools nicely in dashboard "source" column
afscrome Dec 3, 2025
52a95ba
Remove verbosity argument.
afscrome Dec 3, 2025
5673ec9
Move dotnet tool implementation to `Aspire.Hosting`
afscrome Dec 3, 2025
79ff8f8
Make dotnet tool apis experimental.
afscrome Dec 3, 2025
e2e7a1d
Mark dotnet tool apis experimental.
afscrome Dec 3, 2025
fb569c9
Make secret tool example not fail.
afscrome Dec 3, 2025
6efa250
Refactor to local functions to make slightly cleaner.
afscrome Dec 3, 2025
6247973
Add properties to tool resources.
afscrome Dec 3, 2025
ebc8846
Add missing experimental supression in tests.
afscrome Dec 3, 2025
cedafb2
Fix formatting in tests
afscrome Dec 3, 2025
0f3b1fb
Missing docs.
afscrome Dec 3, 2025
ca77ba1
Fix typos.
afscrome Dec 3, 2025
6dab8e6
Add functional tests.
afscrome Dec 4, 2025
82559b5
Refactored `StartSourceFixer` to minimise risk of calling `PublishUpd…
afscrome Dec 4, 2025
7834d5e
Consistently use `ArgumentSeperator`
afscrome Dec 4, 2025
a58e9ac
Renamed `WithPackage*` to `WithTool*`
afscrome Dec 4, 2025
dde6f90
Tweaks.
afscrome Dec 4, 2025
d7a30ec
Remove unused package.
afscrome Dec 8, 2025
96dd4ec
Small tweaks to tools
afscrome Dec 8, 2025
f657a5a
Merge remote-tracking branch 'upstream/main' into dotnettool
afscrome Dec 8, 2025
b0119ec
Back to `.` as the initial working directory.
afscrome Dec 8, 2025
67e6dd5
Merge remote-tracking branch 'upstream/main' into dotnettool
afscrome Dec 11, 2025
c14ab62
Merge remote-tracking branch 'upstream/main' into dotnettool
afscrome Dec 23, 2025
c626bf5
Add first-class support for .NET tool resources
afscrome Jan 1, 2026
9fb3bc5
Apply suggestions from code review
afscrome Jan 1, 2026
585f3a6
More PR feedback.
afscrome Jan 1, 2026
5329838
Fix rogue newline.
afscrome Jan 1, 2026
6e7f288
Self review
afscrome Jan 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@
<Folder Name="/playground/CustomResources/">
<Project Path="playground/CustomResources/CustomResources.AppHost/CustomResources.AppHost.csproj" />
</Folder>
<Folder Name="/playground/DotnetTool/">
<Project Path="playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj" />
</Folder>
<Folder Name="/playground/DatabaseMigration/">
<Project Path="playground/DatabaseMigration/DatabaseMigration.ApiModel/DatabaseMigration.ApiModel.csproj" />
<Project Path="playground/DatabaseMigration/DatabaseMigration.ApiService/DatabaseMigration.ApiService.csproj" />
Expand Down Expand Up @@ -415,6 +418,7 @@
<Project Path="tests/Aspire.Hosting.Azure.Tests/Aspire.Hosting.Azure.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Containers.Tests/Aspire.Hosting.Containers.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.DevTunnels.Tests/Aspire.Hosting.DevTunnels.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.DotnetTool.Tests/Aspire.Hosting.DotnetTool.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Docker.Tests/Aspire.Hosting.Docker.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Garnet.Tests/Aspire.Hosting.Garnet.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.GitHub.Models.Tests/Aspire.Hosting.GitHub.Models.Tests.csproj" />
Expand Down
122 changes: 122 additions & 0 deletions playground/DotnetTool/DotnetTool.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// 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);

var simpleUsage = builder.AddDotnetTool("simpleUsage", "dotnet-ef");

var wildcardVersion = builder.AddDotnetTool("wildcard", "dotnet-ef")
.WithToolVersion("10.0.*")
.WithParentRelationship(simpleUsage);

var preRelease = builder.AddDotnetTool("prerelease", "dotnet-ef")
.WithToolPrerelease()
.WithParentRelationship(simpleUsage);

// Multiple versions
var differentVersion = builder.AddDotnetTool("sameToolDifferentVersion1", "dotnet-dump")
.WithArgs("--version")
.WithToolVersion("9.0.652701");
builder.AddDotnetTool("sameToolDifferentVersion2", "dotnet-dump")
.WithToolVersion("9.0.621003")
.WithArgs("--version")
.WithParentRelationship(differentVersion);

// Concurrency
IResourceBuilder<DotnetToolResource>? concurrencyParent = null;
for (int i = 0; i < 5; i++)
{
var concurrency = builder.AddDotnetTool($"sametoolconcurrency-{i}", "dotnet-trace")
.WithArgs("--version");

if (concurrencyParent == null)
{
concurrencyParent = concurrency;
}
else
{
concurrency.WithParentRelationship(concurrencyParent);
}
}

// Substitution
var substituted = builder.AddDotnetTool("substituted", "dotnet-ef")
.WithCommand("calc")
.WithIconName("Calculator")
.WithExplicitStart();
foreach(var toolAnnotation in substituted.Resource.Annotations.OfType<DotnetToolAnnotation>().ToList())
{
substituted.Resource.Annotations.Remove(toolAnnotation);
}

// Fake Offline by using "empty" package feeds
var fakeSourcesPath = Path.Combine(Path.GetTempPath(), "does-not-exist", Guid.NewGuid().ToString());
var offline = builder.AddDotnetTool("offlineSimpleUsage", "dotnet-ef")
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
.WithToolIgnoreFailedSources()
;

builder.AddDotnetTool("offlineWildcard", "dotnet-ef")
.WithToolVersion("10.0.*")
.WithParentRelationship(offline)
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
.WithToolIgnoreFailedSources();

builder.AddDotnetTool("offlinePrerelease", "dotnet-ef")
.WithToolPrerelease()
.WithParentRelationship(offline)
.WithToolSource(fakeSourcesPath)
.WithToolIgnoreExistingFeeds()
.WithToolIgnoreFailedSources();

var secret = builder.AddParameter("secret", "Shhhhhhh", secret: true);

// Secrets
builder.AddDotnetTool("secretArg", "dotnet-ef")
.WithArgs("--version")
.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<BeforeStartEvent>(async (evt, _) =>
{
var nugetPackagesPath = Path.Join(evt.Services.GetRequiredService<IAspireStore>().BasePath, "nuget");

foreach (var resource in builder.Resources.OfType<DotnetToolResource>())
{
builder.CreateResourceBuilder(resource)
.WithEnvironment("NUGET_PACKAGES", nugetPackagesPath)
.WithCommand("reset", "Reset Packages", ctx =>
{
try
{
Directory.Delete(nugetPackagesPath, true);
return Task.FromResult(CommandResults.Success());
}
catch (Exception ex)
{
return Task.FromResult(CommandResults.Failure(ex));
}
}, new CommandOptions
{
IconName = "Delete"
});
}
});

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
// or build with `/p:SkipDashboardReference=true`, to test end developer
// dashboard launch experience, Refer to Directory.Build.props for the path to
// the dashboard binary (defaults to the Aspire.Dashboard bin output in the
// artifacts dir).
builder.AddProject<Projects.Aspire_Dashboard>(KnownResourceNames.AspireDashboard);
#endif

builder.Build().Run();

19 changes: 19 additions & 0 deletions playground/DotnetTool/DotnetTool.AppHost/DotnetTool.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>

<ItemGroup>
<Compile Include="$(SharedDir)KnownResourceNames.cs" Link="Utils\KnownResourceNames.cs" />
</ItemGroup>

<ItemGroup>
<AspireProjectOrPackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:16179;http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18036",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18037",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
},
"generate-manifest": {
"commandName": "Project",
"dotnetRunMessages": true,
"commandLineArgs": "--publisher manifest --output-path aspire-manifest.json",
"applicationUrl": "http://localhost:16180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions playground/DotnetTool/DotnetTool.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
6 changes: 5 additions & 1 deletion src/Aspire.Dashboard/Model/ResourceSourceViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ public class ResourceSourceViewModel(string value, List<LaunchArgument>? 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))
{
Expand Down
10 changes: 10 additions & 0 deletions src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -48,6 +53,11 @@ 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Aspire.Hosting.ApplicationModel;
/// Represents a container or project application launch argument.
/// </summary>
[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)
{
/// <summary>
/// The evaluated launch argument.
Expand Down
45 changes: 45 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/DotnetToolAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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;
/// <summary>
/// Represents an annotation for dotnet tool resources.
/// </summary>
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public class DotnetToolAnnotation : IResourceAnnotation
{
/// <summary>
/// The NuGet package ID of the .NET tool to execute. You can optionally specify a version using the <c>@</c> syntax, for example <c>dotnetsay@2.1</c>.
/// </summary>
public required string PackageId { get; set; }

/// <summary>
/// The version of the tool package to install.
/// </summary>
public string? Version { get; set; }

/// <summary>
/// Allows prerelease packages to be selected when resolving the version to install.
/// </summary>
public bool Prerelease { get; set; }

/// <summary>
/// NuGet package sources to use during installation
/// </summary>
public List<string> Sources { get; } = [];

/// <summary>
/// Are custom sources used in addition or instead of existing feeds.
/// </summary>
/// <remarks>
/// This value has no impact if <see cref="Sources"/> is empty.
/// </remarks>
public bool IgnoreExistingFeeds { get; set; }

/// <summary>
/// Treats package source failures as warnings.
/// </summary>
public bool IgnoreFailedSources { get; set; }
}
40 changes: 40 additions & 0 deletions src/Aspire.Hosting/ApplicationModel/DotnetToolResource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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;

/// <summary>
/// A resource that represents a specified dotnet tool.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[Experimental("ASPIREDOTNETTOOL", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[DebuggerDisplay("Type = {GetType().Name,nq}, Name = {Name}, Tool = {ToolConfiguration?.PackageId}")]
public class DotnetToolResource : ExecutableResource
{
/// <summary>
/// Initializes a new instance of the <see cref="DotnetToolResource"/> class.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="packageId">The package id of the tool.</param>
public DotnetToolResource(string name, string packageId)
: base(name, "dotnet", ".")
{
ArgumentException.ThrowIfNullOrWhiteSpace(packageId, nameof(packageId));
Annotations.Add(new DotnetToolAnnotation { PackageId = packageId });
}

internal DotnetToolAnnotation? ToolConfiguration
{
get
{
this.TryGetLastAnnotation<DotnetToolAnnotation>(out var toolConfig);
return toolConfig;
}
}
}
Loading
Loading