diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 5f113bfe27a9..2ccb4bce58d6 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -2,25 +2,106 @@ ## 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 (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. -## Background +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. -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) **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). -## The `dotnetup print-env-script` Command +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: + + ``` + To start using .NET in this terminal, run: + 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, run `dotnetup init --shell bash|zsh|pwsh` (or `defaultinstall` / `print-env-script` with `--shell`) before installing. + +### 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 system-managed .NET (removes DOTNET_ROOT from profiles, keeps dotnetup on PATH) +dotnetup defaultinstall system +``` + +**`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 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 + +### 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** | `$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 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 dotnetup-managed block with explicit begin/end markers: + +**Bash / Zsh:** +```bash +# dotnetup: begin +if [ -x '/path/to/dotnetup' ]; then + eval "$('/path/to/dotnetup' print-env-script --shell bash)" +fi +# dotnetup: end +``` + +**PowerShell:** +```powershell +# dotnetup: begin +if (Test-Path -LiteralPath '/path/to/dotnetup' -PathType Leaf) +{ + $dotnetupScript = & '/path/to/dotnetup' print-env-script --shell pwsh | Out-String + if (-not [string]::IsNullOrWhiteSpace($dotnetupScript)) + { + Invoke-Expression $dotnetupScript + } +} +# 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. + +### Safe updates + +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. + +### Idempotency + +If a profile file already contains a dotnetup-managed block, the entry is updated in place rather than 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 @@ -32,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`) @@ -40,14 +122,9 @@ 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 +#### Eval directly (one-time, current terminal only) ```bash -source <(dotnetup print-env-script) +eval "$(dotnetup print-env-script)" ``` #### Explicitly specify shell @@ -59,7 +136,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 @@ -67,89 +144,85 @@ 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 +3. Clear the shell's cached command location for `dotnet` to pick up the new PATH -### 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 -# Source this script to add .NET to your PATH and set DOTNET_ROOT +# 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/dotnet':$PATH +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 +**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 +# 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/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 + +When `--shell` is not specified, the command automatically detects the current shell: + +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 + +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. + ## 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. +The implementation uses a provider model, 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); + 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? dotnetInstallPath = null); + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null); } ``` -**Implementations**: -- `BashEnvShellProvider`: Generates bash-compatible scripts -- `ZshEnvShellProvider`: Generates zsh-compatible scripts -- `PowerShellEnvShellProvider`: Generates PowerShell Core scripts +**Implementations**: `BashEnvShellProvider`, `ZshEnvShellProvider`, `PowerShellEnvShellProvider` -### Shell Detection +### ShellDetection -The command automatically detects the current shell when the `--shell` option is not provided: +`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`. -1. **On Unix**: Reads the `$SHELL` environment variable and extracts the shell name from the path - - Example: `/bin/bash` → `bash` -2. **On Windows**: Defaults to PowerShell (`pwsh`) +### ShellProfileManager -### Security Considerations +`ShellProfileManager` coordinates profile file modifications: +- `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 -**Path Escaping**: 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 +`defaultinstall system` uses `AddProfileEntries(..., dotnetupOnly: true)` to switch the managed entry into dotnetup-only mode. ## Future Work -This command provides the foundation for future enhancements: - -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. **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. ## Related Issues @@ -163,5 +236,4 @@ The implementation includes comprehensive tests: - Shell provider tests for script generation - Security tests for special character handling - Help documentation tests - -All tests ensure that the generated scripts are syntactically correct and properly escape paths. +- Shell profile manager tests for add/remove/idempotency/backup behavior 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/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/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

