Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions src/Cli/dotnet-aot/AotSourceFiles.props
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,34 @@
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\BundledTargetFramework.cs" Link="BundledTargetFramework.cs" />
</ItemGroup>

<!--
'dotnet tool' command (AOT-capable subset): the parser plus the real implementations of the
local 'tool list'/'tool uninstall', 'tool run', and 'tool search'. The shared manifest/package
and command-resolution infrastructure is already linked above (for AOT tool resolution); this
group adds only the pieces unique to running these subcommands in-process. The global/tool-path
variants and install/update/restore/execute fall back to the managed CLI and are not linked.
-->
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\PrintableTable.cs" Link="PrintableTable.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\ToolCommandParser.cs" Link="Commands\Tool\ToolCommandParser.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Common\ToolManifestFinderExtensions.cs" Link="Commands\Tool\Common\ToolManifestFinderExtensions.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\List\ToolListLocalCommand.cs" Link="Commands\Tool\List\ToolListLocalCommand.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\List\ToolListJsonHelper.cs" Link="Commands\Tool\List\ToolListJsonHelper.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Uninstall\ToolUninstallLocalCommand.cs" Link="Commands\Tool\Uninstall\ToolUninstallLocalCommand.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Run\ToolRunCommand.cs" Link="Commands\Tool\Run\ToolRunCommand.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Search\ToolSearchCommand.cs" Link="Commands\Tool\Search\ToolSearchCommand.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Search\SearchResultPrinter.cs" Link="Commands\Tool\Search\SearchResultPrinter.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\Commands\Tool\Search\NugetSearchSerializables.cs" Link="Commands\Tool\Search\NugetSearchSerializables.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\AuthorsConverter.cs" Link="NugetSearch\AuthorsConverter.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\INugetToolSearchApiRequest.cs" Link="NugetSearch\INugetToolSearchApiRequest.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiParameter.cs" Link="NugetSearch\NugetSearchApiParameter.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiRequestException.cs" Link="NugetSearch\NugetSearchApiRequestException.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiResultDeserializer.cs" Link="NugetSearch\NugetSearchApiResultDeserializer.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetToolSearchApiRequest.cs" Link="NugetSearch\NugetToolSearchApiRequest.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiSerializable\NugetSearchApiAuthorsSerializable.cs" Link="NugetSearch\NugetSearchApiSerializable\NugetSearchApiAuthorsSerializable.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiSerializable\NugetSearchApiContainerSerializable.cs" Link="NugetSearch\NugetSearchApiSerializable\NugetSearchApiContainerSerializable.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiSerializable\NugetSearchApiPackageSerializable.cs" Link="NugetSearch\NugetSearchApiSerializable\NugetSearchApiPackageSerializable.cs" />
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet\NugetSearch\NugetSearchApiSerializable\NugetSearchApiVersionSerializable.cs" Link="NugetSearch\NugetSearchApiSerializable\NugetSearchApiVersionSerializable.cs" />
</ItemGroup>

</Project>
28 changes: 24 additions & 4 deletions src/Cli/dotnet/Commands/Tool/ToolCommandParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,43 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
#if !CLI_AOT
using Microsoft.DotNet.Cli.Commands.Tool.Execute;
using Microsoft.DotNet.Cli.Commands.Tool.Install;
using Microsoft.DotNet.Cli.Commands.Tool.List;
using Microsoft.DotNet.Cli.Commands.Tool.Restore;
using Microsoft.DotNet.Cli.Commands.Tool.Update;
using Microsoft.DotNet.Cli.Extensions;
#endif
using Microsoft.DotNet.Cli.Commands.Tool.List;
using Microsoft.DotNet.Cli.Commands.Tool.Run;
using Microsoft.DotNet.Cli.Commands.Tool.Search;
using Microsoft.DotNet.Cli.Commands.Tool.Uninstall;
using Microsoft.DotNet.Cli.Commands.Tool.Update;
using Microsoft.DotNet.Cli.Extensions;

namespace Microsoft.DotNet.Cli.Commands.Tool;

