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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ After `bld` is on PATH, later versions can be installed with:
bld self-update
```

`self-update` and `build` look for the .NET SDK on `PATH`, then in `~/.dotnet` (where the installer puts it). The SDK does not need to stay on `PATH` after install.

### Windows

From a clone:
Expand Down
2 changes: 1 addition & 1 deletion apps/Ingenium.BuildCli/Host/BuildHostService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task<int> RunAsync(BuildHostRequest request, CancellationToken canc
if (!_processes.IsAvailable("dotnet"))
{
throw new BuildCliException(
"dotnet was not found on PATH. Install the .NET SDK and try again.",
"The .NET SDK was not found. Install it or add the SDK to PATH. The installer places it at ~/.dotnet.",
ExitCodes.BuildFailed);
}

Expand Down
180 changes: 180 additions & 0 deletions apps/Ingenium.BuildCli/Process/DotnetMuxer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// This work is licensed under the terms of the MIT license.
// For a copy, see <https://opensource.org/licenses/MIT>.

namespace Ingenium.BuildCli.Execution;

/// <summary>
/// Locates a .NET SDK muxer, including the user-local install used by the install scripts.
/// </summary>
public static class DotnetMuxer
{
/// <summary>
/// The muxer file name for the current OS.
/// </summary>
public static string FileName => OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet";

/// <summary>
/// Returns the user-local muxer path used by <c>scripts/install.sh</c>.
/// </summary>
public static string UserInstallPath
{
get
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, ".dotnet", FileName);
}
}

/// <summary>
/// Returns an SDK muxer path, or <c>null</c> when none can be found.
/// </summary>
public static string? Resolve()
{
return Resolve(Candidates());
}

/// <summary>
/// Returns the first candidate that looks like an SDK install.
/// </summary>
public static string? Resolve(IEnumerable<string> candidates)
{
ArgumentNullException.ThrowIfNull(candidates);

foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}

var resolved = ResolveExistingPath(candidate);
if (resolved is not null && IsSdkInstall(resolved))
{
return resolved;
}
}

return null;
}

/// <summary>
/// Returns candidate muxer locations, preferring PATH and then the installer directory.
/// </summary>
public static IEnumerable<string> Candidates()
{
foreach (var path in FindOnPath())
{
yield return path;
}

yield return UserInstallPath;

var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT");
if (!string.IsNullOrWhiteSpace(dotnetRoot))
{
yield return Path.Combine(dotnetRoot, FileName);
}

if (OperatingSystem.IsWindows())
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (!string.IsNullOrWhiteSpace(programFiles))
{
yield return Path.Combine(programFiles, "dotnet", FileName);
}

var programFilesX86 = Environment.GetEnvironmentVariable("ProgramFiles(x86)");
if (!string.IsNullOrWhiteSpace(programFilesX86))
{
yield return Path.Combine(programFilesX86, "dotnet", FileName);
}

yield break;
}

yield return "/usr/share/dotnet/dotnet";
yield return "/usr/local/share/dotnet/dotnet";
yield return "/usr/lib/dotnet/dotnet";
}

/// <summary>
/// Returns <c>true</c> when <paramref name="muxerPath"/> sits next to an <c>sdk</c> directory.
/// </summary>
public static bool IsSdkInstall(string muxerPath)
{
if (string.IsNullOrWhiteSpace(muxerPath) || !File.Exists(muxerPath))
{
return false;
}

var directory = Path.GetDirectoryName(ResolveExistingPath(muxerPath) ?? muxerPath);
if (string.IsNullOrEmpty(directory))
{
return false;
}

var sdk = Path.Combine(directory, "sdk");
return Directory.Exists(sdk) && Directory.EnumerateDirectories(sdk).Any();
}

/// <summary>
/// Returns <c>true</c> when <paramref name="fileName"/> refers to the dotnet muxer.
/// </summary>
public static bool IsMuxerName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}

var name = Path.GetFileName(fileName);
return name.Equals("dotnet", StringComparison.OrdinalIgnoreCase)
|| name.Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase);
}

private static IEnumerable<string> FindOnPath()
{
var path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path))
{
yield break;
}

foreach (var part in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
string candidate;
try
{
candidate = Path.Combine(part, FileName);
}
catch (ArgumentException)
{
continue;
}

if (File.Exists(candidate))
{
yield return candidate;
}
}
}

private static string? ResolveExistingPath(string path)
{
try
{
var full = Path.GetFullPath(path);
if (!File.Exists(full))
{
return null;
}

var target = File.ResolveLinkTarget(full, returnFinalTarget: true);
return target?.FullName ?? full;
}
catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException or NotSupportedException)
{
return File.Exists(path) ? path : null;
}
}
}
46 changes: 45 additions & 1 deletion apps/Ingenium.BuildCli/Process/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public bool IsAvailable(string fileName)
{
try
{
using var process = Start(fileName, ["--version"], Environment.CurrentDirectory, inheritOutput: false);
using var process = Start(ResolveFileName(fileName), ["--version"], Environment.CurrentDirectory, inheritOutput: false);
process.WaitForExit(5000);
return process.ExitCode == 0;
}
Expand All @@ -55,6 +55,7 @@ public async Task<ProcessRunResult> RunAsync(
throw new BuildCliException($"Working directory '{workingDirectory}' does not exist.");
}

fileName = ResolveFileName(fileName);
_trace?.Write($"$ {fileName} {string.Join(' ', arguments)}");

using var process = Start(fileName, arguments, workingDirectory, inheritOutput);
Expand Down Expand Up @@ -133,6 +134,7 @@ private static System.Diagnostics.Process Start(

startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
startInfo.Environment["DOTNET_NOLOGO"] = "1";
ConfigureDotnetEnvironment(startInfo, fileName);

var process = new System.Diagnostics.Process { StartInfo = startInfo };
if (!process.Start())
Expand All @@ -142,4 +144,46 @@ private static System.Diagnostics.Process Start(

return process;
}

private static string ResolveFileName(string fileName)
{
if (!DotnetMuxer.IsMuxerName(fileName) || Path.IsPathRooted(fileName))
{
return fileName;
}

return DotnetMuxer.Resolve() ?? fileName;
}

private static void ConfigureDotnetEnvironment(ProcessStartInfo startInfo, string fileName)
{
if (!DotnetMuxer.IsMuxerName(fileName))
{
return;
}

string fullPath;
try
{
fullPath = Path.GetFullPath(fileName);
}
catch (ArgumentException)
{
return;
}

if (!File.Exists(fullPath))
{
return;
}

var root = Path.GetDirectoryName(fullPath);
if (string.IsNullOrEmpty(root))
{
return;
}

startInfo.Environment["DOTNET_ROOT"] = root;
startInfo.Environment["DOTNET_HOST_PATH"] = fullPath;
}
}
2 changes: 1 addition & 1 deletion apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public async Task<SelfUpdateResult> UpdateAsync(
if (!_processes.IsAvailable("dotnet"))
{
throw new BuildCliException(
"dotnet was not found on PATH. Install the .NET SDK and try again.",
"The .NET SDK was not found. Install it or add the SDK to PATH. The installer places it at ~/.dotnet.",
ExitCodes.BuildFailed);
}

Expand Down
83 changes: 83 additions & 0 deletions tests/Ingenium.BuildCli.Tests/DotnetMuxerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// This work is licensed under the terms of the MIT license.
// For a copy, see <https://opensource.org/licenses/MIT>.

using Ingenium.BuildCli.Execution;

namespace Ingenium.BuildCli.Tests;

public sealed class DotnetMuxerTests
{
[Fact]
public void UserInstallPath_IsHomeDotnet()
{
var expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".dotnet",
DotnetMuxer.FileName);

Assert.Equal(expected, DotnetMuxer.UserInstallPath);
}

[Fact]
public void Candidates_IncludeUserInstall()
{
Assert.Contains(DotnetMuxer.UserInstallPath, DotnetMuxer.Candidates());
}

[Fact]
public void IsMuxerName_RecognizesDotnet()
{
Assert.True(DotnetMuxer.IsMuxerName("dotnet"));
Assert.True(DotnetMuxer.IsMuxerName("dotnet.exe"));
Assert.True(DotnetMuxer.IsMuxerName(Path.Combine("opt", "dotnet")));
Assert.False(DotnetMuxer.IsMuxerName("git"));
}

[Fact]
public void IsSdkInstall_RequiresSdkDirectory()
{
var root = Path.Combine(Path.GetTempPath(), "buildcli-dotnet-muxer", Guid.NewGuid().ToString("N"));
var muxer = Path.Combine(root, DotnetMuxer.FileName);
Directory.CreateDirectory(root);
File.WriteAllText(muxer, "dotnet");

try
{
Assert.False(DotnetMuxer.IsSdkInstall(muxer));
Directory.CreateDirectory(Path.Combine(root, "sdk", "8.0.100"));
Assert.True(DotnetMuxer.IsSdkInstall(muxer));
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void Resolve_PrefersFirstSdkCandidate()
{
var root = Path.Combine(Path.GetTempPath(), "buildcli-dotnet-muxer", Guid.NewGuid().ToString("N"));
var runtimeOnly = Path.Combine(root, "runtime", DotnetMuxer.FileName);
var sdk = Path.Combine(root, "sdk-install", DotnetMuxer.FileName);
Directory.CreateDirectory(Path.GetDirectoryName(runtimeOnly)!);
Directory.CreateDirectory(Path.Combine(Path.GetDirectoryName(sdk)!, "sdk", "8.0.100"));
File.WriteAllText(runtimeOnly, "dotnet");
File.WriteAllText(sdk, "dotnet");

try
{
var resolved = DotnetMuxer.Resolve([runtimeOnly, sdk]);
Assert.Equal(Path.GetFullPath(sdk), resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}

[Fact]
public void Resolve_ReturnsNullWhenNoSdkExists()
{
Assert.Null(DotnetMuxer.Resolve(["/tmp/does-not-exist/dotnet"]));
}
}
2 changes: 1 addition & 1 deletion tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public async Task Update_ThrowsWhenDotnetIsMissing()

var error = await Assert.ThrowsAsync<BuildCliException>(() => service.UpdateAsync(new SelfUpdateRequest()));
Assert.Equal(ExitCodes.BuildFailed, error.ExitCode);
Assert.Contains("dotnet", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains(".NET SDK", error.Message, StringComparison.Ordinal);
}

[Fact]
Expand Down
Loading