From d98fd2fcde347cb0c0f7dfb1d490e9831e1bc5de Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:10:49 -0400 Subject: [PATCH 01/51] 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> # Conflicts: # src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs # src/Installer/dotnetup/DotnetInstallManager.cs --- .../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 ++ src/Installer/dotnetup/ShellDetection.cs | 36 +++ src/Installer/dotnetup/ShellProfileManager.cs | 130 ++++++++ .../ShellProfileManagerTests.cs | 288 ++++++++++++++++++ 9 files changed, 637 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 c12364ecd63b..be4a19a443e3 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/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 f600b95df72b655fff54049f59fb5cec6392933f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 13 Mar 2026 15:18:02 -0400 Subject: [PATCH 02/51] 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 81bbfea6aa33..ceacfe877cdc 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 ?? _dotnetEnvironment.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 b22597c96fa8d3804a1a94cc086a48ed0dee54e3 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:11:35 -0400 Subject: [PATCH 03/51] 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> # Conflicts: # src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs # src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs # src/Installer/dotnetup/DotnetInstallManager.cs --- .../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 +++++++--- src/Installer/dotnetup/ShellProfileManager.cs | 18 ++++- test/dotnetup.Tests/EnvShellProviderTests.cs | 31 ++++++++ .../ShellProfileManagerTests.cs | 74 +++++++++++++++++-- 10 files changed, 258 insertions(+), 41 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index be4a19a443e3..0ba1968cc872 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -96,7 +96,36 @@ private int SetSystemInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the system 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 ceacfe877cdc..ad79bc320253 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -10,12 +10,14 @@ internal class PrintEnvScriptCommand : CommandBase private readonly IEnvShellProvider? _shellProvider; private readonly string? _dotnetInstallPath; private readonly IDotnetEnvironmentManager _dotnetEnvironment; + private readonly bool _dotnetupOnly; public PrintEnvScriptCommand(ParseResult result, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(result) { _dotnetEnvironment = dotnetEnvironment ?? new DotnetEnvironmentManager(); _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/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 99e2359c0f02ea96e958d6a591569743a2e6ccd9 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 11:56:15 -0400 Subject: [PATCH 04/51] 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 d76fcd0baa37182852e95638235e9fbffba40c24 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:12:17 -0400 Subject: [PATCH 05/51] 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> # Conflicts: # src/Installer/dotnetup/DotnetInstallManager.cs --- .../dotnetup/DotnetEnvironmentManager.cs | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 578773fe4ab9..41b3b0b1e4be 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; @@ -303,13 +304,17 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot) { - // 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)))]; + 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))}"); + } switch (installType) { @@ -318,27 +323,14 @@ private static void ConfigureInstallTypeUnix(InstallType installType, string? do { throw new ArgumentNullException(nameof(dotnetRoot)); } - // Add dotnetRoot to PATH - pathEntries.Insert(0, dotnetRoot); - // Set DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); break; case InstallType.System: - 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); + ShellProfileManager.ReplaceProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); break; 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 f9f50bac50de0e4d4177a7596f3eb61d9116bb27 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:31:19 -0400 Subject: [PATCH 06/51] 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 a2dc1170d9c4bc075f7f5c46f677edbff151075e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:34:46 -0400 Subject: [PATCH 07/51] 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 30e041cc1fd9dfac9d2e357d80f1d30412fb5039 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:35:33 -0400 Subject: [PATCH 08/51] 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 c0369df86f9ef1b0470531513cb29c831f5cffbc Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 12:38:10 -0400 Subject: [PATCH 09/51] 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 f9228c6f15a294e849213a43b66fd2390891d7d9 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:13:22 -0400 Subject: [PATCH 10/51] 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> # Conflicts: # src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs --- .../Commands/Walkthrough/WalkthroughWorkflows.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs index c3f455147579..9986e3a31cc8 100644 --- a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs @@ -203,9 +203,22 @@ private static PathPreference GetPathPreference(bool interactive, bool askEvenIf } else if (!interactive) { + if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + { + return PathPreference.DotnetupDotnet; + } + return PathPreference.ShellProfile; } + if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + SpectreAnsiConsole.MarkupLine(DotnetupTheme.Dim( + $"[{DotnetupTheme.Current.Warning}]Warning:[/] Shell '{shellEnv.EscapeMarkup()}' is not supported for automatic environment configuration. dotnetup will continue without changing your shell profile.")); + return PathPreference.DotnetupDotnet; + } + var preference = PromptPathPreference(); if (preference == PathPreference.FullPathReplacement && !OperatingSystem.IsWindows()) { From 9115b460d9151dd7037c0d8257f175f82dea7468 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:13:45 -0400 Subject: [PATCH 11/51] 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> # Conflicts: # src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs --- .../DefaultInstall/DefaultInstallCommand.cs | 4 +- .../PrintEnvScript/PrintEnvScriptCommand.cs | 4 +- .../PrintEnvScriptCommandParser.cs | 52 +++---------------- src/Installer/dotnetup/ShellDetection.cs | 24 ++++++++- test/dotnetup.Tests/EnvShellProviderTests.cs | 3 +- 5 files changed, 36 insertions(+), 51 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 0ba1968cc872..33dbbff13a0c 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 SetSystemInstallRoot() 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 ad79bc320253..761cbca19f07 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/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 f4982628d08e25bb367ac6af0b779e98a0d2fd97 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:14:11 -0400 Subject: [PATCH 12/51] 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> # Conflicts: # src/Installer/dotnetup/DotnetInstallManager.cs --- .../DefaultInstall/DefaultInstallCommand.cs | 2 +- src/Installer/dotnetup/ShellProfileManager.cs | 73 +++++++++++++------ .../ShellProfileManagerTests.cs | 10 +-- 3 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 33dbbff13a0c..9a6969f13616 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -110,7 +110,7 @@ private int SetSystemInstallRoot() $"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/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 406b1bf982d2d67b1b30a2d58e47da4028dff425 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:08:05 -0400 Subject: [PATCH 13/51] 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 418ca648d3ec4d6c1499076eb7069e0fdd073c1e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:12:54 -0400 Subject: [PATCH 14/51] 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 2db5bcc916ff5f333cc8cf44d6052e2b79c62f3f Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 13:35:44 -0400 Subject: [PATCH 15/51] 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 e5e071f9e8380b85feb4f326ade2409303b44680 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:14:49 -0400 Subject: [PATCH 16/51] 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> # Conflicts: # src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs # src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs # src/Installer/dotnetup/DotnetInstallManager.cs --- .../dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs | 2 +- .../dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs | 1 + .../Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs | 1 + src/Installer/dotnetup/Commands/Shared/InstallExecutor.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 +- 12 files changed, 11 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 9a6969f13616..e1f6bed3ac4f 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 761cbca19f07..feb29e9ea407 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 5d4148031a8d..4c6ba984149c 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -4,7 +4,6 @@ using System.Globalization; using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; -using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; 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 30353d57ab3a2b13c023adac340c45565584ca33 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 15 Mar 2026 16:52:20 -0400 Subject: [PATCH 17/51] 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 2e1f1183a5e6..ef1b65ded34e 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -341,13 +341,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 cbf43ba778732c403aa33d9503b3db4e7022f47a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:16:35 -0400 Subject: [PATCH 18/51] Fix post-rebase namespace fallout --- src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs | 1 + .../dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs | 1 + src/Installer/dotnetup/DotnetEnvironmentManager.cs | 1 + 3 files changed, 3 insertions(+) diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 4c6ba984149c..5d4148031a8d 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.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; +using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs index 9986e3a31cc8..78d134ffc55d 100644 --- a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs @@ -4,6 +4,7 @@ using System.Globalization; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 41b3b0b1e4be..fa25ad506eec 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; From 1b15b8af42c33f5ac0eda257d74a257ecfef935c Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 13 Apr 2026 19:17:03 -0400 Subject: [PATCH 19/51] Align Unix shell setup APIs after rebase --- src/Installer/dotnetup/DotnetEnvironmentManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index fa25ad506eec..ac58eb920eb3 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -314,7 +314,7 @@ private static void ConfigureInstallTypeUnix(InstallType installType, string? do 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))}"); } switch (installType) @@ -327,7 +327,7 @@ private static void ConfigureInstallTypeUnix(InstallType installType, string? do ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); break; case InstallType.System: - ShellProfileManager.ReplaceProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); break; default: throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); From 82c8340e3adf16f8ea4474369bc69ec4c5a72413 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 14 Apr 2026 09:20:39 -0400 Subject: [PATCH 20/51] Fix code style issues --- src/Installer/.editorconfig | 2 +- .../Internal/ChannelVersionResolver.cs | 8 ++++---- .../Internal/DotnetArchiveDownloader.cs | 2 +- .../Internal/DotnetArchiveExtractor.cs | 2 +- .../Internal/SubcomponentResolver.cs | 2 +- .../Internal/VersionSanitizer.cs | 2 +- .../Commands/Runtime/Install/RuntimeInstallCommand.cs | 2 +- .../dotnetup/Commands/Walkthrough/DotnetBotBanner.cs | 2 +- src/Installer/dotnetup/DotnetEnvironmentManager.cs | 1 - src/Installer/dotnetup/Shell/BashEnvShellProvider.cs | 8 ++++---- .../dotnetup/Shell/PowerShellEnvShellProvider.cs | 8 ++++---- src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs | 8 ++++---- src/Installer/dotnetup/SpectreProgressTarget.cs | 2 +- src/Installer/dotnetup/Telemetry/BuildInfo.cs | 2 +- 14 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/Installer/.editorconfig b/src/Installer/.editorconfig index 4a1eb8377bf0..5c146a796b95 100644 --- a/src/Installer/.editorconfig +++ b/src/Installer/.editorconfig @@ -425,7 +425,7 @@ dotnet_diagnostic.IDE0019.severity = warning dotnet_diagnostic.IDE0028.severity = warning # IDE0305: Collection expression can be simplified -dotnet_diagnostic.IDE0305.severity = warning +dotnet_diagnostic.IDE0305.severity = suggestion # IDE0032: Use auto-property dotnet_diagnostic.IDE0032.severity = warning diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs index 1f23a873f31f..d1d737feb9db 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/ChannelVersionResolver.cs @@ -101,7 +101,7 @@ public static bool IsValidChannelFormat(string channel) } // Check for prerelease suffix (e.g., "10.0.100-preview.1.32640") - var dashIndex = channel.IndexOf('-'); + var dashIndex = channel.IndexOf('-', StringComparison.Ordinal); var hasPrerelease = dashIndex >= 0; var versionPart = hasPrerelease ? channel.Substring(0, dashIndex) : channel; @@ -248,7 +248,7 @@ private static (int Major, int Minor, string? FeatureBand, bool IsFullySpecified private static IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable index, int major, int? minor = null) { - var validProducts = index.Where(p => minor is not null ? p.ProductVersion.Equals($"{major}.{minor}") : p.ProductVersion.StartsWith($"{major}.", StringComparison.Ordinal)); + var validProducts = index.Where(p => minor is not null ? p.ProductVersion.Equals($"{major}.{minor}", StringComparison.Ordinal) : p.ProductVersion.StartsWith($"{major}.", StringComparison.Ordinal)); return validProducts; } @@ -336,8 +336,8 @@ private static IEnumerable GetProductsInMajorOrMajorMinor(IEnumerable

"0.1.1-preview") string version = Parser.Version; - int plusIndex = version.IndexOf('+'); + int plusIndex = version.IndexOf('+', StringComparison.Ordinal); if (plusIndex >= 0) { version = version[..plusIndex]; diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index ac58eb920eb3..ba86a0292232 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -5,7 +5,6 @@ using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Tools.Bootstrapper.Shell; -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 6a65b29e6348..033b3d9cf973 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -15,8 +15,8 @@ public class BashEnvShellProvider : IEnvShellProvider public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); + var escapedPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''", StringComparison.Ordinal); string pathExport; if (includeDotnet && escapedDotnetupDir is not null) @@ -85,14 +85,14 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"eval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index ab82d39b7e11..c98334b5c19d 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -15,8 +15,8 @@ public class PowerShellEnvShellProvider : IEnvShellProvider public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "''"); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "''"); + var escapedPath = dotnetInstallPath.Replace("'", "''", StringComparison.Ordinal); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "''", StringComparison.Ordinal); string pathExport; if (includeDotnet && escapedDotnetupDir is not null) @@ -62,14 +62,14 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "''"); + var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"{ShellProfileManager.MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "''"); + var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 0d98e11a4f26..6a30ac32834e 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -15,8 +15,8 @@ public class ZshEnvShellProvider : IEnvShellProvider public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); + var escapedPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''", StringComparison.Ordinal); string pathExport; if (includeDotnet && escapedDotnetupDir is not null) @@ -66,14 +66,14 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) { - var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); var flags = dotnetupOnly ? " --dotnetup-only" : ""; return $"eval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } diff --git a/src/Installer/dotnetup/SpectreProgressTarget.cs b/src/Installer/dotnetup/SpectreProgressTarget.cs index 1884eb8b58af..4cbb41bca482 100644 --- a/src/Installer/dotnetup/SpectreProgressTarget.cs +++ b/src/Installer/dotnetup/SpectreProgressTarget.cs @@ -76,7 +76,7 @@ public ProgressTaskImpl(Spectre.Console.ProgressTask task) _task = task; _baseDescription = task.Description; - int spaceIndex = _baseDescription.IndexOf(' '); + int spaceIndex = _baseDescription.IndexOf(' ', StringComparison.Ordinal); if (spaceIndex > 0 && _baseDescription.StartsWith("Installing", StringComparison.Ordinal)) { _shimmerWord = _baseDescription[..spaceIndex]; diff --git a/src/Installer/dotnetup/Telemetry/BuildInfo.cs b/src/Installer/dotnetup/Telemetry/BuildInfo.cs index 10b4efa2fb80..d638c4a4d90e 100644 --- a/src/Installer/dotnetup/Telemetry/BuildInfo.cs +++ b/src/Installer/dotnetup/Telemetry/BuildInfo.cs @@ -36,7 +36,7 @@ public static class BuildInfo internal static (string Version, string CommitSha) ParseInformationalVersion(string informationalVersion) { // Format: "1.0.0+abc123d" or just "1.0.0" - var plusIndex = informationalVersion.IndexOf('+'); + var plusIndex = informationalVersion.IndexOf('+', StringComparison.Ordinal); if (plusIndex > 0) { var version = informationalVersion.Substring(0, plusIndex); From 2143ad32d8e38175faf0ec9929b2099aeb524aa3 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 14 Apr 2026 14:56:37 -0400 Subject: [PATCH 21/51] Preserve custom dotnetup install paths Thread the selected install root through generated shell profile entries and activation commands, and align the Unix environment docs with the current API behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 7 ++-- .../DefaultInstall/DefaultInstallCommand.cs | 39 +++++++++---------- .../dotnetup/DotnetEnvironmentManager.cs | 2 +- .../dotnetup/Shell/BashEnvShellProvider.cs | 20 ++++++++-- .../dotnetup/Shell/IEnvShellProvider.cs | 6 ++- .../Shell/PowerShellEnvShellProvider.cs | 20 ++++++++-- .../dotnetup/Shell/ShellProfileManager.cs | 9 ++++- .../dotnetup/Shell/ZshEnvShellProvider.cs | 20 ++++++++-- .../ShellProfileManagerTests.cs | 36 ++++++++++++++++- 9 files changed, 117 insertions(+), 42 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 44a86b707bb6..897fc4fd44d9 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -26,7 +26,7 @@ If the user confirms (or passes `--set-default-install` explicitly): ``` To start using .NET in this terminal, run: - eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash)" + eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash --dotnet-install-path '/home/user/.local/share/dotnet')" ``` If the default install is already fully configured and matches the install path, the prompt is skipped entirely. @@ -205,9 +205,10 @@ public interface IEnvShellProvider ### ShellProfileManager `ShellProfileManager` coordinates profile file modifications: -- `AddProfileEntries(provider, dotnetupPath)` — appends entries, creates backups, skips if already present +- `AddProfileEntries(provider, dotnetupPath, dotnetupOnly, dotnetInstallPath)` — creates or updates the managed entry in place, creates backups, and can thread through a custom install path - `RemoveProfileEntries(provider)` — finds and removes marker + eval lines -- `ReplaceProfileEntries(provider, dotnetupPath, dotnetupOnly)` — removes then adds (used by `defaultinstall admin`) + +`defaultinstall admin` uses `AddProfileEntries(..., dotnetupOnly: true)` to switch the managed entry into dotnetup-only mode. ## Future Work diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index e1f6bed3ac4f..c8e840e8191c 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -35,17 +35,10 @@ private int SetUserInstallRoot() { var dotnetupPath = Environment.ProcessPath ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + var userDotnetPath = _installRootManager.GetUserInstallRootChanges().UserDotnetPath; + var shellProvider = GetCurrentShellProviderOrThrow(); - 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(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); - } - - var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: userDotnetPath); if (modifiedFiles.Count == 0) { @@ -62,7 +55,7 @@ private int SetUserInstallRoot() Console.WriteLine(); Console.WriteLine("To start using .NET in this terminal, run:"); - Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath)}"); + Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath, dotnetInstallPath: userDotnetPath)}"); return 0; } @@ -100,15 +93,7 @@ private int SetSystemInstallRoot() // 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(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); - } + var shellProvider = GetCurrentShellProviderOrThrow(); var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); @@ -150,4 +135,18 @@ private int SetSystemInstallRoot() Console.WriteLine("Succeeded. NOTE: You may need to restart your terminal or application for the changes to take effect."); return 0; } + + private static IEnvShellProvider GetCurrentShellProviderOrThrow() + { + var 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(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); + } + + return shellProvider; + } } diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index ba86a0292232..01a24ee4f8a9 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -323,7 +323,7 @@ private static void ConfigureInstallTypeUnix(InstallType installType, string? do { throw new ArgumentNullException(nameof(dotnetRoot)); } - ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: dotnetRoot); break; case InstallType.System: ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 033b3d9cf973..6dfa20881fa7 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -83,17 +83,29 @@ public IReadOnlyList GetProfilePaths() return paths; } - public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } - public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"eval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; } + + private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + { + var escapedInstallPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + flags += $" --dotnet-install-path '{escapedInstallPath}'"; + } + + return flags; + } } diff --git a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs index f03fae93e77c..eeb759105c06 100644 --- a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -44,12 +44,14 @@ public interface IEnvShellProvider ///

