From 4cc46e39c17bd99a490c99f5234916afee67f3bd Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 11 Mar 2026 18:37:36 -0400 Subject: [PATCH 01/20] Add Unix shell profile modification for defaultinstall Extend IEnvShellProvider with GetProfilePaths(), GenerateProfileEntry(), and GenerateActivationCommand() methods. Implement in all three providers: - Bash: ~/.bashrc + login profile (~/.bash_profile or ~/.profile) - Zsh: ~/.zshrc - PowerShell: ~/.config/powershell/Microsoft.PowerShell_profile.ps1 Add ShellProfileManager for coordinating file I/O (add/remove entries, backup, idempotency) and ShellDetection for resolving the current shell. Hook profile modification into DotnetInstallManager.ConfigureInstallType() on non-Windows so that 'sdk install --interactive' persists to profiles. Implement DefaultInstallCommand.SetUserInstallRoot() for non-Windows, replacing the 'not yet supported' error. Print activation command after install so users can immediately use .NET in their current terminal. Closes #51582 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 61 +++- .../DefaultInstall/DefaultInstallCommand.cs | 34 ++- .../PrintEnvScript/BashEnvShellProvider.cs | 37 +++ .../PrintEnvScript/IEnvShellProvider.cs | 19 ++ .../PowerShellEnvShellProvider.cs | 20 ++ .../PrintEnvScript/ZshEnvShellProvider.cs | 20 ++ .../Commands/Shared/InstallExecutor.cs | 14 + .../dotnetup/DotnetInstallManager.cs | 8 + src/Installer/dotnetup/ShellDetection.cs | 36 +++ src/Installer/dotnetup/ShellProfileManager.cs | 130 ++++++++ .../ShellProfileManagerTests.cs | 288 ++++++++++++++++++ 11 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 src/Installer/dotnetup/ShellDetection.cs create mode 100644 src/Installer/dotnetup/ShellProfileManager.cs create mode 100644 test/dotnetup.Tests/ShellProfileManagerTests.cs diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 5f113bfe27a9..4000ad4b3c20 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -141,15 +141,61 @@ As noted in the discussion, generating scripts dynamically has several advantage 4. **Immediate availability**: No download or extraction step needed 5. **Transparency**: Users can easily inspect what the script does by running the command -## Future Work +## Shell Profile Modification + +Building on `print-env-script`, dotnetup can automatically modify shell profile files so that `.NET` is available in every new terminal session. This is triggered in two ways: + +1. **`sdk install --interactive`** — When the user confirms "set as default install?", dotnetup persists the environment configuration to shell profiles in addition to setting environment variables for the current process. +2. **`defaultinstall user`** — Standalone command that configures the default install, including shell profile modification on Unix. + +After either operation, dotnetup prints a command the user can paste into the current terminal to activate `.NET` immediately, since profile changes only take effect in new shells. + +### Which Profile Files Are Modified + +| Shell | Files modified | Rationale | +|-------|---------------|-----------| +| **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. | +| **zsh** | `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. | +| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard `$PROFILE` path on Unix. | -This command provides the foundation for future enhancements: +### Profile Entry Format + +Each profile file gets a marker comment and an eval line: + +**Bash / Zsh:** +```bash +# dotnetup +eval "$(/path/to/dotnetup print-env-script --shell bash)" +``` + +**PowerShell:** +```powershell +# dotnetup +& /path/to/dotnetup print-env-script --shell pwsh | Invoke-Expression +``` + +The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). + +### Reversibility + +- The `# dotnetup` marker comment immediately before the eval line identifies the addition. +- To remove: find the marker line and the line after it, remove both. +- Before modifying any file, dotnetup creates a backup (e.g., `~/.bashrc.dotnetup-backup`). + +### Provider Model + +The `IEnvShellProvider` interface is extended with two methods so each shell provider owns its profile knowledge: + +- `GetProfilePaths()` — Returns the list of profile file paths to modify for the shell. +- `GenerateProfileEntry(string dotnetupPath)` — Generates the marker comment and eval line. + +A `ShellProfileManager` class coordinates the file I/O: adding and removing entries, creating backups, and ensuring idempotency (entries are not duplicated if already present). + +## Future Work -1. **Automatic profile modification**: Add a command to automatically update shell configuration files (`.bashrc`, `.zshrc`, etc.) with user consent -2. **Profile backup**: Create backups of shell configuration files before modification -3. **Uninstall/removal**: Add commands to remove dotnetup configuration from shell profiles -4. **Additional shells**: Support for fish, tcsh, and other shells -5. **Environment validation**: Commands to verify that the environment is correctly configured +1. **`defaultinstall admin` on Unix**: System-wide configuration (e.g., `/etc/profile.d/`) is not yet supported. +2. **Additional shells**: Support for fish, tcsh, and other shells. +3. **Environment validation**: Commands to verify that the environment is correctly configured. ## Related Issues @@ -163,5 +209,6 @@ The implementation includes comprehensive tests: - Shell provider tests for script generation - Security tests for special character handling - Help documentation tests +- Shell profile manager tests for add/remove/idempotency/backup behavior All tests ensure that the generated scripts are syntactically correct and properly escape paths. diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 38dfc459f374..4b4e0f6d2d8a 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall; @@ -32,7 +33,38 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the user install root is not yet supported on non-Windows platforms."); + var dotnetupPath = Environment.ProcessPath + ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (shellProvider is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + } + + var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + + if (modifiedFiles.Count == 0) + { + Console.WriteLine("Shell profile is already configured for dotnetup."); + } + else + { + Console.WriteLine("Updated shell profile files:"); + foreach (var file in modifiedFiles) + { + Console.WriteLine($" {file}"); + } + } + + Console.WriteLine(); + Console.WriteLine("To start using .NET in this terminal, run:"); + Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath)}"); + + return 0; } var changes = _installRootManager.GetUserInstallRootChanges(); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index 7a793a51982e..87c13ffac2fb 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -5,6 +5,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class BashEnvShellProvider : IEnvShellProvider { + private const string MarkerComment = "# dotnetup"; + public string ArgumentName => "bash"; public string Extension => "sh"; @@ -32,4 +34,39 @@ public string GenerateEnvScript(string dotnetInstallPath) export PATH='{escapedPath}':$PATH """; } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var paths = new List { Path.Combine(home, ".bashrc") }; + + // For login shells, use the first existing of .bash_profile / .profile. + // Never create .bash_profile — it would shadow an existing .profile. + string bashProfile = Path.Combine(home, ".bash_profile"); + string profile = Path.Combine(home, ".profile"); + + if (File.Exists(bashProfile)) + { + paths.Add(bashProfile); + } + else + { + // Use .profile (will be created if it doesn't exist) + paths.Add(profile); + } + + return paths; + } + + public string GenerateProfileEntry(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash)\""; + } + + public string GenerateActivationCommand(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + return $"eval \"$('{escapedPath}' print-env-script --shell bash)\""; + } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs index c2d43cf824ae..68b6bcab3b43 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs @@ -29,4 +29,23 @@ public interface IEnvShellProvider /// The path to the .NET installation directory /// A shell script that can be sourced to configure the environment string GenerateEnvScript(string dotnetInstallPath); + + /// + /// Returns the profile file paths that should be modified for this shell. + /// For bash, this may return multiple files (e.g., ~/.bashrc and a login profile). + /// + IReadOnlyList GetProfilePaths(); + + /// + /// Generates the line(s) to append to a shell profile that will eval dotnetup's env script. + /// Includes a marker comment for identification and removal. + /// + /// The full path to the dotnetup binary + string GenerateProfileEntry(string dotnetupPath); + + /// + /// Generates a command that the user can paste into the current terminal to activate .NET. + /// + /// The full path to the dotnetup binary + string GenerateActivationCommand(string dotnetupPath); } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs index 0f95ff0ebb5d..1156a675a1fc 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs @@ -5,6 +5,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class PowerShellEnvShellProvider : IEnvShellProvider { + private const string MarkerComment = "# dotnetup"; + public string ArgumentName => "pwsh"; public string Extension => "ps1"; @@ -28,4 +30,22 @@ public string GenerateEnvScript(string dotnetInstallPath) $env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH """; } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return [Path.Combine(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")]; + } + + public string GenerateProfileEntry(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "''"); + return $"{MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh | Invoke-Expression"; + } + + public string GenerateActivationCommand(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "''"); + return $"& '{escapedPath}' print-env-script --shell pwsh | Invoke-Expression"; + } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index 235d02f99562..b07183f6a777 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -5,6 +5,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class ZshEnvShellProvider : IEnvShellProvider { + private const string MarkerComment = "# dotnetup"; + public string ArgumentName => "zsh"; public string Extension => "zsh"; @@ -32,4 +34,22 @@ public string GenerateEnvScript(string dotnetInstallPath) export PATH='{escapedPath}':$PATH """; } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return [Path.Combine(home, ".zshrc")]; + } + + public string GenerateProfileEntry(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh)\""; + } + + public string GenerateActivationCommand(string dotnetupPath) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + return $"eval \"$('{escapedPath}' print-env-script --shell zsh)\""; + } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 6c3eee013b1a..f7a1c3652364 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -4,6 +4,7 @@ using System.Globalization; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; @@ -162,6 +163,19 @@ public static void ConfigureDefaultInstallIfRequested( if (setDefaultInstall) { dotnetInstaller.ConfigureInstallType(InstallType.User, installPath); + + // On non-Windows, print the activation command for the current terminal + if (!OperatingSystem.IsWindows()) + { + var dotnetupPath = Environment.ProcessPath; + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (dotnetupPath is not null && shellProvider is not null) + { + SpectreAnsiConsole.WriteLine(); + SpectreAnsiConsole.WriteLine("To start using .NET in this terminal, run:"); + SpectreAnsiConsole.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath)}"); + } + } } } diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 81927efa3b50..ce9d31ef0360 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -177,6 +177,14 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n pathEntries.Insert(0, dotnetRoot); // Set DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + + // Persist to shell profiles + var dotnetupPath = Environment.ProcessPath; + var shellProvider = ShellDetection.GetCurrentShellProvider(); + if (dotnetupPath is not null && shellProvider is not null) + { + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + } break; case InstallType.Admin: if (string.IsNullOrEmpty(dotnetRoot)) diff --git a/src/Installer/dotnetup/ShellDetection.cs b/src/Installer/dotnetup/ShellDetection.cs new file mode 100644 index 000000000000..fa8b32cee3f8 --- /dev/null +++ b/src/Installer/dotnetup/ShellDetection.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Detects the user's current shell and resolves the matching . +/// +public static class ShellDetection +{ + private static readonly Dictionary s_shellMap = + PrintEnvScriptCommandParser.s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + + /// + /// Returns the for the user's current shell, + /// or null if the shell cannot be detected or is not supported. + /// + public static IEnvShellProvider? GetCurrentShellProvider() + { + if (OperatingSystem.IsWindows()) + { + return s_shellMap.GetValueOrDefault("pwsh"); + } + + var shellPath = Environment.GetEnvironmentVariable("SHELL"); + if (shellPath is null) + { + return null; + } + + var shellName = Path.GetFileName(shellPath); + return s_shellMap.GetValueOrDefault(shellName); + } +} diff --git a/src/Installer/dotnetup/ShellProfileManager.cs b/src/Installer/dotnetup/ShellProfileManager.cs new file mode 100644 index 000000000000..2f587bd80af5 --- /dev/null +++ b/src/Installer/dotnetup/ShellProfileManager.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; + +namespace Microsoft.DotNet.Tools.Bootstrapper; + +/// +/// Manages shell profile file modifications to persist .NET environment configuration. +/// +public class ShellProfileManager +{ + internal const string MarkerComment = "# dotnetup"; + private const string BackupSuffix = ".dotnetup-backup"; + + /// + /// Adds profile entries to all profile files for the given shell provider. + /// Creates backups before modifying existing files. Skips files that already contain the entry. + /// + /// The list of profile file paths that were modified. + public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider, string dotnetupPath) + { + var profilePaths = provider.GetProfilePaths(); + var entry = provider.GenerateProfileEntry(dotnetupPath); + var modifiedFiles = new List(); + + foreach (var profilePath in profilePaths) + { + if (AddEntryToFile(profilePath, entry)) + { + modifiedFiles.Add(profilePath); + } + } + + return modifiedFiles; + } + + /// + /// Removes dotnetup profile entries from all profile files for the given shell provider. + /// + /// The list of profile file paths that were modified. + public static IReadOnlyList RemoveProfileEntries(IEnvShellProvider provider) + { + var profilePaths = provider.GetProfilePaths(); + var modifiedFiles = new List(); + + foreach (var profilePath in profilePaths) + { + if (RemoveEntryFromFile(profilePath)) + { + modifiedFiles.Add(profilePath); + } + } + + return modifiedFiles; + } + + /// + /// Checks whether a profile file already contains a dotnetup entry. + /// + public static bool HasProfileEntry(string profilePath) + { + if (!File.Exists(profilePath)) + { + return false; + } + + var content = File.ReadAllText(profilePath); + return content.Contains(MarkerComment, StringComparison.Ordinal); + } + + private static bool AddEntryToFile(string profilePath, string entry) + { + if (HasProfileEntry(profilePath)) + { + return false; + } + + var directory = Path.GetDirectoryName(profilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Create backup of existing file + if (File.Exists(profilePath)) + { + File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); + } + + // Append entry with a leading newline to separate from existing content + using var writer = File.AppendText(profilePath); + writer.WriteLine(); + writer.WriteLine(entry); + + return true; + } + + private static bool RemoveEntryFromFile(string profilePath) + { + if (!File.Exists(profilePath)) + { + return false; + } + + var lines = File.ReadAllLines(profilePath).ToList(); + bool modified = false; + + for (int i = lines.Count - 1; i >= 0; i--) + { + if (lines[i].TrimEnd() == MarkerComment) + { + // Remove the marker line and the line after it (the eval/invoke line) + lines.RemoveAt(i); + if (i < lines.Count) + { + lines.RemoveAt(i); + } + modified = true; + } + } + + if (modified) + { + File.WriteAllLines(profilePath, lines); + } + + return modified; + } +} diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs new file mode 100644 index 000000000000..b90f0845884f --- /dev/null +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -0,0 +1,288 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public class ShellProfileManagerTests : IDisposable +{ + private readonly string _tempDir; + private const string FakeDotnetupPath = "/usr/local/bin/dotnetup"; + + public ShellProfileManagerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "dotnetup-profile-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { } + } + } + + [Fact] + public void AddProfileEntries_CreatesFileAndAddsEntry() + { + var provider = new TestShellProvider(_tempDir, "test.sh"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(modified[0]); + content.Should().Contain(ShellProfileManager.MarkerComment); + content.Should().Contain("print-env-script"); + } + + [Fact] + public void AddProfileEntries_AppendsToExistingFile() + { + var profilePath = Path.Combine(_tempDir, "existing.sh"); + File.WriteAllText(profilePath, "# existing config\nexport FOO=bar\n"); + var provider = new TestShellProvider(_tempDir, "existing.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var content = File.ReadAllText(profilePath); + content.Should().StartWith("# existing config"); + content.Should().Contain(ShellProfileManager.MarkerComment); + } + + [Fact] + public void AddProfileEntries_DoesNotDuplicateIfAlreadyPresent() + { + var profilePath = Path.Combine(_tempDir, "dup.sh"); + var provider = new TestShellProvider(_tempDir, "dup.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().BeEmpty(); + var lines = File.ReadAllLines(profilePath); + lines.Count(l => l.TrimEnd() == ShellProfileManager.MarkerComment).Should().Be(1); + } + + [Fact] + public void AddProfileEntries_CreatesBackupOfExistingFile() + { + var profilePath = Path.Combine(_tempDir, "backup.sh"); + var originalContent = "# original content\n"; + File.WriteAllText(profilePath, originalContent); + var provider = new TestShellProvider(_tempDir, "backup.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var backupPath = profilePath + ".dotnetup-backup"; + File.Exists(backupPath).Should().BeTrue(); + File.ReadAllText(backupPath).Should().Be(originalContent); + } + + [Fact] + public void AddProfileEntries_CreatesParentDirectories() + { + var nestedDir = Path.Combine(_tempDir, "config", "powershell"); + var provider = new TestShellProvider(nestedDir, "profile.ps1"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(1); + File.Exists(Path.Combine(nestedDir, "profile.ps1")).Should().BeTrue(); + } + + [Fact] + public void RemoveProfileEntries_RemovesMarkerAndEvalLine() + { + var profilePath = Path.Combine(_tempDir, "remove.sh"); + var provider = new TestShellProvider(_tempDir, "remove.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + File.ReadAllText(profilePath).Should().Contain(ShellProfileManager.MarkerComment); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().NotContain(ShellProfileManager.MarkerComment); + content.Should().NotContain("print-env-script"); + } + + [Fact] + public void RemoveProfileEntries_LeavesOtherContentIntact() + { + var profilePath = Path.Combine(_tempDir, "partial.sh"); + File.WriteAllText(profilePath, "# my config\nexport FOO=bar\n"); + var provider = new TestShellProvider(_tempDir, "partial.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + ShellProfileManager.RemoveProfileEntries(provider); + + var content = File.ReadAllText(profilePath); + content.Should().Contain("# my config"); + content.Should().Contain("export FOO=bar"); + } + + [Fact] + public void RemoveProfileEntries_ReturnsEmptyForMissingFile() + { + var provider = new TestShellProvider(_tempDir, "nonexistent.sh"); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().BeEmpty(); + } + + [Fact] + public void HasProfileEntry_ReturnsFalseForMissingFile() + { + ShellProfileManager.HasProfileEntry(Path.Combine(_tempDir, "missing.sh")).Should().BeFalse(); + } + + [Fact] + public void HasProfileEntry_ReturnsTrueWhenEntryPresent() + { + var profilePath = Path.Combine(_tempDir, "has.sh"); + var provider = new TestShellProvider(_tempDir, "has.sh"); + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + ShellProfileManager.HasProfileEntry(profilePath).Should().BeTrue(); + } + + [Fact] + public void AddProfileEntries_ModifiesMultipleFiles() + { + var provider = new TestShellProvider(_tempDir, "file1.sh", "file2.sh"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(2); + File.ReadAllText(Path.Combine(_tempDir, "file1.sh")).Should().Contain(ShellProfileManager.MarkerComment); + File.ReadAllText(Path.Combine(_tempDir, "file2.sh")).Should().Contain(ShellProfileManager.MarkerComment); + } + + [Fact] + public void BashProvider_GenerateProfileEntry_ContainsEval() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("eval"); + entry.Should().Contain("--shell bash"); + } + + [Fact] + public void ZshProvider_GenerateProfileEntry_ContainsEval() + { + var provider = new ZshEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("eval"); + entry.Should().Contain("--shell zsh"); + } + + [Fact] + public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() + { + var provider = new PowerShellEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("Invoke-Expression"); + entry.Should().Contain("--shell pwsh"); + } + + [Fact] + public void BashProvider_GenerateActivationCommand_IsCorrect() + { + var provider = new BashEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath); + + command.Should().Contain("eval"); + command.Should().Contain("--shell bash"); + command.Should().NotContain(ShellProfileManager.MarkerComment); + } + + [Fact] + public void ZshProvider_GenerateActivationCommand_IsCorrect() + { + var provider = new ZshEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath); + + command.Should().Contain("eval"); + command.Should().Contain("--shell zsh"); + } + + [Fact] + public void PowerShellProvider_GenerateActivationCommand_IsCorrect() + { + var provider = new PowerShellEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath); + + command.Should().Contain("Invoke-Expression"); + command.Should().Contain("--shell pwsh"); + } + + [Fact] + public void BashProvider_GetProfilePaths_ReturnsAtLeastBashrc() + { + var provider = new BashEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCountGreaterThanOrEqualTo(2); + paths[0].Should().EndWith(".bashrc"); + } + + [Fact] + public void ZshProvider_GetProfilePaths_ReturnsZshrc() + { + var provider = new ZshEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCount(1); + paths[0].Should().EndWith(".zshrc"); + } + + [Fact] + public void PowerShellProvider_GetProfilePaths_ReturnsProfilePs1() + { + var provider = new PowerShellEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCount(1); + paths[0].Should().EndWith("Microsoft.PowerShell_profile.ps1"); + } + + /// + /// Test-only shell provider that targets files in the temp directory. + /// + private sealed class TestShellProvider : IEnvShellProvider + { + private readonly string[] _profilePaths; + + public TestShellProvider(string dir, params string[] fileNames) + { + _profilePaths = fileNames.Select(f => Path.Combine(dir, f)).ToArray(); + } + + public string ArgumentName => "test"; + public string Extension => "sh"; + public string? HelpDescription => null; + + public string GenerateEnvScript(string dotnetInstallPath) => + $"export DOTNET_ROOT='{dotnetInstallPath}'"; + + public IReadOnlyList GetProfilePaths() => _profilePaths; + + public string GenerateProfileEntry(string dotnetupPath) => + $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test)\""; + + public string GenerateActivationCommand(string dotnetupPath) => + $"eval \"$('{dotnetupPath}' print-env-script --shell test)\""; + } +} From 41d8f9bcc211c73e647236e48747f01dda0c5c4a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 13 Mar 2026 15:18:02 -0400 Subject: [PATCH 02/20] Include dotnetup directory in PATH in generated env scripts GenerateEnvScript now accepts an optional dotnetupDir parameter. When provided, the dotnetup binary's directory is prepended to PATH alongside the dotnet install path, so both dotnet and dotnetup are available after sourcing the script. The print-env-script command passes Path.GetDirectoryName(Environment.ProcessPath) automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PrintEnvScript/BashEnvShellProvider.cs | 11 ++++++-- .../PrintEnvScript/IEnvShellProvider.cs | 3 ++- .../PowerShellEnvShellProvider.cs | 11 ++++++-- .../PrintEnvScript/PrintEnvScriptCommand.cs | 5 +++- .../PrintEnvScript/ZshEnvShellProvider.cs | 11 ++++++-- test/dotnetup.Tests/EnvShellProviderTests.cs | 27 +++++++++++++++++++ .../ShellProfileManagerTests.cs | 2 +- 7 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index 87c13ffac2fb..2a172428023f 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -15,10 +15,17 @@ public class BashEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) { // Escape single quotes in the path for bash by replacing ' with '\'' var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); + var pathExport = $"export PATH='{escapedPath}':$PATH"; + + if (dotnetupDir is not null) + { + var escapedDotnetupDir = dotnetupDir.Replace("'", "'\\''"); + pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; + } return $""" @@ -31,7 +38,7 @@ public string GenerateEnvScript(string dotnetInstallPath) # When dotnetup modifies shell profiles directly, it will handle this automatically. export DOTNET_ROOT='{escapedPath}' - export PATH='{escapedPath}':$PATH + {pathExport} """; } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs index 68b6bcab3b43..f6907eea9564 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs @@ -27,8 +27,9 @@ public interface IEnvShellProvider /// Generates a shell-specific script that configures PATH and DOTNET_ROOT. /// /// The path to the .NET installation directory + /// The directory containing the dotnetup binary, or null to omit /// A shell script that can be sourced to configure the environment - string GenerateEnvScript(string dotnetInstallPath); + string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null); /// /// Returns the profile file paths that should be modified for this shell. diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs index 1156a675a1fc..58e410ba56b8 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs @@ -15,10 +15,17 @@ public class PowerShellEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) { // Escape single quotes in the path for PowerShell by replacing ' with '' var escapedPath = dotnetInstallPath.Replace("'", "''"); + var pathExport = $"$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + + if (dotnetupDir is not null) + { + var escapedDotnetupDir = dotnetupDir.Replace("'", "''"); + pathExport = $"$env:PATH = '{escapedDotnetupDir}' + [IO.Path]::PathSeparator + '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } return $""" @@ -27,7 +34,7 @@ public string GenerateEnvScript(string dotnetInstallPath) # Example: . ./dotnet-env.ps1 $env:DOTNET_ROOT = '{escapedPath}' - $env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH + {pathExport} """; } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index bcb8e6f560ac..7bf6cd590782 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -46,8 +46,11 @@ protected override int ExecuteCore() // Determine the dotnet install path string installPath = _dotnetInstallPath ?? _dotnetInstaller.GetDefaultDotnetInstallPath(); + // Determine the dotnetup directory so it can be added to PATH + string? dotnetupDir = Path.GetDirectoryName(Environment.ProcessPath); + // Generate the shell script - string script = _shellProvider.GenerateEnvScript(installPath); + string script = _shellProvider.GenerateEnvScript(installPath, dotnetupDir); // Output the script to stdout Console.WriteLine(script); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index b07183f6a777..f6b1aa86cf26 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -15,10 +15,17 @@ public class ZshEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) { // Escape single quotes in the path for zsh by replacing ' with '\'' var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); + var pathExport = $"export PATH='{escapedPath}':$PATH"; + + if (dotnetupDir is not null) + { + var escapedDotnetupDir = dotnetupDir.Replace("'", "'\\''"); + pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; + } return $""" @@ -31,7 +38,7 @@ public string GenerateEnvScript(string dotnetInstallPath) # When dotnetup modifies shell profiles directly, it will handle this automatically. export DOTNET_ROOT='{escapedPath}' - export PATH='{escapedPath}':$PATH + {pathExport} """; } diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index f2bab4124d41..b59413ee12af 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -24,6 +24,15 @@ public void BashProvider_ShouldGenerateValidScript() script.Should().Contain($"export PATH='{installPath}':$PATH"); } + [Fact] + public void BashProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new BashEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); + } + [Fact] public void ZshProvider_ShouldGenerateValidScript() { @@ -41,6 +50,15 @@ public void ZshProvider_ShouldGenerateValidScript() script.Should().Contain($"export PATH='{installPath}':$PATH"); } + [Fact] + public void ZshProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new ZshEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); + } + [Fact] public void PowerShellProvider_ShouldGenerateValidScript() { @@ -58,6 +76,15 @@ public void PowerShellProvider_ShouldGenerateValidScript() script.Should().Contain("[IO.Path]::PathSeparator"); } + [Fact] + public void PowerShellProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new PowerShellEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + '/test/dotnet' + [IO.Path]::PathSeparator + $env:PATH"); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index b90f0845884f..f4e4dc2c44a6 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -274,7 +274,7 @@ public TestShellProvider(string dir, params string[] fileNames) public string Extension => "sh"; public string? HelpDescription => null; - public string GenerateEnvScript(string dotnetInstallPath) => + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) => $"export DOTNET_ROOT='{dotnetInstallPath}'"; public IReadOnlyList GetProfilePaths() => _profilePaths; From 955ca12e50c7d1c412b2f69bcb7f9d1fa3e124db Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 13 Mar 2026 15:25:46 -0400 Subject: [PATCH 03/20] Implement defaultinstall admin on Unix with dotnetup-only profiles When switching to admin install on Unix, shell profile entries are replaced with dotnetup-only versions that add dotnetup to PATH but do not set DOTNET_ROOT or add the dotnet install path (since the admin/system install manages dotnet). Add --dotnetup-only flag to print-env-script command. When set, the generated script only adds the dotnetup directory to PATH. Add ShellProfileManager.ReplaceProfileEntries() for switching between user and admin profile entries. Add includeDotnet parameter to GenerateEnvScript and dotnetupOnly parameter to GenerateProfileEntry/GenerateActivationCommand on IEnvShellProvider. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstall/DefaultInstallCommand.cs | 31 +++++++- .../PrintEnvScript/BashEnvShellProvider.cs | 41 +++++++--- .../PrintEnvScript/IEnvShellProvider.cs | 11 ++- .../PowerShellEnvShellProvider.cs | 40 +++++++--- .../PrintEnvScript/PrintEnvScriptCommand.cs | 5 +- .../PrintEnvScriptCommandParser.cs | 7 ++ .../PrintEnvScript/ZshEnvShellProvider.cs | 41 +++++++--- .../dotnetup/DotnetInstallManager.cs | 8 ++ src/Installer/dotnetup/ShellProfileManager.cs | 18 ++++- test/dotnetup.Tests/EnvShellProviderTests.cs | 31 ++++++++ .../ShellProfileManagerTests.cs | 74 +++++++++++++++++-- 11 files changed, 266 insertions(+), 41 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 4b4e0f6d2d8a..075f6cac9155 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -96,7 +96,36 @@ private int SetAdminInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the admin install root is only supported on Windows."); + // On Unix, switching to admin means the system manages dotnet. + // Replace profile entries with dotnetup-only (keeps dotnetup on PATH but removes DOTNET_ROOT and dotnet PATH). + var dotnetupPath = Environment.ProcessPath + ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (shellProvider is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + } + + var modifiedFiles = ShellProfileManager.ReplaceProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + + if (modifiedFiles.Count == 0) + { + Console.WriteLine("Shell profile is already configured."); + } + else + { + Console.WriteLine("Updated shell profile files (dotnetup only, no DOTNET_ROOT or dotnet PATH):"); + foreach (var file in modifiedFiles) + { + Console.WriteLine($" {file}"); + } + } + + return 0; } var changes = _installRootManager.GetAdminInstallRootChanges(); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index 2a172428023f..90a9a5cb506d 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -15,17 +15,38 @@ public class BashEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - // Escape single quotes in the path for bash by replacing ' with '\'' var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - var pathExport = $"export PATH='{escapedPath}':$PATH"; + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); - if (dotnetupDir is not null) + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) { - var escapedDotnetupDir = dotnetupDir.Replace("'", "'\\''"); pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; } + else if (includeDotnet) + { + pathExport = $"export PATH='{escapedPath}':$PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':$PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + #!/usr/bin/env bash + # This script adds dotnetup to your PATH + {pathExport} + """; + } return $""" @@ -65,15 +86,17 @@ public IReadOnlyList GetProfilePaths() return paths; } - public string GenerateProfileEntry(string dotnetupPath) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "'\\''"); - return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash)\""; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } - public string GenerateActivationCommand(string dotnetupPath) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "'\\''"); - return $"eval \"$('{escapedPath}' print-env-script --shell bash)\""; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs index f6907eea9564..1f9c82a0890b 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs @@ -24,12 +24,13 @@ public interface IEnvShellProvider string? HelpDescription { get; } /// - /// Generates a shell-specific script that configures PATH and DOTNET_ROOT. + /// Generates a shell-specific script that configures the environment. /// /// The path to the .NET installation directory /// The directory containing the dotnetup binary, or null to omit + /// When true, sets DOTNET_ROOT and adds dotnet to PATH. When false, only adds dotnetup to PATH. /// A shell script that can be sourced to configure the environment - string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null); + string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true); /// /// Returns the profile file paths that should be modified for this shell. @@ -42,11 +43,13 @@ public interface IEnvShellProvider /// Includes a marker comment for identification and removal. /// /// The full path to the dotnetup binary - string GenerateProfileEntry(string dotnetupPath); + /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). + string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false); /// /// Generates a command that the user can paste into the current terminal to activate .NET. /// /// The full path to the dotnetup binary - string GenerateActivationCommand(string dotnetupPath); + /// When true, the command only adds dotnetup to PATH. + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false); } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs index 58e410ba56b8..6b29acda0ff8 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs @@ -15,17 +15,37 @@ public class PowerShellEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - // Escape single quotes in the path for PowerShell by replacing ' with '' var escapedPath = dotnetInstallPath.Replace("'", "''"); - var pathExport = $"$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + var escapedDotnetupDir = dotnetupDir?.Replace("'", "''"); - if (dotnetupDir is not null) + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) { - var escapedDotnetupDir = dotnetupDir.Replace("'", "''"); pathExport = $"$env:PATH = '{escapedDotnetupDir}' + [IO.Path]::PathSeparator + '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; } + else if (includeDotnet) + { + pathExport = $"$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"$env:PATH = '{escapedDotnetupDir}' + [IO.Path]::PathSeparator + $env:PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + # This script adds dotnetup to your PATH + {pathExport} + """; + } return $""" @@ -44,15 +64,17 @@ public IReadOnlyList GetProfilePaths() return [Path.Combine(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")]; } - public string GenerateProfileEntry(string dotnetupPath) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "''"); - return $"{MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh | Invoke-Expression"; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } - public string GenerateActivationCommand(string dotnetupPath) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "''"); - return $"& '{escapedPath}' print-env-script --shell pwsh | Invoke-Expression"; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index 7bf6cd590782..6105fb3788c4 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -9,6 +9,7 @@ internal class PrintEnvScriptCommand : CommandBase { private readonly IEnvShellProvider? _shellProvider; private readonly string? _dotnetInstallPath; + private readonly bool _dotnetupOnly; private readonly IDotnetInstallManager _dotnetInstaller; public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result) @@ -16,6 +17,7 @@ public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetIn _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager(); _shellProvider = result.GetValue(PrintEnvScriptCommandParser.ShellOption); _dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption); + _dotnetupOnly = result.GetValue(PrintEnvScriptCommandParser.DotnetupOnlyOption); } protected override string GetCommandName() => "print-env-script"; @@ -50,7 +52,8 @@ protected override int ExecuteCore() string? dotnetupDir = Path.GetDirectoryName(Environment.ProcessPath); // Generate the shell script - string script = _shellProvider.GenerateEnvScript(installPath, dotnetupDir); + bool includeDotnet = !_dotnetupOnly; + string script = _shellProvider.GenerateEnvScript(installPath, dotnetupDir, includeDotnet); // Output the script to stdout Console.WriteLine(script); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index e9215d4ea3d0..fa7255d96581 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -43,6 +43,12 @@ internal static class PrintEnvScriptCommandParser Arity = ArgumentArity.ZeroOrOne }; + public static readonly Option DotnetupOnlyOption = new("--dotnetup-only") + { + Description = "Only add dotnetup to PATH. Do not set DOTNET_ROOT or add the .NET install path.", + Arity = ArgumentArity.ZeroOrOne + }; + static PrintEnvScriptCommandParser() { // Add validator to only accept supported shells @@ -67,6 +73,7 @@ private static Command ConstructCommand() command.Options.Add(ShellOption); command.Options.Add(DotnetInstallPathOption); + command.Options.Add(DotnetupOnlyOption); command.SetAction(parseResult => new PrintEnvScriptCommand(parseResult).Execute()); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index f6b1aa86cf26..bd7507a904b3 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -15,17 +15,38 @@ public class ZshEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - // Escape single quotes in the path for zsh by replacing ' with '\'' var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - var pathExport = $"export PATH='{escapedPath}':$PATH"; + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); - if (dotnetupDir is not null) + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) { - var escapedDotnetupDir = dotnetupDir.Replace("'", "'\\''"); pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; } + else if (includeDotnet) + { + pathExport = $"export PATH='{escapedPath}':$PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':$PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + #!/usr/bin/env zsh + # This script adds dotnetup to your PATH + {pathExport} + """; + } return $""" @@ -48,15 +69,17 @@ public IReadOnlyList GetProfilePaths() return [Path.Combine(home, ".zshrc")]; } - public string GenerateProfileEntry(string dotnetupPath) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "'\\''"); - return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh)\""; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } - public string GenerateActivationCommand(string dotnetupPath) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { var escapedPath = dotnetupPath.Replace("'", "'\\''"); - return $"eval \"$('{escapedPath}' print-env-script --shell zsh)\""; + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } } diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index ce9d31ef0360..32058c0d82b7 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -195,6 +195,14 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n pathEntries.Insert(0, dotnetRoot); // Unset DOTNET_ROOT Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); + + // Replace shell profile entries with dotnetup-only (no DOTNET_ROOT or dotnet PATH) + var adminDotnetupPath = Environment.ProcessPath; + var adminShellProvider = ShellDetection.GetCurrentShellProvider(); + if (adminDotnetupPath is not null && adminShellProvider is not null) + { + ShellProfileManager.ReplaceProfileEntries(adminShellProvider, adminDotnetupPath, dotnetupOnly: true); + } break; default: throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); diff --git a/src/Installer/dotnetup/ShellProfileManager.cs b/src/Installer/dotnetup/ShellProfileManager.cs index 2f587bd80af5..f62195daca89 100644 --- a/src/Installer/dotnetup/ShellProfileManager.cs +++ b/src/Installer/dotnetup/ShellProfileManager.cs @@ -17,11 +17,14 @@ public class ShellProfileManager /// Adds profile entries to all profile files for the given shell provider. /// Creates backups before modifying existing files. Skips files that already contain the entry. /// + /// The shell provider to use. + /// The full path to the dotnetup binary. + /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). /// The list of profile file paths that were modified. - public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider, string dotnetupPath) + public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider, string dotnetupPath, bool dotnetupOnly = false) { var profilePaths = provider.GetProfilePaths(); - var entry = provider.GenerateProfileEntry(dotnetupPath); + var entry = provider.GenerateProfileEntry(dotnetupPath, dotnetupOnly); var modifiedFiles = new List(); foreach (var profilePath in profilePaths) @@ -35,6 +38,17 @@ public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider return modifiedFiles; } + /// + /// Replaces existing dotnetup profile entries with new ones. + /// Removes the old entries first, then adds the new entries. + /// + /// The list of profile file paths that were modified. + public static IReadOnlyList ReplaceProfileEntries(IEnvShellProvider provider, string dotnetupPath, bool dotnetupOnly = false) + { + RemoveProfileEntries(provider); + return AddProfileEntries(provider, dotnetupPath, dotnetupOnly); + } + /// /// Removes dotnetup profile entries from all profile files for the given shell provider. /// diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index b59413ee12af..e3f666263290 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -33,6 +33,17 @@ public void BashProvider_ShouldIncludeDotnetupDirInPath() script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); } + [Fact] + public void BashProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new BashEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("export PATH='/usr/local/bin':$PATH"); + script.Should().NotContain("'/test/dotnet'"); + } + [Fact] public void ZshProvider_ShouldGenerateValidScript() { @@ -59,6 +70,16 @@ public void ZshProvider_ShouldIncludeDotnetupDirInPath() script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); } + [Fact] + public void ZshProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new ZshEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("export PATH='/usr/local/bin':$PATH"); + } + [Fact] public void PowerShellProvider_ShouldGenerateValidScript() { @@ -85,6 +106,16 @@ public void PowerShellProvider_ShouldIncludeDotnetupDirInPath() script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + '/test/dotnet' + [IO.Path]::PathSeparator + $env:PATH"); } + [Fact] + public void PowerShellProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new PowerShellEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + $env:PATH"); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index f4e4dc2c44a6..129e759d1037 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -164,6 +164,48 @@ public void AddProfileEntries_ModifiesMultipleFiles() File.ReadAllText(Path.Combine(_tempDir, "file2.sh")).Should().Contain(ShellProfileManager.MarkerComment); } + [Fact] + public void AddProfileEntries_DotnetupOnly_IncludesFlag() + { + var provider = new TestShellProvider(_tempDir, "admin.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + + var content = File.ReadAllText(Path.Combine(_tempDir, "admin.sh")); + content.Should().Contain("--dotnetup-only"); + } + + [Fact] + public void ReplaceProfileEntries_ReplacesExistingEntry() + { + var profilePath = Path.Combine(_tempDir, "replace.sh"); + var provider = new TestShellProvider(_tempDir, "replace.sh"); + + // Add user entry + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + File.ReadAllText(profilePath).Should().NotContain("--dotnetup-only"); + + // Replace with admin entry + var modified = ShellProfileManager.ReplaceProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().Contain("--dotnetup-only"); + // Should only have one marker + content.Split('\n').Count(l => l.TrimEnd() == ShellProfileManager.MarkerComment).Should().Be(1); + } + + [Fact] + public void ReplaceProfileEntries_WorksWithNoExistingEntry() + { + var provider = new TestShellProvider(_tempDir, "fresh.sh"); + + var modified = ShellProfileManager.ReplaceProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + + modified.Should().HaveCount(1); + File.ReadAllText(Path.Combine(_tempDir, "fresh.sh")).Should().Contain("--dotnetup-only"); + } + [Fact] public void BashProvider_GenerateProfileEntry_ContainsEval() { @@ -173,6 +215,16 @@ public void BashProvider_GenerateProfileEntry_ContainsEval() entry.Should().Contain(ShellProfileManager.MarkerComment); entry.Should().Contain("eval"); entry.Should().Contain("--shell bash"); + entry.Should().NotContain("--dotnetup-only"); + } + + [Fact] + public void BashProvider_GenerateProfileEntry_DotnetupOnly() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath, dotnetupOnly: true); + + entry.Should().Contain("--dotnetup-only"); } [Fact] @@ -184,6 +236,7 @@ public void ZshProvider_GenerateProfileEntry_ContainsEval() entry.Should().Contain(ShellProfileManager.MarkerComment); entry.Should().Contain("eval"); entry.Should().Contain("--shell zsh"); + entry.Should().NotContain("--dotnetup-only"); } [Fact] @@ -195,6 +248,7 @@ public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() entry.Should().Contain(ShellProfileManager.MarkerComment); entry.Should().Contain("Invoke-Expression"); entry.Should().Contain("--shell pwsh"); + entry.Should().NotContain("--dotnetup-only"); } [Fact] @@ -274,15 +328,23 @@ public TestShellProvider(string dir, params string[] fileNames) public string Extension => "sh"; public string? HelpDescription => null; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null) => - $"export DOTNET_ROOT='{dotnetInstallPath}'"; + public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) => + includeDotnet + ? $"export DOTNET_ROOT='{dotnetInstallPath}'" + : dotnetupDir is not null ? $"export PATH='{dotnetupDir}':$PATH" : ""; public IReadOnlyList GetProfilePaths() => _profilePaths; - public string GenerateProfileEntry(string dotnetupPath) => - $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test)\""; + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + } - public string GenerateActivationCommand(string dotnetupPath) => - $"eval \"$('{dotnetupPath}' print-env-script --shell test)\""; + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + } } } From 9729e0764cefaa1dc5fd7b8b0de9490e44a48d42 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 11:56:15 -0400 Subject: [PATCH 04/20] Rewrite unix-environment-setup.md to lead with user-facing workflows Restructure the document so that install and defaultinstall are presented as the primary ways environment setup happens, with print-env-script described as the underlying building block and standalone utility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 216 ++++++++++-------- 1 file changed, 116 insertions(+), 100 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 4000ad4b3c20..1c9a434e648a 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -2,25 +2,98 @@ ## Overview -This document describes the design for setting up the .NET environment via initialization scripts using the `dotnetup print-env-script` command. This is the first step toward enabling automatic user profile configuration for Unix as described in [issue #51582](https://github.com/dotnet/sdk/issues/51582). Note that this also supports PowerShell and thus Windows, but on Windows the main method of configuring the environment will be to set environment variables which are stored in the registry instead of written by initialization scripts. +dotnetup automatically configures the Unix shell environment so that .NET is available in every new terminal session. This involves modifying shell profile files to set the `PATH` and `DOTNET_ROOT` environment variables. The same mechanism also supports PowerShell on any platform. -## Background +On Windows the primary method is registry-based environment variables, which is handled separately. This document focuses on the Unix (and PowerShell) profile-based approach. -The dotnetup tool manages multiple .NET installations in a local user hive. For .NET to be accessible from the command line, the installation directory must be: -1. Added to the `PATH` environment variable -2. Set as the `DOTNET_ROOT` environment variable +## How the Environment Gets Configured -On Unix systems, this requires modifying shell configuration files (like `.bashrc`, `.zshrc`, etc.) or sourcing environment setup scripts. +There are two primary ways the environment is configured: -## Design Goals +### 1. During `dotnetup sdk install` / `dotnetup runtime install` -1. **Non-invasive**: Don't automatically modify user shell configuration files without explicit consent -2. **Flexible**: Support multiple shells (bash, zsh, PowerShell) -3. **Reversible**: Users should be able to easily undo environment changes -4. **Single-file execution**: Generate scripts that can be sourced or saved for later use -5. **Discoverable**: Make it easy for users to understand how to configure their environment +When running interactively (the default in a terminal), the install command prompts the user to set the default install if one is not already configured: -## The `dotnetup print-env-script` Command +``` +Do you want to set the install path (~/.local/share/dotnet) as the default dotnet install? +This will update the PATH and DOTNET_ROOT environment variables. [Y/n] +``` + +If the user confirms (or passes `--set-default-install` explicitly): + +- **On Windows**: Environment variables are set in the registry and updated for the current process. +- **On Unix**: Shell profile files are modified so .NET is available in future terminal sessions. Since profile changes only take effect in new shells, dotnetup also prints an activation command for the current terminal: + + ``` + To start using .NET in this terminal, run: + eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash)" + ``` + +If the default install is already fully configured and matches the install path, the prompt is skipped entirely. + +### 2. `dotnetup defaultinstall` + +A standalone command that explicitly configures (or reconfigures) the default .NET install: + +```bash +# Set up user-level default install (modifies shell profiles) +dotnetup defaultinstall user + +# Switch to admin/system-managed .NET (removes DOTNET_ROOT from profiles, keeps dotnetup on PATH) +dotnetup defaultinstall admin +``` + +**`defaultinstall user`** on Unix: +1. Detects the current shell +2. Modifies the appropriate shell profile files +3. Prints an activation command for the current terminal + +**`defaultinstall admin`** on Unix: +- Replaces existing profile entries with dotnetup-only entries (keeps dotnetup on PATH but removes `DOTNET_ROOT` and dotnet from `PATH`), since the system package manager owns the .NET installation. + +## Shell Profile Modification + +### Which Profile Files Are Modified + +| Shell | Files modified | Rationale | +|-------|---------------|-----------| +| **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. | +| **zsh** | `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. | +| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard `$PROFILE` path on Unix. | + +### Profile Entry Format + +Each profile file gets a marker comment and an eval line: + +**Bash / Zsh:** +```bash +# dotnetup +eval "$('/path/to/dotnetup' print-env-script --shell bash)" +``` + +**PowerShell:** +```powershell +# dotnetup +& '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression +``` + +The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). + +### Backups + +Before modifying an existing profile file, dotnetup creates a backup (e.g., `~/.bashrc.dotnetup-backup`). This allows the user to restore the file if needed. + +### Reversibility + +To remove the environment configuration, find the `# dotnetup` marker comment and the line immediately after it in each profile file, and remove both lines. The backup files can be used as a reference. + +### Idempotency + +If a profile file already contains the `# dotnetup` marker, the entry is not duplicated. + +## The `print-env-script` Command + +`print-env-script` is the low-level building block that generates shell-specific environment scripts. It is called internally by profile entries and activation commands, but can also be used standalone for custom setups, CI pipelines, or when you want to source the environment without modifying profile files. ### Command Structure @@ -40,12 +113,7 @@ dotnetup print-env-script [--shell ] [--dotnet-install-path ] ### Usage Examples -#### Auto-detect current shell -```bash -dotnetup print-env-script -``` - -#### Generate and source in one command +#### Source directly (one-time, current terminal only) ```bash source <(dotnetup print-env-script) ``` @@ -67,13 +135,13 @@ source ~/.dotnet-env.sh dotnetup print-env-script --dotnet-install-path /opt/dotnet ``` -## Generated Script Format +### Generated Script Format The command generates shell-specific scripts that: 1. Set the `DOTNET_ROOT` environment variable to the installation path 2. Prepend the installation path to the `PATH` environment variable -### Bash/Zsh Example +**Bash/Zsh Example:** ```bash #!/usr/bin/env bash # This script configures the environment for .NET installed at /home/user/.local/share/dotnet @@ -83,7 +151,7 @@ export DOTNET_ROOT='/home/user/.local/share/dotnet' export PATH='/home/user/.local/share/dotnet':$PATH ``` -### PowerShell Example +**PowerShell Example:** ```powershell # This script configures the environment for .NET installed at /home/user/.local/share/dotnet # Source this script (dot-source) to add .NET to your PATH and set DOTNET_ROOT @@ -93,103 +161,53 @@ $env:DOTNET_ROOT = '/home/user/.local/share/dotnet' $env:PATH = '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH ``` -## Implementation Details - -### Provider Model - -The implementation uses a provider model similar to `System.CommandLine.StaticCompletions`, making it easy to add support for additional shells in the future. - -**Interface**: `IEnvShellProvider` -```csharp -public interface IEnvShellProvider -{ - string ArgumentName { get; } // Shell name for CLI (e.g., "bash") - string Extension { get; } // File extension (e.g., "sh") - string? HelpDescription { get; } // Help text for the shell - string GenerateEnvScript(string dotnetInstallPath); -} -``` - -**Implementations**: -- `BashEnvShellProvider`: Generates bash-compatible scripts -- `ZshEnvShellProvider`: Generates zsh-compatible scripts -- `PowerShellEnvShellProvider`: Generates PowerShell Core scripts - ### Shell Detection -The command automatically detects the current shell when the `--shell` option is not provided: +When `--shell` is not specified, the command automatically detects the current shell: -1. **On Unix**: Reads the `$SHELL` environment variable and extracts the shell name from the path - - Example: `/bin/bash` → `bash` +1. **On Unix**: Reads the `$SHELL` environment variable and extracts the shell name from the path (e.g., `/bin/bash` → `bash`) 2. **On Windows**: Defaults to PowerShell (`pwsh`) ### Security Considerations -**Path Escaping**: All installation paths are properly escaped to prevent shell injection vulnerabilities: +All installation paths are properly escaped to prevent shell injection vulnerabilities: - **Bash/Zsh**: Uses single quotes with `'\''` escaping for embedded single quotes - **PowerShell**: Uses single quotes with `''` escaping for embedded single quotes This ensures that paths containing special characters, spaces, or shell metacharacters are handled safely. -## Advantages of Generated Scripts - -As noted in the discussion, generating scripts dynamically has several advantages over using embedded resource files: - -1. **Single-file execution**: Users can source the script directly from the command output without needing to extract files -2. **Flexibility**: Easy to customize the installation path or add future features -3. **No signing required**: Generated text doesn't require code signing, unlike downloaded executables or scripts -4. **Immediate availability**: No download or extraction step needed -5. **Transparency**: Users can easily inspect what the script does by running the command - -## Shell Profile Modification - -Building on `print-env-script`, dotnetup can automatically modify shell profile files so that `.NET` is available in every new terminal session. This is triggered in two ways: - -1. **`sdk install --interactive`** — When the user confirms "set as default install?", dotnetup persists the environment configuration to shell profiles in addition to setting environment variables for the current process. -2. **`defaultinstall user`** — Standalone command that configures the default install, including shell profile modification on Unix. - -After either operation, dotnetup prints a command the user can paste into the current terminal to activate `.NET` immediately, since profile changes only take effect in new shells. - -### Which Profile Files Are Modified - -| Shell | Files modified | Rationale | -|-------|---------------|-----------| -| **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. | -| **zsh** | `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. | -| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard `$PROFILE` path on Unix. | +## Implementation Details -### Profile Entry Format +### Provider Model -Each profile file gets a marker comment and an eval line: +The implementation uses a provider model, making it easy to add support for additional shells in the future. -**Bash / Zsh:** -```bash -# dotnetup -eval "$(/path/to/dotnetup print-env-script --shell bash)" -``` - -**PowerShell:** -```powershell -# dotnetup -& /path/to/dotnetup print-env-script --shell pwsh | Invoke-Expression +**Interface**: `IEnvShellProvider` +```csharp +public interface IEnvShellProvider +{ + string ArgumentName { get; } + string Extension { get; } + string? HelpDescription { get; } + string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true); + IReadOnlyList GetProfilePaths(); + string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false); + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false); +} ``` -The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). - -### Reversibility +**Implementations**: `BashEnvShellProvider`, `ZshEnvShellProvider`, `PowerShellEnvShellProvider` -- The `# dotnetup` marker comment immediately before the eval line identifies the addition. -- To remove: find the marker line and the line after it, remove both. -- Before modifying any file, dotnetup creates a backup (e.g., `~/.bashrc.dotnetup-backup`). +### ShellDetection -### Provider Model - -The `IEnvShellProvider` interface is extended with two methods so each shell provider owns its profile knowledge: +`ShellDetection.GetCurrentShellProvider()` resolves the user's current shell to the matching `IEnvShellProvider`. On Windows it returns the PowerShell provider; on Unix it reads `$SHELL`. -- `GetProfilePaths()` — Returns the list of profile file paths to modify for the shell. -- `GenerateProfileEntry(string dotnetupPath)` — Generates the marker comment and eval line. +### ShellProfileManager -A `ShellProfileManager` class coordinates the file I/O: adding and removing entries, creating backups, and ensuring idempotency (entries are not duplicated if already present). +`ShellProfileManager` coordinates profile file modifications: +- `AddProfileEntries(provider, dotnetupPath)` — appends entries, creates backups, skips if already present +- `RemoveProfileEntries(provider)` — finds and removes marker + eval lines +- `ReplaceProfileEntries(provider, dotnetupPath, dotnetupOnly)` — removes then adds (used by `defaultinstall admin`) ## Future Work @@ -210,5 +228,3 @@ The implementation includes comprehensive tests: - Security tests for special character handling - Help documentation tests - Shell profile manager tests for add/remove/idempotency/backup behavior - -All tests ensure that the generated scripts are syntactically correct and properly escape paths. From 2b754e24a215a570bf8332a8d7edb6d522107995 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:27:09 -0400 Subject: [PATCH 05/20] Remove no-op SetEnvironmentVariable calls on Unix EnvironmentVariableTarget.User has no persistent store on Unix in .NET, so the SetEnvironmentVariable calls for DOTNET_ROOT and PATH were effectively process-scoped and had no lasting effect. Shell profile entries are the sole persistence mechanism on Unix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/DotnetInstallManager.cs | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 32058c0d82b7..335255b9a129 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -158,13 +158,10 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n } else { - // Non-Windows platforms: use the simpler PATH-based approach - // Get current PATH - var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; - var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); - string exeName = "dotnet"; - // Remove only actual dotnet installation folders from PATH - pathEntries = [.. pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName)))]; + // Non-Windows platforms: persist environment via shell profiles only. + // Environment.SetEnvironmentVariable with EnvironmentVariableTarget.User + // has no real persistent store on Unix, so shell profile entries are the + // sole persistence mechanism. switch (installType) { @@ -173,10 +170,6 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n { throw new ArgumentNullException(nameof(dotnetRoot)); } - // Add dotnetRoot to PATH - pathEntries.Insert(0, dotnetRoot); - // Set DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); // Persist to shell profiles var dotnetupPath = Environment.ProcessPath; @@ -187,15 +180,6 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n } break; case InstallType.Admin: - if (string.IsNullOrEmpty(dotnetRoot)) - { - throw new ArgumentNullException(nameof(dotnetRoot)); - } - // Add dotnetRoot to PATH - pathEntries.Insert(0, dotnetRoot); - // Unset DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); - // Replace shell profile entries with dotnetup-only (no DOTNET_ROOT or dotnet PATH) var adminDotnetupPath = Environment.ProcessPath; var adminShellProvider = ShellDetection.GetCurrentShellProvider(); @@ -207,9 +191,6 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n default: throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); } - // Update PATH - var newPath = string.Join(Path.PathSeparator, pathEntries); - Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.User); } } } From c2fa1ba0fc03e854da4eae937d7513ad9e408fee Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:31:19 -0400 Subject: [PATCH 06/20] Use eval consistently in print-env-script examples The shell providers generate eval-based commands, so the documentation examples should match. Also use dot-source (.) instead of source for POSIX compatibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 1c9a434e648a..f5f9721c67b1 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -113,9 +113,9 @@ dotnetup print-env-script [--shell ] [--dotnet-install-path ] ### Usage Examples -#### Source directly (one-time, current terminal only) +#### Eval directly (one-time, current terminal only) ```bash -source <(dotnetup print-env-script) +eval "$(dotnetup print-env-script)" ``` #### Explicitly specify shell @@ -127,7 +127,7 @@ dotnetup print-env-script --shell zsh ```bash dotnetup print-env-script --shell bash > ~/.dotnet-env.sh # Later, in .bashrc or manually: -source ~/.dotnet-env.sh +. ~/.dotnet-env.sh ``` #### Use custom installation path From 8ab0cf1cac96c1b30fd07a6f4e0c084a7f1c3bfe Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:34:46 -0400 Subject: [PATCH 07/20] Remove misleading 'source this script' comments from generated env scripts The output of print-env-script is consumed via eval, not sourced as a file. The 'source this script' instructions were misleading. Removed from all three shell providers and the documentation examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 3 --- .../dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs | 1 - .../Commands/PrintEnvScript/PowerShellEnvShellProvider.cs | 2 -- .../dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs | 1 - 4 files changed, 7 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index f5f9721c67b1..6c3d41d84f17 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -145,7 +145,6 @@ The command generates shell-specific scripts that: ```bash #!/usr/bin/env bash # This script configures the environment for .NET installed at /home/user/.local/share/dotnet -# Source this script to add .NET to your PATH and set DOTNET_ROOT export DOTNET_ROOT='/home/user/.local/share/dotnet' export PATH='/home/user/.local/share/dotnet':$PATH @@ -154,8 +153,6 @@ export PATH='/home/user/.local/share/dotnet':$PATH **PowerShell Example:** ```powershell # This script configures the environment for .NET installed at /home/user/.local/share/dotnet -# Source this script (dot-source) to add .NET to your PATH and set DOTNET_ROOT -# Example: . ./dotnet-env.ps1 $env:DOTNET_ROOT = '/home/user/.local/share/dotnet' $env:PATH = '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index 90a9a5cb506d..1ce6ada782ca 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -52,7 +52,6 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = $""" #!/usr/bin/env bash # This script configures the environment for .NET installed at {dotnetInstallPath} - # Source this script to add .NET to your PATH and set DOTNET_ROOT # # Note: If you had a different dotnet in PATH before sourcing this script, # you may need to run 'hash -d dotnet' to clear the cached command location. diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs index 6b29acda0ff8..3a1d5122e602 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs @@ -50,8 +50,6 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = return $""" # This script configures the environment for .NET installed at {dotnetInstallPath} - # Source this script (dot-source) to add .NET to your PATH and set DOTNET_ROOT - # Example: . ./dotnet-env.ps1 $env:DOTNET_ROOT = '{escapedPath}' {pathExport} diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index bd7507a904b3..7a1d5ad6b3d7 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -52,7 +52,6 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = $""" #!/usr/bin/env zsh # This script configures the environment for .NET installed at {dotnetInstallPath} - # Source this script to add .NET to your PATH and set DOTNET_ROOT # # Note: If you had a different dotnet in PATH before sourcing this script, # you may need to run 'rehash' or 'hash -d dotnet' to clear the cached command location. From 04d84b7b51f4c8405089639097a9c59ee7b07ed6 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:35:33 -0400 Subject: [PATCH 08/20] Update script examples to include dotnetup directory in PATH The generated scripts add both the dotnetup binary directory and the dotnet install path to PATH. The documentation examples were missing the dotnetup directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 6c3d41d84f17..a55506dee720 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -147,7 +147,7 @@ The command generates shell-specific scripts that: # This script configures the environment for .NET installed at /home/user/.local/share/dotnet export DOTNET_ROOT='/home/user/.local/share/dotnet' -export PATH='/home/user/.local/share/dotnet':$PATH +export PATH='/home/user/.local/share/dotnetup':'/home/user/.local/share/dotnet':$PATH ``` **PowerShell Example:** @@ -155,7 +155,7 @@ export PATH='/home/user/.local/share/dotnet':$PATH # This script configures the environment for .NET installed at /home/user/.local/share/dotnet $env:DOTNET_ROOT = '/home/user/.local/share/dotnet' -$env:PATH = '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH +$env:PATH = '/home/user/.local/share/dotnetup' + [IO.Path]::PathSeparator + '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH ``` ### Shell Detection From 3b0b5f760b0e6a17123997232583a301e2b561d5 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:38:10 -0400 Subject: [PATCH 09/20] Update future work: defaultinstall admin is implemented The command works on Unix by replacing profile entries with dotnetup-only entries. The remaining gap is system-wide /etc/profile.d/ configuration, so reword the item to reflect that. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index a55506dee720..f78ba3f51445 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -208,7 +208,7 @@ public interface IEnvShellProvider ## Future Work -1. **`defaultinstall admin` on Unix**: System-wide configuration (e.g., `/etc/profile.d/`) is not yet supported. +1. **System-wide configuration on Unix**: Writing to system-wide locations like `/etc/profile.d/` for admin installs is not yet supported. 2. **Additional shells**: Support for fish, tcsh, and other shells. 3. **Environment validation**: Commands to verify that the environment is correctly configured. From ee266143d9bb12fe12554d37665ab7174c28ed09 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:43:15 -0400 Subject: [PATCH 10/20] Skip default install prompt when shell is unsupported on Unix On non-Windows, configuring the default install requires modifying shell profile files. If the current shell cannot be detected or is not supported, the profile modification would silently do nothing. Instead, detect this up-front and skip the prompt with a warning message, so the user knows why the default install setup was skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Commands/Shared/InstallWalkthrough.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index fcfaef1f7123..8cb459918e91 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; @@ -163,7 +164,16 @@ public bool ResolveSetDefaultInstall( // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_options.Interactive && !installPathCameFromGlobalJson) { - if (currentDotnetInstallRoot == null) + // On non-Windows, configuring the default install requires modifying shell profiles. + // If we can't detect a supported shell, skip the prompt — there's nothing useful we can do. + if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + SpectreAnsiConsole.MarkupLine($"[yellow]Shell '{shellEnv}' is not supported for automatic environment configuration. Skipping default install setup.[/]"); + SpectreAnsiConsole.MarkupLine($"[yellow]Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}[/]"); + resolvedSetDefaultInstall = false; + } + else if (currentDotnetInstallRoot == null) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", From fd9b8c942d1ac4cd7dae4b2f322dfac1de5c8a3d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:51:03 -0400 Subject: [PATCH 11/20] Consolidate shell provider list into ShellDetection Move the supported shells array and shell map from PrintEnvScriptCommandParser into ShellDetection, eliminating the duplicate dictionary and the duplicate LookupShellFromEnvironment method. All callers now go through ShellDetection for shell lookup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstall/DefaultInstallCommand.cs | 4 +- .../PrintEnvScript/PrintEnvScriptCommand.cs | 4 +- .../PrintEnvScriptCommandParser.cs | 52 +++---------------- .../Commands/Shared/InstallWalkthrough.cs | 3 +- src/Installer/dotnetup/ShellDetection.cs | 24 ++++++++- test/dotnetup.Tests/EnvShellProviderTests.cs | 3 +- 6 files changed, 37 insertions(+), 53 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 075f6cac9155..e8f11d910e1d 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -42,7 +42,7 @@ private int SetUserInstallRoot() var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; throw new DotnetInstallException( DotnetInstallErrorCode.PlatformNotSupported, - $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); @@ -107,7 +107,7 @@ private int SetAdminInstallRoot() var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; throw new DotnetInstallException( DotnetInstallErrorCode.PlatformNotSupported, - $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } var modifiedFiles = ShellProfileManager.ReplaceProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index 6105fb3788c4..6f603cc49258 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -33,13 +33,13 @@ protected override int ExecuteCore() if (shellPath is null) { Console.Error.WriteLine("Error: Unable to detect current shell. The SHELL environment variable is not set."); - Console.Error.WriteLine($"Please specify the shell using --shell option. Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + Console.Error.WriteLine($"Please specify the shell using --shell option. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } else { var shellName = Path.GetFileName(shellPath); Console.Error.WriteLine($"Error: Unsupported shell '{shellName}'."); - Console.Error.WriteLine($"Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}"); + Console.Error.WriteLine($"Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); Console.Error.WriteLine("Please specify the shell using --shell option."); } return 1; diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index fa7255d96581..d6cd34d66f3e 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -8,30 +8,20 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; internal static class PrintEnvScriptCommandParser { - internal static readonly IEnvShellProvider[] s_supportedShells = - [ - new BashEnvShellProvider(), - new ZshEnvShellProvider(), - new PowerShellEnvShellProvider() - ]; - - private static readonly Dictionary s_shellMap = - s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); - public static readonly Option ShellOption = new("--shell", "-s") { - Description = $"The shell for which to generate the environment script (supported: {string.Join(", ", s_supportedShells.Select(s => s.ArgumentName))}). If not specified, the current shell will be detected.", + Description = $"The shell for which to generate the environment script (supported: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}). If not specified, the current shell will be detected.", Arity = ArgumentArity.ZeroOrOne, // called when no token is presented at all - DefaultValueFactory = (optionResult) => LookupShellFromEnvironment(), + DefaultValueFactory = (optionResult) => ShellDetection.GetCurrentShellProvider(), // called for all other scenarios CustomParser = (optionResult) => { return optionResult.Tokens switch { // shouldn't be required because of the DefaultValueFactory above - [] => LookupShellFromEnvironment(), - [var shellToken] => s_shellMap[shellToken.Value], + [] => ShellDetection.GetCurrentShellProvider(), + [var shellToken] => ShellDetection.GetShellProvider(shellToken.Value), _ => throw new InvalidOperationException("Unexpected number of tokens") // this is impossible because of the Arity set above }; } @@ -80,34 +70,6 @@ private static Command ConstructCommand() return command; } - private static IEnvShellProvider? LookupShellFromEnvironment() - { - if (OperatingSystem.IsWindows()) - { - return s_shellMap["pwsh"]; - } - - var shellPath = Environment.GetEnvironmentVariable("SHELL"); - if (shellPath is null) - { - // Return null if we can't detect the shell - // This allows help to work, but Execute will handle the error - return null; - } - - var shellName = Path.GetFileName(shellPath); - if (s_shellMap.TryGetValue(shellName, out var shellProvider)) - { - return shellProvider; - } - else - { - // Return null for unsupported shells - // This allows help to work, but Execute will handle the error - return null; - } - } - private static Action OnlyAcceptSupportedShells() { return (System.CommandLine.Parsing.OptionResult optionResult) => @@ -117,9 +79,9 @@ private static Command ConstructCommand() return; } var singleToken = optionResult.Tokens[0]; - if (!s_shellMap.ContainsKey(singleToken.Value)) + if (!ShellDetection.IsSupported(singleToken.Value)) { - optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", s_shellMap.Keys)}"); + optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } }; } @@ -128,7 +90,7 @@ private static Func> CreateComple { return (CompletionContext context) => { - return s_shellMap.Values.Select(shellProvider => new CompletionItem(shellProvider.ArgumentName, documentation: shellProvider.HelpDescription)); + return ShellDetection.s_supportedShells.Select(s => new CompletionItem(s.ArgumentName, documentation: s.HelpDescription)); }; } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index 8cb459918e91..55828b3c6519 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; @@ -170,7 +169,7 @@ public bool ResolveSetDefaultInstall( { var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; SpectreAnsiConsole.MarkupLine($"[yellow]Shell '{shellEnv}' is not supported for automatic environment configuration. Skipping default install setup.[/]"); - SpectreAnsiConsole.MarkupLine($"[yellow]Supported shells: {string.Join(", ", PrintEnvScriptCommandParser.s_supportedShells.Select(s => s.ArgumentName))}[/]"); + SpectreAnsiConsole.MarkupLine($"[yellow]Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}[/]"); resolvedSetDefaultInstall = false; } else if (currentDotnetInstallRoot == null) diff --git a/src/Installer/dotnetup/ShellDetection.cs b/src/Installer/dotnetup/ShellDetection.cs index fa8b32cee3f8..ad845f8a62d0 100644 --- a/src/Installer/dotnetup/ShellDetection.cs +++ b/src/Installer/dotnetup/ShellDetection.cs @@ -10,8 +10,30 @@ namespace Microsoft.DotNet.Tools.Bootstrapper; /// public static class ShellDetection { + /// + /// The list of shell providers supported by dotnetup. + /// + internal static readonly IEnvShellProvider[] s_supportedShells = + [ + new BashEnvShellProvider(), + new ZshEnvShellProvider(), + new PowerShellEnvShellProvider() + ]; + private static readonly Dictionary s_shellMap = - PrintEnvScriptCommandParser.s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + + /// + /// Looks up a shell provider by its argument name (e.g., "bash", "zsh", "pwsh"). + /// + internal static IEnvShellProvider? GetShellProvider(string shellName) + => s_shellMap.GetValueOrDefault(shellName); + + /// + /// Checks whether a shell name is supported. + /// + internal static bool IsSupported(string shellName) + => s_shellMap.ContainsKey(shellName); /// /// Returns the for the user's current shell, diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index e3f666263290..b449f424ff5e 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -123,7 +124,7 @@ public void PowerShellProvider_DotnetupOnly_ShouldNotSetDotnetRoot() public void ShellProviders_ShouldHaveCorrectArgumentName(string expectedName) { // Arrange - var provider = PrintEnvScriptCommandParser.s_supportedShells.FirstOrDefault(s => s.ArgumentName == expectedName); + var provider = ShellDetection.s_supportedShells.FirstOrDefault(s => s.ArgumentName == expectedName); // Assert provider.Should().NotBeNull(); From e00539e9652e5958fea4fee4f354f8cae4fb9789 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:59:10 -0400 Subject: [PATCH 12/20] Merge AddProfileEntries and ReplaceProfileEntries into one method AddProfileEntries now replaces existing entries in-place when found, preserving the user's ordering in their profile file. This eliminates the need for a separate ReplaceProfileEntries method and fixes the case where AddProfileEntries would silently skip stale entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstall/DefaultInstallCommand.cs | 2 +- .../dotnetup/DotnetInstallManager.cs | 2 +- src/Installer/dotnetup/ShellProfileManager.cs | 73 +++++++++++++------ .../ShellProfileManagerTests.cs | 10 +-- 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index e8f11d910e1d..6fe23f31d2ce 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -110,7 +110,7 @@ private int SetAdminInstallRoot() $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } - var modifiedFiles = ShellProfileManager.ReplaceProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); if (modifiedFiles.Count == 0) { diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 335255b9a129..a4106f6c44ef 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -185,7 +185,7 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n var adminShellProvider = ShellDetection.GetCurrentShellProvider(); if (adminDotnetupPath is not null && adminShellProvider is not null) { - ShellProfileManager.ReplaceProfileEntries(adminShellProvider, adminDotnetupPath, dotnetupOnly: true); + ShellProfileManager.AddProfileEntries(adminShellProvider, adminDotnetupPath, dotnetupOnly: true); } break; default: diff --git a/src/Installer/dotnetup/ShellProfileManager.cs b/src/Installer/dotnetup/ShellProfileManager.cs index f62195daca89..887ac7071d83 100644 --- a/src/Installer/dotnetup/ShellProfileManager.cs +++ b/src/Installer/dotnetup/ShellProfileManager.cs @@ -14,8 +14,9 @@ public class ShellProfileManager private const string BackupSuffix = ".dotnetup-backup"; /// - /// Adds profile entries to all profile files for the given shell provider. - /// Creates backups before modifying existing files. Skips files that already contain the entry. + /// Ensures the correct dotnetup profile entry is present in all profile files for the given shell provider. + /// If an entry already exists, it is replaced in-place. If no entry exists, one is appended. + /// Creates backups before modifying existing files. /// /// The shell provider to use. /// The full path to the dotnetup binary. @@ -29,7 +30,7 @@ public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider foreach (var profilePath in profilePaths) { - if (AddEntryToFile(profilePath, entry)) + if (EnsureEntryInFile(profilePath, entry)) { modifiedFiles.Add(profilePath); } @@ -38,17 +39,6 @@ public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider return modifiedFiles; } - /// - /// Replaces existing dotnetup profile entries with new ones. - /// Removes the old entries first, then adds the new entries. - /// - /// The list of profile file paths that were modified. - public static IReadOnlyList ReplaceProfileEntries(IEnvShellProvider provider, string dotnetupPath, bool dotnetupOnly = false) - { - RemoveProfileEntries(provider); - return AddProfileEntries(provider, dotnetupPath, dotnetupOnly); - } - /// /// Removes dotnetup profile entries from all profile files for the given shell provider. /// @@ -83,30 +73,65 @@ public static bool HasProfileEntry(string profilePath) return content.Contains(MarkerComment, StringComparison.Ordinal); } - private static bool AddEntryToFile(string profilePath, string entry) + /// + /// Ensures the given entry is present in the file. If an existing dotnetup entry is found, + /// it is replaced in-place to preserve the user's ordering. Otherwise the entry is appended. + /// Returns true if the file was modified, false if the entry was already correct. + /// + private static bool EnsureEntryInFile(string profilePath, string entry) { - if (HasProfileEntry(profilePath)) - { - return false; - } - var directory = Path.GetDirectoryName(profilePath); if (!string.IsNullOrEmpty(directory)) { Directory.CreateDirectory(directory); } - // Create backup of existing file - if (File.Exists(profilePath)) + if (!File.Exists(profilePath)) { + // New file — just write the entry + File.WriteAllText(profilePath, entry + Environment.NewLine); + return true; + } + + var lines = File.ReadAllLines(profilePath).ToList(); + var entryLines = entry.Split('\n', StringSplitOptions.None) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + + // Look for an existing marker + int markerIndex = lines.FindIndex(l => l.TrimEnd() == MarkerComment); + + if (markerIndex >= 0) + { + // Determine how many lines the old entry spans (marker + command lines) + int oldEntryEnd = markerIndex + 1; + // The old entry is the marker line plus the next line (the eval/invoke line) + if (oldEntryEnd < lines.Count) + { + oldEntryEnd++; + } + + // Check if the existing entry already matches + var oldEntry = lines.GetRange(markerIndex, oldEntryEnd - markerIndex); + if (oldEntry.Count == entryLines.Length && + oldEntry.Zip(entryLines).All(pair => pair.First.TrimEnd() == pair.Second.TrimEnd())) + { + return false; // Already correct + } + + // Replace in-place File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); + lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); + lines.InsertRange(markerIndex, entryLines); + File.WriteAllLines(profilePath, lines); + return true; } - // Append entry with a leading newline to separate from existing content + // No existing entry — append + File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); using var writer = File.AppendText(profilePath); writer.WriteLine(); writer.WriteLine(entry); - return true; } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 129e759d1037..c9036ac88f04 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -176,7 +176,7 @@ public void AddProfileEntries_DotnetupOnly_IncludesFlag() } [Fact] - public void ReplaceProfileEntries_ReplacesExistingEntry() + public void AddProfileEntries_ReplacesExistingEntryInPlace() { var profilePath = Path.Combine(_tempDir, "replace.sh"); var provider = new TestShellProvider(_tempDir, "replace.sh"); @@ -185,8 +185,8 @@ public void ReplaceProfileEntries_ReplacesExistingEntry() ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); File.ReadAllText(profilePath).Should().NotContain("--dotnetup-only"); - // Replace with admin entry - var modified = ShellProfileManager.ReplaceProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + // Replace with admin entry (AddProfileEntries now replaces in-place) + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); modified.Should().HaveCount(1); var content = File.ReadAllText(profilePath); @@ -196,11 +196,11 @@ public void ReplaceProfileEntries_ReplacesExistingEntry() } [Fact] - public void ReplaceProfileEntries_WorksWithNoExistingEntry() + public void AddProfileEntries_WorksWithNoExistingEntry() { var provider = new TestShellProvider(_tempDir, "fresh.sh"); - var modified = ShellProfileManager.ReplaceProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); modified.Should().HaveCount(1); File.ReadAllText(Path.Combine(_tempDir, "fresh.sh")).Should().Contain("--dotnetup-only"); From 0605491a1898312e7416d12aeeb10602a075c377 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:08:05 -0400 Subject: [PATCH 13/20] Use single MarkerComment constant from ShellProfileManager Remove duplicate '# dotnetup' constants from BashEnvShellProvider, ZshEnvShellProvider, and PowerShellEnvShellProvider. All three now reference ShellProfileManager.MarkerComment, ensuring the marker used to generate entries always matches the one used to find them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs | 4 +--- .../Commands/PrintEnvScript/PowerShellEnvShellProvider.cs | 4 +--- .../dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index 1ce6ada782ca..ed9a9a37328d 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -5,8 +5,6 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class BashEnvShellProvider : IEnvShellProvider { - private const string MarkerComment = "# dotnetup"; - public string ArgumentName => "bash"; public string Extension => "sh"; @@ -89,7 +87,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals { var escapedPath = dotnetupPath.Replace("'", "'\\''"); var flags = dotnetupOnly ? " --dotnetup-only" : ""; - return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; + return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs index 3a1d5122e602..82fd4131d29f 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs @@ -5,8 +5,6 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class PowerShellEnvShellProvider : IEnvShellProvider { - private const string MarkerComment = "# dotnetup"; - public string ArgumentName => "pwsh"; public string Extension => "ps1"; @@ -66,7 +64,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals { var escapedPath = dotnetupPath.Replace("'", "''"); var flags = dotnetupOnly ? " --dotnetup-only" : ""; - return $"{MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; + return $"{ShellProfileManager.MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index 7a1d5ad6b3d7..0cb9cdef0e7f 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -5,8 +5,6 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; public class ZshEnvShellProvider : IEnvShellProvider { - private const string MarkerComment = "# dotnetup"; - public string ArgumentName => "zsh"; public string Extension => "zsh"; @@ -72,7 +70,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals { var escapedPath = dotnetupPath.Replace("'", "'\\''"); var flags = dotnetupOnly ? " --dotnetup-only" : ""; - return $"{MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; + return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) From f001d3df1f65926d3b2f0eecc267cd1c11296e5f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:12:54 -0400 Subject: [PATCH 14/20] Clear shell command cache in generated env scripts Add 'hash -d dotnet' (bash) and 'rehash' (zsh) to the generated scripts so a stale cached dotnet path is cleared when the environment is configured. This replaces the misleading comments that claimed dotnetup would handle it automatically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 3 +++ .../Commands/PrintEnvScript/BashEnvShellProvider.cs | 8 ++++---- .../Commands/PrintEnvScript/ZshEnvShellProvider.cs | 6 ++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index f78ba3f51445..44a86b707bb6 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -140,6 +140,7 @@ dotnetup print-env-script --dotnet-install-path /opt/dotnet The command generates shell-specific scripts that: 1. Set the `DOTNET_ROOT` environment variable to the installation path 2. Prepend the installation path to the `PATH` environment variable +3. Clear the shell's cached command location for `dotnet` to pick up the new PATH **Bash/Zsh Example:** ```bash @@ -148,6 +149,8 @@ The command generates shell-specific scripts that: export DOTNET_ROOT='/home/user/.local/share/dotnet' export PATH='/home/user/.local/share/dotnetup':'/home/user/.local/share/dotnet':$PATH +hash -d dotnet 2>/dev/null +hash -d dotnetup 2>/dev/null ``` **PowerShell Example:** diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs index ed9a9a37328d..f45ed483909f 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs @@ -43,6 +43,8 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = #!/usr/bin/env bash # This script adds dotnetup to your PATH {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null """; } @@ -50,13 +52,11 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = $""" #!/usr/bin/env bash # This script configures the environment for .NET installed at {dotnetInstallPath} - # - # Note: If you had a different dotnet in PATH before sourcing this script, - # you may need to run 'hash -d dotnet' to clear the cached command location. - # When dotnetup modifies shell profiles directly, it will handle this automatically. export DOTNET_ROOT='{escapedPath}' {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null """; } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs index 0cb9cdef0e7f..47f8b6c3cb25 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs @@ -43,6 +43,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = #!/usr/bin/env zsh # This script adds dotnetup to your PATH {pathExport} + rehash 2>/dev/null """; } @@ -50,13 +51,10 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = $""" #!/usr/bin/env zsh # This script configures the environment for .NET installed at {dotnetInstallPath} - # - # Note: If you had a different dotnet in PATH before sourcing this script, - # you may need to run 'rehash' or 'hash -d dotnet' to clear the cached command location. - # When dotnetup modifies shell profiles directly, it will handle this automatically. export DOTNET_ROOT='{escapedPath}' {pathExport} + rehash 2>/dev/null """; } From ee3acb8285df53cf5f20e8af11bde60b4d79b5fe Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:35:44 -0400 Subject: [PATCH 15/20] Inline ShellOption validators and completions into initializer Move the validator and completion source setup from the static constructor into the ShellOption object initializer using collection initializer syntax. Remove the now-unnecessary helper methods. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PrintEnvScript/PrintEnvScriptCommandParser.cs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index d6cd34d66f3e..7f97c5d72396 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -24,7 +24,9 @@ internal static class PrintEnvScriptCommandParser [var shellToken] => ShellDetection.GetShellProvider(shellToken.Value), _ => throw new InvalidOperationException("Unexpected number of tokens") // this is impossible because of the Arity set above }; - } + }, + Validators = { OnlyAcceptSupportedShells() }, + CompletionSources = { CreateCompletions() } }; public static readonly Option DotnetInstallPathOption = new("--dotnet-install-path", "-d") @@ -39,17 +41,6 @@ internal static class PrintEnvScriptCommandParser Arity = ArgumentArity.ZeroOrOne }; - static PrintEnvScriptCommandParser() - { - // Add validator to only accept supported shells - ShellOption.Validators.Clear(); - ShellOption.Validators.Add(OnlyAcceptSupportedShells()); - - // Add completions for shell names - ShellOption.CompletionSources.Clear(); - ShellOption.CompletionSources.Add(CreateCompletions()); - } - private static readonly Command s_printEnvScriptCommand = ConstructCommand(); public static Command GetCommand() From 7216bbac67afdf7c67399ac5ff7169e0bc59e586 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:42:45 -0400 Subject: [PATCH 16/20] Move shell types to Microsoft.DotNet.Tools.Bootstrapper.Shell namespace Move IEnvShellProvider, BashEnvShellProvider, ZshEnvShellProvider, PowerShellEnvShellProvider, ShellDetection, and ShellProfileManager into a new Shell/ directory and namespace. These types are used across multiple commands and don't belong in the PrintEnvScript namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs | 2 +- .../dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs | 1 + .../Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs | 1 + src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs | 2 +- src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs | 1 + src/Installer/dotnetup/DotnetInstallManager.cs | 1 + .../PrintEnvScript => Shell}/BashEnvShellProvider.cs | 2 +- .../{Commands/PrintEnvScript => Shell}/IEnvShellProvider.cs | 2 +- .../PrintEnvScript => Shell}/PowerShellEnvShellProvider.cs | 2 +- src/Installer/dotnetup/{ => Shell}/ShellDetection.cs | 4 +--- src/Installer/dotnetup/{ => Shell}/ShellProfileManager.cs | 4 +--- .../{Commands/PrintEnvScript => Shell}/ZshEnvShellProvider.cs | 2 +- test/dotnetup.Tests/EnvShellProviderTests.cs | 2 +- test/dotnetup.Tests/ShellProfileManagerTests.cs | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) rename src/Installer/dotnetup/{Commands/PrintEnvScript => Shell}/BashEnvShellProvider.cs (97%) rename src/Installer/dotnetup/{Commands/PrintEnvScript => Shell}/IEnvShellProvider.cs (97%) rename src/Installer/dotnetup/{Commands/PrintEnvScript => Shell}/PowerShellEnvShellProvider.cs (97%) rename src/Installer/dotnetup/{ => Shell}/ShellDetection.cs (94%) rename src/Installer/dotnetup/{ => Shell}/ShellProfileManager.cs (98%) rename src/Installer/dotnetup/{Commands/PrintEnvScript => Shell}/ZshEnvShellProvider.cs (97%) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 6fe23f31d2ce..d141ab5d0824 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall; diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index 6f603cc49258..cacfd6b4e103 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index 7f97c5d72396..e3bdcf220484 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Completions; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index f7a1c3652364..c4a232d464a9 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -4,7 +4,7 @@ using System.Globalization; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index 55828b3c6519..b06795a00ffc 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index a4106f6c44ef..89223ad768a8 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -5,6 +5,7 @@ using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs similarity index 97% rename from src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs rename to src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index f45ed483909f..6a65b29e6348 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class BashEnvShellProvider : IEnvShellProvider { diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs similarity index 97% rename from src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs rename to src/Installer/dotnetup/Shell/IEnvShellProvider.cs index 1f9c82a0890b..f03fae93e77c 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// /// Provides shell-specific environment configuration scripts. diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs similarity index 97% rename from src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs rename to src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 82fd4131d29f..ab82d39b7e11 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class PowerShellEnvShellProvider : IEnvShellProvider { diff --git a/src/Installer/dotnetup/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs similarity index 94% rename from src/Installer/dotnetup/ShellDetection.cs rename to src/Installer/dotnetup/Shell/ShellDetection.cs index ad845f8a62d0..756057f77213 100644 --- a/src/Installer/dotnetup/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; - -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// /// Detects the user's current shell and resolves the matching . diff --git a/src/Installer/dotnetup/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs similarity index 98% rename from src/Installer/dotnetup/ShellProfileManager.cs rename to src/Installer/dotnetup/Shell/ShellProfileManager.cs index 887ac7071d83..fd35b665a411 100644 --- a/src/Installer/dotnetup/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -1,9 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; - -namespace Microsoft.DotNet.Tools.Bootstrapper; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// /// Manages shell profile file modifications to persist .NET environment configuration. diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs similarity index 97% rename from src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs rename to src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 47f8b6c3cb25..0d98e11a4f26 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class ZshEnvShellProvider : IEnvShellProvider { diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index b449f424ff5e..ba2f1ce1dfc4 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Tools.Bootstrapper; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index c9036ac88f04..b2d1f3dff05d 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.DotNet.Tools.Bootstrapper; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; From b3afd4e74ec16506be908f0e51693efb9c802ecc Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 16:52:20 -0400 Subject: [PATCH 17/20] Fix E2E test PATH assertion to handle dotnetup directory in PATH The test was checking that the first PATH entry containing 'dotnet' was the install path. Now that the dotnetup directory is also added to PATH, the dotnetup binary path (which contains 'dotnet' as a substring) can appear first. Changed to simply verify the install path is contained in PATH entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/dotnetup.Tests/DnupE2Etest.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index 6e3649cd384a..650e7ef10cdd 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -342,13 +342,11 @@ private static void VerifyEnvScriptWorks(string shell, string installPath, strin pathLine.Should().NotBeNull($"PATH should be printed for {shell}"); dotnetRootLine.Should().NotBeNull($"DOTNET_ROOT should be printed for {shell}"); - // Verify PATH contains the install path (find first entry with 'dotnet' to handle shell startup files that may prepend entries) + // Verify PATH contains the install path var pathValue = pathLine!.Substring("PATH=".Length); var pathSeparator = OperatingSystem.IsWindows() ? ';' : ':'; var pathEntries = pathValue.Split(pathSeparator); - var dotnetPathEntries = pathEntries.Where(p => p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); - var firstDotnetPathEntry = dotnetPathEntries.FirstOrDefault(); - firstDotnetPathEntry.Should().Be(installPath, $"First PATH entry containing 'dotnet' should be the dotnet install path for {shell}. Found dotnet entries: [{string.Join(", ", dotnetPathEntries)}]"); + pathEntries.Should().Contain(installPath, $"PATH should contain the dotnet install path for {shell}. PATH entries: [{string.Join(", ", pathEntries)}]"); // Verify DOTNET_ROOT matches install path var dotnetRootValue = dotnetRootLine!.Substring("DOTNET_ROOT=".Length); From 156706d20fd740c8d51c31cb1103417df178a28d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 14:07:49 -0400 Subject: [PATCH 18/20] Support multiple component-specs in runtime install Change the runtime install parser from a single optional argument to zero-or-more positional arguments, allowing commands like: dotnetup runtime install 8.0 9.0 10.0 dotnetup runtime install aspnetcore@10.0 windowsdesktop@10.0 All specs are parsed and validated up front before any installs begin. Installs then execute sequentially. When no specs are provided, the behavior is unchanged (defaults to latest core runtime). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Runtime/Install/RuntimeInstallCommand.cs | 72 ++++++++++--------- .../Install/RuntimeInstallCommandParser.cs | 4 +- src/Installer/dotnetup/CommonOptions.cs | 18 +++++ 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs index f46b09ddd40c..2e558c13564d 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install; internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result) { - private readonly string? _componentSpec = result.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument); + private readonly string[] _componentSpecs = result.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument) ?? []; private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption); private readonly bool? _setDefaultInstall = result.GetValue(CommonOptions.SetDefaultInstallOption); private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption); @@ -36,44 +36,52 @@ internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result) protected override int ExecuteCore() { - // Parse the component spec to determine runtime type and version - var (component, versionOrChannel) = ParseComponentSpec(_componentSpec); + // If no specs provided, default to installing latest core runtime + var specs = _componentSpecs.Length > 0 ? (string?[])_componentSpecs : [null]; - // Windows Desktop Runtime is only available on Windows - if (component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows()) + // Parse and validate all specs up front before installing any + var parsed = new List<(InstallComponent Component, string? VersionOrChannel, string Description)>(); + foreach (var spec in specs) { - throw new DotnetInstallException( - DotnetInstallErrorCode.PlatformNotSupported, - $"Windows Desktop Runtime is only available on Windows. Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}"); - } + var (component, versionOrChannel) = ParseComponentSpec(spec); - // SDK versions and feature bands (like 9.0.103, 9.0.1xx) are SDK-specific and not valid for runtimes - if (!string.IsNullOrEmpty(versionOrChannel) && new UpdateChannel(versionOrChannel).IsSdkVersionOrFeatureBand()) - { - throw new DotnetInstallException( - DotnetInstallErrorCode.InvalidChannel, - $"'{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations. " - + "Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'."); - } + if (component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows()) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Windows Desktop Runtime is only available on Windows. Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}"); + } - // Use GetDisplayName() from InstallComponentExtensions for consistent descriptions - string componentDescription = component.GetDisplayName(); + if (!string.IsNullOrEmpty(versionOrChannel) && new UpdateChannel(versionOrChannel).IsSdkVersionOrFeatureBand()) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InvalidChannel, + $"'{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations. " + + "Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'."); + } + + parsed.Add((component, versionOrChannel, component.GetDisplayName())); + } InstallWorkflow workflow = new(_dotnetInstaller, _channelVersionResolver); - InstallWorkflow.InstallWorkflowOptions options = new( - versionOrChannel, - _installPath, - _setDefaultInstall, - _manifestPath, - _interactive, - _noProgress, - component, - componentDescription, - RequireMuxerUpdate: _requireMuxerUpdate, - Untracked: _untracked); - - workflow.Execute(options); + foreach (var (component, versionOrChannel, componentDescription) in parsed) + { + InstallWorkflow.InstallWorkflowOptions options = new( + versionOrChannel, + _installPath, + _setDefaultInstall, + _manifestPath, + _interactive, + _noProgress, + component, + componentDescription, + RequireMuxerUpdate: _requireMuxerUpdate, + Untracked: _untracked); + + workflow.Execute(options); + } + return 0; } diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs index 10a708fdc296..92abf2b3634f 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs @@ -7,8 +7,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install; internal static class RuntimeInstallCommandParser { - public static readonly Argument ComponentSpecArgument = - CommonOptions.CreateRuntimeComponentSpecArgument(required: false, actionVerb: "install"); + public static readonly Argument ComponentSpecArgument = + CommonOptions.CreateMultipleRuntimeComponentSpecArgument(actionVerb: "install"); private static readonly Command s_command = ConstructCommand(); diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs index 23bb9116de37..1493ec56685e 100644 --- a/src/Installer/dotnetup/CommonOptions.cs +++ b/src/Installer/dotnetup/CommonOptions.cs @@ -122,6 +122,24 @@ internal class CommonOptions }; } + /// + /// Creates a positional argument that accepts one or more runtime component specifications. + /// Used by runtime install to support installing multiple runtimes in a single invocation. + /// + /// Verb for the description (e.g., "install"). + public static Argument CreateMultipleRuntimeComponentSpecArgument(string actionVerb) + { + return new Argument("component-spec") + { + HelpName = "COMPONENT_SPEC", + Description = $"One or more version/channel (e.g., 10.0) or component@version (e.g., aspnetcore@10.0) to {actionVerb}. " + + "When only a version is provided, the core .NET runtime is targeted. " + + "Multiple specs can be provided to {actionVerb} several runtimes at once. " + + "Valid component types: " + string.Join(", ", RuntimeInstallCommand.GetValidRuntimeTypes()), + Arity = ArgumentArity.ZeroOrMore, + }; + } + private static bool IsCIEnvironmentOrRedirected() => new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; } From e9be3b0e950554f8054524b2a56474731b3f7fe8 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 14:13:41 -0400 Subject: [PATCH 19/20] Add concurrent download support for multi-runtime install When multiple runtime specs are provided, downloads now run in parallel with separate progress bars in a shared Spectre.Console progress context. Extraction/installation remains serialized. Key changes: - DotnetArchiveExtractor accepts an optional shared IProgressReporter so multiple extractors can share one progress display session - InstallerOrchestratorSingleton.InstallMultiple validates all requests, downloads concurrently via Task.WhenAll, then commits sequentially - InstallExecutor.ExecuteInstallMultiple wraps the batch orchestration with status messages - InstallWorkflow.ExecuteMultiple resolves shared context (install path, default install) once, then delegates to the batch executor - RuntimeInstallCommand routes to ExecuteMultiple for 2+ specs Single-spec installs continue through the existing code path unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Internal/DotnetArchiveExtractor.cs | 13 +- .../Runtime/Install/RuntimeInstallCommand.cs | 19 +- .../Commands/Shared/InstallExecutor.cs | 41 ++++ .../Commands/Shared/InstallWorkflow.cs | 82 ++++++++ .../InstallerOrchestratorSingleton.cs | 177 +++++++++++++++++- 5 files changed, 327 insertions(+), 5 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 6d32fe803761..83b4e2cf024c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -14,6 +14,7 @@ internal class DotnetArchiveExtractor : IDisposable private readonly IProgressTarget _progressTarget; private readonly IArchiveDownloader _archiveDownloader; private readonly bool _shouldDisposeDownloader; + private readonly bool _ownsReporter; private MuxerHandler? MuxerHandler { get; set; } private string? _archivePath; private IProgressReporter? _progressReporter; @@ -29,11 +30,14 @@ public DotnetArchiveExtractor( ReleaseVersion resolvedVersion, ReleaseManifest releaseManifest, IProgressTarget progressTarget, - IArchiveDownloader? archiveDownloader = null) + IArchiveDownloader? archiveDownloader = null, + IProgressReporter? sharedReporter = null) { _request = request; _resolvedVersion = resolvedVersion; _progressTarget = progressTarget; + _progressReporter = sharedReporter; + _ownsReporter = sharedReporter is null; ScratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; if (archiveDownloader != null) @@ -468,8 +472,11 @@ public void Dispose() { try { - // Dispose the progress reporter to finalize progress display - _progressReporter?.Dispose(); + // Dispose the progress reporter to finalize progress display (only if we own it) + if (_ownsReporter) + { + _progressReporter?.Dispose(); + } } catch { diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs index 2e558c13564d..34ffba1e7a6d 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -65,8 +65,9 @@ protected override int ExecuteCore() InstallWorkflow workflow = new(_dotnetInstaller, _channelVersionResolver); - foreach (var (component, versionOrChannel, componentDescription) in parsed) + if (parsed.Count == 1) { + var (component, versionOrChannel, componentDescription) = parsed[0]; InstallWorkflow.InstallWorkflowOptions options = new( versionOrChannel, _installPath, @@ -81,6 +82,22 @@ protected override int ExecuteCore() workflow.Execute(options); } + else + { + var optionsList = parsed.Select(p => new InstallWorkflow.InstallWorkflowOptions( + p.VersionOrChannel, + _installPath, + _setDefaultInstall, + _manifestPath, + _interactive, + _noProgress, + p.Component, + p.Description, + RequireMuxerUpdate: _requireMuxerUpdate, + Untracked: _untracked)).ToList(); + + workflow.ExecuteMultiple(optionsList); + } return 0; } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index c4a232d464a9..13f47d74b091 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -100,6 +100,47 @@ public static InstallResult ExecuteInstall( return new InstallResult(orchestratorResult.Install, orchestratorResult.WasAlreadyInstalled); } + /// + /// Executes multiple installations with concurrent downloads and serialized extraction. + /// Each component gets its own progress bar during download. + /// + /// The list of resolved install requests to execute. + /// Whether to suppress progress display. + /// The installation results in the same order as the requests. + public static IReadOnlyList ExecuteInstallMultiple( + IReadOnlyList requests, + bool noProgress) + { + var installRequests = requests.Select(r => r.Request).ToList(); + var descriptions = requests.Select(r => r.Request.Component.GetDisplayName()).ToList(); + +#pragma warning disable CA1305 // Spectre.Console API does not accept IFormatProvider + for (int i = 0; i < requests.Count; i++) + { + SpectreAnsiConsole.MarkupLineInterpolated($"Installing {descriptions[i]} [blue]{requests[i].ResolvedVersion}[/] to [blue]{requests[i].Request.InstallRoot.Path}[/]..."); + } +#pragma warning restore CA1305 + + var orchestratorResults = InstallerOrchestratorSingleton.Instance.InstallMultiple(installRequests, noProgress); + + var results = new List(); + for (int i = 0; i < orchestratorResults.Count; i++) + { + var orchResult = orchestratorResults[i]; + if (orchResult.WasAlreadyInstalled) + { + SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]{descriptions[i]} {orchResult.Install.Version} is already installed at {orchResult.Install.InstallRoot}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Installed {descriptions[i]} {orchResult.Install.Version}, available via {orchResult.Install.InstallRoot}[/]"); + } + results.Add(new InstallResult(orchResult.Install, orchResult.WasAlreadyInstalled)); + } + + return results; + } + /// /// Executes the installation of additional versions of a .NET component. /// diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index 25ecb7b99a96..8b8e22e3e76f 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -226,4 +226,86 @@ private void ApplyPostInstallConfiguration(WorkflowContext context, InstallExecu } } + /// + /// Executes multiple installations with concurrent downloads. Context resolution (install path, + /// default install prompting) happens once for the first spec's options. Each spec's component + /// and version are resolved independently, then downloads run in parallel and installs are + /// serialized. + /// + public void ExecuteMultiple(IReadOnlyList optionsList) + { + if (optionsList.Count == 0) + { + return; + } + + // For a single spec, use the standard path + if (optionsList.Count == 1) + { + Execute(optionsList[0]); + return; + } + + // Use the first spec's options for shared context (install path, interactive, etc.) + var baseOptions = optionsList[0]; + + // Record telemetry for the batch + Activity.Current?.SetTag(TelemetryTagNames.InstallComponent, "multi"); + Activity.Current?.SetTag("install.count", optionsList.Count); + + // Resolve shared context once (install path, default install decision) + var context = ResolveWorkflowContext(baseOptions, out string? error); + if (context is null) + { + throw new DotnetInstallException(DotnetInstallErrorCode.ContextResolutionFailed, error ?? "Failed to resolve workflow context."); + } + + if (File.Exists(context.InstallPath)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallPathIsFile, + $"The install path '{context.InstallPath}' is an existing file, not a directory. " + + "Please specify a directory path for the installation."); + } + + if (InstallExecutor.IsAdminInstallPath(context.InstallPath)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.AdminPathBlocked, + $"The install path '{context.InstallPath}' is a system-managed .NET location. " + + "dotnetup cannot install to the default system .NET directory. " + + "Use your system package manager or choose a different path."); + } + + // Create install requests for all specs + var resolvedRequests = new List(); + foreach (var options in optionsList) + { + string channel = options.VersionOrChannel ?? "latest"; + + var resolved = InstallExecutor.CreateAndResolveRequest( + context.InstallPath, + channel, + options.Component, + options.ManifestPath, + _channelVersionResolver, + options.RequireMuxerUpdate, + untracked: options.Untracked); + + resolvedRequests.Add(resolved); + } + + // Execute all installations with concurrent downloads + InstallExecutor.ExecuteInstallMultiple(resolvedRequests, baseOptions.NoProgress); + + // Apply post-install configuration once + if (context.SetDefaultInstall) + { + InstallExecutor.ConfigureDefaultInstallIfRequested(_dotnetInstaller, context.SetDefaultInstall, context.InstallPath); + } + + Activity.Current?.SetTag(TelemetryTagNames.InstallResult, "installed"); + InstallExecutor.DisplayComplete(); + } + } diff --git a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index bc3986924e74..c9f70229f163 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -153,7 +153,182 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres #pragma warning restore CA1822 /// - /// Records the install spec in the manifest, respecting Untracked and SkipInstallSpecRecording flags. + /// Installs multiple components with concurrent downloads and serialized extraction. + /// All pre-checks run first, then archives download in parallel with separate progress bars, + /// and finally extraction/commit happens one at a time. + /// +#pragma warning disable CA1822 // Intentionally an instance method on a singleton + public IReadOnlyList InstallMultiple(IReadOnlyList installRequests, bool noProgress = false) + { + if (installRequests.Count == 0) + { + return []; + } + + // For a single request, delegate to the existing path + if (installRequests.Count == 1) + { + return [Install(installRequests[0], noProgress)]; + } + + ReleaseManifest releaseManifest = new(); + + // Phase 1: Validate and resolve all requests, check for already-installed + var prepared = new List<(int OriginalIndex, DotnetInstallRequest Request, ReleaseVersion Version, DotnetInstall Install, string? ManifestPath)>(); + var results = new InstallResult?[installRequests.Count]; + + for (int i = 0; i < installRequests.Count; i++) + { + var request = installRequests[i]; + + if (!ChannelVersionResolver.IsValidChannelFormat(request.Channel.Name)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InvalidChannel, + $"'{request.Channel.Name}' is not a valid .NET version or channel. " + + $"Use a version like '9.0', '9.0.100', or a channel keyword: {string.Join(", ", ChannelVersionResolver.KnownChannelKeywords)}.", + version: null, + component: request.Component.ToString()); + } + + ReleaseVersion? version = request.ResolvedVersion + ?? new ChannelVersionResolver(releaseManifest).Resolve(request); + + if (version == null) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.VersionNotFound, + $"Could not find .NET version '{request.Channel.Name}'. The version may not exist or may not be supported.", + version: null, + component: request.Component.ToString()); + } + + DotnetInstall install = new(request.InstallRoot, version, request.Component); + string? customManifestPath = request.Options.ManifestPath; + + using (var finalizeLock = ModifyInstallStateMutex()) + { + var manifestData = request.Options.Untracked + ? new DotnetupManifestData() + : new DotnetupSharedManifest(customManifestPath).ReadManifest(); + + if (InstallAlreadyExists(manifestData, install)) + { + RecordInstallSpec(request, customManifestPath); + results[i] = new InstallResult(install, WasAlreadyInstalled: true); + continue; + } + + if (!request.Options.Untracked + && !IsRootInManifest(manifestData, request.InstallRoot) + && HasDotnetArtifacts(request.InstallRoot.Path)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.Unknown, + $"The install path '{request.InstallRoot.Path}' already contains a .NET installation that is not tracked by dotnetup. " + + "To avoid conflicts, use a different install path or remove the existing installation first.", + version: version.ToString(), + component: request.Component.ToString()); + } + + if (request.Options.RequireMuxerUpdate && request.InstallRoot.Path is not null) + { + MuxerHandler.EnsureMuxerIsWritable(request.InstallRoot.Path); + } + } + + prepared.Add((i, request, version, install, customManifestPath)); + } + + // If everything was already installed, return early + if (prepared.Count == 0) + { + return [.. results.Select(r => r!)]; + } + + // Phase 2: Download all archives concurrently with shared progress + IProgressTarget progressTarget = noProgress ? new NonUpdatingProgressTarget() : new SpectreProgressTarget(); + using var sharedReporter = progressTarget.CreateProgressReporter(); + + var extractors = new DotnetArchiveExtractor[prepared.Count]; + try + { + for (int i = 0; i < prepared.Count; i++) + { + extractors[i] = new DotnetArchiveExtractor( + prepared[i].Request, + prepared[i].Version, + releaseManifest, + progressTarget, + sharedReporter: sharedReporter); + } + + // Download concurrently + var downloadTasks = extractors.Select(e => Task.Run(() => e.Prepare())).ToArray(); + Task.WaitAll(downloadTasks); + + // Phase 3: Commit sequentially + for (int i = 0; i < prepared.Count; i++) + { + var (originalIndex, request, version, install, customManifestPath) = prepared[i]; + + using (var finalizeLock = ModifyInstallStateMutex()) + { + var manifestData = request.Options.Untracked + ? new DotnetupManifestData() + : new DotnetupSharedManifest(customManifestPath).ReadManifest(); + + if (InstallAlreadyExists(manifestData, install)) + { + results[originalIndex] = new InstallResult(install, WasAlreadyInstalled: true); + continue; + } + + extractors[i].Commit(); + + ArchiveInstallationValidator validator = new(); + if (validator.Validate(install, out string? validationFailure)) + { + RecordInstallSpec(request, customManifestPath); + + if (!request.Options.Untracked) + { + var manifestManager = new DotnetupSharedManifest(customManifestPath); + manifestManager.AddInstallation(request.InstallRoot, new Installation + { + Component = request.Component, + Version = version.ToString(), + Subcomponents = [.. extractors[i].ExtractedSubcomponents] + }); + } + } + else + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallFailed, + $"Installation validation failed: {validationFailure}", + version: version.ToString(), + component: request.Component.ToString()); + } + } + + results[originalIndex] = new InstallResult(install, WasAlreadyInstalled: false); + } + } + finally + { + foreach (var extractor in extractors) + { + extractor?.Dispose(); + } + } + + return [.. results.Select(r => r!)]; + } +#pragma warning restore CA1822 + + /// + /// Records the install specin the manifest, respecting Untracked and SkipInstallSpecRecording flags. /// private static void RecordInstallSpec(DotnetInstallRequest installRequest, string? customManifestPath) { From 81006c590f6f4f83964cd8de206e62d66afc479c Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 14:16:14 -0400 Subject: [PATCH 20/20] Add tests for multi-spec parsing and InstallMultiple Test multi-spec parser behavior: multiple positional args, mixed component types, and that parsed values are correctly extracted. Test InstallMultiple empty-list early return. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/DotnetInstallManager.cs | 2 +- .../InstallerOrchestratorTests.cs | 11 +++++++++ test/dotnetup.Tests/RuntimeInstallTests.cs | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 89223ad768a8..f3286dc621c5 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; diff --git a/test/dotnetup.Tests/InstallerOrchestratorTests.cs b/test/dotnetup.Tests/InstallerOrchestratorTests.cs index 2e47d36909c4..ca956a85aa8c 100644 --- a/test/dotnetup.Tests/InstallerOrchestratorTests.cs +++ b/test/dotnetup.Tests/InstallerOrchestratorTests.cs @@ -176,6 +176,17 @@ public void HasDotnetArtifacts_ReturnsFalseForEmptyDirectory() #endregion + #region InstallMultiple + + [Fact] + public void InstallMultiple_EmptyList_ReturnsEmpty() + { + var results = InstallerOrchestratorSingleton.Instance.InstallMultiple([], noProgress: true); + results.Should().BeEmpty(); + } + + #endregion + #region Helpers private static DotnetInstall MakeInstall(string path, string version, InstallComponent component) diff --git a/test/dotnetup.Tests/RuntimeInstallTests.cs b/test/dotnetup.Tests/RuntimeInstallTests.cs index c49f5dbee14d..601fd638b850 100644 --- a/test/dotnetup.Tests/RuntimeInstallTests.cs +++ b/test/dotnetup.Tests/RuntimeInstallTests.cs @@ -138,5 +138,29 @@ public void Parser_RuntimeInstallWithValidComponentSpec_NoErrors(string componen parseResult.Errors.Should().BeEmpty(); } + [Fact] + public void Parser_RuntimeInstallWithMultipleSpecs_NoErrors() + { + var parseResult = Parser.Parse(["runtime", "install", "9.0", "10.0"]); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_RuntimeInstallWithMixedComponentSpecs_NoErrors() + { + var parseResult = Parser.Parse(["runtime", "install", "aspnetcore@9.0", "windowsdesktop@10.0", "8.0"]); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_RuntimeInstallMultipleSpecs_ParsesAllValues() + { + var parseResult = Parser.Parse(["runtime", "install", "9.0", "10.0", "aspnetcore@8.0"]); + var specs = parseResult.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument); + specs.Should().NotBeNull(); + specs.Should().HaveCount(3); + specs.Should().ContainInOrder("9.0", "10.0", "aspnetcore@8.0"); + } + #endregion }