internal static class ToolCommandParser
{
public static void ConfigureCommand(ToolCommandDefinition command)
{
#if CLI_AOT
// ConfigureAotActions already set every `tool` subcommand (and the bare `tool` command) to
// throw CommandNotAvailableInAotException by default, so we only override the paths that run
// in AOT. Only the local `list`/`uninstall`, `run`, and `search` paths are AOT-capable; the
// `--global`/`--tool-path` variants and `install`/`update`/`restore`/`execute` keep the
// default fallback because they depend on NuGet package install/restore infrastructure that
// isn't AOT-ready. NativeEntryPoint catches the exception and hosts the managed CLI.
command.ListCommand.SetAction(parseResult =>
command.ListCommand.LocationOptions.IsGlobalOrToolPath(parseResult)
? throw new CommandNotAvailableInAotException()
: new ToolListLocalCommand(parseResult).Execute());
command.UninstallCommand.SetAction(parseResult =>
command.UninstallCommand.LocationOptions.IsGlobalOrToolPath(parseResult)
? throw new CommandNotAvailableInAotException()
: new ToolUninstallLocalCommand(parseResult).Execute());
command.RunCommand.SetAction(parseResult => new ToolRunCommand(parseResult).Execute());
command.SearchCommand.SetAction(parseResult => new ToolSearchCommand(parseResult).Execute());
#else
command.SetAction(parseResult => parseResult.HandleMissingCommand());

command.InstallCommand.SetAction(parseResult => new ToolInstallCommand(parseResult).Execute());
command.UninstallCommand.SetAction(parseResult => new ToolUninstallCommand(parseResult).Execute());
command.UpdateCommand.SetAction(parseResult => new ToolUpdateCommand(parseResult).Execute());
Expand All @@ -28,5 +47,6 @@ public static void ConfigureCommand(ToolCommandDefinition command)
command.SearchCommand.SetAction(parseResult => new ToolSearchCommand(parseResult).Execute());
command.RestoreCommand.SetAction(parseResult => new ToolRestoreCommand(parseResult).Execute());
command.ExecuteCommand.SetAction(parseResult => new ToolExecuteCommand(parseResult).Execute());
#endif
}
}
107 changes: 107 additions & 0 deletions src/Cli/dotnet/NugetSearch/NugetToolSearchApiRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@

using System.Collections.Specialized;
using System.Web;
#if CLI_AOT
using System.Text.Json;
using System.Text.Json.Serialization;
#else
using NuGet.Protocol;
using NuGet.Protocol.Core.Types;
#endif

namespace Microsoft.DotNet.Cli.NugetSearch;

Expand Down Expand Up @@ -79,11 +84,113 @@ internal static async Task<Uri> ConstructUrl(string searchTerm = null, int? skip
}

// More detail on this API https://github.com/dotnet/sdk/issues/12038
#if CLI_AOT
// NuGet.Protocol's service-index resolution isn't AOT-compatible, so under AOT we read the
// service index directly over HTTP and select the SearchQueryService endpoint using
// System.Text.Json source generation. Failures are translated into NugetSearchApiRequestException
// (a GracefulException) with the same retriable/non-retriable messaging as GetResult.
private static async Task<Uri> DomainAndPath()
{
const string serviceIndexUrl = "https://api.nuget.org/v3/index.json";
const string searchQueryServiceType = "SearchQueryService/3.5.0";

using var httpClient = new HttpClient();
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10));

HttpResponseMessage response;
Comment thread
baronfel marked this conversation as resolved.
Resolved
try
{
response = await httpClient.GetAsync(serviceIndexUrl, cancellation.Token);
}
catch (Exception e) when (e is HttpRequestException or OperationCanceledException)
{
// Transient network failures (DNS, connection refused, timeout) are retriable.
throw new NugetSearchApiRequestException(
string.Format(
CliStrings.RetriableNugetSearchFailure,
serviceIndexUrl, e.Message, "N/A"));
}

using (response)
{
if (!response.IsSuccessStatusCode)
{
if ((int)response.StatusCode >= 500 && (int)response.StatusCode < 600)
{
throw new NugetSearchApiRequestException(
string.Format(
CliStrings.RetriableNugetSearchFailure,
serviceIndexUrl, response.ReasonPhrase, response.StatusCode));
}

throw new NugetSearchApiRequestException(
string.Format(
CliStrings.NonRetriableNugetSearchFailure,
serviceIndexUrl, response.ReasonPhrase, response.StatusCode));
}

var indexJson = await response.Content.ReadAsStringAsync(cancellation.Token);

NugetServiceIndex index;
try
{
index = JsonSerializer.Deserialize(indexJson, NugetServiceIndexJsonSerializerContext.Default.NugetServiceIndex);
}
catch (JsonException e)
{
throw new NugetSearchApiRequestException(
string.Format(
CliStrings.NonRetriableNugetSearchFailure,
serviceIndexUrl, e.Message, response.StatusCode));
}

var resource = index?.Resources?.FirstOrDefault(r => r.Type == searchQueryServiceType)
?? throw new NugetSearchApiRequestException(
string.Format(
CliStrings.NonRetriableNugetSearchFailure,
serviceIndexUrl, $"{searchQueryServiceType} not found in service index", response.StatusCode));

// The service index is server-supplied, so don't trust the URL to be well-formed.
try
{
return new Uri(resource.Id);
}
catch (Exception e) when (e is UriFormatException or ArgumentException)
{
throw new NugetSearchApiRequestException(
string.Format(
CliStrings.NonRetriableNugetSearchFailure,
serviceIndexUrl, $"{searchQueryServiceType} returned a malformed URL: {e.Message}", response.StatusCode));
}
}
}
#else
private static async Task<Uri> DomainAndPath()
{
var repository = Repository.Factory.GetCoreV3("https://api.nuget.org/v3/index.json");
var resource = await repository.GetResourceAsync<ServiceIndexResourceV3>();
var uris = resource.GetServiceEntryUris("SearchQueryService/3.5.0");
return uris[0];
}
#endif
}

