From ca2f1038850e7e72fff0b83d384409cf0c6ed90f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 11:59:50 +0000 Subject: [PATCH 1/2] Find the .NET SDK in ~/.dotnet when it is not on PATH. self-update and build were failing after a script install because the SDK lives in ~/.dotnet and that directory is often omitted from PATH. Co-authored-by: Matthew Abbott --- README.md | 2 + .../Host/BuildHostService.cs | 2 +- apps/Ingenium.BuildCli/Process/DotnetMuxer.cs | 180 ++++++++++++++++++ .../Process/ProcessRunner.cs | 46 ++++- .../SelfUpdate/SelfUpdateService.cs | 2 +- .../DotnetMuxerTests.cs | 83 ++++++++ 6 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 apps/Ingenium.BuildCli/Process/DotnetMuxer.cs create mode 100644 tests/Ingenium.BuildCli.Tests/DotnetMuxerTests.cs diff --git a/README.md b/README.md index a991de6..ef3a70a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/apps/Ingenium.BuildCli/Host/BuildHostService.cs b/apps/Ingenium.BuildCli/Host/BuildHostService.cs index 30fc81d..5276fe3 100644 --- a/apps/Ingenium.BuildCli/Host/BuildHostService.cs +++ b/apps/Ingenium.BuildCli/Host/BuildHostService.cs @@ -29,7 +29,7 @@ public async Task 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); } diff --git a/apps/Ingenium.BuildCli/Process/DotnetMuxer.cs b/apps/Ingenium.BuildCli/Process/DotnetMuxer.cs new file mode 100644 index 0000000..1e34f66 --- /dev/null +++ b/apps/Ingenium.BuildCli/Process/DotnetMuxer.cs @@ -0,0 +1,180 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +namespace Ingenium.BuildCli.Execution; + +/// +/// Locates a .NET SDK muxer, including the user-local install used by the install scripts. +/// +public static class DotnetMuxer +{ + /// + /// The muxer file name for the current OS. + /// + public static string FileName => OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + /// + /// Returns the user-local muxer path used by scripts/install.sh. + /// + public static string UserInstallPath + { + get + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".dotnet", FileName); + } + } + + /// + /// Returns an SDK muxer path, or null when none can be found. + /// + public static string? Resolve() + { + return Resolve(Candidates()); + } + + /// + /// Returns the first candidate that looks like an SDK install. + /// + public static string? Resolve(IEnumerable 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; + } + + /// + /// Returns candidate muxer locations, preferring PATH and then the installer directory. + /// + public static IEnumerable 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"; + } + + /// + /// Returns true when sits next to an sdk directory. + /// + 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(); + } + + /// + /// Returns true when refers to the dotnet muxer. + /// + 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 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; + } + } +} diff --git a/apps/Ingenium.BuildCli/Process/ProcessRunner.cs b/apps/Ingenium.BuildCli/Process/ProcessRunner.cs index 2e29b7d..a82d73a 100644 --- a/apps/Ingenium.BuildCli/Process/ProcessRunner.cs +++ b/apps/Ingenium.BuildCli/Process/ProcessRunner.cs @@ -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; } @@ -55,6 +55,7 @@ public async Task 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); @@ -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()) @@ -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; + } } diff --git a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs index 8273b45..265180d 100644 --- a/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs +++ b/apps/Ingenium.BuildCli/SelfUpdate/SelfUpdateService.cs @@ -33,7 +33,7 @@ public async Task 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); } diff --git a/tests/Ingenium.BuildCli.Tests/DotnetMuxerTests.cs b/tests/Ingenium.BuildCli.Tests/DotnetMuxerTests.cs new file mode 100644 index 0000000..7b85e40 --- /dev/null +++ b/tests/Ingenium.BuildCli.Tests/DotnetMuxerTests.cs @@ -0,0 +1,83 @@ +// This work is licensed under the terms of the MIT license. +// For a copy, see . + +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"])); + } +} From 1d4d2b1a0cfd244d4899f7b04df9d6e9cffd4df0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 11:59:58 +0000 Subject: [PATCH 2/2] Tighten the missing-SDK assertion to the new error text. Co-authored-by: Matthew Abbott --- tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs b/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs index 2a044d8..8c8d746 100644 --- a/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs +++ b/tests/Ingenium.BuildCli.Tests/SelfUpdateServiceTests.cs @@ -75,7 +75,7 @@ public async Task Update_ThrowsWhenDotnetIsMissing() var error = await Assert.ThrowsAsync(() => 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]