Skip to content

Commit 4dbd47b

Browse files
committed
Add .NET tool helpers
1 parent 01c9fe3 commit 4dbd47b

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using System;
2+
using System.IO;
3+
using System.Threading.Tasks;
4+
5+
namespace Riverside.CompilerPlatform.Helpers;
6+
7+
/// <summary>
8+
/// Provides helpers for installing and locating .NET tools installed to a specific tool-path directory.
9+
/// </summary>
10+
public static class NETCoreToolHelpers
11+
{
12+
/// <summary>
13+
/// Returns the full path to the tool executable expected in <paramref name="toolDirectory"/>.
14+
/// Appends <c>.exe</c> on Windows; uses the bare name on all other platforms.
15+
/// </summary>
16+
/// <param name="toolDirectory">The directory the tool was installed into via <c>--tool-path</c>.</param>
17+
/// <param name="toolName">The tool executable name (e.g. <c>kiota</c>).</param>
18+
/// <returns>The full path including the platform-appropriate extension.</returns>
19+
public static string GetExecutablePath(string toolDirectory, string toolName)
20+
=> Path.Combine(
21+
toolDirectory,
22+
Environment.OSVersion.Platform == PlatformID.Win32NT ? toolName + ".exe" : toolName);
23+
24+
/// <summary>
25+
/// Ensures the specified .NET tool is available in <paramref name="toolDirectory"/>.
26+
/// </summary>
27+
/// <remarks>
28+
/// <list type="bullet">
29+
/// <item>If the executable already exists and no specific <paramref name="version"/> is requested, the tool is reused immediately.</item>
30+
/// <item>Otherwise <c>dotnet tool install</c> is attempted. If it fails because the tool is already installed, <c>dotnet tool update</c> is tried instead.</item>
31+
/// <item>Installation succeeds when the executable is present after the above steps.</item>
32+
/// </list>
33+
/// </remarks>
34+
/// <param name="toolName">The NuGet package ID of the tool (e.g. <c>Riverside.JsonBinder.Console</c>).</param>
35+
/// <param name="toolDirectory">The directory to install the tool into, passed to <c>--tool-path</c>.</param>
36+
/// <param name="version">
37+
/// A specific version to pin. Pass <see langword="null"/> to install or keep the latest.
38+
/// </param>
39+
/// <param name="timeout">Maximum wait time per install or update process. Defaults to 5 minutes.</param>
40+
/// <returns>
41+
/// A tuple where <c>Success</c> is <see langword="true"/> when the executable is available, and <c>Error</c> carries the captured stderr when installation fails.
42+
/// </returns>
43+
public static async Task<(bool Success, string? Error)> EnsureToolAsync(
44+
string toolName,
45+
string toolDirectory,
46+
string? version = null,
47+
TimeSpan? timeout = null)
48+
{
49+
var exe = GetExecutablePath(toolDirectory, toolName);
50+
var effectiveTimeout = timeout ?? TimeSpan.FromMinutes(5);
51+
52+
Directory.CreateDirectory(toolDirectory);
53+
54+
if (File.Exists(exe) && string.IsNullOrWhiteSpace(version))
55+
return (true, null);
56+
57+
var toolPathArg = $"--tool-path \"{toolDirectory}\"";
58+
var versionArg = string.IsNullOrWhiteSpace(version) ? string.Empty : $" --version {version}";
59+
60+
var installResult = await ProcessHelpers.RunNETCoreCliAsync(
61+
$"tool install {toolName} {toolPathArg}{versionArg}", effectiveTimeout);
62+
63+
if (installResult.ExitCode == 0)
64+
return (true, null);
65+
66+
// install exits non-zero when the tool is already present; attempt an update instead
67+
var updateResult = await ProcessHelpers.RunNETCoreCliAsync(
68+
$"tool update {toolName} {toolPathArg}{versionArg}", effectiveTimeout);
69+
70+
return File.Exists(exe)
71+
? (true, null)
72+
: (false, updateResult.StandardError);
73+
}
74+
}

src/roslyn/Riverside.CompilerPlatform.Extensions/Helpers/ProcessHelpers.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ public class ProcessOutput(int code, string stdout, string stderr)
4949
/// A tuple containing the process exit code, the captured standard output, and the captured standard error.
5050
/// If the process times out, the exit code is <c>-1</c> and the standard error includes a timeout message.
5151
/// </returns>
52-
public static async Task<ProcessOutput>
53-
RunProcess(string fileName, string arguments, TimeSpan timeout)
52+
public static async Task<ProcessOutput> RunProcess(string fileName, string arguments, TimeSpan timeout)
5453
{
5554
var psi = new ProcessStartInfo
5655
{
@@ -82,4 +81,14 @@ public static async Task<ProcessOutput>
8281

8382
return new(proc.ExitCode, outputSb.ToString(), errorSb.ToString());
8483
}
84+
85+
/// <summary>
86+
/// Runs a <c>dotnet</c> command asynchronously with the specified arguments and timeout,
87+
/// capturing its exit code, standard output, and standard error.
88+
/// </summary>
89+
/// <param name="arguments">The arguments to pass after <c>dotnet</c> (e.g. <c>tool install Riverside.JsonBinder.Console ...</c>).</param>
90+
/// <param name="timeout">The maximum duration to wait before forcibly terminating the process.</param>
91+
/// <returns>A <see cref="ProcessOutput"/> containing the exit code and captured streams.</returns>
92+
public static Task<ProcessOutput> RunNETCoreCliAsync(string arguments, TimeSpan timeout)
93+
=> RunProcess("dotnet", arguments, timeout);
8594
}

0 commit comments

Comments
 (0)