#if CLI_AOT
internal sealed class NugetServiceIndex
{
[JsonPropertyName("resources")]
public NugetServiceResource[] Resources { get; set; }
}

internal sealed class NugetServiceResource
{
[JsonPropertyName("@id")]
public string Id { get; set; }

[JsonPropertyName("@type")]
public string Type { get; set; }
}

[JsonSerializable(typeof(NugetServiceIndex))]
internal partial class NugetServiceIndexJsonSerializerContext : JsonSerializerContext;
#endif
7 changes: 6 additions & 1 deletion src/Cli/dotnet/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Microsoft.DotNet.Cli.Commands.NuGet;
using Microsoft.DotNet.Cli.Commands.Solution;
using Microsoft.DotNet.Cli.Commands.Test;
using Microsoft.DotNet.Cli.Commands.Tool;
using Microsoft.DotNet.Cli.Commands.VSTest;
using Microsoft.DotNet.Cli.Commands.Workload.Search;
using Microsoft.DotNet.Cli.Extensions;
Expand Down Expand Up @@ -49,7 +50,6 @@
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Commands.Run.Api;
using Microsoft.DotNet.Cli.Commands.Sdk;
using Microsoft.DotNet.Cli.Commands.Tool;
using Microsoft.DotNet.Cli.Commands.Tool.Store;
using Microsoft.DotNet.Cli.Commands.Workload;
#endif
Expand Down Expand Up @@ -202,6 +202,11 @@ private static void ConfigureAotActions(DotNetCommandDefinition rootCommand)
// for `sln list`/`migrate`/`remove` and falls back for `sln` and `sln add`.
SolutionCommandParser.ConfigureCommand(rootCommand.SolutionCommand);

// ToolCommandParser is AOT-aware: it keeps real implementations for the local `tool list`/
// `tool uninstall`, `tool run`, and `tool search`, and falls back to the managed CLI for the
// global/tool-path variants and for install/update/restore/execute.
ToolCommandParser.ConfigureCommand(rootCommand.ToolCommand);

rootCommand.VersionOption.Action = new PrintVersionAction(rootCommand.VersionOption);
rootCommand.InfoOption.Action = new PrintInfoAction(rootCommand.InfoOption);
rootCommand.CliSchemaOption.Action = new PrintCliSchemaAction(rootCommand.CliSchemaOption);
Expand Down
46 changes: 46 additions & 0 deletions test/dotnet-aot.Tests/AotParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,52 @@ public void InvokeNoArgs_ReturnsZeroAndShowsUsage()
Assert.Contains("Usage:", stdout);
}

[Theory]
[InlineData("tool list")]
[InlineData("tool list --local")]
[InlineData("tool run mytool")]
[InlineData("tool uninstall mypackage")]
[InlineData("tool search mysearchterm")]
public void ParseAotToolCommand_HasNoErrors(string commandLine)
{
// The AOT-capable `tool` subcommands (local list/uninstall, run, search) parse cleanly
// because their real implementations are linked into the AOT CLI.
var result = Parser.Parse(commandLine.Split(' '));
Assert.Empty(result.Errors);
}

[Theory]
[InlineData("tool list")]
[InlineData("tool list --local")]
public void InvokeAotToolListCommand_ExecutesWithoutManagedFallback(string commandLine)
{
// `tool list` is AOT-capable: the real ToolListLocalCommand is linked in. It succeeds even
// when no manifest is present (empty output), so invoking it should return 0 rather than
// throw CommandNotAvailableInAotException. This catches mis-wired actions that parse-only
// tests would miss.
var (exitCode, _, _) = InvokeWithCapture(commandLine.Split(' '));

Assert.Equal(0, exitCode);
}

[Theory]
[InlineData("tool list --global")] // global list dispatches to managed CLI
[InlineData("tool list --tool-path somepath")] // tool-path list dispatches to managed CLI
[InlineData("tool uninstall mypackage --global")] // global uninstall dispatches to managed CLI
[InlineData("tool install mypackage")] // install is not AOT-capable
[InlineData("tool update mypackage")] // update is not AOT-capable
[InlineData("tool restore")] // restore is not AOT-capable
[InlineData("tool execute dotnetsay")] // execute is not AOT-capable
public void InvokeManagedOnlyToolCommand_FallsBackToManaged(string commandLine)
{
// The global/tool-path variants and install/update/restore depend on NuGet package
// install/restore infrastructure that isn't AOT-ready, so they must signal a managed
// fallback via CommandNotAvailableInAotException rather than execute.
var result = Parser.Parse(commandLine.Split(' '));
Assert.Empty(result.Errors);
Assert.Throws<CommandNotAvailableInAotException>(() => Parser.Invoke(result));
}

/// <summary>
/// Invokes the AOT parser and captures output.
/// Uses BufferedReporter for Reporter.Output (used by --version, --info)
Expand Down
Loading