"defaultinstall"; @@ -32,7 +38,7 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the user install root is not yet supported on non-Windows platforms."); + return SetUnixShellProfile(dotnetupOnly: false); } var changes = _installRootManager.GetUserInstallRootChanges(); @@ -64,7 +70,7 @@ private int SetSystemInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the system install root is only supported on Windows."); + return SetUnixShellProfile(dotnetupOnly: true); } var changes = _installRootManager.GetAdminInstallRootChanges(); @@ -89,4 +95,52 @@ 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 int SetUnixShellProfile(bool dotnetupOnly, string? dotnetInstallPath = null) + { + var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); + var shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(_shellProvider); + var profileDotnetInstallPath = GetInstallPathToPassToProfile(dotnetInstallPath); + + var modifiedFiles = ShellProfileManager.AddProfileEntries( + shellProvider, + dotnetupPath, + dotnetupOnly, + profileDotnetInstallPath); + + 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: profileDotnetInstallPath)}"); + } + + return 0; + } + + private string? GetInstallPathToPassToProfile(string? dotnetInstallPath) + { + return dotnetInstallPath is { Length: > 0 } installPath && + !DotnetupUtilities.PathsEqual(installPath, _dotnetEnvironment.GetDefaultDotnetInstallPath()) + ? installPath + : null; + } } 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/DotnetBotBanner.cs b/src/Installer/dotnetup/Commands/Init/DotnetBotBanner.cs index 8830b8e70555..b78cf24f7231 100644 --- a/src/Installer/dotnetup/Commands/Init/DotnetBotBanner.cs +++ b/src/Installer/dotnetup/Commands/Init/DotnetBotBanner.cs @@ -17,7 +17,7 @@ internal static Panel BuildPanel() { // Trim the commit hash from the informational version (e.g. "0.1.1-preview+abc123" -> "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/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 55bc3770d27c..d24ab7fdd944 100644 --- a/src/Installer/dotnetup/Commands/Init/InitWorkflows.cs +++ b/src/Installer/dotnetup/Commands/Init/InitWorkflows.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; @@ -40,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 == PathPreference.FullPathReplacement; + preference is PathPreference.FullPathReplacement; ///

