Skip to content

Commit 4eeab32

Browse files
authored
Add dotnet tool exec command for one-shot tool execution (#49329)
2 parents ab899c9 + b080028 commit 4eeab32

File tree

62 files changed

+1418
-243
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1418
-243
lines changed

src/Cli/dotnet/CliStrings.resx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@
317317
<value>More than one command is defined for the tool.</value>
318318
</data>
319319
<data name="ToolSettingsUnsupportedRunner" xml:space="preserve">
320-
<value>Command '{0}' uses unsupported runner '{1}'."</value>
320+
<value>Tool '{0}' uses unsupported runner '{1}'."</value>
321321
</data>
322322
<data name="ToolUnsupportedRuntimeIdentifier" xml:space="preserve">
323323
<value>The tool does not support the current architecture or operating system ({0}). Supported runtimes: {1}</value>
@@ -808,4 +808,7 @@ The default is 'false.' However, when targeting .NET 7 or lower, the default is
808808
<value>Cannot specify --version when the package argument already contains a version.</value>
809809
<comment>{Locked="--version"}</comment>
810810
</data>
811+
<data name="YesOptionDescription" xml:space="preserve">
812+
<value>Accept all confirmation prompts using "yes."</value>
813+
</data>
811814
</root>

src/Cli/dotnet/CommandFactory/CommandResolution/LocalToolsCommandResolver.cs

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
#nullable disable
55

6+
using Microsoft.DotNet.Cli.Commands.Tool;
67
using Microsoft.DotNet.Cli.ToolManifest;
78
using Microsoft.DotNet.Cli.ToolPackage;
89
using Microsoft.DotNet.Cli.Utils;
@@ -91,31 +92,8 @@ private CommandSpec GetPackageCommandSpecUsingMuxer(CommandResolverArguments arg
9192
toolCommandName.ToString()));
9293
}
9394