/// The full path to the dotnetup binary /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). - string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false); + /// An optional .NET install path to pass through to print-env-script. + string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null); /// /// Generates a command that the user can paste into the current terminal to activate .NET. /// /// The full path to the dotnetup binary /// When true, the command only adds dotnetup to PATH. - string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false); + /// An optional .NET install path to pass through to print-env-script. + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null); } diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index c98334b5c19d..4cd62074d865 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -60,17 +60,29 @@ public IReadOnlyList GetProfilePaths() return [Path.Combine(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")]; } - public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"{ShellProfileManager.MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } - public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; } + + private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + { + var escapedInstallPath = dotnetInstallPath.Replace("'", "''", StringComparison.Ordinal); + flags += $" --dotnet-install-path '{escapedInstallPath}'"; + } + + return flags; + } } diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index fd35b665a411..d532780e2723 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -19,11 +19,16 @@ public class ShellProfileManager /// 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). + /// An optional .NET install path to pass through to print-env-script. /// The list of profile file paths that were modified. - public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider, string dotnetupPath, bool dotnetupOnly = false) + public static IReadOnlyList AddProfileEntries( + IEnvShellProvider provider, + string dotnetupPath, + bool dotnetupOnly = false, + string? dotnetInstallPath = null) { var profilePaths = provider.GetProfilePaths(); - var entry = provider.GenerateProfileEntry(dotnetupPath, dotnetupOnly); + var entry = provider.GenerateProfileEntry(dotnetupPath, dotnetupOnly, dotnetInstallPath); var modifiedFiles = new List(); foreach (var profilePath in profilePaths) diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 6a30ac32834e..ff4a4a06e03b 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -64,17 +64,29 @@ public IReadOnlyList GetProfilePaths() return [Path.Combine(home, ".zshrc")]; } - public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } - public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = dotnetupOnly ? " --dotnetup-only" : ""; + var flags = GetFlags(dotnetupOnly, dotnetInstallPath); return $"eval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; } + + private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + { + var escapedInstallPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + flags += $" --dotnet-install-path '{escapedInstallPath}'"; + } + + return flags; + } } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index b2d1f3dff05d..f8d069a77d0c 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -10,6 +10,7 @@ public class ShellProfileManagerTests : IDisposable { private readonly string _tempDir; private const string FakeDotnetupPath = "/usr/local/bin/dotnetup"; + private const string FakeDotnetInstallPath = "/custom/dotnet path"; public ShellProfileManagerTests() { @@ -175,6 +176,17 @@ public void AddProfileEntries_DotnetupOnly_IncludesFlag() content.Should().Contain("--dotnetup-only"); } + [Fact] + public void AddProfileEntries_CustomDotnetInstallPath_IncludesFlag() + { + var provider = new TestShellProvider(_tempDir, "custom.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath, dotnetInstallPath: FakeDotnetInstallPath); + + var content = File.ReadAllText(Path.Combine(_tempDir, "custom.sh")); + content.Should().Contain($"--dotnet-install-path '{FakeDotnetInstallPath}'"); + } + [Fact] public void AddProfileEntries_ReplacesExistingEntryInPlace() { @@ -227,6 +239,16 @@ public void BashProvider_GenerateProfileEntry_DotnetupOnly() entry.Should().Contain("--dotnetup-only"); } + [Fact] + public void BashProvider_GenerateActivationCommand_WithCustomInstallPath_IncludesFlag() + { + var provider = new BashEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath, dotnetInstallPath: FakeDotnetInstallPath); + + command.Should().Contain($"--dotnet-install-path '{FakeDotnetInstallPath}'"); + command.Should().NotContain("--dotnetup-only"); + } + [Fact] public void ZshProvider_GenerateProfileEntry_ContainsEval() { @@ -335,15 +357,25 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = public IReadOnlyList GetProfilePaths() => _profilePaths; - public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var flags = dotnetupOnly ? " --dotnetup-only" : ""; + if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + { + flags += $" --dotnet-install-path '{dotnetInstallPath}'"; + } + return $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; } - public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { var flags = dotnetupOnly ? " --dotnetup-only" : ""; + if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + { + flags += $" --dotnet-install-path '{dotnetInstallPath}'"; + } + return $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; } } From 2e20591688f7ce4694f5b37b8e89ae2214cc5526 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 14 Apr 2026 15:57:09 -0400 Subject: [PATCH 22/51] Don't pass --default-install-path to print-env-script unless necessary --- .../general/dotnetup/unix-environment-setup.md | 4 ++-- src/Installer/dotnetup/Shell/BashEnvShellProvider.cs | 8 ++++++-- .../dotnetup/Shell/PowerShellEnvShellProvider.cs | 8 ++++++-- src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs | 8 ++++++-- test/dotnetup.Tests/ShellProfileManagerTests.cs | 12 ++++++++++++ 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 897fc4fd44d9..e2b56191dfad 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -26,7 +26,7 @@ If the user confirms (or passes `--set-default-install` explicitly): ``` To start using .NET in this terminal, run: - eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash --dotnet-install-path '/home/user/.local/share/dotnet')" + 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. @@ -77,7 +77,7 @@ eval "$('/path/to/dotnetup' print-env-script --shell bash)" & '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression ``` -The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). +The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). The `--dotnet-install-path` argument is only included in generated profile entries when dotnetup is configured to use a non-default install root. ### Backups diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 6dfa20881fa7..9beed2e4ea98 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -1,6 +1,8 @@ // 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.Installation.Internal; + namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class BashEnvShellProvider : IEnvShellProvider @@ -100,9 +102,11 @@ public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) { var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + if (!dotnetupOnly && + dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) { - var escapedInstallPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + var escapedInstallPath = installPath.Replace("'", "'\\''", StringComparison.Ordinal); flags += $" --dotnet-install-path '{escapedInstallPath}'"; } diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 4cd62074d865..d8d1b1365398 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -1,6 +1,8 @@ // 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.Installation.Internal; + namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class PowerShellEnvShellProvider : IEnvShellProvider @@ -77,9 +79,11 @@ public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) { var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + if (!dotnetupOnly && + dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) { - var escapedInstallPath = dotnetInstallPath.Replace("'", "''", StringComparison.Ordinal); + var escapedInstallPath = installPath.Replace("'", "''", StringComparison.Ordinal); flags += $" --dotnet-install-path '{escapedInstallPath}'"; } diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index ff4a4a06e03b..3489752fb9f8 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -1,6 +1,8 @@ // 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.Installation.Internal; + namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class ZshEnvShellProvider : IEnvShellProvider @@ -81,9 +83,11 @@ public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) { var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) + if (!dotnetupOnly && + dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) { - var escapedInstallPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); + var escapedInstallPath = installPath.Replace("'", "'\\''", StringComparison.Ordinal); flags += $" --dotnet-install-path '{escapedInstallPath}'"; } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index f8d069a77d0c..28afe24e8af2 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -249,6 +249,18 @@ public void BashProvider_GenerateActivationCommand_WithCustomInstallPath_Include command.Should().NotContain("--dotnetup-only"); } + [Fact] + public void BashProvider_DefaultInstallPath_KeepsCommandSimple() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath, dotnetInstallPath: DotnetupPaths.DefaultDotnetInstallPath); + var command = provider.GenerateActivationCommand(FakeDotnetupPath, dotnetInstallPath: DotnetupPaths.DefaultDotnetInstallPath); + + entry.Should().NotContain("--dotnet-install-path"); + command.Should().NotContain("--dotnet-install-path"); + command.Should().NotContain("--dotnetup-only"); + } + [Fact] public void ZshProvider_GenerateProfileEntry_ContainsEval() { From 9eb7acfb9018159a59be9b6a62e722005bf6502d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 14 Apr 2026 23:41:15 -0400 Subject: [PATCH 23/51] Misc fixes --- .../general/dotnetup/unix-environment-setup.md | 11 +++-------- src/Installer/dotnetup/Parser.cs | 1 - .../dotnetup/Shell/ShellProfileManager.cs | 14 -------------- test/dotnetup.Tests/ShellProfileManagerTests.cs | 16 ---------------- 4 files changed, 3 insertions(+), 39 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index e2b56191dfad..767db3095b9a 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -12,14 +12,9 @@ There are two primary ways the environment is configured: ### 1. During `dotnetup sdk install` / `dotnetup runtime install` -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: +When running interactively (the default in a terminal) **and no explicit `--install-path` is provided**, the install commands flow through the walkthrough. The walkthrough asks how the user wants to use dotnetup (for example, keeping it isolated vs. configuring the shell profile so `dotnet` works directly in new terminals). -``` -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): +Choosing the shell-profile option in the walkthrough is what corresponds to making the dotnetup-managed user install the default: - **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: @@ -29,7 +24,7 @@ If the user confirms (or passes `--set-default-install` explicitly): 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. +If the user already has a saved path preference, or if the command is non-interactive / uses an explicit `--install-path`, the walkthrough prompt is skipped and dotnetup uses the existing configuration or the explicit path directly. ### 2. `dotnetup defaultinstall` diff --git a/src/Installer/dotnetup/Parser.cs b/src/Installer/dotnetup/Parser.cs index 5a824b5bddd0..edf2129bfee0 100644 --- a/src/Installer/dotnetup/Parser.cs +++ b/src/Installer/dotnetup/Parser.cs @@ -116,7 +116,6 @@ public override int Invoke(ParseResult parseResult) // For subcommands, delegate to System.CommandLine's built-in help // so users see only the relevant command's arguments, options, and subcommands. - if (command != rootCommand && defaultHelpAction is SynchronousCommandLineAction syncAction) { return syncAction.Invoke(parseResult); diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index d532780e2723..aa6b461ff17a 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -62,20 +62,6 @@ public static IReadOnlyList RemoveProfileEntries(IEnvShellProvider provi 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); - } - /// /// 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. diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 28afe24e8af2..bae2c4ad9d11 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -137,22 +137,6 @@ public void RemoveProfileEntries_ReturnsEmptyForMissingFile() 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() { From 3d921b2f8c47a5f11ee9561c6189042535a3199b Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 15 Apr 2026 09:53:24 -0400 Subject: [PATCH 24/51] Fix dotnetup Unix install root detection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/DotnetEnvironmentManager.cs | 20 ++++++-- src/Installer/dotnetup/dotnetup.csproj | 1 - .../FileInterop.cs | 5 ++ .../InstallPathResolverTests.cs | 49 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 01a24ee4f8a9..e1911e23801f 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -3,11 +3,11 @@ using System.Diagnostics; using Microsoft.Dotnet.Installation.Internal; -using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; +using CliEnvironmentProvider = Microsoft.DotNet.Cli.Utils.EnvironmentProvider; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -29,14 +29,20 @@ public DotnetEnvironmentManager() public DotnetInstallRootConfiguration? GetCurrentPathConfiguration() { - var environmentProvider = new EnvironmentProvider(); + var environmentProvider = new CliEnvironmentProvider(); string? foundDotnet = environmentProvider.GetCommandPath("dotnet"); if (string.IsNullOrEmpty(foundDotnet)) { return null; } - var currentInstallRoot = new DotnetInstallRoot(Path.GetDirectoryName(foundDotnet)!, InstallerUtilities.GetDefaultInstallArchitecture()); + // On Linux/WSL, `dotnet` on PATH is often exposed through a symlink such + // as /usr/bin/dotnet -> /usr/lib/dotnet/dotnet. We need to classify the + // real install root, not the shim location, or dotnetup can mistake + // /usr/bin for the install directory. + var currentInstallRoot = new DotnetInstallRoot( + ResolveCurrentInstallRootPath(foundDotnet), + InstallerUtilities.GetDefaultInstallArchitecture()); // Use InstallRootManager to determine if the install is fully configured if (OperatingSystem.IsWindows()) @@ -80,6 +86,14 @@ public string GetDefaultDotnetInstallPath() return DotnetupPaths.DefaultDotnetInstallPath; } + internal static string ResolveCurrentInstallRootPath(string dotnetExecutablePath) + { + string fullPath = Path.GetFullPath(dotnetExecutablePath); + string resolvedExecutablePath = Microsoft.DotNet.NativeWrapper.FileInterop.ResolveRealPath(fullPath) ?? fullPath; + return Path.GetDirectoryName(resolvedExecutablePath) + ?? throw new InvalidOperationException($"Unable to determine the install root for '{dotnetExecutablePath}'."); + } + public string? GetLatestInstalledSystemVersion() { var sdkInstalls = GetExistingSystemInstalls() diff --git a/src/Installer/dotnetup/dotnetup.csproj b/src/Installer/dotnetup/dotnetup.csproj index e1789ac060db..40704cbcccbb 100644 --- a/src/Installer/dotnetup/dotnetup.csproj +++ b/src/Installer/dotnetup/dotnetup.csproj @@ -70,7 +70,6 @@ - diff --git a/src/Resolvers/Microsoft.DotNet.NativeWrapper/FileInterop.cs b/src/Resolvers/Microsoft.DotNet.NativeWrapper/FileInterop.cs index 026f0c35a617..7a580c2c2c74 100644 --- a/src/Resolvers/Microsoft.DotNet.NativeWrapper/FileInterop.cs +++ b/src/Resolvers/Microsoft.DotNet.NativeWrapper/FileInterop.cs @@ -7,6 +7,11 @@ public static partial class FileInterop { public static readonly bool RunningOnWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public static string? ResolveRealPath(string path) + { + return RunningOnWindows ? null : Unix.realpath(path); + } + internal static class Unix { // Ansi marshaling on Unix is actually UTF8 diff --git a/test/dotnetup.Tests/InstallPathResolverTests.cs b/test/dotnetup.Tests/InstallPathResolverTests.cs index bfc74c825c10..382003a9371a 100644 --- a/test/dotnetup.Tests/InstallPathResolverTests.cs +++ b/test/dotnetup.Tests/InstallPathResolverTests.cs @@ -7,6 +7,7 @@ using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; +using Microsoft.DotNet.Tools.Dotnetup.Tests.Utilities; using Xunit; using Xunit.Abstractions; @@ -206,6 +207,54 @@ public void Resolve_AdminInstall_FallsToDefault_NotExistingInstall() result.PathSource.Should().Be(PathSource.Default); } + [Fact] + public void ResolveCurrentInstallRootPath_UsesSymlinkTargetDirectory() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var testEnvironment = new TestEnvironment(); + string actualRoot = Path.Combine(testEnvironment.TempRoot, "usr", "lib", "dotnet"); + string binDir = Path.Combine(testEnvironment.TempRoot, "usr", "bin"); + Directory.CreateDirectory(actualRoot); + Directory.CreateDirectory(binDir); + + string targetPath = Path.Combine(actualRoot, "dotnet"); + File.WriteAllText(targetPath, string.Empty); + + string linkPath = Path.Combine(binDir, "dotnet"); + File.CreateSymbolicLink(linkPath, targetPath); + + string resolvedRoot = DotnetEnvironmentManager.ResolveCurrentInstallRootPath(linkPath); + + resolvedRoot.Should().Be(actualRoot); + } + + [Fact] + public void ResolveCurrentInstallRootPath_UsesRealDirectoryWhenParentDirectoryIsSymlinked() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var testEnvironment = new TestEnvironment(); + string actualRoot = Path.Combine(testEnvironment.TempRoot, "usr", "lib", "dotnet"); + Directory.CreateDirectory(actualRoot); + + string targetPath = Path.Combine(actualRoot, "dotnet"); + File.WriteAllText(targetPath, string.Empty); + + string symlinkedRoot = Path.Combine(testEnvironment.TempRoot, "current-dotnet"); + Directory.CreateSymbolicLink(symlinkedRoot, actualRoot); + + string resolvedRoot = DotnetEnvironmentManager.ResolveCurrentInstallRootPath(Path.Combine(symlinkedRoot, "dotnet")); + + resolvedRoot.Should().Be(actualRoot); + } + private static GlobalJsonInfo CreateGlobalJsonInfo(string sdkPath) { // GlobalJsonInfo.SdkPath is computed from GlobalJsonContents.Sdk.Paths relative to GlobalJsonPath From 9c8a286e83f353996df449ebf32ce9b8cf39b11b Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 15 Apr 2026 10:06:04 -0400 Subject: [PATCH 25/51] Fix dotnetup Unix shell profile setup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/Walkthrough/WalkthroughWorkflows.cs | 2 +- test/dotnetup.Tests/WalkthroughWorkflowTests.cs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs index 78d134ffc55d..43be89011537 100644 --- a/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Walkthrough/WalkthroughWorkflows.cs @@ -41,7 +41,7 @@ public WalkthroughWorkflows(IDotnetEnvironmentManager dotnetEnvironment, Channel /// replace the default dotnet installation (i.e. update PATH / DOTNET_ROOT). /// public static bool ShouldReplaceSystemConfiguration(PathPreference preference) => - preference == PathPreference.FullPathReplacement; + preference is PathPreference.FullPathReplacement or PathPreference.ShellProfile; /// /// Returns true when the user chose to convert existing system-level .NET installs diff --git a/test/dotnetup.Tests/WalkthroughWorkflowTests.cs b/test/dotnetup.Tests/WalkthroughWorkflowTests.cs index adff9ad1d94f..e51c44bf9b7c 100644 --- a/test/dotnetup.Tests/WalkthroughWorkflowTests.cs +++ b/test/dotnetup.Tests/WalkthroughWorkflowTests.cs @@ -33,6 +33,22 @@ public void Dispose() // ── ShouldPromptToConvertSystemInstalls ── + [Fact] + public void ShouldReplaceSystemConfiguration_ReturnsFalse_ForDotnetupDotnet() + { + WalkthroughWorkflows.ShouldReplaceSystemConfiguration(PathPreference.DotnetupDotnet) + .Should().BeFalse(); + } + + [Theory] + [InlineData(PathPreference.ShellProfile)] + [InlineData(PathPreference.FullPathReplacement)] + internal void ShouldReplaceSystemConfiguration_ReturnsTrue_ForPathReplacingModes(PathPreference preference) + { + WalkthroughWorkflows.ShouldReplaceSystemConfiguration(preference) + .Should().BeTrue(); + } + [Fact] public void ShouldPromptToConvertSystemInstalls_ReturnsFalse_ForDotnetupDotnet() { From 469e1e1a8c8da716d00c1e973a9436fb20b38394 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 09:41:03 -0400 Subject: [PATCH 26/51] Address dotnetup shell feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 16 +- .../DefaultInstall/DefaultInstallCommand.cs | 95 ++++----- .../DefaultInstallCommandParser.cs | 1 + .../Commands/Init/InitCommandParser.cs | 1 + .../dotnetup/Commands/Init/InitWorkflows.cs | 22 +- .../PrintEnvScript/PrintEnvScriptCommand.cs | 2 +- .../PrintEnvScriptCommandParser.cs | 46 +--- .../Install/RuntimeInstallCommandParser.cs | 1 + .../Sdk/Install/SdkInstallCommandParser.cs | 1 + .../Commands/Shared/InstallCommand.cs | 3 + .../Commands/Shared/InstallWorkflow.cs | 3 +- src/Installer/dotnetup/CommonOptions.cs | 43 ++++ .../dotnetup/DotnetEnvironmentManager.cs | 13 +- .../dotnetup/IDotnetEnvironmentManager.cs | 3 +- .../dotnetup/Shell/BashEnvShellProvider.cs | 60 ++---- .../dotnetup/Shell/IEnvShellProvider.cs | 4 +- .../Shell/PowerShellEnvShellProvider.cs | 64 ++---- .../dotnetup/Shell/ShellDetection.cs | 21 +- .../dotnetup/Shell/ShellProviderHelpers.cs | 196 ++++++++++++++++++ .../dotnetup/Shell/ZshEnvShellProvider.cs | 62 ++---- test/dotnetup.Tests/EnvShellProviderTests.cs | 36 ++++ .../MockDotnetInstallManager.cs | 3 +- test/dotnetup.Tests/ParserTests.cs | 18 ++ 23 files changed, 445 insertions(+), 269 deletions(-) create mode 100644 src/Installer/dotnetup/Shell/ShellProviderHelpers.cs diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 767db3095b9a..aa3b83f092fc 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -24,7 +24,7 @@ Choosing the shell-profile option in the walkthrough is what corresponds to maki eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash)" ``` -If the user already has a saved path preference, or if the command is non-interactive / uses an explicit `--install-path`, the walkthrough prompt is skipped and dotnetup uses the existing configuration or the explicit path directly. +If the user already has a saved path preference, or if the command is non-interactive / uses an explicit `--install-path`, the walkthrough prompt is skipped and dotnetup uses the existing configuration or the explicit path directly. If shell auto-detection is wrong or unavailable, commands that modify the profile also accept `--shell bash|zsh|pwsh`. ### 2. `dotnetup defaultinstall` @@ -34,8 +34,8 @@ A standalone command that explicitly configures (or reconfigures) the default .N # 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 +# Switch to system-managed .NET (removes DOTNET_ROOT from profiles, keeps dotnetup on PATH) +dotnetup defaultinstall system ``` **`defaultinstall user`** on Unix: @@ -43,7 +43,7 @@ dotnetup defaultinstall admin 2. Modifies the appropriate shell profile files 3. Prints an activation command for the current terminal -**`defaultinstall admin`** on Unix: +**`defaultinstall system`** 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 @@ -53,7 +53,7 @@ dotnetup defaultinstall admin | 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. | +| **zsh** | `$ZDOTDIR/.zshrc` when `ZDOTDIR` is set; otherwise `~/.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 @@ -160,7 +160,7 @@ $env:PATH = '/home/user/.local/share/dotnetup' + [IO.Path]::PathSeparator + '/ho 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 (e.g., `/bin/bash` → `bash`) +1. **On Unix**: Reads the `$SHELL` environment variable, resolves symlinks when possible, and extracts the shell name from the resulting path (for example `/bin/bash` → `bash`) 2. **On Windows**: Defaults to PowerShell (`pwsh`) ### Security Considerations @@ -195,7 +195,7 @@ public interface IEnvShellProvider ### ShellDetection -`ShellDetection.GetCurrentShellProvider()` resolves the user's current shell to the matching `IEnvShellProvider`. On Windows it returns the PowerShell provider; on Unix it reads `$SHELL`. +`ShellDetection.GetCurrentShellProvider()` resolves the user's current shell to the matching `IEnvShellProvider`. On Windows it returns the PowerShell provider; on Unix it reads `$SHELL`, resolves symlinks when possible, and allows callers to override detection with `--shell`. ### ShellProfileManager @@ -203,7 +203,7 @@ public interface IEnvShellProvider - `AddProfileEntries(provider, dotnetupPath, dotnetupOnly, dotnetInstallPath)` — creates or updates the managed entry in place, creates backups, and can thread through a custom install path - `RemoveProfileEntries(provider)` — finds and removes marker + eval lines -`defaultinstall admin` uses `AddProfileEntries(..., dotnetupOnly: true)` to switch the managed entry into dotnetup-only mode. +`defaultinstall system` uses `AddProfileEntries(..., dotnetupOnly: true)` to switch the managed entry into dotnetup-only mode. ## Future Work diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index c8e840e8191c..5935b69f9c64 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -10,11 +10,13 @@ internal class DefaultInstallCommand : CommandBase { private readonly string _installType; private readonly InstallRootManager _installRootManager; + private readonly IEnvShellProvider? _shellProvider; public DefaultInstallCommand(ParseResult result, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(result) { _installType = result.GetValue(DefaultInstallCommandParser.InstallTypeArgument)!; _installRootManager = new InstallRootManager(dotnetEnvironment); + _shellProvider = result.GetValue(CommonOptions.ShellOption); } protected override string GetCommandName() => "defaultinstall"; @@ -33,31 +35,8 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - var dotnetupPath = Environment.ProcessPath - ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); var userDotnetPath = _installRootManager.GetUserInstallRootChanges().UserDotnetPath; - var shellProvider = GetCurrentShellProviderOrThrow(); - - var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: userDotnetPath); - - 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, dotnetInstallPath: userDotnetPath)}"); - - return 0; + return SetUnixShellProfile(dotnetupOnly: false, userDotnetPath); } var changes = _installRootManager.GetUserInstallRootChanges(); @@ -89,28 +68,7 @@ private int SetSystemInstallRoot() { if (!OperatingSystem.IsWindows()) { - // 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."); - var shellProvider = GetCurrentShellProviderOrThrow(); - - var modifiedFiles = ShellProfileManager.AddProfileEntries(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; + return SetUnixShellProfile(dotnetupOnly: true); } var changes = _installRootManager.GetAdminInstallRootChanges(); @@ -136,15 +94,54 @@ private int SetSystemInstallRoot() return 0; } - private static IEnvShellProvider GetCurrentShellProviderOrThrow() + private int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = null) + { + var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); + var shellProvider = GetCurrentShellProviderOrThrow(); + + var modifiedFiles = ShellProfileManager.AddProfileEntries( + shellProvider, + dotnetupPath, + dotnetupOnly, + dotnetInstallPath); + + if (modifiedFiles.Count == 0) + { + Console.WriteLine(dotnetupOnly + ? "Shell profile is already configured." + : "Shell profile is already configured for dotnetup."); + } + else + { + Console.WriteLine(dotnetupOnly + ? "Updated shell profile files (dotnetup only, no DOTNET_ROOT or dotnet PATH):" + : "Updated shell profile files:"); + + foreach (var file in modifiedFiles) + { + Console.WriteLine($" {file}"); + } + } + + if (!dotnetupOnly) + { + Console.WriteLine(); + Console.WriteLine("To start using .NET in this terminal, run:"); + Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath, dotnetInstallPath: dotnetInstallPath)}"); + } + + return 0; + } + + private IEnvShellProvider GetCurrentShellProviderOrThrow() { - var shellProvider = ShellDetection.GetCurrentShellProvider(); + var shellProvider = _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(", ", ShellDetection.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))}. You can specify one explicitly with --shell."); } return shellProvider; diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs index e099d7d84d93..43ef7a74d63a 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommandParser.cs @@ -36,6 +36,7 @@ private static Command ConstructCommand() Command command = new("defaultinstall", "Sets the default dotnet installation"); command.Arguments.Add(InstallTypeArgument); + command.Options.Add(CommonOptions.ShellOption); command.SetAction(parseResult => new DefaultInstallCommand(parseResult).Execute()); diff --git a/src/Installer/dotnetup/Commands/Init/InitCommandParser.cs b/src/Installer/dotnetup/Commands/Init/InitCommandParser.cs index aac1f37d1b7e..167e772ef58f 100644 --- a/src/Installer/dotnetup/Commands/Init/InitCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Init/InitCommandParser.cs @@ -18,6 +18,7 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.InstallPathOption); command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.NoProgressOption); + command.Options.Add(CommonOptions.ShellOption); command.Options.Add(CommonOptions.VerbosityOption); command.Options.Add(CommonOptions.RequireMuxerUpdateOption); diff --git a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs index 79bc7b606b43..a7cfec7cb28c 100644 --- a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs @@ -87,7 +87,8 @@ public void FullIntroductionWalkthrough(InstallCommand command) BaseConfigurationWalkthrough( [], () => { }, - command.NoProgress); + command.NoProgress, + shellProvider: command.ShellProvider); return; } @@ -99,7 +100,8 @@ public void FullIntroductionWalkthrough(InstallCommand command) BaseConfigurationWalkthrough( requests, () => InstallExecutor.ExecuteInstalls(requests, command.NoProgress), - command.NoProgress); + command.NoProgress, + shellProvider: command.ShellProvider); } /// @@ -115,13 +117,15 @@ public void FullIntroductionWalkthrough(InstallCommand command) /// Whether to prompt the user. When false, uses existing config or defaults — no prompts are shown. /// When true, defers the admin migration prompt until the end of the init flow. /// When true, prompts the user even if a preference was previously saved. + /// An optional shell override to use for environment configuration instead of auto-detection. public void BaseConfigurationWalkthrough( List requests, Action primaryActionAfterConfigured, bool noProgress, bool interactive = true, bool deferAdminMigrationUntilEnd = false, - bool askEvenIfConfigured = true) + bool askEvenIfConfigured = true, + IEnvShellProvider? shellProvider = null) { // Determine the install root for environment configuration and migration. // Use the first request's root if available, otherwise fall back to the default path. @@ -138,7 +142,7 @@ public void BaseConfigurationWalkthrough( // User chooses how to access .NET PathPreference? previousPreference = DotnetupConfig.ReadPathPreference(); - var pathPreference = GetPathPreference(interactive, askEvenIfConfigured); + var pathPreference = GetPathPreference(interactive, askEvenIfConfigured, shellProvider); string? manifestPath = requests.Count > 0 ? requests[0].Request.Options.ManifestPath : null; // (Can Defer) Step 2: Prompt about admin installs before setting up the environment. @@ -155,7 +159,7 @@ public void BaseConfigurationWalkthrough( if (ShouldReplaceSystemConfiguration(pathPreference)) { - _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path); + _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path, shellProvider); } // Step 4: Prompt migrating admin installs now that the environment is configured (if deferred). @@ -193,7 +197,7 @@ private static void RunPrimaryInstall( primaryAction(); } - private static PathPreference GetPathPreference(bool interactive, bool askEvenIfConfigured) + private static PathPreference GetPathPreference(bool interactive, bool askEvenIfConfigured, IEnvShellProvider? shellProvider) { // If the user already configured their preference (e.g. prior init), reuse it. // In non-interactive mode, use the existing config or default to ShellProfile. @@ -204,7 +208,7 @@ private static PathPreference GetPathPreference(bool interactive, bool askEvenIf } else if (!interactive) { - if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + if (!OperatingSystem.IsWindows() && (shellProvider ?? ShellDetection.GetCurrentShellProvider()) is null) { return PathPreference.DotnetupDotnet; } @@ -212,11 +216,11 @@ private static PathPreference GetPathPreference(bool interactive, bool askEvenIf return PathPreference.ShellProfile; } - if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + if (!OperatingSystem.IsWindows() && (shellProvider ?? ShellDetection.GetCurrentShellProvider()) is null) { var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; SpectreAnsiConsole.MarkupLine(DotnetupTheme.Dim( - $"[{DotnetupTheme.Current.Warning}]Warning:[/] Shell '{shellEnv.EscapeMarkup()}' is not supported for automatic environment configuration. dotnetup will continue without changing your shell profile.")); + $"[{DotnetupTheme.Current.Warning}]Warning:[/] Shell '{shellEnv.EscapeMarkup()}' is not supported for automatic environment configuration. dotnetup will continue without changing your shell profile unless you specify one with --shell.")); return PathPreference.DotnetupDotnet; } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index 9cc4be700cfa..f7a82effb324 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -50,7 +50,7 @@ protected override int ExecuteCore() string installPath = _dotnetInstallPath ?? _dotnetEnvironment.GetDefaultDotnetInstallPath(); // Determine the dotnetup directory so it can be added to PATH - string? dotnetupDir = Path.GetDirectoryName(Environment.ProcessPath); + string dotnetupDir = ShellProviderHelpers.GetDotnetupDirectoryOrThrow(); // Generate the shell script bool includeDotnet = !_dotnetupOnly; diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index e3bdcf220484..4424f28fc2ee 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -2,33 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; -using System.CommandLine.Completions; using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; internal static class PrintEnvScriptCommandParser { - public static readonly Option ShellOption = new("--shell", "-s") - { - 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) => ShellDetection.GetCurrentShellProvider(), - // called for all other scenarios - CustomParser = (optionResult) => - { - return optionResult.Tokens switch - { - // shouldn't be required because of the DefaultValueFactory above - [] => ShellDetection.GetCurrentShellProvider(), - [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 ShellOption = CommonOptions.ShellOption; public static readonly Option DotnetInstallPathOption = new("--dotnet-install-path", "-d") { @@ -61,28 +41,4 @@ private static Command ConstructCommand() return command; } - - private static Action OnlyAcceptSupportedShells() - { - return (System.CommandLine.Parsing.OptionResult optionResult) => - { - if (optionResult.Tokens.Count == 0) - { - return; - } - var singleToken = optionResult.Tokens[0]; - if (!ShellDetection.IsSupported(singleToken.Value)) - { - optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); - } - }; - } - - private static Func> CreateCompletions() - { - return (CompletionContext context) => - { - return ShellDetection.s_supportedShells.Select(s => new CompletionItem(s.ArgumentName, documentation: s.HelpDescription)); - }; - } } diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs index e79c40d442c0..bfbfe5f2f327 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs @@ -26,6 +26,7 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.SetDefaultInstallOption); command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); + command.Options.Add(CommonOptions.ShellOption); command.Options.Add(CommonOptions.NoProgressOption); command.Options.Add(CommonOptions.VerbosityOption); command.Options.Add(CommonOptions.RequireMuxerUpdateOption); diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs index 7527f7812460..d7621e1babfa 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -46,6 +46,7 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); + command.Options.Add(CommonOptions.ShellOption); command.Options.Add(CommonOptions.NoProgressOption); command.Options.Add(CommonOptions.VerbosityOption); command.Options.Add(CommonOptions.RequireMuxerUpdateOption); diff --git a/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs b/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs index 9491ea489231..6674ad9d8fa9 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallCommand.cs @@ -3,6 +3,7 @@ using System.CommandLine; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; @@ -20,6 +21,7 @@ internal abstract class InstallCommand : CommandBase public Verbosity Verbosity { get; } public bool RequireMuxerUpdate { get; } public bool Untracked { get; } + public IEnvShellProvider? ShellProvider { get; } public virtual bool UpdateGlobalJson => false; public IDotnetEnvironmentManager DotnetEnvironment { get; } @@ -35,6 +37,7 @@ protected InstallCommand(ParseResult parseResult) Verbosity = parseResult.GetValue(CommonOptions.VerbosityOption); RequireMuxerUpdate = parseResult.GetValue(CommonOptions.RequireMuxerUpdateOption); Untracked = parseResult.GetValue(CommonOptions.UntrackedOption); + ShellProvider = parseResult.GetValue(CommonOptions.ShellOption); DotnetEnvironment = new DotnetEnvironmentManager(); ChannelVersionResolver = new ChannelVersionResolver(); diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index f38908a5bc5f..b3e138122f6b 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -57,7 +57,8 @@ public void Execute(MinimalInstallSpec[] componentSpecs) _command.NoProgress, _command.Interactive, true, - false); + false, + _command.ShellProvider); } // Global.json update runs after install in all code paths, but only when diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs index 1d106474ed79..faf060de336d 100644 --- a/src/Installer/dotnetup/CommonOptions.cs +++ b/src/Installer/dotnetup/CommonOptions.cs @@ -4,6 +4,8 @@ using System.CommandLine; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; +using System.CommandLine.Completions; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -57,6 +59,24 @@ internal class CommonOptions Description = "The path to install .NET to", }; + public static readonly Option ShellOption = new("--shell", "-s") + { + Description = $"The shell to use for profile-based environment configuration (supported: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}). If not specified, the current shell will be detected.", + Arity = ArgumentArity.ZeroOrOne, + DefaultValueFactory = _ => ShellDetection.GetCurrentShellProvider(), + CustomParser = (optionResult) => + { + return optionResult.Tokens switch + { + [] => ShellDetection.GetCurrentShellProvider(), + [var shellToken] => ShellDetection.GetShellProvider(shellToken.Value), + _ => throw new InvalidOperationException("Unexpected number of tokens") + }; + }, + Validators = { ValidateShellOption() }, + CompletionSources = { CreateShellCompletions() } + }; + public static readonly Option SetDefaultInstallOption = new("--set-default-install") { Description = "Set the install path as the default dotnet install. This will update the PATH and DOTNET_ROOT environment variables.", @@ -166,4 +186,27 @@ public static Argument CreateRuntimeComponentSpecsArgument(string acti private static bool IsCIEnvironmentOrRedirected() => new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; + + private static Action ValidateShellOption() + { + return (System.CommandLine.Parsing.OptionResult optionResult) => + { + if (optionResult.Tokens.Count == 0) + { + return; + } + + var shellToken = optionResult.Tokens[0]; + if (!ShellDetection.IsSupported(shellToken.Value)) + { + optionResult.AddError($"Unsupported shell '{shellToken.Value}'. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); + } + }; + } + + private static Func> CreateShellCompletions() + { + return _ => ShellDetection.s_supportedShells + .Select(s => new CompletionItem(s.ArgumentName, documentation: s.HelpDescription)); + } } diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index e1911e23801f..02cbc83c2378 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -266,7 +266,7 @@ private static void TryAddPath(List paths, string path) paths.Add(path); } } - public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) { if (OperatingSystem.IsWindows()) { @@ -312,22 +312,21 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne } else { - ConfigureInstallTypeUnix(installType, dotnetRoot); + ConfigureInstallTypeUnix(installType, dotnetRoot, shellProvider); } } - private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot) + private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) { - var dotnetupPath = Environment.ProcessPath - ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); - IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + 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(", ", ShellDetection.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))}. You can specify one explicitly with --shell."); } switch (installType) diff --git a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs index b873a72ea8c4..fe0090163067 100644 --- a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -23,7 +24,7 @@ internal interface IDotnetEnvironmentManager List GetExistingSystemInstalls(); - void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null); + void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null); /// /// Updates the global.json file to reflect the installed SDK version, diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 9beed2e4ea98..1a6c16bd9d1e 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -1,8 +1,6 @@ // 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.Installation.Internal; - namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class BashEnvShellProvider : IEnvShellProvider @@ -15,35 +13,17 @@ public class BashEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''", StringComparison.Ordinal); - - string pathExport; - if (includeDotnet && escapedDotnetupDir is not null) - { - 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 = ""; - } + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPosixPathExport(escapedPath, dotnetupDir, includeDotnet); if (!includeDotnet) { return $""" #!/usr/bin/env bash - # This script adds dotnetup to your PATH + {ShellProviderHelpers.GetDotnetupOnlyComment()} {pathExport} hash -d dotnet 2>/dev/null hash -d dotnetup 2>/dev/null @@ -53,7 +33,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = return $""" #!/usr/bin/env bash - # This script configures the environment for .NET installed at {dotnetInstallPath} + {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} export DOTNET_ROOT='{escapedPath}' {pathExport} @@ -64,7 +44,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = public IReadOnlyList GetProfilePaths() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var home = ShellProviderHelpers.GetUserHomeDirectoryOrThrow(); var paths = new List { Path.Combine(home, ".bashrc") }; // For login shells, use the first existing of .bash_profile / .profile. @@ -87,29 +67,17 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell bash", flags); + return $"{ShellProfileManager.MarkerComment}\neval \"$({command})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"eval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; - } - - private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) - { - var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && - dotnetInstallPath is { Length: > 0 } installPath && - !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) - { - var escapedInstallPath = installPath.Replace("'", "'\\''", StringComparison.Ordinal); - flags += $" --dotnet-install-path '{escapedInstallPath}'"; - } - - return flags; + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell bash", flags); + return $"eval \"$({command})\""; } } diff --git a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs index eeb759105c06..a0815331ce02 100644 --- a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -27,10 +27,10 @@ public interface IEnvShellProvider /// 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 + /// The directory containing the dotnetup binary, or an empty string to omit it. /// 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, bool includeDotnet = true); + string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true); /// /// Returns the profile file paths that should be modified for this shell. diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index d8d1b1365398..68f1e806d57d 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -1,8 +1,6 @@ // 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.Installation.Internal; - namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class PowerShellEnvShellProvider : IEnvShellProvider @@ -15,42 +13,24 @@ public class PowerShellEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "''", StringComparison.Ordinal); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "''", StringComparison.Ordinal); - - string pathExport; - if (includeDotnet && escapedDotnetupDir is not null) - { - 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 = ""; - } + var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPowerShellPathExport(escapedPath, dotnetupDir, includeDotnet); if (!includeDotnet) { return $""" - # This script adds dotnetup to your PATH + {ShellProviderHelpers.GetDotnetupOnlyComment()} {pathExport} """; } return $""" - # This script configures the environment for .NET installed at {dotnetInstallPath} - + {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} + $env:DOTNET_ROOT = '{escapedPath}' {pathExport} """; @@ -58,35 +38,23 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = public IReadOnlyList GetProfilePaths() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return [Path.Combine(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")]; + var profileDir = ShellProviderHelpers.GetPowerShellProfileDirectoryOrThrow(); + return [Path.Combine(profileDir, "Microsoft.PowerShell_profile.ps1")]; } public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"{ShellProfileManager.MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; + var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); + var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); + return $"{ShellProfileManager.MarkerComment}\n{command} | Invoke-Expression"; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; - } - - private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) - { - var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && - dotnetInstallPath is { Length: > 0 } installPath && - !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) - { - var escapedInstallPath = installPath.Replace("'", "''", StringComparison.Ordinal); - flags += $" --dotnet-install-path '{escapedInstallPath}'"; - } - - return flags; + var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); + var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); + return $"{command} | Invoke-Expression"; } } diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs index 756057f77213..d23aa83e37f1 100644 --- a/src/Installer/dotnetup/Shell/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -25,13 +25,27 @@ public static class ShellDetection /// Looks up a shell provider by its argument name (e.g., "bash", "zsh", "pwsh"). /// internal static IEnvShellProvider? GetShellProvider(string shellName) - => s_shellMap.GetValueOrDefault(shellName); + { + if (string.IsNullOrWhiteSpace(shellName)) + { + return null; + } + + if (s_shellMap.TryGetValue(shellName, out var provider)) + { + return provider; + } + + var resolvedShellPath = Microsoft.DotNet.NativeWrapper.FileInterop.ResolveRealPath(shellName) ?? shellName; + var normalizedShellName = Path.GetFileNameWithoutExtension(resolvedShellPath); + return s_shellMap.GetValueOrDefault(normalizedShellName); + } /// /// Checks whether a shell name is supported. /// internal static bool IsSupported(string shellName) - => s_shellMap.ContainsKey(shellName); + => GetShellProvider(shellName) is not null; /// /// Returns the for the user's current shell, @@ -50,7 +64,6 @@ internal static bool IsSupported(string shellName) return null; } - var shellName = Path.GetFileName(shellPath); - return s_shellMap.GetValueOrDefault(shellName); + return GetShellProvider(shellPath); } } diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs new file mode 100644 index 000000000000..ef97136ba496 --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -0,0 +1,196 @@ +// 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.Installation; +using Microsoft.Dotnet.Installation.Internal; + +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; + +internal static class ShellProviderHelpers +{ + private const string DotnetupOnlyComment = "# This script adds dotnetup to your PATH"; + + internal static string GetDotnetupOnlyComment() => DotnetupOnlyComment; + + internal static string GetEnvironmentConfigurationComment(string dotnetInstallPath) + => $"# This script configures the environment for .NET installed at {dotnetInstallPath}"; + + internal static string EscapePosixPath(string path) + => path.Replace("'", "'\\''", StringComparison.Ordinal); + + internal static string EscapePowerShellPath(string path) + => path.Replace("'", "''", StringComparison.Ordinal); + + internal static string GetCommandFlags(bool dotnetupOnly, string? dotnetInstallPath, Func escapePath) + { + List flags = []; + + if (dotnetupOnly) + { + flags.Add("--dotnetup-only"); + } + else if (dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) + { + flags.Add($"--dotnet-install-path '{escapePath(installPath)}'"); + } + + return string.Join(" ", flags); + } + + internal static string AppendArguments(string command, string flags) + => string.IsNullOrEmpty(flags) ? command : $"{command} {flags}"; + + internal static string BuildPosixPathExport(string escapedPath, string dotnetupDir, bool includeDotnet) + { + // Put the managed paths first so the shell resolves dotnet/dotnetup from the selected install immediately. + if (includeDotnet && !string.IsNullOrWhiteSpace(dotnetupDir)) + { + return $"export PATH='{EscapePosixPath(dotnetupDir)}':'{escapedPath}':$PATH"; + } + + if (includeDotnet) + { + return $"export PATH='{escapedPath}':$PATH"; + } + + return string.IsNullOrWhiteSpace(dotnetupDir) + ? string.Empty + : $"export PATH='{EscapePosixPath(dotnetupDir)}':$PATH"; + } + + internal static string BuildPowerShellPathExport(string escapedPath, string dotnetupDir, bool includeDotnet) + { + // Put the managed paths first so the shell resolves dotnet/dotnetup from the selected install immediately. + if (includeDotnet && !string.IsNullOrWhiteSpace(dotnetupDir)) + { + return $"$env:PATH = '{EscapePowerShellPath(dotnetupDir)}' + [IO.Path]::PathSeparator + '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } + + if (includeDotnet) + { + return $"$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } + + return string.IsNullOrWhiteSpace(dotnetupDir) + ? string.Empty + : $"$env:PATH = '{EscapePowerShellPath(dotnetupDir)}' + [IO.Path]::PathSeparator + $env:PATH"; + } + + internal static string GetDotnetupExecutablePathOrThrow() + { + return Environment.ProcessPath + ?? throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + "Unable to determine the dotnetup executable path."); + } + + internal static string GetDotnetupDirectoryOrThrow() + { + var dotnetupPath = GetDotnetupExecutablePathOrThrow(); + var dotnetupDir = Path.GetDirectoryName(dotnetupPath); + + if (string.IsNullOrWhiteSpace(dotnetupDir)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + $"Unable to determine the directory containing '{dotnetupPath}'."); + } + + return dotnetupDir; + } + + internal static string GetUserHomeDirectoryOrThrow() + { + var envVarName = OperatingSystem.IsWindows() ? "USERPROFILE" : "HOME"; + var home = Environment.GetEnvironmentVariable(envVarName); + + if (string.IsNullOrWhiteSpace(home)) + { + home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + if (string.IsNullOrWhiteSpace(home)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + $"Unable to determine the current user's home directory. The {envVarName} environment variable is not set."); + } + + var fullPath = Path.GetFullPath(home); + if (!Directory.Exists(fullPath)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + $"The current user's home directory '{fullPath}' does not exist."); + } + + EnsureDirectoryWritable(fullPath, "home directory"); + return fullPath; + } + + internal static string GetZshConfigurationDirectoryOrThrow() + { + var zdotdir = Environment.GetEnvironmentVariable("ZDOTDIR"); + if (string.IsNullOrWhiteSpace(zdotdir)) + { + return GetUserHomeDirectoryOrThrow(); + } + + var fullPath = Path.GetFullPath(zdotdir); + EnsureDirectoryWritable(fullPath, "ZDOTDIR", createIfMissing: true); + return fullPath; + } + + internal static string GetPowerShellProfileDirectoryOrThrow() + { + var profileDir = Path.Combine(GetUserHomeDirectoryOrThrow(), ".config", "powershell"); + EnsureDirectoryWritable(profileDir, "PowerShell profile directory", createIfMissing: true); + return profileDir; + } + + private static void EnsureDirectoryWritable(string path, string description, bool createIfMissing = false) + { + string tempFile = Path.Combine(path, Path.GetRandomFileName()); + + try + { + if (createIfMissing) + { + Directory.CreateDirectory(path); + } + + using var stream = new FileStream(tempFile, FileMode.CreateNew, FileAccess.Write, FileShare.None); + } + catch (UnauthorizedAccessException ex) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.PermissionDenied, + $"The {description} '{path}' is not writable.", + ex); + } + catch (IOException ex) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.ContextResolutionFailed, + $"Unable to verify that the {description} '{path}' is writable.", + ex); + } + finally + { + try + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + catch (UnauthorizedAccessException) + { + } + catch (IOException) + { + } + } + } +} diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 3489752fb9f8..e503ace25304 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -1,8 +1,6 @@ // 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.Installation.Internal; - namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; public class ZshEnvShellProvider : IEnvShellProvider @@ -15,35 +13,17 @@ public class ZshEnvShellProvider : IEnvShellProvider public override string ToString() => ArgumentName; - public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) { - var escapedPath = dotnetInstallPath.Replace("'", "'\\''", StringComparison.Ordinal); - var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''", StringComparison.Ordinal); - - string pathExport; - if (includeDotnet && escapedDotnetupDir is not null) - { - 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 = ""; - } + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPosixPathExport(escapedPath, dotnetupDir, includeDotnet); if (!includeDotnet) { return $""" #!/usr/bin/env zsh - # This script adds dotnetup to your PATH + {ShellProviderHelpers.GetDotnetupOnlyComment()} {pathExport} rehash 2>/dev/null """; @@ -52,7 +32,7 @@ rehash 2>/dev/null return $""" #!/usr/bin/env zsh - # This script configures the environment for .NET installed at {dotnetInstallPath} + {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} export DOTNET_ROOT='{escapedPath}' {pathExport} @@ -62,35 +42,23 @@ rehash 2>/dev/null public IReadOnlyList GetProfilePaths() { - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return [Path.Combine(home, ".zshrc")]; + var zshDirectory = ShellProviderHelpers.GetZshConfigurationDirectoryOrThrow(); + return [Path.Combine(zshDirectory, ".zshrc")]; } public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell zsh", flags); + return $"{ShellProfileManager.MarkerComment}\neval \"$({command})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = dotnetupPath.Replace("'", "'\\''", StringComparison.Ordinal); - var flags = GetFlags(dotnetupOnly, dotnetInstallPath); - return $"eval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; - } - - private static string GetFlags(bool dotnetupOnly, string? dotnetInstallPath) - { - var flags = dotnetupOnly ? " --dotnetup-only" : ""; - if (!dotnetupOnly && - dotnetInstallPath is { Length: > 0 } installPath && - !DotnetupUtilities.PathsEqual(installPath, DotnetupPaths.DefaultDotnetInstallPath)) - { - var escapedInstallPath = installPath.Replace("'", "'\\''", StringComparison.Ordinal); - flags += $" --dotnet-install-path '{escapedInstallPath}'"; - } - - return flags; + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell zsh", flags); + return $"eval \"$({command})\""; } } diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index 9833288531d4..b3ec41688931 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -83,6 +83,30 @@ public void ZshProvider_DotnetupOnly_ShouldNotSetDotnetRoot() script.Should().Contain("export PATH='/usr/local/bin':$PATH"); } + [Fact] + public void ZshProvider_ShouldPreferZdotdirForProfilePath() + { + var originalZdotdir = Environment.GetEnvironmentVariable("ZDOTDIR"); + var temporaryDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(temporaryDirectory); + + try + { + Environment.SetEnvironmentVariable("ZDOTDIR", temporaryDirectory); + + var provider = new ZshEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().ContainSingle(); + paths[0].Should().Be(Path.Combine(temporaryDirectory, ".zshrc")); + } + finally + { + Environment.SetEnvironmentVariable("ZDOTDIR", originalZdotdir); + Directory.Delete(temporaryDirectory, recursive: true); + } + } + [Fact] public void PowerShellProvider_ShouldGenerateValidScript() { @@ -133,6 +157,18 @@ public void ShellProviders_ShouldHaveCorrectArgumentName(string expectedName) provider!.ArgumentName.Should().Be(expectedName); } + [Theory] + [InlineData("/bin/bash", "bash")] + [InlineData("/bin/zsh", "zsh")] + [InlineData(@"C:\Program Files\PowerShell\7\pwsh.exe", "pwsh")] + public void ShellDetection_ShouldResolveProviderFromShellPath(string shellPath, string expectedName) + { + var provider = ShellDetection.GetShellProvider(shellPath); + + provider.Should().NotBeNull(); + provider!.ArgumentName.Should().Be(expectedName); + } + [Fact] public void BashProvider_ShouldHaveCorrectProperties() { diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index f324dfbbc214..7c2e690c27dc 100644 --- a/test/dotnetup.Tests/MockDotnetInstallManager.cs +++ b/test/dotnetup.Tests/MockDotnetInstallManager.cs @@ -4,6 +4,7 @@ using Microsoft.Dotnet.Installation; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper.Tests; @@ -53,7 +54,7 @@ public List GetExistingSystemInstalls() public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); - public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) { ApplyEnvironmentModificationsCallCount++; } diff --git a/test/dotnetup.Tests/ParserTests.cs b/test/dotnetup.Tests/ParserTests.cs index d069ed3932a5..9d0c2a9e48b6 100644 --- a/test/dotnetup.Tests/ParserTests.cs +++ b/test/dotnetup.Tests/ParserTests.cs @@ -7,6 +7,14 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class ParserTests { + public static IEnumerable ShellOverrideCommandArgs => + [ + [new[] { "defaultinstall", "user", "--shell", "bash" }], + [new[] { "sdk", "install", "9.0", "--shell", "zsh" }], + [new[] { "runtime", "install", "aspnetcore@9.0", "--shell", "pwsh" }], + [new[] { "init", "--shell", "bash" }] + ]; + [Fact] public void Parser_ShouldParseValidCommands() { @@ -113,6 +121,16 @@ public void Parser_ShouldParseDefaultInstallCommand() parseResult.Errors.Should().BeEmpty(); } + [Theory] + [MemberData(nameof(ShellOverrideCommandArgs))] + public void Parser_ShouldParseCommandsWithShellOverride(string[] args) + { + var parseResult = Parser.Parse(args); + + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().BeEmpty(); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] From ec74344610f43575bc691f7359a538b770fb806d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 10:04:50 -0400 Subject: [PATCH 27/51] Keep shell override on init Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../general/dotnetup/unix-environment-setup.md | 2 +- .../Install/RuntimeInstallCommandParser.cs | 5 ++++- .../Sdk/Install/SdkInstallCommandParser.cs | 5 ++++- src/Installer/dotnetup/CommonOptions.cs | 12 ++++++++++++ test/dotnetup.Tests/ParserTests.cs | 15 +++++++++++++-- 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index aa3b83f092fc..2d5554cb8c7f 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -24,7 +24,7 @@ Choosing the shell-profile option in the walkthrough is what corresponds to maki eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash)" ``` -If the user already has a saved path preference, or if the command is non-interactive / uses an explicit `--install-path`, the walkthrough prompt is skipped and dotnetup uses the existing configuration or the explicit path directly. If shell auto-detection is wrong or unavailable, commands that modify the profile also accept `--shell bash|zsh|pwsh`. +If the user already has a saved path preference, or if the command is non-interactive / uses an explicit `--install-path`, the walkthrough prompt is skipped and dotnetup uses the existing configuration or the explicit path directly. If shell auto-detection is wrong or unavailable, run `dotnetup init --shell bash|zsh|pwsh` (or `defaultinstall` / `print-env-script` with `--shell`) before installing. ### 2. `dotnetup defaultinstall` diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs index bfbfe5f2f327..fea2d144dd31 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs @@ -26,11 +26,14 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.SetDefaultInstallOption); command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); - command.Options.Add(CommonOptions.ShellOption); + // Intentionally do not expose --shell on install commands. + // If a user wants to override shell detection for the profile-setup experience, + // they can run `dotnetup init --shell ` before installing. command.Options.Add(CommonOptions.NoProgressOption); command.Options.Add(CommonOptions.VerbosityOption); command.Options.Add(CommonOptions.RequireMuxerUpdateOption); command.Options.Add(CommonOptions.UntrackedOption); + command.Validators.Add(CommonOptions.RejectShellOptionOnInstallCommand()); command.SetAction(parseResult => new RuntimeInstallCommand(parseResult).Execute()); diff --git a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs index d7621e1babfa..541d24e182fc 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -46,11 +46,14 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); - command.Options.Add(CommonOptions.ShellOption); + // Intentionally do not expose --shell on install commands. + // If a user wants to override shell detection for the profile-setup experience, + // they can run `dotnetup init --shell ` before installing. command.Options.Add(CommonOptions.NoProgressOption); command.Options.Add(CommonOptions.VerbosityOption); command.Options.Add(CommonOptions.RequireMuxerUpdateOption); command.Options.Add(CommonOptions.UntrackedOption); + command.Validators.Add(CommonOptions.RejectShellOptionOnInstallCommand()); command.SetAction(parseResult => new SdkInstallCommand(parseResult).Execute()); diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs index faf060de336d..df00fe85718b 100644 --- a/src/Installer/dotnetup/CommonOptions.cs +++ b/src/Installer/dotnetup/CommonOptions.cs @@ -209,4 +209,16 @@ private static Func> CreateShellC return _ => ShellDetection.s_supportedShells .Select(s => new CompletionItem(s.ArgumentName, documentation: s.HelpDescription)); } + + internal static Action RejectShellOptionOnInstallCommand() + { + return commandResult => + { + if (commandResult.Tokens.Any(token => token.Value is "--shell" or "-s")) + { + commandResult.AddError( + "The --shell option isn't supported on install commands. If you need to override shell detection, run 'dotnetup init --shell ' before installing."); + } + }; + } } diff --git a/test/dotnetup.Tests/ParserTests.cs b/test/dotnetup.Tests/ParserTests.cs index 9d0c2a9e48b6..a94dbd4aeb48 100644 --- a/test/dotnetup.Tests/ParserTests.cs +++ b/test/dotnetup.Tests/ParserTests.cs @@ -10,8 +10,6 @@ public class ParserTests public static IEnumerable ShellOverrideCommandArgs => [ [new[] { "defaultinstall", "user", "--shell", "bash" }], - [new[] { "sdk", "install", "9.0", "--shell", "zsh" }], - [new[] { "runtime", "install", "aspnetcore@9.0", "--shell", "pwsh" }], [new[] { "init", "--shell", "bash" }] ]; @@ -131,6 +129,19 @@ public void Parser_ShouldParseCommandsWithShellOverride(string[] args) parseResult.Errors.Should().BeEmpty(); } + [Theory] + [InlineData("sdk", "install", "9.0", "--shell", "zsh")] + [InlineData("runtime", "install", "aspnetcore@9.0", "--shell", "pwsh")] + public void Parser_ShouldRejectShellOverrideOnInstallCommands(params string[] args) + { + var parseResult = Parser.Parse(args); + + parseResult.Should().NotBeNull(); + parseResult.Errors.Should().NotBeEmpty(); + parseResult.Errors.Select(error => error.Message) + .Should().Contain(message => message.Contains("--shell")); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] From fb3e47ffc1d415b9a91a78678b16ab8dd2d29e9d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 10:12:16 -0400 Subject: [PATCH 28/51] Clarify shell provider resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Installer/dotnetup/CommonOptions.cs | 2 +- .../dotnetup/Shell/ShellDetection.cs | 30 ++++++++++++++----- test/dotnetup.Tests/EnvShellProviderTests.cs | 2 +- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs index df00fe85718b..dcdf92141f7a 100644 --- a/src/Installer/dotnetup/CommonOptions.cs +++ b/src/Installer/dotnetup/CommonOptions.cs @@ -69,7 +69,7 @@ internal class CommonOptions return optionResult.Tokens switch { [] => ShellDetection.GetCurrentShellProvider(), - [var shellToken] => ShellDetection.GetShellProvider(shellToken.Value), + [var shellToken] => ShellDetection.GetShellProviderByName(shellToken.Value), _ => throw new InvalidOperationException("Unexpected number of tokens") }; }, diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs index d23aa83e37f1..cd8275af7d1c 100644 --- a/src/Installer/dotnetup/Shell/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -22,30 +22,44 @@ public static class ShellDetection s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); /// - /// Looks up a shell provider by its argument name (e.g., "bash", "zsh", "pwsh"). + /// Looks up a shell provider by its command-line argument name (for example, "bash", "zsh", or "pwsh"). /// - internal static IEnvShellProvider? GetShellProvider(string shellName) + internal static IEnvShellProvider? GetShellProviderByName(string shellName) { if (string.IsNullOrWhiteSpace(shellName)) { return null; } - if (s_shellMap.TryGetValue(shellName, out var provider)) + return s_shellMap.GetValueOrDefault(shellName); + } + + /// + /// Resolves a shell provider from either a shell name or the path to a shell executable. + /// + internal static IEnvShellProvider? ResolveShellProvider(string shellPathOrName) + { + if (string.IsNullOrWhiteSpace(shellPathOrName)) + { + return null; + } + + var provider = GetShellProviderByName(shellPathOrName); + if (provider is not null) { return provider; } - var resolvedShellPath = Microsoft.DotNet.NativeWrapper.FileInterop.ResolveRealPath(shellName) ?? shellName; + var resolvedShellPath = Microsoft.DotNet.NativeWrapper.FileInterop.ResolveRealPath(shellPathOrName) ?? shellPathOrName; var normalizedShellName = Path.GetFileNameWithoutExtension(resolvedShellPath); - return s_shellMap.GetValueOrDefault(normalizedShellName); + return GetShellProviderByName(normalizedShellName); } /// - /// Checks whether a shell name is supported. + /// Checks whether a shell argument name is supported. /// internal static bool IsSupported(string shellName) - => GetShellProvider(shellName) is not null; + => GetShellProviderByName(shellName) is not null; /// /// Returns the for the user's current shell, @@ -64,6 +78,6 @@ internal static bool IsSupported(string shellName) return null; } - return GetShellProvider(shellPath); + return ResolveShellProvider(shellPath); } } diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index b3ec41688931..c223fe67d63f 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -163,7 +163,7 @@ public void ShellProviders_ShouldHaveCorrectArgumentName(string expectedName) [InlineData(@"C:\Program Files\PowerShell\7\pwsh.exe", "pwsh")] public void ShellDetection_ShouldResolveProviderFromShellPath(string shellPath, string expectedName) { - var provider = ShellDetection.GetShellProvider(shellPath); + var provider = ShellDetection.ResolveShellProvider(shellPath); provider.Should().NotBeNull(); provider!.ArgumentName.Should().Be(expectedName); From 72bdbbcf7ae848bc16e253d23128a4225561a9db Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 10:21:25 -0400 Subject: [PATCH 29/51] Preserve shell profile formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Shell/ShellProfileManager.cs | 119 ++++++++++++++++-- .../ShellProfileManagerTests.cs | 42 +++++++ 2 files changed, 153 insertions(+), 8 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index aa6b461ff17a..7f383f089866 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; + namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// @@ -11,6 +13,12 @@ public class ShellProfileManager internal const string MarkerComment = "# dotnetup"; private const string BackupSuffix = ".dotnetup-backup"; + private sealed record ProfileFileState( + List Lines, + Encoding Encoding, + string NewLine, + bool EndsWithTrailingNewLine); + /// /// 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. @@ -78,11 +86,12 @@ private static bool EnsureEntryInFile(string profilePath, string entry) if (!File.Exists(profilePath)) { // New file — just write the entry - File.WriteAllText(profilePath, entry + Environment.NewLine); + File.WriteAllText(profilePath, entry + Environment.NewLine, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); return true; } - var lines = File.ReadAllLines(profilePath).ToList(); + var fileState = ReadProfileFile(profilePath); + var lines = fileState.Lines; var entryLines = entry.Split('\n', StringSplitOptions.None) .Select(l => l.TrimEnd('\r')) .ToArray(); @@ -112,15 +121,19 @@ private static bool EnsureEntryInFile(string profilePath, string entry) File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); lines.InsertRange(markerIndex, entryLines); - File.WriteAllLines(profilePath, lines); + WriteProfileFile(profilePath, lines, fileState, ensureTrailingNewLine: fileState.EndsWithTrailingNewLine); return true; } // No existing entry — append File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - using var writer = File.AppendText(profilePath); - writer.WriteLine(); - writer.WriteLine(entry); + if (lines.Count > 0) + { + lines.Add(string.Empty); + } + + lines.AddRange(entryLines); + WriteProfileFile(profilePath, lines, fileState, ensureTrailingNewLine: true); return true; } @@ -131,7 +144,8 @@ private static bool RemoveEntryFromFile(string profilePath) return false; } - var lines = File.ReadAllLines(profilePath).ToList(); + var fileState = ReadProfileFile(profilePath); + var lines = fileState.Lines; bool modified = false; for (int i = lines.Count - 1; i >= 0; i--) @@ -150,9 +164,98 @@ private static bool RemoveEntryFromFile(string profilePath) if (modified) { - File.WriteAllLines(profilePath, lines); + WriteProfileFile( + profilePath, + lines, + fileState, + ensureTrailingNewLine: lines.Count > 0 && fileState.EndsWithTrailingNewLine); } return modified; } + + private static ProfileFileState ReadProfileFile(string profilePath) + { + byte[] bytes = File.ReadAllBytes(profilePath); + + using var stream = new MemoryStream(bytes); + using var reader = new StreamReader( + stream, + encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + detectEncodingFromByteOrderMarks: true); + + string content = reader.ReadToEnd(); + var encoding = GetWritableEncoding(reader.CurrentEncoding, HasPreamble(bytes, reader.CurrentEncoding)); + var lines = new List(); + + using var stringReader = new StringReader(content); + string? line; + while ((line = stringReader.ReadLine()) is not null) + { + lines.Add(line); + } + + return new ProfileFileState( + lines, + encoding, + DetectLineEnding(content), + EndsWithLineEnding(content)); + } + + private static void WriteProfileFile( + string profilePath, + IReadOnlyList lines, + ProfileFileState fileState, + bool ensureTrailingNewLine) + { + string content = string.Join(fileState.NewLine, lines); + + if (lines.Count > 0 && ensureTrailingNewLine) + { + content += fileState.NewLine; + } + + File.WriteAllText(profilePath, content, fileState.Encoding); + } + + private static string DetectLineEnding(string content) + { + if (content.Contains("\r\n", StringComparison.Ordinal)) + { + return "\r\n"; + } + + if (content.Contains('\n')) + { + return "\n"; + } + + if (content.Contains('\r')) + { + return "\r"; + } + + return Environment.NewLine; + } + + private static bool EndsWithLineEnding(string content) => + content.EndsWith("\r\n", StringComparison.Ordinal) || + content.EndsWith('\n') || + content.EndsWith('\r'); + + private static Encoding GetWritableEncoding(Encoding detectedEncoding, bool hadBom) + { + if (detectedEncoding.CodePage == Encoding.UTF8.CodePage) + { + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: hadBom); + } + + return detectedEncoding; + } + + private static bool HasPreamble(byte[] bytes, Encoding encoding) + { + byte[] preamble = encoding.GetPreamble(); + return preamble.Length > 0 && bytes.AsSpan().StartsWith(preamble); + } } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index bae2c4ad9d11..9eb527d14212 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -3,6 +3,7 @@ using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Shell; +using System.Text; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -83,6 +84,20 @@ public void AddProfileEntries_CreatesBackupOfExistingFile() File.ReadAllText(backupPath).Should().Be(originalContent); } + [Fact] + public void AddProfileEntries_PreservesUtf8BomAndCrLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-add.sh"); + File.WriteAllText(profilePath, "# existing config\r\nexport FOO=bar\r\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + var provider = new TestShellProvider(_tempDir, "preserve-add.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan(0, Encoding.UTF8.Preamble.Length).SequenceEqual(Encoding.UTF8.Preamble).Should().BeTrue(); + AssertUsesOnlyCrLfLineEndings(File.ReadAllText(profilePath)); + } + [Fact] public void AddProfileEntries_CreatesParentDirectories() { @@ -127,6 +142,27 @@ public void RemoveProfileEntries_LeavesOtherContentIntact() content.Should().Contain("export FOO=bar"); } + [Fact] + public void RemoveProfileEntries_PreservesUtf8BomAndCrLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-remove.sh"); + var provider = new TestShellProvider(_tempDir, "preserve-remove.sh"); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath).Replace("\n", "\r\n", StringComparison.Ordinal); + var originalContent = $"# existing config\r\n{entry}\r\nexport FOO=bar\r\n"; + File.WriteAllText(profilePath, originalContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + + ShellProfileManager.RemoveProfileEntries(provider); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan(0, Encoding.UTF8.Preamble.Length).SequenceEqual(Encoding.UTF8.Preamble).Should().BeTrue(); + + var content = File.ReadAllText(profilePath); + content.Should().Contain("# existing config"); + content.Should().Contain("export FOO=bar"); + content.Should().NotContain(ShellProfileManager.MarkerComment); + AssertUsesOnlyCrLfLineEndings(content); + } + [Fact] public void RemoveProfileEntries_ReturnsEmptyForMissingFile() { @@ -375,4 +411,10 @@ public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = return $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; } } + + private static void AssertUsesOnlyCrLfLineEndings(string content) + { + content.Should().Contain("\r\n"); + content.Replace("\r\n", string.Empty, StringComparison.Ordinal).Should().NotContain("\n"); + } } From 0b3254063857842da9faf98effeda09ef5ccfc3e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 10:39:42 -0400 Subject: [PATCH 30/51] Simplify shell profile state usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Shell/ShellProfileManager.cs | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index 7f383f089866..e8979a5d7848 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -1,8 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Text; - namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// @@ -91,26 +89,25 @@ private static bool EnsureEntryInFile(string profilePath, string entry) } var fileState = ReadProfileFile(profilePath); - var lines = fileState.Lines; 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); + int markerIndex = fileState.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) + if (oldEntryEnd < fileState.Lines.Count) { oldEntryEnd++; } // Check if the existing entry already matches - var oldEntry = lines.GetRange(markerIndex, oldEntryEnd - markerIndex); + var oldEntry = fileState.Lines.GetRange(markerIndex, oldEntryEnd - markerIndex); if (oldEntry.Count == entryLines.Length && oldEntry.Zip(entryLines).All(pair => pair.First.TrimEnd() == pair.Second.TrimEnd())) { @@ -119,21 +116,22 @@ private static bool EnsureEntryInFile(string profilePath, string entry) // Replace in-place File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); - lines.InsertRange(markerIndex, entryLines); - WriteProfileFile(profilePath, lines, fileState, ensureTrailingNewLine: fileState.EndsWithTrailingNewLine); + fileState.Lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); + fileState.Lines.InsertRange(markerIndex, entryLines); + WriteProfileFile(profilePath, fileState); return true; } // No existing entry — append File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - if (lines.Count > 0) + if (fileState.Lines.Count > 0) { - lines.Add(string.Empty); + fileState.Lines.Add(string.Empty); } - lines.AddRange(entryLines); - WriteProfileFile(profilePath, lines, fileState, ensureTrailingNewLine: true); + fileState.Lines.AddRange(entryLines); + fileState = fileState with { EndsWithTrailingNewLine = true }; + WriteProfileFile(profilePath, fileState); return true; } @@ -145,18 +143,17 @@ private static bool RemoveEntryFromFile(string profilePath) } var fileState = ReadProfileFile(profilePath); - var lines = fileState.Lines; bool modified = false; - for (int i = lines.Count - 1; i >= 0; i--) + for (int i = fileState.Lines.Count - 1; i >= 0; i--) { - if (lines[i].TrimEnd() == MarkerComment) + if (fileState.Lines[i].TrimEnd() == MarkerComment) { // Remove the marker line and the line after it (the eval/invoke line) - lines.RemoveAt(i); - if (i < lines.Count) + fileState.Lines.RemoveAt(i); + if (i < fileState.Lines.Count) { - lines.RemoveAt(i); + fileState.Lines.RemoveAt(i); } modified = true; } @@ -164,11 +161,12 @@ private static bool RemoveEntryFromFile(string profilePath) if (modified) { - WriteProfileFile( - profilePath, - lines, - fileState, - ensureTrailingNewLine: lines.Count > 0 && fileState.EndsWithTrailingNewLine); + fileState = fileState with + { + EndsWithTrailingNewLine = fileState.Lines.Count > 0 && fileState.EndsWithTrailingNewLine + }; + + WriteProfileFile(profilePath, fileState); } return modified; @@ -202,15 +200,11 @@ private static ProfileFileState ReadProfileFile(string profilePath) EndsWithLineEnding(content)); } - private static void WriteProfileFile( - string profilePath, - IReadOnlyList lines, - ProfileFileState fileState, - bool ensureTrailingNewLine) + private static void WriteProfileFile(string profilePath, ProfileFileState fileState) { - string content = string.Join(fileState.NewLine, lines); + string content = string.Join(fileState.NewLine, fileState.Lines); - if (lines.Count > 0 && ensureTrailingNewLine) + if (fileState.Lines.Count > 0 && fileState.EndsWithTrailingNewLine) { content += fileState.NewLine; } @@ -225,12 +219,12 @@ private static string DetectLineEnding(string content) return "\r\n"; } - if (content.Contains('\n')) + if (content.Contains('\n', StringComparison.Ordinal)) { return "\n"; } - if (content.Contains('\r')) + if (content.Contains('\r', StringComparison.Ordinal)) { return "\r"; } @@ -240,8 +234,8 @@ private static string DetectLineEnding(string content) private static bool EndsWithLineEnding(string content) => content.EndsWith("\r\n", StringComparison.Ordinal) || - content.EndsWith('\n') || - content.EndsWith('\r'); + content.EndsWith('\n', StringComparison.Ordinal) || + content.EndsWith('\r', StringComparison.Ordinal); private static Encoding GetWritableEncoding(Encoding detectedEncoding, bool hadBom) { From 5e8ae65a63729817dc9b497b1e9e45b0722f61c9 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 10:48:17 -0400 Subject: [PATCH 31/51] Fix formatting --- src/Installer/dotnetup/DotnetEnvironmentManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 02cbc83c2378..34aee7165f31 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -266,6 +266,7 @@ private static void TryAddPath(List paths, string path) paths.Add(path); } } + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) { if (OperatingSystem.IsWindows()) From f181c8fa3e0ad9fd76ebace925a48131fc5df04e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Fri, 17 Apr 2026 18:48:48 -0400 Subject: [PATCH 32/51] Make difference between profile and environment modifications clearer --- .../dotnetup/Commands/Init/InitWorkflows.cs | 8 ++++++-- src/Installer/dotnetup/DotnetEnvironmentManager.cs | 13 +++++++++++-- src/Installer/dotnetup/IDotnetEnvironmentManager.cs | 2 ++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs index a7cfec7cb28c..ba83ce7d61d7 100644 --- a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs @@ -41,7 +41,7 @@ public InitWorkflows(IDotnetEnvironmentManager dotnetEnvironment, ChannelVersion /// replace the default dotnet installation (i.e. update PATH / DOTNET_ROOT). /// public static bool ShouldReplaceSystemConfiguration(PathPreference preference) => - preference is PathPreference.FullPathReplacement or PathPreference.ShellProfile; + preference is PathPreference.FullPathReplacement; /// /// Returns true when the user chose to convert existing system-level .NET installs @@ -154,9 +154,13 @@ public void BaseConfigurationWalkthrough( // Step 3: Run the primary action (typically installing the base SDK from global.json/latest). RunPrimaryInstall(requests, primaryActionAfterConfigured, predownloadTask); - // Save config and apply configuration(s) - NOTE: Terminal Profile not yet implemented. SaveConfigAndDisplayResult(pathPreference, previousPreference); + if (pathPreference is PathPreference.ShellProfile) + { + _dotnetEnvironment.ApplyTerminalProfileModifications(shellProvider); + } + if (ShouldReplaceSystemConfiguration(pathPreference)) { _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path, shellProvider); diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 34aee7165f31..39aacc97ecb6 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -266,7 +266,7 @@ private static void TryAddPath(List paths, string path) paths.Add(path); } } - + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) { if (OperatingSystem.IsWindows()) @@ -311,9 +311,18 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); } } + } + + public void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null) + { + if (OperatingSystem.IsWindows()) + { + // Not implemented yet on Windows + return; + } else { - ConfigureInstallTypeUnix(installType, dotnetRoot, shellProvider); + ConfigureInstallTypeUnix(InstallType.User, dotnetRoot, shellProvider); } } diff --git a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs index fe0090163067..184395e9c151 100644 --- a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs @@ -26,6 +26,8 @@ internal interface IDotnetEnvironmentManager void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null); + void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null); + /// /// Updates the global.json file to reflect the installed SDK version, /// if a global.json exists and the install was global.json-sourced. From e37ba1c66563600293c49ba81d2246753481d595 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sat, 18 Apr 2026 23:50:24 -0400 Subject: [PATCH 33/51] Switch to begin / end markers for profile files --- .../dotnetup/unix-environment-setup.md | 24 ++-- .../dotnetup/Shell/BashEnvShellProvider.cs | 2 +- .../dotnetup/Shell/IEnvShellProvider.cs | 4 +- .../Shell/PowerShellEnvShellProvider.cs | 2 +- .../dotnetup/Shell/ShellProfileManager.cs | 113 +++++++++------ .../dotnetup/Shell/ZshEnvShellProvider.cs | 2 +- .../ShellProfileManagerTests.cs | 131 +++++++++++++++--- 7 files changed, 201 insertions(+), 77 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 2d5554cb8c7f..adb82d217b99 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -54,22 +54,26 @@ dotnetup defaultinstall system |-------|---------------|-----------| | **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** | `$ZDOTDIR/.zshrc` when `ZDOTDIR` is set; otherwise `~/.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. | +| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard PowerShell profile path on Unix. | + +The home directory used for these lookups comes from the user's current environment (`HOME`, or `USERPROFILE` / `Environment.SpecialFolder.UserProfile` as a fallback). dotnetup fails with a clear error if it cannot determine a writable profile location. ### Profile Entry Format -Each profile file gets a marker comment and an eval line: +Each profile file gets a dotnetup-managed block with explicit begin/end markers: **Bash / Zsh:** ```bash -# dotnetup +# dotnetup: begin eval "$('/path/to/dotnetup' print-env-script --shell bash)" +# dotnetup: end ``` **PowerShell:** ```powershell -# dotnetup +# dotnetup: begin & '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression +# dotnetup: end ``` The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). The `--dotnet-install-path` argument is only included in generated profile entries when dotnetup is configured to use a non-default install root. @@ -80,11 +84,11 @@ Before modifying an existing profile file, dotnetup creates a backup (e.g., `~/. ### 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. +To remove the environment configuration manually, remove the full block from `# dotnetup: begin` through `# dotnetup: end` in each profile file. The backup files can be used as a reference. ### Idempotency -If a profile file already contains the `# dotnetup` marker, the entry is not duplicated. +If a profile file already contains a dotnetup-managed block, the entry is updated in place rather than duplicated. ## The `print-env-script` Command @@ -186,8 +190,8 @@ public interface IEnvShellProvider 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); + string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null); + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null); } ``` @@ -200,8 +204,8 @@ public interface IEnvShellProvider ### ShellProfileManager `ShellProfileManager` coordinates profile file modifications: -- `AddProfileEntries(provider, dotnetupPath, dotnetupOnly, dotnetInstallPath)` — creates or updates the managed entry in place, creates backups, and can thread through a custom install path -- `RemoveProfileEntries(provider)` — finds and removes marker + eval lines +- `AddProfileEntries(provider, dotnetupPath, dotnetupOnly, dotnetInstallPath)` — creates or updates the managed begin/end block in place, creates backups, and can thread through a custom install path +- `RemoveProfileEntries(provider)` — finds and removes the full managed block `defaultinstall system` uses `AddProfileEntries(..., dotnetupOnly: true)` to switch the managed entry into dotnetup-only mode. diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 1a6c16bd9d1e..4b01e1302cc1 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -70,7 +70,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell bash", flags); - return $"{ShellProfileManager.MarkerComment}\neval \"$({command})\""; + return $"eval \"$({command})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs index a0815331ce02..faa8c7b45e24 100644 --- a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -39,8 +39,8 @@ public interface IEnvShellProvider 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. + /// Generates the shell command block to append to a shell profile that will eval dotnetup's env script. + /// adds the surrounding marker comments when it writes the block. /// /// The full path to the dotnetup binary /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 68f1e806d57d..81dbe3dca0fd 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -47,7 +47,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); - return $"{ShellProfileManager.MarkerComment}\n{command} | Invoke-Expression"; + return $"{command} | Invoke-Expression"; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index e8979a5d7848..25f68546de4c 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -8,7 +8,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// public class ShellProfileManager { - internal const string MarkerComment = "# dotnetup"; + internal const string BeginMarkerComment = "# dotnetup: begin"; + internal const string EndMarkerComment = "# dotnetup: end"; private const string BackupSuffix = ".dotnetup-backup"; private sealed record ProfileFileState( @@ -83,48 +84,44 @@ private static bool EnsureEntryInFile(string profilePath, string entry) if (!File.Exists(profilePath)) { - // New file — just write the entry - File.WriteAllText(profilePath, entry + Environment.NewLine, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + // New file — just write the managed block. + File.WriteAllText(profilePath, WrapEntryWithMarkers(entry) + Environment.NewLine, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); return true; } var fileState = ReadProfileFile(profilePath); - var entryLines = entry.Split('\n', StringSplitOptions.None) + var entryLines = WrapEntryWithMarkers(entry).Split('\n', StringSplitOptions.None) .Select(l => l.TrimEnd('\r')) .ToArray(); + var existingBlocks = FindManagedBlocks(fileState.Lines); - // Look for an existing marker - int markerIndex = fileState.Lines.FindIndex(l => l.TrimEnd() == MarkerComment); - - if (markerIndex >= 0) + if (existingBlocks.Count > 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 < fileState.Lines.Count) - { - oldEntryEnd++; - } - - // Check if the existing entry already matches - var oldEntry = fileState.Lines.GetRange(markerIndex, oldEntryEnd - markerIndex); - if (oldEntry.Count == entryLines.Length && + var firstBlock = existingBlocks[0]; + var oldEntry = fileState.Lines.GetRange(firstBlock.Start, firstBlock.EndExclusive - firstBlock.Start); + if (existingBlocks.Count == 1 && + 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); - fileState.Lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); - fileState.Lines.InsertRange(markerIndex, entryLines); + + for (int i = existingBlocks.Count - 1; i >= 0; i--) + { + var block = existingBlocks[i]; + fileState.Lines.RemoveRange(block.Start, block.EndExclusive - block.Start); + } + + fileState.Lines.InsertRange(firstBlock.Start, entryLines); WriteProfileFile(profilePath, fileState); return true; } // No existing entry — append File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - if (fileState.Lines.Count > 0) + if (fileState.Lines.Count > 0 && !string.IsNullOrWhiteSpace(fileState.Lines[^1])) { fileState.Lines.Add(string.Empty); } @@ -143,43 +140,40 @@ private static bool RemoveEntryFromFile(string profilePath) } var fileState = ReadProfileFile(profilePath); - bool modified = false; + var existingBlocks = FindManagedBlocks(fileState.Lines); - for (int i = fileState.Lines.Count - 1; i >= 0; i--) + if (existingBlocks.Count == 0) { - if (fileState.Lines[i].TrimEnd() == MarkerComment) - { - // Remove the marker line and the line after it (the eval/invoke line) - fileState.Lines.RemoveAt(i); - if (i < fileState.Lines.Count) - { - fileState.Lines.RemoveAt(i); - } - modified = true; - } + return false; } - if (modified) - { - fileState = fileState with - { - EndsWithTrailingNewLine = fileState.Lines.Count > 0 && fileState.EndsWithTrailingNewLine - }; + File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - WriteProfileFile(profilePath, fileState); + for (int i = existingBlocks.Count - 1; i >= 0; i--) + { + var block = existingBlocks[i]; + fileState.Lines.RemoveRange(block.Start, block.EndExclusive - block.Start); } - return modified; + fileState = fileState with + { + EndsWithTrailingNewLine = fileState.Lines.Count > 0 && fileState.EndsWithTrailingNewLine + }; + + WriteProfileFile(profilePath, fileState); + return true; } private static ProfileFileState ReadProfileFile(string profilePath) { byte[] bytes = File.ReadAllBytes(profilePath); + var utf8FallbackEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); using var stream = new MemoryStream(bytes); using var reader = new StreamReader( stream, - encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + // Use UTF-8 as the fallback, but allow an existing BOM to override it. + encoding: utf8FallbackEncoding, detectEncodingFromByteOrderMarks: true); string content = reader.ReadToEnd(); @@ -212,6 +206,32 @@ private static void WriteProfileFile(string profilePath, ProfileFileState fileSt File.WriteAllText(profilePath, content, fileState.Encoding); } + private static string WrapEntryWithMarkers(string entry) => + $"{BeginMarkerComment}\n{entry}\n{EndMarkerComment}"; + + private static List<(int Start, int EndExclusive)> FindManagedBlocks(List lines) + { + var blocks = new List<(int Start, int EndExclusive)>(); + + for (int i = 0; i < lines.Count; i++) + { + var trimmedLine = lines[i].TrimEnd(); + if (trimmedLine == BeginMarkerComment) + { + int endIndex = i + 1; + while (endIndex < lines.Count && lines[endIndex].TrimEnd() != EndMarkerComment) + { + endIndex++; + } + + blocks.Add((i, endIndex < lines.Count ? endIndex + 1 : lines.Count)); + i = endIndex; + } + } + + return blocks; + } + private static string DetectLineEnding(string content) { if (content.Contains("\r\n", StringComparison.Ordinal)) @@ -249,6 +269,11 @@ private static Encoding GetWritableEncoding(Encoding detectedEncoding, bool hadB private static bool HasPreamble(byte[] bytes, Encoding encoding) { + if (encoding.CodePage == Encoding.UTF8.CodePage) + { + return bytes.AsSpan().StartsWith(Encoding.UTF8.Preamble); + } + byte[] preamble = encoding.GetPreamble(); return preamble.Length > 0 && bytes.AsSpan().StartsWith(preamble); } diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index e503ace25304..7f688bba3bfb 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -51,7 +51,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell zsh", flags); - return $"{ShellProfileManager.MarkerComment}\neval \"$({command})\""; + return $"eval \"$({command})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 9eb527d14212..e62c5b2217f9 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -37,7 +37,8 @@ public void AddProfileEntries_CreatesFileAndAddsEntry() modified.Should().HaveCount(1); var content = File.ReadAllText(modified[0]); - content.Should().Contain(ShellProfileManager.MarkerComment); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().Contain(ShellProfileManager.EndMarkerComment); content.Should().Contain("print-env-script"); } @@ -52,7 +53,8 @@ public void AddProfileEntries_AppendsToExistingFile() var content = File.ReadAllText(profilePath); content.Should().StartWith("# existing config"); - content.Should().Contain(ShellProfileManager.MarkerComment); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().Contain(ShellProfileManager.EndMarkerComment); } [Fact] @@ -66,7 +68,8 @@ public void AddProfileEntries_DoesNotDuplicateIfAlreadyPresent() modified.Should().BeEmpty(); var lines = File.ReadAllLines(profilePath); - lines.Count(l => l.TrimEnd() == ShellProfileManager.MarkerComment).Should().Be(1); + lines.Count(l => l.TrimEnd() == ShellProfileManager.BeginMarkerComment).Should().Be(1); + lines.Count(l => l.TrimEnd() == ShellProfileManager.EndMarkerComment).Should().Be(1); } [Fact] @@ -111,19 +114,20 @@ public void AddProfileEntries_CreatesParentDirectories() } [Fact] - public void RemoveProfileEntries_RemovesMarkerAndEvalLine() + public void RemoveProfileEntries_RemovesManagedBlock() { 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); + File.ReadAllText(profilePath).Should().Contain(ShellProfileManager.BeginMarkerComment); var modified = ShellProfileManager.RemoveProfileEntries(provider); modified.Should().HaveCount(1); var content = File.ReadAllText(profilePath); - content.Should().NotContain(ShellProfileManager.MarkerComment); + content.Should().NotContain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain(ShellProfileManager.EndMarkerComment); content.Should().NotContain("print-env-script"); } @@ -147,8 +151,9 @@ public void RemoveProfileEntries_PreservesUtf8BomAndCrLfLineEndings() { var profilePath = Path.Combine(_tempDir, "preserve-remove.sh"); var provider = new TestShellProvider(_tempDir, "preserve-remove.sh"); - var entry = provider.GenerateProfileEntry(FakeDotnetupPath).Replace("\n", "\r\n", StringComparison.Ordinal); - var originalContent = $"# existing config\r\n{entry}\r\nexport FOO=bar\r\n"; + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + var originalContent = + $"# existing config\r\n{ShellProfileManager.BeginMarkerComment}\r\n{entry}\r\n{ShellProfileManager.EndMarkerComment}\r\nexport FOO=bar\r\n"; File.WriteAllText(profilePath, originalContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); ShellProfileManager.RemoveProfileEntries(provider); @@ -159,10 +164,54 @@ public void RemoveProfileEntries_PreservesUtf8BomAndCrLfLineEndings() var content = File.ReadAllText(profilePath); content.Should().Contain("# existing config"); content.Should().Contain("export FOO=bar"); - content.Should().NotContain(ShellProfileManager.MarkerComment); + content.Should().NotContain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain(ShellProfileManager.EndMarkerComment); AssertUsesOnlyCrLfLineEndings(content); } + [Fact] + public void RemoveProfileEntries_RemovesManagedBlockWhenEndMarkerIsMissing() + { + var profilePath = Path.Combine(_tempDir, "missing-end.sh"); + File.WriteAllText( + profilePath, + $""" + # existing config + {ShellProfileManager.BeginMarkerComment} + eval "$('/old/dotnetup' print-env-script --shell test)" + export TEMP_VAR=1 + """); + var provider = new TestShellProvider(_tempDir, "missing-end.sh"); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().Contain("# existing config"); + content.Should().NotContain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain(ShellProfileManager.EndMarkerComment); + content.Should().NotContain("print-env-script"); + } + + [Fact] + public void RemoveProfileEntries_IgnoresOrphanedEndMarkerWithoutBegin() + { + var profilePath = Path.Combine(_tempDir, "missing-begin.sh"); + var originalContent = + $""" + # existing config + {ShellProfileManager.EndMarkerComment} + export FOO=bar + """; + File.WriteAllText(profilePath, originalContent); + var provider = new TestShellProvider(_tempDir, "missing-begin.sh"); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().BeEmpty(); + File.ReadAllText(profilePath).Should().Be(originalContent.ReplaceLineEndings(Environment.NewLine)); + } + [Fact] public void RemoveProfileEntries_ReturnsEmptyForMissingFile() { @@ -181,8 +230,8 @@ public void AddProfileEntries_ModifiesMultipleFiles() 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); + File.ReadAllText(Path.Combine(_tempDir, "file1.sh")).Should().Contain(ShellProfileManager.BeginMarkerComment); + File.ReadAllText(Path.Combine(_tempDir, "file2.sh")).Should().Contain(ShellProfileManager.BeginMarkerComment); } [Fact] @@ -223,8 +272,44 @@ public void AddProfileEntries_ReplacesExistingEntryInPlace() 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); + content.Split('\n').Count(l => l.TrimEnd() == ShellProfileManager.BeginMarkerComment).Should().Be(1); + content.Split('\n').Count(l => l.TrimEnd() == ShellProfileManager.EndMarkerComment).Should().Be(1); + } + + [Fact] + public void AddProfileEntries_ReplacesManagedBlockOfArbitraryLength() + { + var profilePath = Path.Combine(_tempDir, "multiline.sh"); + File.WriteAllText( + profilePath, + $""" + # existing config + {ShellProfileManager.BeginMarkerComment} + old line 1 + old line 2 + old line 3 + {ShellProfileManager.EndMarkerComment} + export FOO=bar + """); + + var provider = new TestShellProvider(_tempDir, "multiline.sh") + { + ProfileEntryOverride = + """ + eval "$('/usr/local/bin/dotnetup' print-env-script --shell test)" + hash -r 2>/dev/null + """, + }; + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().Contain(ShellProfileManager.BeginMarkerComment); + content.Should().Contain(ShellProfileManager.EndMarkerComment); + content.Should().Contain("hash -r 2>/dev/null"); + content.Should().Contain("export FOO=bar"); + content.Should().NotContain("old line 1"); } [Fact] @@ -244,7 +329,8 @@ public void BashProvider_GenerateProfileEntry_ContainsEval() var provider = new BashEnvShellProvider(); var entry = provider.GenerateProfileEntry(FakeDotnetupPath); - entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); + entry.Should().NotContain(ShellProfileManager.EndMarkerComment); entry.Should().Contain("eval"); entry.Should().Contain("--shell bash"); entry.Should().NotContain("--dotnetup-only"); @@ -287,7 +373,8 @@ public void ZshProvider_GenerateProfileEntry_ContainsEval() var provider = new ZshEnvShellProvider(); var entry = provider.GenerateProfileEntry(FakeDotnetupPath); - entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); + entry.Should().NotContain(ShellProfileManager.EndMarkerComment); entry.Should().Contain("eval"); entry.Should().Contain("--shell zsh"); entry.Should().NotContain("--dotnetup-only"); @@ -299,7 +386,8 @@ public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() var provider = new PowerShellEnvShellProvider(); var entry = provider.GenerateProfileEntry(FakeDotnetupPath); - entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); + entry.Should().NotContain(ShellProfileManager.EndMarkerComment); entry.Should().Contain("Invoke-Expression"); entry.Should().Contain("--shell pwsh"); entry.Should().NotContain("--dotnetup-only"); @@ -313,7 +401,8 @@ public void BashProvider_GenerateActivationCommand_IsCorrect() command.Should().Contain("eval"); command.Should().Contain("--shell bash"); - command.Should().NotContain(ShellProfileManager.MarkerComment); + command.Should().NotContain(ShellProfileManager.BeginMarkerComment); + command.Should().NotContain(ShellProfileManager.EndMarkerComment); } [Fact] @@ -381,6 +470,7 @@ public TestShellProvider(string dir, params string[] fileNames) public string ArgumentName => "test"; public string Extension => "sh"; public string? HelpDescription => null; + public string? ProfileEntryOverride { get; init; } public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, bool includeDotnet = true) => includeDotnet @@ -391,13 +481,18 @@ public string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { + if (ProfileEntryOverride is not null) + { + return ProfileEntryOverride; + } + var flags = dotnetupOnly ? " --dotnetup-only" : ""; if (!dotnetupOnly && !string.IsNullOrEmpty(dotnetInstallPath)) { flags += $" --dotnet-install-path '{dotnetInstallPath}'"; } - return $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + return $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) From e5ecf33662b2027bc1cf6f162202377f1ce3b958 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 11:16:46 -0400 Subject: [PATCH 34/51] Guard dotnetup profile entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 9 +++++-- .../dotnetup/Shell/BashEnvShellProvider.cs | 4 +-- .../Shell/PowerShellEnvShellProvider.cs | 4 +-- .../dotnetup/Shell/ShellProviderHelpers.cs | 25 +++++++++++++++++++ .../dotnetup/Shell/ZshEnvShellProvider.cs | 4 +-- .../MockDotnetInstallManager.cs | 3 +++ .../ShellProfileManagerTests.cs | 3 +++ 7 files changed, 41 insertions(+), 11 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index adb82d217b99..95c48a0a3789 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -65,14 +65,19 @@ Each profile file gets a dotnetup-managed block with explicit begin/end markers: **Bash / Zsh:** ```bash # dotnetup: begin -eval "$('/path/to/dotnetup' print-env-script --shell bash)" +if [ -x '/path/to/dotnetup' ]; then + eval "$('/path/to/dotnetup' print-env-script --shell bash)" +fi # dotnetup: end ``` **PowerShell:** ```powershell # dotnetup: begin -& '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression +if (Test-Path -LiteralPath '/path/to/dotnetup' -PathType Leaf) +{ + & '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression +} # dotnetup: end ``` diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 4b01e1302cc1..1ca0597cf7ab 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -67,10 +67,8 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); - var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell bash", flags); - return $"eval \"$({command})\""; + return ShellProviderHelpers.BuildPosixProfileEntry(dotnetupPath, "bash", flags); } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 81dbe3dca0fd..1fe8fcccfe6e 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -44,10 +44,8 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); - var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); - return $"{command} | Invoke-Expression"; + return ShellProviderHelpers.BuildPowerShellProfileEntry(dotnetupPath, "pwsh", flags); } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index ef97136ba496..9c7bbf59940b 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -41,6 +41,31 @@ internal static string GetCommandFlags(bool dotnetupOnly, string? dotnetInstallP internal static string AppendArguments(string command, string flags) => string.IsNullOrEmpty(flags) ? command : $"{command} {flags}"; + internal static string BuildPosixProfileEntry(string dotnetupPath, string shellName, string flags) + { + var escapedPath = EscapePosixPath(dotnetupPath); + var command = AppendArguments($"'{escapedPath}' print-env-script --shell {shellName}", flags); + + return $$""" + if [ -x '{{escapedPath}}' ]; then + eval "$({{command}})" + fi + """; + } + + internal static string BuildPowerShellProfileEntry(string dotnetupPath, string shellName, string flags) + { + var escapedPath = EscapePowerShellPath(dotnetupPath); + var command = AppendArguments($"& '{escapedPath}' print-env-script --shell {shellName}", flags); + + return $$""" + if (Test-Path -LiteralPath '{{escapedPath}}' -PathType Leaf) + { + {{command}} | Invoke-Expression + } + """; + } + internal static string BuildPosixPathExport(string escapedPath, string dotnetupDir, bool includeDotnet) { // Put the managed paths first so the shell resolves dotnet/dotnetup from the selected install immediately. diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 7f688bba3bfb..0d3db3bf1079 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -48,10 +48,8 @@ public IReadOnlyList GetProfilePaths() public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); - var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell zsh", flags); - return $"eval \"$({command})\""; + return ShellProviderHelpers.BuildPosixProfileEntry(dotnetupPath, "zsh", flags); } public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index 7c2e690c27dc..efd0aae0abed 100644 --- a/test/dotnetup.Tests/MockDotnetInstallManager.cs +++ b/test/dotnetup.Tests/MockDotnetInstallManager.cs @@ -59,5 +59,8 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne ApplyEnvironmentModificationsCallCount++; } + public void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null) + => throw new NotImplementedException(); + public void ApplyGlobalJsonModifications(IReadOnlyList requests) { } } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index e62c5b2217f9..3e0c5081a2fd 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -331,6 +331,7 @@ public void BashProvider_GenerateProfileEntry_ContainsEval() entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); entry.Should().NotContain(ShellProfileManager.EndMarkerComment); + entry.Should().Contain($"if [ -x '{FakeDotnetupPath}' ]; then"); entry.Should().Contain("eval"); entry.Should().Contain("--shell bash"); entry.Should().NotContain("--dotnetup-only"); @@ -375,6 +376,7 @@ public void ZshProvider_GenerateProfileEntry_ContainsEval() entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); entry.Should().NotContain(ShellProfileManager.EndMarkerComment); + entry.Should().Contain($"if [ -x '{FakeDotnetupPath}' ]; then"); entry.Should().Contain("eval"); entry.Should().Contain("--shell zsh"); entry.Should().NotContain("--dotnetup-only"); @@ -388,6 +390,7 @@ public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() entry.Should().NotContain(ShellProfileManager.BeginMarkerComment); entry.Should().NotContain(ShellProfileManager.EndMarkerComment); + entry.Should().Contain($"if (Test-Path -LiteralPath '{FakeDotnetupPath}' -PathType Leaf)"); entry.Should().Contain("Invoke-Expression"); entry.Should().Contain("--shell pwsh"); entry.Should().NotContain("--dotnetup-only"); From 5cd9eb348237c8bfb22c4c0fafdea2fbcf988e13 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 11:24:25 -0400 Subject: [PATCH 35/51] Add additional profile script line ending coverage --- .../ShellProfileManagerTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 3e0c5081a2fd..ef3cf107d4ac 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -101,6 +101,20 @@ public void AddProfileEntries_PreservesUtf8BomAndCrLfLineEndings() AssertUsesOnlyCrLfLineEndings(File.ReadAllText(profilePath)); } + [Fact] + public void AddProfileEntries_PreservesUtf8BomAndLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-add-lf.sh"); + File.WriteAllText(profilePath, "# existing config\nexport FOO=bar\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + var provider = new TestShellProvider(_tempDir, "preserve-add-lf.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan(0, Encoding.UTF8.Preamble.Length).SequenceEqual(Encoding.UTF8.Preamble).Should().BeTrue(); + AssertUsesOnlyLfLineEndings(File.ReadAllText(profilePath)); + } + [Fact] public void AddProfileEntries_CreatesParentDirectories() { @@ -169,6 +183,29 @@ public void RemoveProfileEntries_PreservesUtf8BomAndCrLfLineEndings() AssertUsesOnlyCrLfLineEndings(content); } + [Fact] + public void RemoveProfileEntries_PreservesUtf8BomAndLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-remove-lf.sh"); + var provider = new TestShellProvider(_tempDir, "preserve-remove-lf.sh"); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + var originalContent = + $"# existing config\n{ShellProfileManager.BeginMarkerComment}\n{entry}\n{ShellProfileManager.EndMarkerComment}\nexport FOO=bar\n"; + File.WriteAllText(profilePath, originalContent, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + + ShellProfileManager.RemoveProfileEntries(provider); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan(0, Encoding.UTF8.Preamble.Length).SequenceEqual(Encoding.UTF8.Preamble).Should().BeTrue(); + + var content = File.ReadAllText(profilePath); + content.Should().Contain("# existing config"); + content.Should().Contain("export FOO=bar"); + content.Should().NotContain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain(ShellProfileManager.EndMarkerComment); + AssertUsesOnlyLfLineEndings(content); + } + [Fact] public void RemoveProfileEntries_RemovesManagedBlockWhenEndMarkerIsMissing() { @@ -515,4 +552,11 @@ private static void AssertUsesOnlyCrLfLineEndings(string content) content.Should().Contain("\r\n"); content.Replace("\r\n", string.Empty, StringComparison.Ordinal).Should().NotContain("\n"); } + + private static void AssertUsesOnlyLfLineEndings(string content) + { + content.Should().Contain("\n"); + content.Should().NotContain("\r\n"); + content.Should().NotContain("\r"); + } } From 6baddc430e445701ce66dcdd8fa9f4df2de2d07e Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 11:26:45 -0400 Subject: [PATCH 36/51] Add comment per PR review feedback --- src/Installer/dotnetup/Shell/BashEnvShellProvider.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 1ca0597cf7ab..538aac9d46d3 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -49,6 +49,8 @@ public IReadOnlyList GetProfilePaths() // For login shells, use the first existing of .bash_profile / .profile. // Never create .bash_profile — it would shadow an existing .profile. + // If the user later creates .bash_profile themselves and it does not source .profile, + // the dotnetup initialization we wrote to .profile will no longer run for login shells. string bashProfile = Path.Combine(home, ".bash_profile"); string profile = Path.Combine(home, ".profile"); From 4ce4f3b17378e145e008903434e2ca4304c55328 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 11:47:24 -0400 Subject: [PATCH 37/51] Use atomic shell profile updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/unix-environment-setup.md | 6 +- .../dotnetup/Shell/ShellProfileManager.cs | 73 +++++++++++++++++-- .../ShellProfileManagerTests.cs | 20 ++++- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 95c48a0a3789..873d2151cebf 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -83,13 +83,13 @@ if (Test-Path -LiteralPath '/path/to/dotnetup' -PathType Leaf) The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). The `--dotnet-install-path` argument is only included in generated profile entries when dotnetup is configured to use a non-default install root. -### Backups +### Safe updates -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. +When updating an existing profile file, dotnetup writes the new content to a separate file and then swaps it into place. This avoids leaving a partially written profile behind if the update is interrupted, and it does not keep a persistent backup file after a successful update. ### Reversibility -To remove the environment configuration manually, remove the full block from `# dotnetup: begin` through `# dotnetup: end` in each profile file. The backup files can be used as a reference. +To remove the environment configuration manually, remove the full block from `# dotnetup: begin` through `# dotnetup: end` in each profile file. ### Idempotency diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index 25f68546de4c..51b8dfd1d8bb 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -10,6 +10,7 @@ public class ShellProfileManager { internal const string BeginMarkerComment = "# dotnetup: begin"; internal const string EndMarkerComment = "# dotnetup: end"; + // Used only during file replacement and deleted after a successful update. private const string BackupSuffix = ".dotnetup-backup"; private sealed record ProfileFileState( @@ -21,7 +22,7 @@ private sealed record ProfileFileState( /// /// 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. + /// Existing files are updated via a write-and-rename flow to avoid partially written profiles. /// /// The shell provider to use. /// The full path to the dotnetup binary. @@ -106,8 +107,6 @@ private static bool EnsureEntryInFile(string profilePath, string entry) return false; // Already correct } - File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - for (int i = existingBlocks.Count - 1; i >= 0; i--) { var block = existingBlocks[i]; @@ -120,7 +119,6 @@ private static bool EnsureEntryInFile(string profilePath, string entry) } // No existing entry — append - File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); if (fileState.Lines.Count > 0 && !string.IsNullOrWhiteSpace(fileState.Lines[^1])) { fileState.Lines.Add(string.Empty); @@ -147,8 +145,6 @@ private static bool RemoveEntryFromFile(string profilePath) return false; } - File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); - for (int i = existingBlocks.Count - 1; i >= 0; i--) { var block = existingBlocks[i]; @@ -203,7 +199,70 @@ private static void WriteProfileFile(string profilePath, ProfileFileState fileSt content += fileState.NewLine; } - File.WriteAllText(profilePath, content, fileState.Encoding); + if (!File.Exists(profilePath)) + { + File.WriteAllText(profilePath, content, fileState.Encoding); + return; + } + + var directory = Path.GetDirectoryName(profilePath) + ?? throw new InvalidOperationException($"Unable to determine the directory for '{profilePath}'."); + var tempPath = Path.Combine(directory, $"{Path.GetFileName(profilePath)}.{Path.GetRandomFileName()}.tmp"); + var backupPath = profilePath + BackupSuffix; + + File.WriteAllText(tempPath, content, fileState.Encoding); + + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + + try + { + File.Move(profilePath, backupPath); + + try + { + File.Move(tempPath, profilePath); + } + catch (IOException) + { + RestoreOriginalFile(profilePath, backupPath, tempPath); + throw; + } + catch (UnauthorizedAccessException) + { + RestoreOriginalFile(profilePath, backupPath, tempPath); + throw; + } + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + + File.Delete(backupPath); + } + + private static void RestoreOriginalFile(string profilePath, string backupPath, string tempPath) + { + if (File.Exists(profilePath)) + { + File.Delete(profilePath); + } + + if (File.Exists(backupPath)) + { + File.Move(backupPath, profilePath); + } + + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } } private static string WrapEntryWithMarkers(string entry) => diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index ef3cf107d4ac..38c74e05c2b2 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -73,7 +73,7 @@ public void AddProfileEntries_DoesNotDuplicateIfAlreadyPresent() } [Fact] - public void AddProfileEntries_CreatesBackupOfExistingFile() + public void AddProfileEntries_DoesNotLeaveBackupOfExistingFile() { var profilePath = Path.Combine(_tempDir, "backup.sh"); var originalContent = "# original content\n"; @@ -83,8 +83,8 @@ public void AddProfileEntries_CreatesBackupOfExistingFile() ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); var backupPath = profilePath + ".dotnetup-backup"; - File.Exists(backupPath).Should().BeTrue(); - File.ReadAllText(backupPath).Should().Be(originalContent); + File.Exists(backupPath).Should().BeFalse(); + File.ReadAllText(profilePath).Should().NotBe(originalContent); } [Fact] @@ -145,6 +145,20 @@ public void RemoveProfileEntries_RemovesManagedBlock() content.Should().NotContain("print-env-script"); } + [Fact] + public void RemoveProfileEntries_DoesNotLeaveBackupOfExistingFile() + { + var profilePath = Path.Combine(_tempDir, "remove-backup.sh"); + var provider = new TestShellProvider(_tempDir, "remove-backup.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().HaveCount(1); + File.Exists(profilePath + ".dotnetup-backup").Should().BeFalse(); + } + [Fact] public void RemoveProfileEntries_LeavesOtherContentIntact() { From 06eef24a5b56480456d87b7956cfccf5f1b95c5d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 17:25:39 -0400 Subject: [PATCH 38/51] Fix tests --- src/Installer/dotnetup/Shell/ShellDetection.cs | 3 ++- test/dotnetup.Tests/InitWorkflowTests.cs | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs index cd8275af7d1c..04213e7c22b1 100644 --- a/src/Installer/dotnetup/Shell/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -51,7 +51,8 @@ public static class ShellDetection } var resolvedShellPath = Microsoft.DotNet.NativeWrapper.FileInterop.ResolveRealPath(shellPathOrName) ?? shellPathOrName; - var normalizedShellName = Path.GetFileNameWithoutExtension(resolvedShellPath); + var normalizedShellPath = resolvedShellPath.Replace('\\', '/'); + var normalizedShellName = Path.GetFileNameWithoutExtension(normalizedShellPath); return GetShellProviderByName(normalizedShellName); } diff --git a/test/dotnetup.Tests/InitWorkflowTests.cs b/test/dotnetup.Tests/InitWorkflowTests.cs index fda8c820059c..2c1d53ca3c05 100644 --- a/test/dotnetup.Tests/InitWorkflowTests.cs +++ b/test/dotnetup.Tests/InitWorkflowTests.cs @@ -41,7 +41,6 @@ public void ShouldReplaceSystemConfiguration_ReturnsFalse_ForDotnetupDotnet() } [Theory] - [InlineData(PathPreference.ShellProfile)] [InlineData(PathPreference.FullPathReplacement)] internal void ShouldReplaceSystemConfiguration_ReturnsTrue_ForPathReplacingModes(PathPreference preference) { From 390787f24880c866900da85b260abd6a51770f4a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 18:24:00 -0400 Subject: [PATCH 39/51] Fix dotnetup init shell-profile install root Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Commands/Init/InitWorkflows.cs | 2 +- .../dotnetup/DotnetEnvironmentManager.cs | 4 +- .../dotnetup/IDotnetEnvironmentManager.cs | 2 +- test/dotnetup.Tests/InitWorkflowTests.cs | 39 +++++++++++++++++++ .../MockDotnetInstallManager.cs | 11 +++++- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs index ba83ce7d61d7..920588505163 100644 --- a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs @@ -158,7 +158,7 @@ public void BaseConfigurationWalkthrough( if (pathPreference is PathPreference.ShellProfile) { - _dotnetEnvironment.ApplyTerminalProfileModifications(shellProvider); + _dotnetEnvironment.ApplyTerminalProfileModifications(installRoot.Path, shellProvider); } if (ShouldReplaceSystemConfiguration(pathPreference)) diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 39aacc97ecb6..958ca0b5494d 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -313,8 +313,10 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne } } - public void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null) + public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) { + ArgumentNullException.ThrowIfNull(dotnetRoot); + if (OperatingSystem.IsWindows()) { // Not implemented yet on Windows diff --git a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs index 184395e9c151..dce85ac790ea 100644 --- a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs @@ -26,7 +26,7 @@ internal interface IDotnetEnvironmentManager void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null); - void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null); + void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null); /// /// Updates the global.json file to reflect the installed SDK version, diff --git a/test/dotnetup.Tests/InitWorkflowTests.cs b/test/dotnetup.Tests/InitWorkflowTests.cs index 2c1d53ca3c05..f6d2713cbc88 100644 --- a/test/dotnetup.Tests/InitWorkflowTests.cs +++ b/test/dotnetup.Tests/InitWorkflowTests.cs @@ -7,6 +7,7 @@ using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Init; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Tests; using Xunit; @@ -121,6 +122,25 @@ public void PromptInstallsToMigrateIfDesired_ReturnsEmpty_WhenNoSystemInstallsEx mock.GetExistingSystemInstallsCallCount.Should().Be(1); } + [Fact] + public void BaseConfigurationWalkthrough_PassesInstallRootToTerminalProfileModifications() + { + var mock = new MockDotnetInstallManager( + defaultInstallPath: _tempDir, + existingSystemInstalls: []); + var workflow = new InitWorkflows(mock, null!); + + workflow.BaseConfigurationWalkthrough( + requests: [], + primaryActionAfterConfigured: () => { }, + noProgress: true, + interactive: false, + shellProvider: new TestShellProvider()); + + mock.ApplyTerminalProfileModificationsCallCount.Should().Be(1); + mock.LastDotnetRootForTerminalProfileModifications.Should().Be(_tempDir); + } + // ── GetExistingSystemInstalls — architecture filtering ── [Fact] @@ -191,4 +211,23 @@ public void PromptInstallsToMigrateIfDesired_QueriesSystemInstalls_WhenConversio // Should still query system installs because ignoreConfig overrides the disabled flag mock.GetExistingSystemInstallsCallCount.Should().Be(1); } + + private sealed class TestShellProvider : IEnvShellProvider + { + public string ArgumentName => "test"; + public string Extension => "test"; + public string? HelpDescription => "Test shell provider"; + + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) + => string.Empty; + + public IReadOnlyList GetProfilePaths() + => []; + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + => string.Empty; + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + => string.Empty; + } } diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index efd0aae0abed..3bf215720c42 100644 --- a/test/dotnetup.Tests/MockDotnetInstallManager.cs +++ b/test/dotnetup.Tests/MockDotnetInstallManager.cs @@ -22,6 +22,9 @@ internal class MockDotnetInstallManager : IDotnetEnvironmentManager public int GetExistingSystemInstallsCallCount { get; private set; } public int ApplyEnvironmentModificationsCallCount { get; private set; } + public int ApplyTerminalProfileModificationsCallCount { get; private set; } + public string? LastDotnetRootForEnvironmentModifications { get; private set; } + public string? LastDotnetRootForTerminalProfileModifications { get; private set; } public MockDotnetInstallManager( string defaultInstallPath, @@ -57,10 +60,14 @@ public List GetExistingSystemInstalls() public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) { ApplyEnvironmentModificationsCallCount++; + LastDotnetRootForEnvironmentModifications = dotnetRoot; } - public void ApplyTerminalProfileModifications(IEnvShellProvider? shellProvider = null, string? dotnetRoot = null) - => throw new NotImplementedException(); + public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) + { + ApplyTerminalProfileModificationsCallCount++; + LastDotnetRootForTerminalProfileModifications = dotnetRoot; + } public void ApplyGlobalJsonModifications(IReadOnlyList requests) { } } From a7508d02bb743ef0695ecb48eab66dfe5eafbc9d Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 18:37:46 -0400 Subject: [PATCH 40/51] Fix defaultinstall user on Unix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstall/DefaultInstallCommand.cs | 6 +- .../DefaultInstallCommandTests.cs | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 test/dotnetup.Tests/DefaultInstallCommandTests.cs diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 5935b69f9c64..0eab5f17a352 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -9,13 +9,15 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall; internal class DefaultInstallCommand : CommandBase { private readonly string _installType; + private readonly IDotnetEnvironmentManager _dotnetEnvironment; private readonly InstallRootManager _installRootManager; private readonly IEnvShellProvider? _shellProvider; public DefaultInstallCommand(ParseResult result, IDotnetEnvironmentManager? dotnetEnvironment = null) : base(result) { + _dotnetEnvironment = dotnetEnvironment ?? new DotnetEnvironmentManager(); _installType = result.GetValue(DefaultInstallCommandParser.InstallTypeArgument)!; - _installRootManager = new InstallRootManager(dotnetEnvironment); + _installRootManager = new InstallRootManager(_dotnetEnvironment); _shellProvider = result.GetValue(CommonOptions.ShellOption); } @@ -35,7 +37,7 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - var userDotnetPath = _installRootManager.GetUserInstallRootChanges().UserDotnetPath; + var userDotnetPath = _dotnetEnvironment.GetDefaultDotnetInstallPath(); return SetUnixShellProfile(dotnetupOnly: false, userDotnetPath); } diff --git a/test/dotnetup.Tests/DefaultInstallCommandTests.cs b/test/dotnetup.Tests/DefaultInstallCommandTests.cs new file mode 100644 index 000000000000..4b1cd9aeb64f --- /dev/null +++ b/test/dotnetup.Tests/DefaultInstallCommandTests.cs @@ -0,0 +1,58 @@ +// 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.DefaultInstall; +using Microsoft.DotNet.Tools.Bootstrapper.Tests; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public sealed class DefaultInstallCommandTests : IDisposable +{ + private readonly string _tempHome; + private readonly string? _originalHome; + + public DefaultInstallCommandTests() + { + _tempHome = Path.Combine(Path.GetTempPath(), "dotnetup-defaultinstall-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempHome); + _originalHome = Environment.GetEnvironmentVariable("HOME"); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable("HOME", _originalHome); + + try + { + Directory.Delete(_tempHome, recursive: true); + } + catch + { + // Cleanup is best-effort in tests. + } + } + + [Fact] + public void DefaultInstallUser_UsesDefaultInstallPathForPwshProfileOnUnix() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + Environment.SetEnvironmentVariable("HOME", _tempHome); + + string defaultInstallPath = Path.Combine(_tempHome, "dotnet-managed"); + var parseResult = Parser.Parse(["defaultinstall", "user", "--shell", "pwsh"]); + var environmentManager = new MockDotnetInstallManager(defaultInstallPath: defaultInstallPath); + + var exitCode = new DefaultInstallCommand(parseResult, environmentManager).Execute(); + + exitCode.Should().Be(0); + + string profilePath = Path.Combine(_tempHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); + File.Exists(profilePath).Should().BeTrue(); + File.ReadAllText(profilePath).Should().Contain(defaultInstallPath); + } +} From 919a1a37dbab97cf9ca41a0c34bc28ade9fbc668 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 18:49:46 -0400 Subject: [PATCH 41/51] Fix pwsh profile activation script Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Shell/PowerShellEnvShellProvider.cs | 2 +- .../dotnetup/Shell/ShellProviderHelpers.cs | 6 +++++- test/dotnetup.Tests/EnvShellProviderTests.cs | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 1fe8fcccfe6e..2a3171b0f306 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -53,6 +53,6 @@ public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); - return $"{command} | Invoke-Expression"; + return $"$dotnetupScript = {command} | Out-String; if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) {{ Invoke-Expression $dotnetupScript }}"; } } diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index 9c7bbf59940b..5654dbdc22ae 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -61,7 +61,11 @@ internal static string BuildPowerShellProfileEntry(string dotnetupPath, string s return $$""" if (Test-Path -LiteralPath '{{escapedPath}}' -PathType Leaf) { - {{command}} | Invoke-Expression + $dotnetupScript = {{command}} | Out-String + if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) + { + Invoke-Expression $dotnetupScript + } } """; } diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index c223fe67d63f..81fac8d9e2b8 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -143,6 +143,22 @@ public void PowerShellProvider_DotnetupOnly_ShouldNotSetDotnetRoot() script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + $env:PATH"); } + [Fact] + public void PowerShellProvider_ShouldCaptureScriptBeforeInvokingExpression() + { + var provider = new PowerShellEnvShellProvider(); + + var profileEntry = provider.GenerateProfileEntry("/test/dotnetup"); + var activationCommand = provider.GenerateActivationCommand("/test/dotnetup"); + + profileEntry.Should().Contain("$dotnetupScript = & '/test/dotnetup' print-env-script --shell pwsh | Out-String"); + profileEntry.Should().Contain("if (-not [string]::IsNullOrWhiteSpace($dotnetupScript))"); + profileEntry.Should().Contain("Invoke-Expression $dotnetupScript"); + + activationCommand.Should().Contain("| Out-String"); + activationCommand.Should().Contain("Invoke-Expression $dotnetupScript"); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] From bcd8a1bd364c9ff7b9fa639f4ea0f593f2419994 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 19:04:44 -0400 Subject: [PATCH 42/51] Refactor shell activation helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Shell/BashEnvShellProvider.cs | 4 +- .../Shell/PowerShellEnvShellProvider.cs | 4 +- .../dotnetup/Shell/ShellProviderHelpers.cs | 48 ++++++++++++++++--- .../dotnetup/Shell/ZshEnvShellProvider.cs | 4 +- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 538aac9d46d3..0f73cf4d338d 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -75,9 +75,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); - var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell bash", flags); - return $"eval \"$({command})\""; + return ShellProviderHelpers.BuildPosixActivationCommand(dotnetupPath, "bash", flags); } } diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 2a3171b0f306..40c4559b7254 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -50,9 +50,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); - var command = ShellProviderHelpers.AppendArguments($"& '{escapedPath}' print-env-script --shell pwsh", flags); - return $"$dotnetupScript = {command} | Out-String; if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) {{ Invoke-Expression $dotnetupScript }}"; + return ShellProviderHelpers.BuildPowerShellActivationCommand(dotnetupPath, "pwsh", flags); } } diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index 5654dbdc22ae..0bde6674c84c 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -41,10 +41,16 @@ internal static string GetCommandFlags(bool dotnetupOnly, string? dotnetInstallP internal static string AppendArguments(string command, string flags) => string.IsNullOrEmpty(flags) ? command : $"{command} {flags}"; + internal static string BuildPosixActivationCommand(string dotnetupPath, string shellName, string flags) + { + var command = BuildPosixPrintEnvCommand(dotnetupPath, shellName, flags); + return $"eval \"$({command})\""; + } + internal static string BuildPosixProfileEntry(string dotnetupPath, string shellName, string flags) { var escapedPath = EscapePosixPath(dotnetupPath); - var command = AppendArguments($"'{escapedPath}' print-env-script --shell {shellName}", flags); + var command = BuildPosixPrintEnvCommand(dotnetupPath, shellName, flags); return $$""" if [ -x '{{escapedPath}}' ]; then @@ -53,23 +59,51 @@ internal static string BuildPosixProfileEntry(string dotnetupPath, string shellN """; } + internal static string BuildPowerShellActivationCommand(string dotnetupPath, string shellName, string flags) + { + var command = BuildPowerShellPrintEnvCommand(dotnetupPath, shellName, flags); + return BuildPowerShellInvocationBlock(command); + } + internal static string BuildPowerShellProfileEntry(string dotnetupPath, string shellName, string flags) { var escapedPath = EscapePowerShellPath(dotnetupPath); - var command = AppendArguments($"& '{escapedPath}' print-env-script --shell {shellName}", flags); + var activationBlock = IndentLines(BuildPowerShellActivationCommand(dotnetupPath, shellName, flags), " "); return $$""" if (Test-Path -LiteralPath '{{escapedPath}}' -PathType Leaf) { - $dotnetupScript = {{command}} | Out-String - if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) - { - Invoke-Expression $dotnetupScript - } + {{activationBlock}} + } + """; + } + + private static string BuildPosixPrintEnvCommand(string dotnetupPath, string shellName, string flags) + { + var escapedPath = EscapePosixPath(dotnetupPath); + return AppendArguments($"'{escapedPath}' print-env-script --shell {shellName}", flags); + } + + private static string BuildPowerShellPrintEnvCommand(string dotnetupPath, string shellName, string flags) + { + var escapedPath = EscapePowerShellPath(dotnetupPath); + return AppendArguments($"& '{escapedPath}' print-env-script --shell {shellName}", flags); + } + + private static string BuildPowerShellInvocationBlock(string command) + { + return $$""" + $dotnetupScript = {{command}} | Out-String + if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) + { + Invoke-Expression $dotnetupScript } """; } + private static string IndentLines(string text, string indentation) + => indentation + text.ReplaceLineEndings(Environment.NewLine + indentation); + internal static string BuildPosixPathExport(string escapedPath, string dotnetupDir, bool includeDotnet) { // Put the managed paths first so the shell resolves dotnet/dotnetup from the selected install immediately. diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 0d3db3bf1079..4b4092c215d1 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -54,9 +54,7 @@ public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = fals public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) { - var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetupPath); var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); - var command = ShellProviderHelpers.AppendArguments($"'{escapedPath}' print-env-script --shell zsh", flags); - return $"eval \"$({command})\""; + return ShellProviderHelpers.BuildPosixActivationCommand(dotnetupPath, "zsh", flags); } } From 676a295c9d29fa92ba2056761ab518afe7e0518a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Sun, 19 Apr 2026 19:49:32 -0400 Subject: [PATCH 43/51] Align pwsh profile and activation output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- documentation/general/dotnetup/unix-environment-setup.md | 6 +++++- src/Installer/dotnetup/Shell/ShellProviderHelpers.cs | 7 ++++--- test/dotnetup.Tests/EnvShellProviderTests.cs | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 873d2151cebf..dfd3ab9a663b 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -76,7 +76,11 @@ fi # dotnetup: begin if (Test-Path -LiteralPath '/path/to/dotnetup' -PathType Leaf) { - & '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression + $dotnetupScript = & '/path/to/dotnetup' print-env-script --shell pwsh | Out-String + if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) + { + Invoke-Expression $dotnetupScript + } } # dotnetup: end ``` diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index 0bde6674c84c..3e48f59a825e 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -62,13 +62,14 @@ internal static string BuildPosixProfileEntry(string dotnetupPath, string shellN internal static string BuildPowerShellActivationCommand(string dotnetupPath, string shellName, string flags) { var command = BuildPowerShellPrintEnvCommand(dotnetupPath, shellName, flags); - return BuildPowerShellInvocationBlock(command); + return $"Invoke-Expression ({command} | Out-String)"; } internal static string BuildPowerShellProfileEntry(string dotnetupPath, string shellName, string flags) { var escapedPath = EscapePowerShellPath(dotnetupPath); - var activationBlock = IndentLines(BuildPowerShellActivationCommand(dotnetupPath, shellName, flags), " "); + var command = BuildPowerShellPrintEnvCommand(dotnetupPath, shellName, flags); + var activationBlock = IndentLines(BuildPowerShellGuardedInvocationBlock(command), " "); return $$""" if (Test-Path -LiteralPath '{{escapedPath}}' -PathType Leaf) @@ -90,7 +91,7 @@ private static string BuildPowerShellPrintEnvCommand(string dotnetupPath, string return AppendArguments($"& '{escapedPath}' print-env-script --shell {shellName}", flags); } - private static string BuildPowerShellInvocationBlock(string command) + private static string BuildPowerShellGuardedInvocationBlock(string command) { return $$""" $dotnetupScript = {{command}} | Out-String diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index 81fac8d9e2b8..da3fa234a065 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -155,8 +155,7 @@ public void PowerShellProvider_ShouldCaptureScriptBeforeInvokingExpression() profileEntry.Should().Contain("if (-not [string]::IsNullOrWhiteSpace($dotnetupScript))"); profileEntry.Should().Contain("Invoke-Expression $dotnetupScript"); - activationCommand.Should().Contain("| Out-String"); - activationCommand.Should().Contain("Invoke-Expression $dotnetupScript"); + activationCommand.Should().Be("Invoke-Expression (& '/test/dotnetup' print-env-script --shell pwsh | Out-String)"); } [Theory] From 44b57e0258994da28414089bb71e9a5bb77936c6 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Apr 2026 09:58:41 -0400 Subject: [PATCH 44/51] Fix test and style issue --- src/Installer/dotnetup/Shell/ShellProviderHelpers.cs | 1 - test/dotnetup.Tests/DefaultInstallCommandTests.cs | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index 3e48f59a825e..a9e40e03c686 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -1,7 +1,6 @@ // 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.Installation; using Microsoft.Dotnet.Installation.Internal; namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; diff --git a/test/dotnetup.Tests/DefaultInstallCommandTests.cs b/test/dotnetup.Tests/DefaultInstallCommandTests.cs index 4b1cd9aeb64f..6044ff8ecdae 100644 --- a/test/dotnetup.Tests/DefaultInstallCommandTests.cs +++ b/test/dotnetup.Tests/DefaultInstallCommandTests.cs @@ -34,7 +34,7 @@ public void Dispose() } [Fact] - public void DefaultInstallUser_UsesDefaultInstallPathForPwshProfileOnUnix() + public void DefaultInstallUser_DoesNotPassDefaultInstallPathToPwshProfileOnUnix() { if (OperatingSystem.IsWindows()) { @@ -53,6 +53,8 @@ public void DefaultInstallUser_UsesDefaultInstallPathForPwshProfileOnUnix() string profilePath = Path.Combine(_tempHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); File.Exists(profilePath).Should().BeTrue(); - File.ReadAllText(profilePath).Should().Contain(defaultInstallPath); + var profileContents = File.ReadAllText(profilePath); + profileContents.Should().Contain("print-env-script --shell pwsh"); + profileContents.Should().NotContain("--dotnet-install-path"); } } From d9c326091de37dc7213893f40857fc772305bb09 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Mon, 20 Apr 2026 23:37:36 -0400 Subject: [PATCH 45/51] Fix default-install Unix profile generation Suppress the explicit install-path argument when the effective path is the environment default, and keep the helper naming aligned with its return value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstall/DefaultInstallCommand.cs | 17 +++++++++++++---- .../dotnetup/DotnetEnvironmentManager.cs | 9 +++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 0eab5f17a352..944294cbfe85 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.Installation.Internal; using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall; @@ -37,8 +38,7 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - var userDotnetPath = _dotnetEnvironment.GetDefaultDotnetInstallPath(); - return SetUnixShellProfile(dotnetupOnly: false, userDotnetPath); + return SetUnixShellProfile(dotnetupOnly: false); } var changes = _installRootManager.GetUserInstallRootChanges(); @@ -100,12 +100,13 @@ private int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = n { var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); var shellProvider = GetCurrentShellProviderOrThrow(); + var profileDotnetInstallPath = GetInstallPathToPassToProfile(dotnetInstallPath); var modifiedFiles = ShellProfileManager.AddProfileEntries( shellProvider, dotnetupPath, dotnetupOnly, - dotnetInstallPath); + profileDotnetInstallPath); if (modifiedFiles.Count == 0) { @@ -129,12 +130,20 @@ private int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = n { Console.WriteLine(); Console.WriteLine("To start using .NET in this terminal, run:"); - Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath, dotnetInstallPath: dotnetInstallPath)}"); + Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath, dotnetInstallPath: profileDotnetInstallPath)}"); } return 0; } + private string? GetInstallPathToPassToProfile(string? dotnetInstallPath) + { + return dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, _dotnetEnvironment.GetDefaultDotnetInstallPath()) + ? installPath + : null; + } + private IEnvShellProvider GetCurrentShellProviderOrThrow() { var shellProvider = _shellProvider ?? ShellDetection.GetCurrentShellProvider(); diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 958ca0b5494d..73f9d41ff554 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -328,7 +328,7 @@ public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvid } } - private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) + private void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) { var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); @@ -348,7 +348,12 @@ private static void ConfigureInstallTypeUnix(InstallType installType, string? do { throw new ArgumentNullException(nameof(dotnetRoot)); } - ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: dotnetRoot); + + string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath()) + ? null + : dotnetRoot; + + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot); break; case InstallType.System: ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); From 76016134a17f8c88c85c88af883dbef905d88beb Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 21 Apr 2026 16:26:39 -0400 Subject: [PATCH 46/51] Fail safely on malformed profile blocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DotnetInstallException.cs | 3 ++ .../dotnetup/Shell/ShellProfileManager.cs | 15 +++++-- .../Telemetry/ErrorCategoryClassifier.cs | 1 + .../ShellProfileManagerTests.cs | 43 ++++++++++++++----- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs index aec165b3bfb7..19b5dce41b0e 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/DotnetInstallException.cs @@ -62,6 +62,9 @@ public enum DotnetInstallErrorCode /// The dotnetup installation manifest was modified externally and is now corrupted. LocalManifestUserCorrupted, + /// A user-managed configuration file contains malformed dotnetup state. + UserConfigurationCorrupted, + /// The install path points to an existing file instead of a directory. InstallPathIsFile, diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index 51b8dfd1d8bb..962e9b3b1827 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -94,7 +94,7 @@ private static bool EnsureEntryInFile(string profilePath, string entry) var entryLines = WrapEntryWithMarkers(entry).Split('\n', StringSplitOptions.None) .Select(l => l.TrimEnd('\r')) .ToArray(); - var existingBlocks = FindManagedBlocks(fileState.Lines); + var existingBlocks = FindManagedBlocks(fileState.Lines, profilePath); if (existingBlocks.Count > 0) { @@ -138,7 +138,7 @@ private static bool RemoveEntryFromFile(string profilePath) } var fileState = ReadProfileFile(profilePath); - var existingBlocks = FindManagedBlocks(fileState.Lines); + var existingBlocks = FindManagedBlocks(fileState.Lines, profilePath); if (existingBlocks.Count == 0) { @@ -268,7 +268,7 @@ private static void RestoreOriginalFile(string profilePath, string backupPath, s private static string WrapEntryWithMarkers(string entry) => $"{BeginMarkerComment}\n{entry}\n{EndMarkerComment}"; - private static List<(int Start, int EndExclusive)> FindManagedBlocks(List lines) + private static List<(int Start, int EndExclusive)> FindManagedBlocks(List lines, string profilePath) { var blocks = new List<(int Start, int EndExclusive)>(); @@ -283,7 +283,14 @@ private static string WrapEntryWithMarkers(string entry) => endIndex++; } - blocks.Add((i, endIndex < lines.Count ? endIndex + 1 : lines.Count)); + if (endIndex >= lines.Count) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.UserConfigurationCorrupted, + $"The shell profile '{profilePath}' contains a malformed dotnetup block: '{BeginMarkerComment}' does not have a matching '{EndMarkerComment}'. Remove or repair the block manually and try again."); + } + + blocks.Add((i, endIndex + 1)); i = endIndex; } } diff --git a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs index 145f645b75fd..d8adc5024503 100644 --- a/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs +++ b/src/Installer/dotnetup/Telemetry/ErrorCategoryClassifier.cs @@ -44,6 +44,7 @@ internal static ErrorCategory ClassifyInstallError(DotnetInstallErrorCode errorC DotnetInstallErrorCode.LocalManifestError => ErrorCategory.Product, DotnetInstallErrorCode.LocalManifestCorrupted => ErrorCategory.Product, DotnetInstallErrorCode.LocalManifestUserCorrupted => ErrorCategory.User, + DotnetInstallErrorCode.UserConfigurationCorrupted => ErrorCategory.User, DotnetInstallErrorCode.InstallPathIsFile => ErrorCategory.User, DotnetInstallErrorCode.AdminPathBlocked => ErrorCategory.User, DotnetInstallErrorCode.ContextResolutionFailed => ErrorCategory.User, diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 38c74e05c2b2..eae5c3ba38ee 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -3,6 +3,7 @@ using Microsoft.DotNet.Tools.Bootstrapper; using Microsoft.DotNet.Tools.Bootstrapper.Shell; +using Microsoft.Dotnet.Installation; using System.Text; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -221,27 +222,47 @@ public void RemoveProfileEntries_PreservesUtf8BomAndLfLineEndings() } [Fact] - public void RemoveProfileEntries_RemovesManagedBlockWhenEndMarkerIsMissing() + public void RemoveProfileEntries_ThrowsWhenEndMarkerIsMissing() { var profilePath = Path.Combine(_tempDir, "missing-end.sh"); - File.WriteAllText( - profilePath, + var originalContent = $""" # existing config {ShellProfileManager.BeginMarkerComment} eval "$('/old/dotnetup' print-env-script --shell test)" export TEMP_VAR=1 - """); + """; + File.WriteAllText(profilePath, originalContent); var provider = new TestShellProvider(_tempDir, "missing-end.sh"); - var modified = ShellProfileManager.RemoveProfileEntries(provider); + Action act = () => ShellProfileManager.RemoveProfileEntries(provider); - modified.Should().HaveCount(1); - var content = File.ReadAllText(profilePath); - content.Should().Contain("# existing config"); - content.Should().NotContain(ShellProfileManager.BeginMarkerComment); - content.Should().NotContain(ShellProfileManager.EndMarkerComment); - content.Should().NotContain("print-env-script"); + act.Should().Throw() + .Where(ex => ex.ErrorCode == DotnetInstallErrorCode.UserConfigurationCorrupted) + .WithMessage("*malformed dotnetup block*"); + File.ReadAllText(profilePath).Should().Be(originalContent.ReplaceLineEndings(Environment.NewLine)); + } + + [Fact] + public void AddProfileEntries_ThrowsWhenEndMarkerIsMissing() + { + var profilePath = Path.Combine(_tempDir, "missing-end-add.sh"); + var originalContent = + $""" + # existing config + {ShellProfileManager.BeginMarkerComment} + eval "$('/old/dotnetup' print-env-script --shell test)" + export TEMP_VAR=1 + """; + File.WriteAllText(profilePath, originalContent); + var provider = new TestShellProvider(_tempDir, "missing-end-add.sh"); + + Action act = () => ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + act.Should().Throw() + .Where(ex => ex.ErrorCode == DotnetInstallErrorCode.UserConfigurationCorrupted) + .WithMessage("*malformed dotnetup block*"); + File.ReadAllText(profilePath).Should().Be(originalContent.ReplaceLineEndings(Environment.NewLine)); } [Fact] From 3f698c004303b395c5589a0897220a5166943c71 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 21 Apr 2026 16:51:02 -0400 Subject: [PATCH 47/51] Address shell profile review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dotnetup/Shell/ShellProfileManager.cs | 77 ++++++++----------- .../ShellProfileManagerTests.cs | 13 ++++ 2 files changed, 43 insertions(+), 47 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs index 962e9b3b1827..d19b6c44c6ee 100644 --- a/src/Installer/dotnetup/Shell/ShellProfileManager.cs +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -22,7 +22,7 @@ private sealed record ProfileFileState( /// /// 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. - /// Existing files are updated via a write-and-rename flow to avoid partially written profiles. + /// Existing files are updated via a write-and-replace flow to avoid partially written profiles. /// /// The shell provider to use. /// The full path to the dotnetup binary. @@ -85,15 +85,18 @@ private static bool EnsureEntryInFile(string profilePath, string entry) if (!File.Exists(profilePath)) { - // New file — just write the managed block. - File.WriteAllText(profilePath, WrapEntryWithMarkers(entry) + Environment.NewLine, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + // New file — write the managed block using a consistent newline style. + var newFileState = new ProfileFileState( + [.. GetWrappedEntryLines(entry)], + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Environment.NewLine, + EndsWithTrailingNewLine: true); + WriteProfileFile(profilePath, newFileState); return true; } var fileState = ReadProfileFile(profilePath); - var entryLines = WrapEntryWithMarkers(entry).Split('\n', StringSplitOptions.None) - .Select(l => l.TrimEnd('\r')) - .ToArray(); + var entryLines = GetWrappedEntryLines(entry); var existingBlocks = FindManagedBlocks(fileState.Lines, profilePath); if (existingBlocks.Count > 0) @@ -199,74 +202,54 @@ private static void WriteProfileFile(string profilePath, ProfileFileState fileSt content += fileState.NewLine; } - if (!File.Exists(profilePath)) - { - File.WriteAllText(profilePath, content, fileState.Encoding); - return; - } - var directory = Path.GetDirectoryName(profilePath) ?? throw new InvalidOperationException($"Unable to determine the directory for '{profilePath}'."); + Directory.CreateDirectory(directory); var tempPath = Path.Combine(directory, $"{Path.GetFileName(profilePath)}.{Path.GetRandomFileName()}.tmp"); var backupPath = profilePath + BackupSuffix; - File.WriteAllText(tempPath, content, fileState.Encoding); - - if (File.Exists(backupPath)) - { - File.Delete(backupPath); - } - try { - File.Move(profilePath, backupPath); + File.WriteAllText(tempPath, content, fileState.Encoding); - try - { - File.Move(tempPath, profilePath); - } - catch (IOException) + if (File.Exists(profilePath)) { - RestoreOriginalFile(profilePath, backupPath, tempPath); - throw; + TryDeleteFile(backupPath); + File.Replace(tempPath, profilePath, backupPath, ignoreMetadataErrors: true); + TryDeleteFile(backupPath); } - catch (UnauthorizedAccessException) + else { - RestoreOriginalFile(profilePath, backupPath, tempPath); - throw; + File.Move(tempPath, profilePath); } } finally { - if (File.Exists(tempPath)) - { - File.Delete(tempPath); - } + TryDeleteFile(tempPath); + TryDeleteFile(backupPath); } - - File.Delete(backupPath); } - private static void RestoreOriginalFile(string profilePath, string backupPath, string tempPath) + private static void TryDeleteFile(string path) { - if (File.Exists(profilePath)) + try { - File.Delete(profilePath); + File.Delete(path); } - - if (File.Exists(backupPath)) + catch (FileNotFoundException) { - File.Move(backupPath, profilePath); } - - if (File.Exists(tempPath)) + catch (DirectoryNotFoundException) { - File.Delete(tempPath); } } - private static string WrapEntryWithMarkers(string entry) => - $"{BeginMarkerComment}\n{entry}\n{EndMarkerComment}"; + private static string[] GetWrappedEntryLines(string entry) => + [ + BeginMarkerComment, + .. entry.ReplaceLineEndings("\n").Split('\n', StringSplitOptions.None), + EndMarkerComment, + ]; private static List<(int Start, int EndExclusive)> FindManagedBlocks(List lines, string profilePath) { diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index eae5c3ba38ee..382eeddd039e 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -41,6 +41,7 @@ public void AddProfileEntries_CreatesFileAndAddsEntry() content.Should().Contain(ShellProfileManager.BeginMarkerComment); content.Should().Contain(ShellProfileManager.EndMarkerComment); content.Should().Contain("print-env-script"); + AssertUsesOnlyCurrentPlatformLineEndings(content); } [Fact] @@ -594,4 +595,16 @@ private static void AssertUsesOnlyLfLineEndings(string content) content.Should().NotContain("\r\n"); content.Should().NotContain("\r"); } + + private static void AssertUsesOnlyCurrentPlatformLineEndings(string content) + { + if (Environment.NewLine == "\r\n") + { + AssertUsesOnlyCrLfLineEndings(content); + } + else + { + AssertUsesOnlyLfLineEndings(content); + } + } } From 638361de96a9cf02af3ac1660c53d9b6bea3f6e5 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 21 Apr 2026 18:39:01 -0400 Subject: [PATCH 48/51] Apply code review feedback --- .../dotnetup/unix-environment-setup.md | 5 ++-- .../DefaultInstall/DefaultInstallCommand.cs | 16 +----------- .../dotnetup/Commands/Init/InitWorkflows.cs | 5 ++-- .../dotnetup/DotnetEnvironmentManager.cs | 12 ++------- .../dotnetup/IDotnetEnvironmentManager.cs | 2 +- .../dotnetup/Shell/ShellDetection.cs | 16 ++++++++++++ .../dotnetup/Shell/ShellProviderHelpers.cs | 6 +++-- test/dotnetup.Tests/EnvShellProviderTests.cs | 11 ++++++++ .../MockDotnetInstallManager.cs | 2 +- .../ShellProfileManagerTests.cs | 26 +++++++++++++++++++ 10 files changed, 67 insertions(+), 34 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index dfd3ab9a663b..de0fd3aea6f6 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -2,9 +2,9 @@ ## Overview -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. +dotnetup (upon user consent) 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. -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. +This document focuses on the Unix (and PowerShell) profile-based approach. On Windows, registry-based environment variables also impact the environment in addition to profile files. ## How the Environment Gets Configured @@ -113,6 +113,7 @@ dotnetup print-env-script [--shell ] [--dotnet-install-path ] - `--shell` / `-s`: The target shell for which to generate the environment script - Supported values: `bash`, `zsh`, `pwsh` + - The supported shell values are based on what `nvm` and `rustup` support today. - Optional: If not specified, automatically detects the current shell from the `$SHELL` environment variable - On Windows, defaults to PowerShell (`pwsh`) diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 944294cbfe85..22dc17a0c608 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -99,7 +99,7 @@ private int SetSystemInstallRoot() private int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = null) { var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); - var shellProvider = GetCurrentShellProviderOrThrow(); + var shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(_shellProvider); var profileDotnetInstallPath = GetInstallPathToPassToProfile(dotnetInstallPath); var modifiedFiles = ShellProfileManager.AddProfileEntries( @@ -143,18 +143,4 @@ private int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = n ? installPath : null; } - - private IEnvShellProvider GetCurrentShellProviderOrThrow() - { - var shellProvider = _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(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}. You can specify one explicitly with --shell."); - } - - return shellProvider; - } } diff --git a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs index 920588505163..d24ab7fdd944 100644 --- a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs @@ -163,7 +163,7 @@ public void BaseConfigurationWalkthrough( if (ShouldReplaceSystemConfiguration(pathPreference)) { - _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path, shellProvider); + _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path); } // Step 4: Prompt migrating admin installs now that the environment is configured (if deferred). @@ -222,9 +222,8 @@ private static PathPreference GetPathPreference(bool interactive, bool askEvenIf if (!OperatingSystem.IsWindows() && (shellProvider ?? ShellDetection.GetCurrentShellProvider()) is null) { - var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; SpectreAnsiConsole.MarkupLine(DotnetupTheme.Dim( - $"[{DotnetupTheme.Current.Warning}]Warning:[/] Shell '{shellEnv.EscapeMarkup()}' is not supported for automatic environment configuration. dotnetup will continue without changing your shell profile unless you specify one with --shell.")); + $"[{DotnetupTheme.Current.Warning}]Warning:[/] Shell '{ShellDetection.GetCurrentShellDisplayName().EscapeMarkup()}' is not supported for automatic environment configuration. dotnetup will continue without changing your shell profile unless you specify one with --shell.")); return PathPreference.DotnetupDotnet; } diff --git a/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 73f9d41ff554..788f4f4fb75d 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -267,7 +267,7 @@ private static void TryAddPath(List paths, string path) } } - public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) { if (OperatingSystem.IsWindows()) { @@ -331,15 +331,7 @@ public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvid private void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) { var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); - - 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(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}. You can specify one explicitly with --shell."); - } + shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(shellProvider); switch (installType) { diff --git a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs index dce85ac790ea..ce958e703796 100644 --- a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs @@ -24,7 +24,7 @@ internal interface IDotnetEnvironmentManager List GetExistingSystemInstalls(); - void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null); + void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null); void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null); diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs index 04213e7c22b1..02fd42971a01 100644 --- a/src/Installer/dotnetup/Shell/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -1,6 +1,8 @@ // 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.Installation; + namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// @@ -10,6 +12,8 @@ public static class ShellDetection { /// /// The list of shell providers supported by dotnetup. + /// Revisit the generated script/comment helpers before adding a new shell here, + /// since profile blocks assume the comment and quoting behavior of these shells. /// internal static readonly IEnvShellProvider[] s_supportedShells = [ @@ -34,6 +38,12 @@ public static class ShellDetection return s_shellMap.GetValueOrDefault(shellName); } + internal static string GetCurrentShellDisplayName() + => Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + + internal static string GetUnsupportedShellMessage() + => $"Unable to detect a supported shell. SHELL={GetCurrentShellDisplayName()}. Supported shells: {string.Join(", ", s_supportedShells.Select(s => s.ArgumentName))}. You can specify one explicitly with --shell."; + /// /// Resolves a shell provider from either a shell name or the path to a shell executable. /// @@ -81,4 +91,10 @@ internal static bool IsSupported(string shellName) return ResolveShellProvider(shellPath); } + + internal static IEnvShellProvider GetCurrentShellProviderOrThrow(IEnvShellProvider? shellProvider = null) + => shellProvider ?? GetCurrentShellProvider() + ?? throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + GetUnsupportedShellMessage()); } diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index a9e40e03c686..92ac0afd8fc7 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -11,8 +11,10 @@ internal static class ShellProviderHelpers internal static string GetDotnetupOnlyComment() => DotnetupOnlyComment; + // This text is emitted as a shell comment for the supported providers in ShellDetection. + // Keep it on one line so an unusual install path can't break out of the comment block. internal static string GetEnvironmentConfigurationComment(string dotnetInstallPath) - => $"# This script configures the environment for .NET installed at {dotnetInstallPath}"; + => $"# This script configures the environment for .NET installed at {dotnetInstallPath.ReplaceLineEndings(" ")}"; internal static string EscapePosixPath(string path) => path.Replace("'", "'\\''", StringComparison.Ordinal); @@ -145,7 +147,7 @@ internal static string GetDotnetupExecutablePathOrThrow() return Environment.ProcessPath ?? throw new DotnetInstallException( DotnetInstallErrorCode.ContextResolutionFailed, - "Unable to determine the dotnetup executable path."); + "Unable to determine the full path to the running dotnetup executable."); } internal static string GetDotnetupDirectoryOrThrow() diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index da3fa234a065..018a05a279f8 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -47,6 +47,17 @@ public void BashProvider_DotnetupOnly_ShouldNotSetDotnetRoot() script.Should().NotContain("'/test/dotnet'"); } + [Fact] + public void BashProvider_ShouldNormalizePathInCommentToSingleLine() + { + var provider = new BashEnvShellProvider(); + var installPath = "/test/dotnet" + Environment.NewLine + "path"; + + var script = provider.GenerateEnvScript(installPath); + + script.Should().Contain("# This script configures the environment for .NET installed at /test/dotnet path"); + } + [Fact] public void ZshProvider_ShouldGenerateValidScript() { diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index 3bf215720c42..a7a042adec52 100644 --- a/test/dotnetup.Tests/MockDotnetInstallManager.cs +++ b/test/dotnetup.Tests/MockDotnetInstallManager.cs @@ -57,7 +57,7 @@ public List GetExistingSystemInstalls() public void InstallSdks(DotnetInstallRoot dotnetRoot, ProgressContext progressContext, IEnumerable sdkVersions) => throw new NotImplementedException(); - public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null, IEnvShellProvider? shellProvider = null) + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) { ApplyEnvironmentModificationsCallCount++; LastDotnetRootForEnvironmentModifications = dotnetRoot; diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs index 382eeddd039e..950617a97ed6 100644 --- a/test/dotnetup.Tests/ShellProfileManagerTests.cs +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -59,6 +59,18 @@ public void AddProfileEntries_AppendsToExistingFile() content.Should().Contain(ShellProfileManager.EndMarkerComment); } + [Fact] + public void AddProfileEntries_BlankFile_UsesConsistentLineEndings() + { + var profilePath = Path.Combine(_tempDir, "blank.sh"); + File.WriteAllText(profilePath, string.Empty); + var provider = new TestShellProvider(_tempDir, "blank.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + AssertUsesOnlyCurrentPlatformLineEndings(File.ReadAllText(profilePath)); + } + [Fact] public void AddProfileEntries_DoesNotDuplicateIfAlreadyPresent() { @@ -117,6 +129,20 @@ public void AddProfileEntries_PreservesUtf8BomAndLfLineEndings() AssertUsesOnlyLfLineEndings(File.ReadAllText(profilePath)); } + [Fact] + public void AddProfileEntries_PreservesUnicodeBomAndCrLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-add-unicode.sh"); + File.WriteAllText(profilePath, "# existing config\r\nexport FOO=bar\r\n", Encoding.Unicode); + var provider = new TestShellProvider(_tempDir, "preserve-add-unicode.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var bytes = File.ReadAllBytes(profilePath); + bytes.AsSpan(0, Encoding.Unicode.Preamble.Length).SequenceEqual(Encoding.Unicode.Preamble).Should().BeTrue(); + AssertUsesOnlyCrLfLineEndings(File.ReadAllText(profilePath, Encoding.Unicode)); + } + [Fact] public void AddProfileEntries_CreatesParentDirectories() { From 3437ba29a735f340624d6d7a69a14c3e66643730 Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Tue, 21 Apr 2026 18:57:29 -0400 Subject: [PATCH 49/51] Isolate defaultinstall test environment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DefaultInstallCommandTests.cs | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/test/dotnetup.Tests/DefaultInstallCommandTests.cs b/test/dotnetup.Tests/DefaultInstallCommandTests.cs index 6044ff8ecdae..9e48e653d71b 100644 --- a/test/dotnetup.Tests/DefaultInstallCommandTests.cs +++ b/test/dotnetup.Tests/DefaultInstallCommandTests.cs @@ -1,28 +1,24 @@ // 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.DefaultInstall; -using Microsoft.DotNet.Tools.Bootstrapper.Tests; +using Microsoft.DotNet.Tools.Dotnetup.Tests.Utilities; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public sealed class DefaultInstallCommandTests : IDisposable { private readonly string _tempHome; - private readonly string? _originalHome; + private readonly string _tempXdgDataHome; public DefaultInstallCommandTests() { _tempHome = Path.Combine(Path.GetTempPath(), "dotnetup-defaultinstall-tests", Guid.NewGuid().ToString("N")); + _tempXdgDataHome = Path.Combine(_tempHome, ".local", "share"); Directory.CreateDirectory(_tempHome); - _originalHome = Environment.GetEnvironmentVariable("HOME"); } public void Dispose() { - Environment.SetEnvironmentVariable("HOME", _originalHome); - try { Directory.Delete(_tempHome, recursive: true); @@ -41,15 +37,17 @@ public void DefaultInstallUser_DoesNotPassDefaultInstallPathToPwshProfileOnUnix( return; } - Environment.SetEnvironmentVariable("HOME", _tempHome); - - string defaultInstallPath = Path.Combine(_tempHome, "dotnet-managed"); - var parseResult = Parser.Parse(["defaultinstall", "user", "--shell", "pwsh"]); - var environmentManager = new MockDotnetInstallManager(defaultInstallPath: defaultInstallPath); - - var exitCode = new DefaultInstallCommand(parseResult, environmentManager).Execute(); - - exitCode.Should().Be(0); + var (exitCode, output) = DotnetupTestUtilities.RunDotnetupProcess( + ["defaultinstall", "user", "--shell", "pwsh"], + captureOutput: true, + workingDirectory: AppContext.BaseDirectory, + environmentVariables: new Dictionary + { + ["HOME"] = _tempHome, + ["XDG_DATA_HOME"] = _tempXdgDataHome, + }); + + exitCode.Should().Be(0, output); string profilePath = Path.Combine(_tempHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); File.Exists(profilePath).Should().BeTrue(); From 5cbb0a26002c29bd280459a10d84e41850b19cfd Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 22 Apr 2026 12:46:35 -0400 Subject: [PATCH 50/51] Remove unnecessary using --- src/Installer/dotnetup/Shell/ShellDetection.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs index 02fd42971a01..6ae289a24cc6 100644 --- a/src/Installer/dotnetup/Shell/ShellDetection.cs +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -1,8 +1,6 @@ // 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.Installation; - namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; /// From a0a64ce62f6951da410eb9da6d3d7fad230d6e2a Mon Sep 17 00:00:00 2001 From: Daniel Plaisted Date: Wed, 22 Apr 2026 15:30:05 -0400 Subject: [PATCH 51/51] Remove shebang from env scripts --- .../general/dotnetup/unix-environment-setup.md | 5 ++--- src/Installer/dotnetup/Shell/BashEnvShellProvider.cs | 6 ++---- .../dotnetup/Shell/PowerShellEnvShellProvider.cs | 4 ++-- src/Installer/dotnetup/Shell/ShellProviderHelpers.cs | 12 +++++++----- src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs | 6 ++---- test/dotnetup.Tests/EnvShellProviderTests.cs | 12 +++++++++--- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index de0fd3aea6f6..2ccb4bce58d6 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -153,8 +153,7 @@ The command generates shell-specific scripts that: **Bash/Zsh Example:** ```bash -#!/usr/bin/env bash -# This script configures the environment for .NET installed at /home/user/.local/share/dotnet +# This bash 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/dotnetup':'/home/user/.local/share/dotnet':$PATH @@ -164,7 +163,7 @@ hash -d dotnetup 2>/dev/null **PowerShell Example:** ```powershell -# This script configures the environment for .NET installed at /home/user/.local/share/dotnet +# This PowerShell 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/dotnetup' + [IO.Path]::PathSeparator + '/home/user/.local/share/dotnet' + [IO.Path]::PathSeparator + $env:PATH diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs index 0f73cf4d338d..9973201c1efc 100644 --- a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -22,8 +22,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = " { return $""" - #!/usr/bin/env bash - {ShellProviderHelpers.GetDotnetupOnlyComment()} + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} {pathExport} hash -d dotnet 2>/dev/null hash -d dotnetup 2>/dev/null @@ -32,8 +31,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = " return $""" - #!/usr/bin/env bash - {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} export DOTNET_ROOT='{escapedPath}' {pathExport} diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs index 40c4559b7254..f5293c7b2584 100644 --- a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -22,14 +22,14 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = " { return $""" - {ShellProviderHelpers.GetDotnetupOnlyComment()} + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} {pathExport} """; } return $""" - {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} $env:DOTNET_ROOT = '{escapedPath}' {pathExport} diff --git a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs index 92ac0afd8fc7..38a625344c7e 100644 --- a/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -7,14 +7,16 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; internal static class ShellProviderHelpers { - private const string DotnetupOnlyComment = "# This script adds dotnetup to your PATH"; - - internal static string GetDotnetupOnlyComment() => DotnetupOnlyComment; + internal static string GetDotnetupOnlyComment(string shellName) + => $"# This {GetShellDisplayName(shellName)} script adds dotnetup to your PATH"; // This text is emitted as a shell comment for the supported providers in ShellDetection. // Keep it on one line so an unusual install path can't break out of the comment block. - internal static string GetEnvironmentConfigurationComment(string dotnetInstallPath) - => $"# This script configures the environment for .NET installed at {dotnetInstallPath.ReplaceLineEndings(" ")}"; + internal static string GetEnvironmentConfigurationComment(string shellName, string dotnetInstallPath) + => $"# This {GetShellDisplayName(shellName)} script configures the environment for .NET installed at {dotnetInstallPath.ReplaceLineEndings(" ")}"; + + private static string GetShellDisplayName(string shellName) + => shellName.Equals("pwsh", StringComparison.OrdinalIgnoreCase) ? "PowerShell" : shellName; internal static string EscapePosixPath(string path) => path.Replace("'", "'\\''", StringComparison.Ordinal); diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs index 4b4092c215d1..93537baee6af 100644 --- a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -22,8 +22,7 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = " { return $""" - #!/usr/bin/env zsh - {ShellProviderHelpers.GetDotnetupOnlyComment()} + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} {pathExport} rehash 2>/dev/null """; @@ -31,8 +30,7 @@ rehash 2>/dev/null return $""" - #!/usr/bin/env zsh - {ShellProviderHelpers.GetEnvironmentConfigurationComment(dotnetInstallPath)} + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} export DOTNET_ROOT='{escapedPath}' {pathExport} diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index 018a05a279f8..9135ad39ed8b 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -22,7 +22,8 @@ public void BashProvider_ShouldGenerateValidScript() // Assert script.Should().NotBeNullOrEmpty(); - script.Should().Contain("#!/usr/bin/env bash"); + script.Should().NotContain("#!/usr/bin/env"); + script.Should().Contain("# This bash script configures the environment for .NET installed at /test/dotnet/path"); script.Should().Contain($"export DOTNET_ROOT='{installPath}'"); script.Should().Contain($"export PATH='{installPath}':$PATH"); } @@ -43,6 +44,7 @@ public void BashProvider_DotnetupOnly_ShouldNotSetDotnetRoot() var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("# This bash script adds dotnetup to your PATH"); script.Should().Contain("export PATH='/usr/local/bin':$PATH"); script.Should().NotContain("'/test/dotnet'"); } @@ -55,7 +57,7 @@ public void BashProvider_ShouldNormalizePathInCommentToSingleLine() var script = provider.GenerateEnvScript(installPath); - script.Should().Contain("# This script configures the environment for .NET installed at /test/dotnet path"); + script.Should().Contain("# This bash script configures the environment for .NET installed at /test/dotnet path"); } [Fact] @@ -70,7 +72,8 @@ public void ZshProvider_ShouldGenerateValidScript() // Assert script.Should().NotBeNullOrEmpty(); - script.Should().Contain("#!/usr/bin/env zsh"); + script.Should().NotContain("#!/usr/bin/env"); + script.Should().Contain("# This zsh script configures the environment for .NET installed at /test/dotnet/path"); script.Should().Contain($"export DOTNET_ROOT='{installPath}'"); script.Should().Contain($"export PATH='{installPath}':$PATH"); } @@ -91,6 +94,7 @@ public void ZshProvider_DotnetupOnly_ShouldNotSetDotnetRoot() var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("# This zsh script adds dotnetup to your PATH"); script.Should().Contain("export PATH='/usr/local/bin':$PATH"); } @@ -130,6 +134,7 @@ public void PowerShellProvider_ShouldGenerateValidScript() // Assert script.Should().NotBeNullOrEmpty(); + script.Should().Contain("# This PowerShell script configures the environment for .NET installed at /test/dotnet/path"); script.Should().Contain($"$env:DOTNET_ROOT = '{installPath}'"); script.Should().Contain($"$env:PATH = '{installPath}'"); script.Should().Contain("[IO.Path]::PathSeparator"); @@ -151,6 +156,7 @@ public void PowerShellProvider_DotnetupOnly_ShouldNotSetDotnetRoot() var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("# This PowerShell script adds dotnetup to your PATH"); script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + $env:PATH"); }