/// Returns true when the user chose to convert existing system-level .NET installs @@ -86,7 +87,8 @@ public void FullIntroductionWalkthrough(InstallCommand command) BaseConfigurationWalkthrough( [], () => { }, - command.NoProgress); + command.NoProgress, + shellProvider: command.ShellProvider); return; } @@ -98,7 +100,8 @@ public void FullIntroductionWalkthrough(InstallCommand command) BaseConfigurationWalkthrough( requests, () => InstallExecutor.ExecuteInstalls(requests, command.NoProgress), - command.NoProgress); + command.NoProgress, + shellProvider: command.ShellProvider); } /// @@ -114,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. @@ -137,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. @@ -149,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(installRoot.Path, shellProvider); + } + if (ShouldReplaceSystemConfiguration(pathPreference)) { _dotnetEnvironment.ApplyEnvironmentModifications(InstallType.User, installRoot.Path); @@ -192,7 +201,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. @@ -203,9 +212,21 @@ private static PathPreference GetPathPreference(bool interactive, bool askEvenIf } else if (!interactive) { + if (!OperatingSystem.IsWindows() && (shellProvider ?? ShellDetection.GetCurrentShellProvider()) is null) + { + return PathPreference.DotnetupDotnet; + } + return PathPreference.ShellProfile; } + if (!OperatingSystem.IsWindows() && (shellProvider ?? ShellDetection.GetCurrentShellProvider()) is null) + { + SpectreAnsiConsole.MarkupLine(DotnetupTheme.Dim( + $"[{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; + } + var preference = PromptPathPreference(); if (preference == PathPreference.FullPathReplacement && !OperatingSystem.IsWindows()) { diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs deleted file mode 100644 index 7a793a51982e..000000000000 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/BashEnvShellProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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; - -public class BashEnvShellProvider : IEnvShellProvider -{ - public string ArgumentName => "bash"; - - public string Extension => "sh"; - - public string? HelpDescription => "Bash shell"; - - public override string ToString() => ArgumentName; - - public string GenerateEnvScript(string dotnetInstallPath) - { - // Escape single quotes in the path for bash by replacing ' with '\'' - var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - - return - $""" - #!/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. - # When dotnetup modifies shell profiles directly, it will handle this automatically. - - export DOTNET_ROOT='{escapedPath}' - export PATH='{escapedPath}':$PATH - """; - } -} diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs deleted file mode 100644 index c2d43cf824ae..000000000000 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/IEnvShellProvider.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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; - -/// -/// Provides shell-specific environment configuration scripts. -/// -public interface IEnvShellProvider -{ - /// - /// The name of this shell as exposed on the command line arguments. - /// - string ArgumentName { get; } - - /// - /// The file extension typically used for this shell's scripts (sans period). - /// - string Extension { get; } - - /// - /// This will be used when specifying the shell in CLI help text. - /// - string? HelpDescription { get; } - - /// - /// Generates a shell-specific script that configures PATH and DOTNET_ROOT. - /// - /// The path to the .NET installation directory - /// A shell script that can be sourced to configure the environment - string GenerateEnvScript(string dotnetInstallPath); -} diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs deleted file mode 100644 index 0f95ff0ebb5d..000000000000 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PowerShellEnvShellProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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; - -public class PowerShellEnvShellProvider : IEnvShellProvider -{ - public string ArgumentName => "pwsh"; - - public string Extension => "ps1"; - - public string? HelpDescription => "PowerShell Core (pwsh)"; - - public override string ToString() => ArgumentName; - - public string GenerateEnvScript(string dotnetInstallPath) - { - // Escape single quotes in the path for PowerShell by replacing ' with '' - var escapedPath = dotnetInstallPath.Replace("'", "''"); - - 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}' - $env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH - """; - } -} diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs index 02b6039efe2c..f7a82effb324 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; @@ -10,12 +11,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"; @@ -31,13 +34,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; @@ -46,8 +49,12 @@ 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 = ShellProviderHelpers.GetDotnetupDirectoryOrThrow(); + // Generate the shell script - string script = _shellProvider.GenerateEnvScript(installPath); + bool includeDotnet = !_dotnetupOnly; + string script = _shellProvider.GenerateEnvScript(installPath, dotnetupDir, includeDotnet); WriteScriptToStandardOutput(script); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index e9215d4ea3d0..4424f28fc2ee 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -2,40 +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 { - 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.", - Arity = ArgumentArity.ZeroOrOne, - // called when no token is presented at all - DefaultValueFactory = (optionResult) => LookupShellFromEnvironment(), - // 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], - _ => throw new InvalidOperationException("Unexpected number of tokens") // this is impossible because of the Arity set above - }; - } - }; + public static readonly Option ShellOption = CommonOptions.ShellOption; public static readonly Option DotnetInstallPathOption = new("--dotnet-install-path", "-d") { @@ -43,16 +16,11 @@ internal static class PrintEnvScriptCommandParser Arity = ArgumentArity.ZeroOrOne }; - static PrintEnvScriptCommandParser() + public static readonly Option DotnetupOnlyOption = new("--dotnetup-only") { - // 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()); - } + Description = "Only add dotnetup to PATH. Do not set DOTNET_ROOT or add the .NET install path.", + Arity = ArgumentArity.ZeroOrOne + }; private static readonly Command s_printEnvScriptCommand = ConstructCommand(); @@ -67,61 +35,10 @@ private static Command ConstructCommand() command.Options.Add(ShellOption); command.Options.Add(DotnetInstallPathOption); + command.Options.Add(DotnetupOnlyOption); command.SetAction(parseResult => new PrintEnvScriptCommand(parseResult).Execute()); 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) => - { - if (optionResult.Tokens.Count == 0) - { - return; - } - var singleToken = optionResult.Tokens[0]; - if (!s_shellMap.ContainsKey(singleToken.Value)) - { - optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", s_shellMap.Keys)}"); - } - }; - } - - private static Func> CreateCompletions() - { - return (CompletionContext context) => - { - return s_shellMap.Values.Select(shellProvider => new CompletionItem(shellProvider.ArgumentName, documentation: shellProvider.HelpDescription)); - }; - } } diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs deleted file mode 100644 index 235d02f99562..000000000000 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/ZshEnvShellProvider.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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; - -public class ZshEnvShellProvider : IEnvShellProvider -{ - public string ArgumentName => "zsh"; - - public string Extension => "zsh"; - - public string? HelpDescription => "Zsh shell"; - - public override string ToString() => ArgumentName; - - public string GenerateEnvScript(string dotnetInstallPath) - { - // Escape single quotes in the path for zsh by replacing ' with '\'' - var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); - - return - $""" - #!/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. - # When dotnetup modifies shell profiles directly, it will handle this automatically. - - export DOTNET_ROOT='{escapedPath}' - export PATH='{escapedPath}':$PATH - """; - } -} diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs index c9ca7f7a8373..b18cf71484d1 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -91,7 +91,7 @@ internal static (InstallComponent Component, string? VersionOrChannel) ParseComp } // Check for component@version syntax - int atIndex = spec.IndexOf('@'); + int atIndex = spec.IndexOf('@', StringComparison.Ordinal); if (atIndex == 0) { throw new DotnetInstallException( diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs index e79c40d442c0..fea2d144dd31 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs @@ -26,10 +26,14 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.SetDefaultInstallOption); command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); + // 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 7527f7812460..541d24e182fc 100644 --- a/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Sdk/Install/SdkInstallCommandParser.cs @@ -46,10 +46,14 @@ private static Command ConstructCommand() command.Options.Add(CommonOptions.ManifestPathOption); command.Options.Add(CommonOptions.InteractiveOption); + // 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/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..dcdf92141f7a 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.GetShellProviderByName(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,39 @@ 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)); + } + + 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/src/Installer/dotnetup/DotnetEnvironmentManager.cs b/src/Installer/dotnetup/DotnetEnvironmentManager.cs index 578773fe4ab9..788f4f4fb75d 100644 --- a/src/Installer/dotnetup/DotnetEnvironmentManager.cs +++ b/src/Installer/dotnetup/DotnetEnvironmentManager.cs @@ -3,10 +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; @@ -28,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()) @@ -79,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() @@ -251,6 +266,7 @@ private static void TryAddPath(List paths, string path) paths.Add(path); } } + public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) { if (OperatingSystem.IsWindows()) @@ -295,21 +311,27 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne throw new ArgumentException($"Unknown install type: {installType}", nameof(installType)); } } + } + + public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) + { + ArgumentNullException.ThrowIfNull(dotnetRoot); + + if (OperatingSystem.IsWindows()) + { + // Not implemented yet on Windows + return; + } else { - ConfigureInstallTypeUnix(installType, dotnetRoot); + ConfigureInstallTypeUnix(InstallType.User, dotnetRoot, shellProvider); } } - private static void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot) + private void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider) { - // 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 = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow(); + shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(shellProvider); switch (installType) { @@ -318,27 +340,19 @@ 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); + + string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath()) + ? null + : dotnetRoot; + + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot); 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.AddProfileEntries(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); } /// diff --git a/src/Installer/dotnetup/IDotnetEnvironmentManager.cs b/src/Installer/dotnetup/IDotnetEnvironmentManager.cs index b873a72ea8c4..ce958e703796 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; @@ -25,6 +26,8 @@ internal interface IDotnetEnvironmentManager void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null); + void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null); + /// /// Updates the global.json file to reflect the installed SDK version, /// if a global.json exists and the install was global.json-sourced. diff --git a/src/Installer/dotnetup/Parser.cs b/src/Installer/dotnetup/Parser.cs index cc1e029ab9dd..1a7adafaa988 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/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs new file mode 100644 index 000000000000..9973201c1efc --- /dev/null +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -0,0 +1,79 @@ +// 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.Shell; + +public class BashEnvShellProvider : IEnvShellProvider +{ + public string ArgumentName => "bash"; + + public string Extension => "sh"; + + public string? HelpDescription => "Bash shell"; + + public override string ToString() => ArgumentName; + + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) + { + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPosixPathExport(escapedPath, dotnetupDir, includeDotnet); + + if (!includeDotnet) + { + return + $""" + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} + {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null + """; + } + + return + $""" + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} + + export DOTNET_ROOT='{escapedPath}' + {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null + """; + } + + public IReadOnlyList GetProfilePaths() + { + var home = ShellProviderHelpers.GetUserHomeDirectoryOrThrow(); + 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. + // 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"); + + 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, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + return ShellProviderHelpers.BuildPosixProfileEntry(dotnetupPath, "bash", flags); + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + return ShellProviderHelpers.BuildPosixActivationCommand(dotnetupPath, "bash", flags); + } +} diff --git a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs new file mode 100644 index 000000000000..faa8c7b45e24 --- /dev/null +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -0,0 +1,57 @@ +// 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.Shell; + +/// +/// Provides shell-specific environment configuration scripts. +/// +public interface IEnvShellProvider +{ + /// + /// The name of this shell as exposed on the command line arguments. + /// + string ArgumentName { get; } + + /// + /// The file extension typically used for this shell's scripts (sans period). + /// + string Extension { get; } + + /// + /// This will be used when specifying the shell in CLI help text. + /// + string? HelpDescription { get; } + + /// + /// Generates a shell-specific script that configures the environment. + /// + /// The path to the .NET installation directory + /// 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 = "", bool includeDotnet = true); + + /// + /// 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 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). + /// 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. + /// 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 new file mode 100644 index 000000000000..f5293c7b2584 --- /dev/null +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -0,0 +1,56 @@ +// 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.Shell; + +public class PowerShellEnvShellProvider : IEnvShellProvider +{ + public string ArgumentName => "pwsh"; + + public string Extension => "ps1"; + + public string? HelpDescription => "PowerShell Core (pwsh)"; + + public override string ToString() => ArgumentName; + + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) + { + var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPowerShellPathExport(escapedPath, dotnetupDir, includeDotnet); + + if (!includeDotnet) + { + return + $""" + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} + {pathExport} + """; + } + + return + $""" + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} + + $env:DOTNET_ROOT = '{escapedPath}' + {pathExport} + """; + } + + public IReadOnlyList GetProfilePaths() + { + var profileDir = ShellProviderHelpers.GetPowerShellProfileDirectoryOrThrow(); + return [Path.Combine(profileDir, "Microsoft.PowerShell_profile.ps1")]; + } + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); + return ShellProviderHelpers.BuildPowerShellProfileEntry(dotnetupPath, "pwsh", flags); + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePowerShellPath); + return ShellProviderHelpers.BuildPowerShellActivationCommand(dotnetupPath, "pwsh", flags); + } +} diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs new file mode 100644 index 000000000000..6ae289a24cc6 --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellDetection.cs @@ -0,0 +1,98 @@ +// 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.Shell; + +/// +/// Detects the user's current shell and resolves the matching . +/// +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 = + [ + new BashEnvShellProvider(), + new ZshEnvShellProvider(), + new PowerShellEnvShellProvider() + ]; + + private static readonly Dictionary s_shellMap = + s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + + /// + /// Looks up a shell provider by its command-line argument name (for example, "bash", "zsh", or "pwsh"). + /// + internal static IEnvShellProvider? GetShellProviderByName(string shellName) + { + if (string.IsNullOrWhiteSpace(shellName)) + { + return null; + } + + 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. + /// + 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(shellPathOrName) ?? shellPathOrName; + var normalizedShellPath = resolvedShellPath.Replace('\\', '/'); + var normalizedShellName = Path.GetFileNameWithoutExtension(normalizedShellPath); + return GetShellProviderByName(normalizedShellName); + } + + /// + /// Checks whether a shell argument name is supported. + /// + internal static bool IsSupported(string shellName) + => GetShellProviderByName(shellName) is not null; + + /// + /// 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; + } + + 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/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs new file mode 100644 index 000000000000..d19b6c44c6ee --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -0,0 +1,329 @@ +// 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.Shell; + +/// +/// Manages shell profile file modifications to persist .NET environment configuration. +/// +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( + 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. + /// 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. + /// 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, + string? dotnetInstallPath = null) + { + var profilePaths = provider.GetProfilePaths(); + var entry = provider.GenerateProfileEntry(dotnetupPath, dotnetupOnly, dotnetInstallPath); + var modifiedFiles = new List(); + + foreach (var profilePath in profilePaths) + { + if (EnsureEntryInFile(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; + } + + /// + /// 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) + { + var directory = Path.GetDirectoryName(profilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + if (!File.Exists(profilePath)) + { + // 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 = GetWrappedEntryLines(entry); + var existingBlocks = FindManagedBlocks(fileState.Lines, profilePath); + + if (existingBlocks.Count > 0) + { + 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 + } + + 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 + if (fileState.Lines.Count > 0 && !string.IsNullOrWhiteSpace(fileState.Lines[^1])) + { + fileState.Lines.Add(string.Empty); + } + + fileState.Lines.AddRange(entryLines); + fileState = fileState with { EndsWithTrailingNewLine = true }; + WriteProfileFile(profilePath, fileState); + return true; + } + + private static bool RemoveEntryFromFile(string profilePath) + { + if (!File.Exists(profilePath)) + { + return false; + } + + var fileState = ReadProfileFile(profilePath); + var existingBlocks = FindManagedBlocks(fileState.Lines, profilePath); + + if (existingBlocks.Count == 0) + { + return false; + } + + for (int i = existingBlocks.Count - 1; i >= 0; i--) + { + var block = existingBlocks[i]; + fileState.Lines.RemoveRange(block.Start, block.EndExclusive - block.Start); + } + + 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, + // Use UTF-8 as the fallback, but allow an existing BOM to override it. + encoding: utf8FallbackEncoding, + 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, ProfileFileState fileState) + { + string content = string.Join(fileState.NewLine, fileState.Lines); + + if (fileState.Lines.Count > 0 && fileState.EndsWithTrailingNewLine) + { + content += fileState.NewLine; + } + + 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; + + try + { + File.WriteAllText(tempPath, content, fileState.Encoding); + + if (File.Exists(profilePath)) + { + TryDeleteFile(backupPath); + File.Replace(tempPath, profilePath, backupPath, ignoreMetadataErrors: true); + TryDeleteFile(backupPath); + } + else + { + File.Move(tempPath, profilePath); + } + } + finally + { + TryDeleteFile(tempPath); + TryDeleteFile(backupPath); + } + } + + private static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (FileNotFoundException) + { + } + catch (DirectoryNotFoundException) + { + } + } + + 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) + { + 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++; + } + + 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; + } + } + + return blocks; + } + + private static string DetectLineEnding(string content) + { + if (content.Contains("\r\n", StringComparison.Ordinal)) + { + return "\r\n"; + } + + if (content.Contains('\n', StringComparison.Ordinal)) + { + return "\n"; + } + + if (content.Contains('\r', StringComparison.Ordinal)) + { + return "\r"; + } + + return Environment.NewLine; + } + + private static bool EndsWithLineEnding(string content) => + content.EndsWith("\r\n", StringComparison.Ordinal) || + content.EndsWith('\n', StringComparison.Ordinal) || + content.EndsWith('\r', StringComparison.Ordinal); + + 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) + { + 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/ShellProviderHelpers.cs b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs new file mode 100644 index 000000000000..38a625344c7e --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellProviderHelpers.cs @@ -0,0 +1,263 @@ +// 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; + +internal static class ShellProviderHelpers +{ + 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 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); + + 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 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 = BuildPosixPrintEnvCommand(dotnetupPath, shellName, flags); + + return $$""" + if [ -x '{{escapedPath}}' ]; then + eval "$({{command}})" + fi + """; + } + + internal static string BuildPowerShellActivationCommand(string dotnetupPath, string shellName, string flags) + { + var command = BuildPowerShellPrintEnvCommand(dotnetupPath, shellName, flags); + return $"Invoke-Expression ({command} | Out-String)"; + } + + internal static string BuildPowerShellProfileEntry(string dotnetupPath, string shellName, string flags) + { + var escapedPath = EscapePowerShellPath(dotnetupPath); + var command = BuildPowerShellPrintEnvCommand(dotnetupPath, shellName, flags); + var activationBlock = IndentLines(BuildPowerShellGuardedInvocationBlock(command), " "); + + return $$""" + if (Test-Path -LiteralPath '{{escapedPath}}' -PathType Leaf) + { + {{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 BuildPowerShellGuardedInvocationBlock(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. + 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 full path to the running dotnetup executable."); + } + + 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 new file mode 100644 index 000000000000..93537baee6af --- /dev/null +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.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. + +namespace Microsoft.DotNet.Tools.Bootstrapper.Shell; + +public class ZshEnvShellProvider : IEnvShellProvider +{ + public string ArgumentName => "zsh"; + + public string Extension => "zsh"; + + public string? HelpDescription => "Zsh shell"; + + public override string ToString() => ArgumentName; + + public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true) + { + var escapedPath = ShellProviderHelpers.EscapePosixPath(dotnetInstallPath); + var pathExport = ShellProviderHelpers.BuildPosixPathExport(escapedPath, dotnetupDir, includeDotnet); + + if (!includeDotnet) + { + return + $""" + {ShellProviderHelpers.GetDotnetupOnlyComment(ArgumentName)} + {pathExport} + rehash 2>/dev/null + """; + } + + return + $""" + {ShellProviderHelpers.GetEnvironmentConfigurationComment(ArgumentName, dotnetInstallPath)} + + export DOTNET_ROOT='{escapedPath}' + {pathExport} + rehash 2>/dev/null + """; + } + + public IReadOnlyList GetProfilePaths() + { + var zshDirectory = ShellProviderHelpers.GetZshConfigurationDirectoryOrThrow(); + return [Path.Combine(zshDirectory, ".zshrc")]; + } + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + return ShellProviderHelpers.BuildPosixProfileEntry(dotnetupPath, "zsh", flags); + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null) + { + var flags = ShellProviderHelpers.GetCommandFlags(dotnetupOnly, dotnetInstallPath, ShellProviderHelpers.EscapePosixPath); + return ShellProviderHelpers.BuildPosixActivationCommand(dotnetupPath, "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); 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/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 new file mode 100644 index 000000000000..7a580c2c2c74 --- /dev/null +++ b/src/Resolvers/Microsoft.DotNet.NativeWrapper/FileInterop.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. + +namespace Microsoft.DotNet.NativeWrapper +{ + 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 + private const CharSet UTF8 = CharSet.Ansi; + private static string? PtrToStringUTF8(IntPtr ptr) => Marshal.PtrToStringAnsi(ptr); + + [DllImport("libc", CharSet = UTF8, ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr realpath(string path, IntPtr buffer); + + [DllImport("libc", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)] + private static extern void free(IntPtr ptr); + + public static string? realpath(string path) + { + var ptr = realpath(path, IntPtr.Zero); + var result = PtrToStringUTF8(ptr); + free(ptr); + return result; + } + } + } +} diff --git a/test/dotnetup.Tests/DefaultInstallCommandTests.cs b/test/dotnetup.Tests/DefaultInstallCommandTests.cs new file mode 100644 index 000000000000..9e48e653d71b --- /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.Dotnetup.Tests.Utilities; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public sealed class DefaultInstallCommandTests : IDisposable +{ + private readonly string _tempHome; + 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); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempHome, recursive: true); + } + catch + { + // Cleanup is best-effort in tests. + } + } + + [Fact] + public void DefaultInstallUser_DoesNotPassDefaultInstallPathToPwshProfileOnUnix() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + 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(); + var profileContents = File.ReadAllText(profilePath); + profileContents.Should().Contain("print-env-script --shell pwsh"); + profileContents.Should().NotContain("--dotnet-install-path"); + } +} 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); diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index 930aa3934589..9135ad39ed8b 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -20,11 +22,44 @@ 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"); } + [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 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("# This bash script adds dotnetup to your PATH"); + script.Should().Contain("export PATH='/usr/local/bin':$PATH"); + 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 bash script configures the environment for .NET installed at /test/dotnet path"); + } + [Fact] public void ZshProvider_ShouldGenerateValidScript() { @@ -37,11 +72,56 @@ 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"); } + [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 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("# This zsh script adds dotnetup to your PATH"); + 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() { @@ -54,11 +134,47 @@ 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"); } + [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"); + } + + [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("# This PowerShell script adds dotnetup to your PATH"); + 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().Be("Invoke-Expression (& '/test/dotnetup' print-env-script --shell pwsh | Out-String)"); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] @@ -66,13 +182,25 @@ public void PowerShellProvider_ShouldGenerateValidScript() 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(); 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.ResolveShellProvider(shellPath); + + provider.Should().NotBeNull(); + provider!.ArgumentName.Should().Be(expectedName); + } + [Fact] public void BashProvider_ShouldHaveCorrectProperties() { diff --git a/test/dotnetup.Tests/InitWorkflowTests.cs b/test/dotnetup.Tests/InitWorkflowTests.cs index 5ea1689f52b7..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; @@ -33,6 +34,21 @@ public void Dispose() // ── ShouldPromptToConvertSystemInstalls ── + [Fact] + public void ShouldReplaceSystemConfiguration_ReturnsFalse_ForDotnetupDotnet() + { + InitWorkflows.ShouldReplaceSystemConfiguration(PathPreference.DotnetupDotnet) + .Should().BeFalse(); + } + + [Theory] + [InlineData(PathPreference.FullPathReplacement)] + internal void ShouldReplaceSystemConfiguration_ReturnsTrue_ForPathReplacingModes(PathPreference preference) + { + InitWorkflows.ShouldReplaceSystemConfiguration(preference) + .Should().BeTrue(); + } + [Fact] public void ShouldPromptToConvertSystemInstalls_ReturnsFalse_ForDotnetupDotnet() { @@ -106,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] @@ -176,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/InstallPathResolverTests.cs b/test/dotnetup.Tests/InstallPathResolverTests.cs index e85ef223dcc1..1de29eb05b89 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; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -205,6 +206,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 diff --git a/test/dotnetup.Tests/MockDotnetInstallManager.cs b/test/dotnetup.Tests/MockDotnetInstallManager.cs index f324dfbbc214..a7a042adec52 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; @@ -21,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, @@ -56,6 +60,13 @@ public List GetExistingSystemInstalls() public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null) { ApplyEnvironmentModificationsCallCount++; + LastDotnetRootForEnvironmentModifications = dotnetRoot; + } + + public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null) + { + ApplyTerminalProfileModificationsCallCount++; + LastDotnetRootForTerminalProfileModifications = dotnetRoot; } public void ApplyGlobalJsonModifications(IReadOnlyList requests) { } diff --git a/test/dotnetup.Tests/ParserTests.cs b/test/dotnetup.Tests/ParserTests.cs index d069ed3932a5..a94dbd4aeb48 100644 --- a/test/dotnetup.Tests/ParserTests.cs +++ b/test/dotnetup.Tests/ParserTests.cs @@ -7,6 +7,12 @@ namespace Microsoft.DotNet.Tools.Dotnetup.Tests; public class ParserTests { + public static IEnumerable ShellOverrideCommandArgs => + [ + [new[] { "defaultinstall", "user", "--shell", "bash" }], + [new[] { "init", "--shell", "bash" }] + ]; + [Fact] public void Parser_ShouldParseValidCommands() { @@ -113,6 +119,29 @@ 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("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")] diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs new file mode 100644 index 000000000000..950617a97ed6 --- /dev/null +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -0,0 +1,636 @@ +// 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.Shell; +using Microsoft.Dotnet.Installation; +using System.Text; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +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() + { + _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.BeginMarkerComment); + content.Should().Contain(ShellProfileManager.EndMarkerComment); + content.Should().Contain("print-env-script"); + AssertUsesOnlyCurrentPlatformLineEndings(content); + } + + [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.BeginMarkerComment); + 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() + { + 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.BeginMarkerComment).Should().Be(1); + lines.Count(l => l.TrimEnd() == ShellProfileManager.EndMarkerComment).Should().Be(1); + } + + [Fact] + public void AddProfileEntries_DoesNotLeaveBackupOfExistingFile() + { + 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().BeFalse(); + File.ReadAllText(profilePath).Should().NotBe(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_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_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() + { + 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_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.BeginMarkerComment); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().NotContain(ShellProfileManager.BeginMarkerComment); + content.Should().NotContain(ShellProfileManager.EndMarkerComment); + 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() + { + 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_PreservesUtf8BomAndCrLfLineEndings() + { + var profilePath = Path.Combine(_tempDir, "preserve-remove.sh"); + var provider = new TestShellProvider(_tempDir, "preserve-remove.sh"); + 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); + + 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); + 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_ThrowsWhenEndMarkerIsMissing() + { + var profilePath = Path.Combine(_tempDir, "missing-end.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.sh"); + + Action act = () => ShellProfileManager.RemoveProfileEntries(provider); + + 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] + 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() + { + var provider = new TestShellProvider(_tempDir, "nonexistent.sh"); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().BeEmpty(); + } + + [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.BeginMarkerComment); + File.ReadAllText(Path.Combine(_tempDir, "file2.sh")).Should().Contain(ShellProfileManager.BeginMarkerComment); + } + + [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 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() + { + 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 (AddProfileEntries now replaces in-place) + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath, dotnetupOnly: true); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().Contain("--dotnetup-only"); + 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] + public void AddProfileEntries_WorksWithNoExistingEntry() + { + var provider = new TestShellProvider(_tempDir, "fresh.sh"); + + var modified = ShellProfileManager.AddProfileEntries(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() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + 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"); + } + + [Fact] + public void BashProvider_GenerateProfileEntry_DotnetupOnly() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath, dotnetupOnly: true); + + 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 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() + { + var provider = new ZshEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + 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"); + } + + [Fact] + public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() + { + var provider = new PowerShellEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + 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"); + } + + [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.BeginMarkerComment); + command.Should().NotContain(ShellProfileManager.EndMarkerComment); + } + + [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? ProfileEntryOverride { get; init; } + + 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, 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 $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + } + + 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})\""; + } + } + + 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"); + } + + private static void AssertUsesOnlyCurrentPlatformLineEndings(string content) + { + if (Environment.NewLine == "\r\n") + { + AssertUsesOnlyCrLfLineEndings(content); + } + else + { + AssertUsesOnlyLfLineEndings(content); + } + } +}