94-
if (toolCommand.Runner == "dotnet")
95-
{
96-
if (toolManifestPackage.RollForward || allowRollForward)
97-
{
98-
arguments.CommandArguments = ["--allow-roll-forward", .. arguments.CommandArguments];
99-
}
100-
101-
return MuxerCommandSpecMaker.CreatePackageCommandSpecUsingMuxer(
102-
toolCommand.Executable.Value,
103-
arguments.CommandArguments);
104-
}
105-
else if (toolCommand.Runner == "executable")
106-
{
107-
var escapedArgs = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(
108-
arguments.CommandArguments);
109-
110-
return new CommandSpec(
111-
toolCommand.Executable.Value,
112-
escapedArgs);
113-
}
114-
else
115-
{
116-
throw new GracefulException(string.Format(CliStrings.ToolSettingsUnsupportedRunner,
117-
toolCommand.Name, toolCommand.Runner));
118-
}
95+
return ToolCommandSpecCreator.CreateToolCommandSpec(toolCommand.Name.Value, toolCommand.Executable.Value, toolCommand.Runner,
96+
toolManifestPackage.RollForward || allowRollForward, arguments.CommandArguments);
11997
}
12098
else
12199
{

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2056,6 +2056,9 @@ Tool '{1}' (version '{2}') was successfully installed.</value>
20562056
<data name="ToolRunCommandDescription" xml:space="preserve">
20572057
<value>Run a local tool. Note that this command cannot be used to run a global tool. </value>
20582058
</data>
2059+
<data name="ToolRunArgumentsDescription" xml:space="preserve">
2060+
<value>Arguments forwarded to the tool</value>
2061+
</data>
20592062
<data name="ToolSearchCommandDescription" xml:space="preserve">
20602063
<value>Search dotnet tools in nuget.org</value>
20612064
</data>
@@ -2480,4 +2483,29 @@ To display a value, specify the corresponding command-line option without provid
24802483
<data name="SolutionAddReferencedProjectsOptionDescription" xml:space="preserve">
24812484
<value>Recursively add projects' ReferencedProjects to solution</value>
24822485
</data>
2486+
<data name="ToolExecuteCommandDescription" xml:space="preserve">
2487+
<value>Executes a tool from source without permanently installing it.</value>
2488+
</data>
2489+
<data name="ToolDownloadConfirmationPrompt" xml:space="preserve">
2490+
<value>Tool package {0}@{1} will be downloaded from source {2}.
2491+
Proceed?</value>
2492+
</data>
2493+
<data name="ConfirmationPromptYesValue" xml:space="preserve">
2494+
<value>y</value>
2495+
<comment>For a command line connfirmation prompt, this is the key that should be pressed for "yes", ie to agree.</comment>
2496+
</data>
2497+
<data name="ConfirmationPromptNoValue" xml:space="preserve">
2498+
<value>n</value>
2499+
<comment>For a command line connfirmation prompt, this is the key that should be pressed for "no", ie to cancel the operation.</comment>
2500+
</data>
2501+
<data name="ConfirmationPromptInvalidChoiceMessage" xml:space="preserve">
2502+
<value>Please type '{0}' for yes or '{1}' for no.</value>
2503+
</data>
2504+
<data name="ToolDownloadCanceled" xml:space="preserve">
2505+
<value>Tool package download canceled</value>
2506+
</data>
2507+
<data name="ToolDownloadNeedsConfirmation" xml:space="preserve">
2508+
<value>Tool package download needs confirmation. Run in interactive mode or use the "--yes" command-line option to confirm.</value>
2509+
<comment>{Locked="--yes"}</comment>
2510+
</data>
24832511
</root>

src/Cli/dotnet/Commands/Package/Add/PackageAddCommandParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace Microsoft.DotNet.Cli.Commands.Package.Add;
1515

1616
internal static class PackageAddCommandParser
1717
{
18-
public static readonly Argument<PackageIdentity> CmdPackageArgument = CommonArguments.PackageIdentityArgument(true)
18+
public static readonly Argument<PackageIdentity> CmdPackageArgument = CommonArguments.RequiredPackageIdentityArgument()
1919
.AddCompletions((context) =>
2020
{
2121
// we should take --prerelease flags into account for version completion
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
using Microsoft.DotNet.Cli.CommandFactory;
6+
using Microsoft.DotNet.Cli.CommandFactory.CommandResolution;
7+
using Microsoft.DotNet.Cli.Commands.Tool.Install;
8+
using Microsoft.DotNet.Cli.Commands.Tool.Restore;
9+
using Microsoft.DotNet.Cli.Commands.Tool.Run;
10+
using Microsoft.DotNet.Cli.Extensions;
11+
using Microsoft.DotNet.Cli.NuGetPackageDownloader;
12+
using Microsoft.DotNet.Cli.ToolManifest;
13+
using Microsoft.DotNet.Cli.ToolPackage;
14+
using Microsoft.DotNet.Cli.Utils;
15+
using Microsoft.DotNet.Cli.Utils.Extensions;
16+
17+
using Microsoft.Extensions.EnvironmentAbstractions;
18+
using NuGet.Common;
19+
using NuGet.Configuration;
20+
using NuGet.Packaging.Core;
21+
using NuGet.Versioning;
22+
23+
24+
namespace Microsoft.DotNet.Cli.Commands.Tool.Execute;
25+
26+
internal class ToolExecuteCommand(ParseResult result, ToolManifestFinder? toolManifestFinder = null, string? currentWorkingDirectory = null) : CommandBase(result)
27+
{
28+
const int ERROR_CANCELLED = 1223; // Windows error code for "Operation canceled by user"
29+
30+
private readonly PackageIdentity _packageToolIdentityArgument = result.GetRequiredValue(ToolExecuteCommandParser.PackageIdentityArgument);
31+
private readonly IEnumerable<string> _forwardArguments = result.GetValue(ToolExecuteCommandParser.CommandArgument) ?? Enumerable.Empty<string>();
32+
private readonly bool _allowRollForward = result.GetValue(ToolExecuteCommandParser.RollForwardOption);
33+
private readonly string? _configFile = result.GetValue(ToolExecuteCommandParser.ConfigOption);
34+
private readonly string[] _sources = result.GetValue(ToolExecuteCommandParser.SourceOption) ?? [];
35+
private readonly string[] _addSource = result.GetValue(ToolExecuteCommandParser.AddSourceOption) ?? [];
36+
private readonly bool _interactive = result.GetValue(ToolExecuteCommandParser.InteractiveOption);
37+
private readonly VerbosityOptions _verbosity = result.GetValue(ToolExecuteCommandParser.VerbosityOption);
38+
private readonly bool _yes = result.GetValue(ToolExecuteCommandParser.YesOption);
39+
private readonly IToolPackageDownloader _toolPackageDownloader = ToolPackageFactory.CreateToolPackageStoresAndDownloader().downloader;
40+
41+
private readonly RestoreActionConfig _restoreActionConfig = new RestoreActionConfig(DisableParallel: result.GetValue(ToolCommandRestorePassThroughOptions.DisableParallelOption),
42+
NoCache: result.GetValue(ToolCommandRestorePassThroughOptions.NoCacheOption) || result.GetValue(ToolCommandRestorePassThroughOptions.NoHttpCacheOption),
43+
IgnoreFailedSources: result.GetValue(ToolCommandRestorePassThroughOptions.IgnoreFailedSourcesOption),
44+
Interactive: result.GetValue(ToolExecuteCommandParser.InteractiveOption));
45+
46+
private readonly ToolManifestFinder _toolManifestFinder = toolManifestFinder ?? new ToolManifestFinder(new DirectoryPath(currentWorkingDirectory ?? Directory.GetCurrentDirectory()));
47+
48+
public override int Execute()
49+
{
50+
VersionRange versionRange = _parseResult.GetVersionRange();
51+
PackageId packageId = new PackageId(_packageToolIdentityArgument.Id);
52+
53+
// Look in local tools manifest first, but only if version is not specified
54+
if (versionRange == null)
55+
{
56+
var localToolsResolverCache = new LocalToolsResolverCache();
57+
58+
if (_toolManifestFinder.TryFindPackageId(packageId, out var toolManifestPackage))
59+
{
60+
var toolPackageRestorer = new ToolPackageRestorer(
61+
_toolPackageDownloader,
62+
_sources,
63+
overrideSources: [],
64+
_verbosity,
65+
_restoreActionConfig,
66+
localToolsResolverCache,
67+
new FileSystemWrapper());
68+
69+
var restoreResult = toolPackageRestorer.InstallPackage(toolManifestPackage, _configFile == null ? null : new FilePath(_configFile));
70+
71+
if (!restoreResult.IsSuccess)
72+
{
73+
Reporter.Error.WriteLine(restoreResult.Message.Red());
74+
return 1;
75+
}
76+
77+
var localToolsCommandResolver = new LocalToolsCommandResolver(
78+
_toolManifestFinder,
79+
localToolsResolverCache);
80+
81+
return ToolRunCommand.ExecuteCommand(localToolsCommandResolver, toolManifestPackage.CommandNames.Single().Value, _forwardArguments, _allowRollForward);
82+
}
83+
}
84+
85+
var packageLocation = new PackageLocation(
86+
nugetConfig: _configFile != null ? new(_configFile) : null,
87+
sourceFeedOverrides: _sources,
88+
additionalFeeds: _addSource);
89+
90+
(var bestVersion, var packageSource) = _toolPackageDownloader.GetNuGetVersion(packageLocation, packageId, _verbosity, versionRange, _restoreActionConfig);
91+
92+
IToolPackage toolPackage;
93+
94+
// TargetFramework is null, which means to use the current framework. Global tools can override the target framework to use (or select assets for),
95+
// but we don't support this for local or one-shot tools.
96+
if (!_toolPackageDownloader.TryGetDownloadedTool(packageId, bestVersion, targetFramework: null, out toolPackage))
97+
{
98+
if (!UserAgreedToRunFromSource(packageId, bestVersion, packageSource))
99+
{
100+
if (_interactive)
101+
{
102+
Reporter.Error.WriteLine(CliCommandStrings.ToolDownloadCanceled.Red().Bold());
103+
return ERROR_CANCELLED;
104+
}
105+
else
106+
{
107+
Reporter.Error.WriteLine(CliCommandStrings.ToolDownloadNeedsConfirmation.Red().Bold());
108+
return 1;
109+
}
110+
}
111+
112+
// We've already determined which source we will use and displayed that in a confirmation message to the user.
113+
// So set the package location here to override the source feeds to just the source we already resolved to.
114+
// This does mean that we won't work with feeds that have a primary package but where the RID-specific packages are on
115+
// other feeds, but this is probably OK.
116+
var downloadPackageLocation = new PackageLocation(
117+
nugetConfig: _configFile != null ? new(_configFile) : null,
118+
sourceFeedOverrides: [packageSource.Source],
119+
additionalFeeds: _addSource);
120+
121+
toolPackage = _toolPackageDownloader.InstallPackage(
122+
downloadPackageLocation,
123+
packageId: packageId,
124+
verbosity: _verbosity,
125+
versionRange: new VersionRange(bestVersion, true, bestVersion, true),
126+
isGlobalToolRollForward: false,
127+
restoreActionConfig: _restoreActionConfig);
128+
}
129+
130+
var commandSpec = ToolCommandSpecCreator.CreateToolCommandSpec(toolPackage.Command.Name.Value, toolPackage.Command.Executable.Value, toolPackage.Command.Runner, _allowRollForward, _forwardArguments);
131+
var command = CommandFactoryUsingResolver.Create(commandSpec);
132+
var result = command.Execute();
133+
return result.ExitCode;
134+
}
135+
136+
private bool UserAgreedToRunFromSource(PackageId packageId, NuGetVersion version, PackageSource source)
137+
{
138+
if (_yes)
139+
{
140+
return true;
141+
}
142+
143+
if (!_interactive)
144+
{
145+
return false;
146+
}
147+
148+
string promptMessage = string.Format(CliCommandStrings.ToolDownloadConfirmationPrompt, packageId, version.ToString(), source.Source);
149+
150+
static string AddPromptOptions(string message)
151+
{
152+
return $"{message} [{CliCommandStrings.ConfirmationPromptYesValue}/{CliCommandStrings.ConfirmationPromptNoValue}] ({CliCommandStrings.ConfirmationPromptYesValue}): ";
153+
}
154+
155+
Console.Write(AddPromptOptions(promptMessage));
156+
157+
static bool KeyMatches(ConsoleKeyInfo pressedKey, string valueKey)
158+
{
159+
// Apparently you can't do invariant case insensitive comparison on a char directly, so we have to convert it to a string.
160+
// The resource string should be a single character, but we take the first character just to be sure.
161+
return pressedKey.KeyChar.ToString().ToLowerInvariant().Equals(
162+
valueKey.ToLowerInvariant().Substring(0, 1));
163+
}
164+
165+
while (true)
166+
{
167+
var key = Console.ReadKey();
168+
Console.WriteLine();
169+
if (key.Key == ConsoleKey.Enter || KeyMatches(key, CliCommandStrings.ConfirmationPromptYesValue))
170+
{
171+
return true;
172+
}
173+
if (key.Key == ConsoleKey.Escape || KeyMatches(key, CliCommandStrings.ConfirmationPromptNoValue))
174+
{
175+
return false;
176+
}
177+
178+
Console.Write(AddPromptOptions(string.Format(CliCommandStrings.ConfirmationPromptInvalidChoiceMessage, CliCommandStrings.ConfirmationPromptYesValue, CliCommandStrings.ConfirmationPromptNoValue)));
179+
}
180+
}
181+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
using Microsoft.DotNet.Cli.Commands.Tool.Install;
6+
using NuGet.Packaging.Core;
7+
8+
namespace Microsoft.DotNet.Cli.Commands.Tool.Execute;
9+
10+
internal static class ToolExecuteCommandParser
11+
12+
{
13+
public static readonly Argument<PackageIdentity> PackageIdentityArgument = ToolInstallCommandParser.PackageIdentityArgument;
14+
15+
public static readonly Argument<IEnumerable<string>> CommandArgument = new("commandArguments")
16+
{
17+
Description = CliCommandStrings.ToolRunArgumentsDescription
18+
};
19+
20+
public static readonly Option<string> VersionOption = ToolInstallCommandParser.VersionOption;
21+
public static readonly Option<bool> RollForwardOption = ToolInstallCommandParser.RollForwardOption;
22+
public static readonly Option<bool> PrereleaseOption = ToolInstallCommandParser.PrereleaseOption;
23+
public static readonly Option<string> ConfigOption = ToolInstallCommandParser.ConfigOption;
24+
public static readonly Option<string[]> SourceOption = ToolInstallCommandParser.SourceOption;
25+
public static readonly Option<string[]> AddSourceOption = ToolInstallCommandParser.AddSourceOption;
26+
public static readonly Option<bool> InteractiveOption = CommonOptions.InteractiveOption();
27+
public static readonly Option<bool> YesOption = CommonOptions.YesOption;
28+
public static readonly Option<VerbosityOptions> VerbosityOption = ToolInstallCommandParser.VerbosityOption;
29+
30+
31+
public static readonly Command Command = ConstructCommand();
32+
public static Command GetCommand()
33+
{
34+
return Command;
35+
}
36+
37+
private static Command ConstructCommand()
38+
{
39+
Command command = new("execute", CliCommandStrings.ToolExecuteCommandDescription);
40+
41+
command.Aliases.Add("exec");
42+
43+
command.Arguments.Add(PackageIdentityArgument);
44+
command.Arguments.Add(CommandArgument);
45+
46+
command.Options.Add(VersionOption);
47+
command.Options.Add(YesOption);
48+
command.Options.Add(InteractiveOption);
49+
command.Options.Add(RollForwardOption);
50+
command.Options.Add(PrereleaseOption);
51+
command.Options.Add(ConfigOption);
52+
command.Options.Add(SourceOption);
53+
command.Options.Add(AddSourceOption);
54+
command.Options.Add(ToolCommandRestorePassThroughOptions.DisableParallelOption);
55+
command.Options.Add(ToolCommandRestorePassThroughOptions.IgnoreFailedSourcesOption);
56+
command.Options.Add(ToolCommandRestorePassThroughOptions.NoCacheOption);
57+
command.Options.Add(ToolCommandRestorePassThroughOptions.NoHttpCacheOption);
58+
command.Options.Add(VerbosityOption);
59+
60+
command.SetAction((parseResult) => new ToolExecuteCommand(parseResult).Execute());
61+
62+
return command;
63+
}
64+
}

src/Cli/dotnet/Commands/Tool/Install/ParseResultExtension.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ internal static class ParseResultExtension
1313
{
1414
public static VersionRange GetVersionRange(this ParseResult parseResult)
1515
{
16-
string packageVersion = parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString() ??
17-
parseResult.GetValue(ToolInstallCommandParser.VersionOption);
16+
var packageVersionFromIdentityArgument = parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString();
17+
var packageVersionFromVersionOption = parseResult.GetValue(ToolInstallCommandParser.VersionOption);
18+
19+
// Check that only one of these has a value
20+
if (!string.IsNullOrEmpty(packageVersionFromIdentityArgument) && !string.IsNullOrEmpty(packageVersionFromVersionOption))
21+
{
22+
throw new GracefulException(CliStrings.PackageIdentityArgumentVersionOptionConflict);
23+
}
24+
25+
string packageVersion = packageVersionFromIdentityArgument ?? packageVersionFromVersionOption;
26+
1827
bool prerelease = parseResult.GetValue(ToolInstallCommandParser.PrereleaseOption);
1928

2029
if (!string.IsNullOrEmpty(packageVersion) && prerelease)

src/Cli/dotnet/Commands/Tool/Install/ToolInstallCommand.cs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,6 @@ internal class ToolInstallCommand(
2121
private readonly string _framework = parseResult.GetValue(ToolInstallCommandParser.FrameworkOption);
2222

2323

24-
internal static void EnsureNoConflictPackageIdentityVersionOption(ParseResult parseResult)
25-
{
26-
if (!string.IsNullOrEmpty(parseResult.GetValue(ToolInstallCommandParser.PackageIdentityArgument)?.Version?.ToString()) &&
27-
!string.IsNullOrEmpty(parseResult.GetValue(ToolInstallCommandParser.VersionOption)))
28-
{
29-
throw new GracefulException(CliStrings.PackageIdentityArgumentVersionOptionConflict);
30-
}
31-
}
32-
3324
public override int Execute()
3425
{
3526
ToolAppliedOption.EnsureNoConflictGlobalLocalToolPathOption(
@@ -39,8 +30,6 @@ public override int Execute()
3930
ToolAppliedOption.EnsureToolManifestAndOnlyLocalFlagCombination(
4031
_parseResult);
4132

42-
EnsureNoConflictPackageIdentityVersionOption(_parseResult);
43-
4433
if (_global || !string.IsNullOrWhiteSpace(_toolPath))
4534
{
4635
return (_toolInstallGlobalOrToolPathCommand ?? new ToolInstallGlobalOrToolPathCommand(_parseResult)).Execute();

0 commit comments

Comments
 (0)