diff --git a/documentation/general/dotnetup/unix-environment-setup.md b/documentation/general/dotnetup/unix-environment-setup.md index 5f113bfe27a9..44a86b707bb6 100644 --- a/documentation/general/dotnetup/unix-environment-setup.md +++ b/documentation/general/dotnetup/unix-environment-setup.md @@ -2,25 +2,98 @@ ## Overview -This document describes the design for setting up the .NET environment via initialization scripts using the `dotnetup print-env-script` command. This is the first step toward enabling automatic user profile configuration for Unix as described in [issue #51582](https://github.com/dotnet/sdk/issues/51582). Note that this also supports PowerShell and thus Windows, but on Windows the main method of configuring the environment will be to set environment variables which are stored in the registry instead of written by initialization scripts. +dotnetup automatically configures the Unix shell environment so that .NET is available in every new terminal session. This involves modifying shell profile files to set the `PATH` and `DOTNET_ROOT` environment variables. The same mechanism also supports PowerShell on any platform. -## Background +On Windows the primary method is registry-based environment variables, which is handled separately. This document focuses on the Unix (and PowerShell) profile-based approach. -The dotnetup tool manages multiple .NET installations in a local user hive. For .NET to be accessible from the command line, the installation directory must be: -1. Added to the `PATH` environment variable -2. Set as the `DOTNET_ROOT` environment variable +## How the Environment Gets Configured -On Unix systems, this requires modifying shell configuration files (like `.bashrc`, `.zshrc`, etc.) or sourcing environment setup scripts. +There are two primary ways the environment is configured: -## Design Goals +### 1. During `dotnetup sdk install` / `dotnetup runtime install` -1. **Non-invasive**: Don't automatically modify user shell configuration files without explicit consent -2. **Flexible**: Support multiple shells (bash, zsh, PowerShell) -3. **Reversible**: Users should be able to easily undo environment changes -4. **Single-file execution**: Generate scripts that can be sourced or saved for later use -5. **Discoverable**: Make it easy for users to understand how to configure their environment +When running interactively (the default in a terminal), the install command prompts the user to set the default install if one is not already configured: -## The `dotnetup print-env-script` Command +``` +Do you want to set the install path (~/.local/share/dotnet) as the default dotnet install? +This will update the PATH and DOTNET_ROOT environment variables. [Y/n] +``` + +If the user confirms (or passes `--set-default-install` explicitly): + +- **On Windows**: Environment variables are set in the registry and updated for the current process. +- **On Unix**: Shell profile files are modified so .NET is available in future terminal sessions. Since profile changes only take effect in new shells, dotnetup also prints an activation command for the current terminal: + + ``` + To start using .NET in this terminal, run: + eval "$('/home/user/.local/share/dotnetup/dotnetup' print-env-script --shell bash)" + ``` + +If the default install is already fully configured and matches the install path, the prompt is skipped entirely. + +### 2. `dotnetup defaultinstall` + +A standalone command that explicitly configures (or reconfigures) the default .NET install: + +```bash +# Set up user-level default install (modifies shell profiles) +dotnetup defaultinstall user + +# Switch to admin/system-managed .NET (removes DOTNET_ROOT from profiles, keeps dotnetup on PATH) +dotnetup defaultinstall admin +``` + +**`defaultinstall user`** on Unix: +1. Detects the current shell +2. Modifies the appropriate shell profile files +3. Prints an activation command for the current terminal + +**`defaultinstall admin`** on Unix: +- Replaces existing profile entries with dotnetup-only entries (keeps dotnetup on PATH but removes `DOTNET_ROOT` and dotnet from `PATH`), since the system package manager owns the .NET installation. + +## Shell Profile Modification + +### Which Profile Files Are Modified + +| Shell | Files modified | Rationale | +|-------|---------------|-----------| +| **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. | +| **zsh** | `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. | +| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard `$PROFILE` path on Unix. | + +### Profile Entry Format + +Each profile file gets a marker comment and an eval line: + +**Bash / Zsh:** +```bash +# dotnetup +eval "$('/path/to/dotnetup' print-env-script --shell bash)" +``` + +**PowerShell:** +```powershell +# dotnetup +& '/path/to/dotnetup' print-env-script --shell pwsh | Invoke-Expression +``` + +The path to dotnetup is the full path to the running binary (`Environment.ProcessPath`). + +### Backups + +Before modifying an existing profile file, dotnetup creates a backup (e.g., `~/.bashrc.dotnetup-backup`). This allows the user to restore the file if needed. + +### Reversibility + +To remove the environment configuration, find the `# dotnetup` marker comment and the line immediately after it in each profile file, and remove both lines. The backup files can be used as a reference. + +### Idempotency + +If a profile file already contains the `# dotnetup` marker, the entry is not duplicated. + +## The `print-env-script` Command + +`print-env-script` is the low-level building block that generates shell-specific environment scripts. It is called internally by profile entries and activation commands, but can also be used standalone for custom setups, CI pipelines, or when you want to source the environment without modifying profile files. ### Command Structure @@ -40,14 +113,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 +127,7 @@ dotnetup print-env-script --shell zsh ```bash dotnetup print-env-script --shell bash > ~/.dotnet-env.sh # Later, in .bashrc or manually: -source ~/.dotnet-env.sh +. ~/.dotnet-env.sh ``` #### Use custom installation path @@ -67,89 +135,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 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 $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 and extracts the shell name from the path (e.g., `/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 GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false); } ``` -**Implementations**: -- `BashEnvShellProvider`: Generates bash-compatible scripts -- `ZshEnvShellProvider`: Generates zsh-compatible scripts -- `PowerShellEnvShellProvider`: Generates PowerShell Core scripts - -### Shell Detection +**Implementations**: `BashEnvShellProvider`, `ZshEnvShellProvider`, `PowerShellEnvShellProvider` -The command automatically detects the current shell when the `--shell` option is not provided: +### ShellDetection -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`) +`ShellDetection.GetCurrentShellProvider()` resolves the user's current shell to the matching `IEnvShellProvider`. On Windows it returns the PowerShell provider; on Unix it reads `$SHELL`. -### Security Considerations +### ShellProfileManager -**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 +`ShellProfileManager` coordinates profile file modifications: +- `AddProfileEntries(provider, dotnetupPath)` — appends entries, creates backups, skips if already present +- `RemoveProfileEntries(provider)` — finds and removes marker + eval lines +- `ReplaceProfileEntries(provider, dotnetupPath, dotnetupOnly)` — removes then adds (used by `defaultinstall admin`) ## Future Work -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 +227,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/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 6d32fe803761..83b4e2cf024c 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -14,6 +14,7 @@ internal class DotnetArchiveExtractor : IDisposable private readonly IProgressTarget _progressTarget; private readonly IArchiveDownloader _archiveDownloader; private readonly bool _shouldDisposeDownloader; + private readonly bool _ownsReporter; private MuxerHandler? MuxerHandler { get; set; } private string? _archivePath; private IProgressReporter? _progressReporter; @@ -29,11 +30,14 @@ public DotnetArchiveExtractor( ReleaseVersion resolvedVersion, ReleaseManifest releaseManifest, IProgressTarget progressTarget, - IArchiveDownloader? archiveDownloader = null) + IArchiveDownloader? archiveDownloader = null, + IProgressReporter? sharedReporter = null) { _request = request; _resolvedVersion = resolvedVersion; _progressTarget = progressTarget; + _progressReporter = sharedReporter; + _ownsReporter = sharedReporter is null; ScratchDownloadDirectory = Directory.CreateTempSubdirectory().FullName; if (archiveDownloader != null) @@ -468,8 +472,11 @@ public void Dispose() { try { - // Dispose the progress reporter to finalize progress display - _progressReporter?.Dispose(); + // Dispose the progress reporter to finalize progress display (only if we own it) + if (_ownsReporter) + { + _progressReporter?.Dispose(); + } } catch { diff --git a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs index 38dfc459f374..d141ab5d0824 100644 --- a/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/DefaultInstall/DefaultInstallCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.DefaultInstall; @@ -32,7 +33,38 @@ private int SetUserInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the user install root is not yet supported on non-Windows platforms."); + var dotnetupPath = Environment.ProcessPath + ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (shellProvider is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); + } + + var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + + if (modifiedFiles.Count == 0) + { + Console.WriteLine("Shell profile is already configured for dotnetup."); + } + else + { + Console.WriteLine("Updated shell profile files:"); + foreach (var file in modifiedFiles) + { + Console.WriteLine($" {file}"); + } + } + + Console.WriteLine(); + Console.WriteLine("To start using .NET in this terminal, run:"); + Console.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath)}"); + + return 0; } var changes = _installRootManager.GetUserInstallRootChanges(); @@ -64,7 +96,36 @@ private int SetAdminInstallRoot() { if (!OperatingSystem.IsWindows()) { - throw new DotnetInstallException(DotnetInstallErrorCode.PlatformNotSupported, "Configuring the admin install root is only supported on Windows."); + // On Unix, switching to admin means the system manages dotnet. + // Replace profile entries with dotnetup-only (keeps dotnetup on PATH but removes DOTNET_ROOT and dotnet PATH). + var dotnetupPath = Environment.ProcessPath + ?? throw new DotnetInstallException(DotnetInstallErrorCode.Unknown, "Unable to determine the dotnetup executable path."); + + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (shellProvider is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Unable to detect a supported shell. SHELL={shellEnv}. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); + } + + var modifiedFiles = ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true); + + if (modifiedFiles.Count == 0) + { + Console.WriteLine("Shell profile is already configured."); + } + else + { + Console.WriteLine("Updated shell profile files (dotnetup only, no DOTNET_ROOT or dotnet PATH):"); + foreach (var file in modifiedFiles) + { + Console.WriteLine($" {file}"); + } + } + + return 0; } var changes = _installRootManager.GetAdminInstallRootChanges(); 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 bcb8e6f560ac..cacfd6b4e103 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommand.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; @@ -9,6 +10,7 @@ internal class PrintEnvScriptCommand : CommandBase { private readonly IEnvShellProvider? _shellProvider; private readonly string? _dotnetInstallPath; + private readonly bool _dotnetupOnly; private readonly IDotnetInstallManager _dotnetInstaller; public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result) @@ -16,6 +18,7 @@ public PrintEnvScriptCommand(ParseResult result, IDotnetInstallManager? dotnetIn _dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager(); _shellProvider = result.GetValue(PrintEnvScriptCommandParser.ShellOption); _dotnetInstallPath = result.GetValue(PrintEnvScriptCommandParser.DotnetInstallPathOption); + _dotnetupOnly = result.GetValue(PrintEnvScriptCommandParser.DotnetupOnlyOption); } protected override string GetCommandName() => "print-env-script"; @@ -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 ?? _dotnetInstaller.GetDefaultDotnetInstallPath(); + // Determine the dotnetup directory so it can be added to PATH + string? dotnetupDir = Path.GetDirectoryName(Environment.ProcessPath); + // Generate the shell script - string script = _shellProvider.GenerateEnvScript(installPath); + bool includeDotnet = !_dotnetupOnly; + string script = _shellProvider.GenerateEnvScript(installPath, dotnetupDir, includeDotnet); // Output the script to stdout Console.WriteLine(script); diff --git a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs index e9215d4ea3d0..e3bdcf220484 100644 --- a/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs +++ b/src/Installer/dotnetup/Commands/PrintEnvScript/PrintEnvScriptCommandParser.cs @@ -3,38 +3,31 @@ 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.", + Description = $"The shell for which to generate the environment script (supported: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}). If not specified, the current shell will be detected.", Arity = ArgumentArity.ZeroOrOne, // called when no token is presented at all - DefaultValueFactory = (optionResult) => LookupShellFromEnvironment(), + DefaultValueFactory = (optionResult) => ShellDetection.GetCurrentShellProvider(), // called for all other scenarios CustomParser = (optionResult) => { return optionResult.Tokens switch { // shouldn't be required because of the DefaultValueFactory above - [] => LookupShellFromEnvironment(), - [var shellToken] => s_shellMap[shellToken.Value], + [] => ShellDetection.GetCurrentShellProvider(), + [var shellToken] => ShellDetection.GetShellProvider(shellToken.Value), _ => throw new InvalidOperationException("Unexpected number of tokens") // this is impossible because of the Arity set above }; - } + }, + Validators = { OnlyAcceptSupportedShells() }, + CompletionSources = { CreateCompletions() } }; public static readonly Option DotnetInstallPathOption = new("--dotnet-install-path", "-d") @@ -43,16 +36,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,40 +55,13 @@ 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) => @@ -110,9 +71,9 @@ private static Command ConstructCommand() return; } var singleToken = optionResult.Tokens[0]; - if (!s_shellMap.ContainsKey(singleToken.Value)) + if (!ShellDetection.IsSupported(singleToken.Value)) { - optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", s_shellMap.Keys)}"); + optionResult.AddError($"Unsupported shell '{singleToken.Value}'. Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}"); } }; } @@ -121,7 +82,7 @@ private static Func> CreateComple { return (CompletionContext context) => { - return s_shellMap.Values.Select(shellProvider => new CompletionItem(shellProvider.ArgumentName, documentation: shellProvider.HelpDescription)); + return ShellDetection.s_supportedShells.Select(s => new CompletionItem(s.ArgumentName, documentation: s.HelpDescription)); }; } } diff --git a/src/Installer/dotnetup/Commands/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 f46b09ddd40c..34ffba1e7a6d 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommand.cs @@ -9,7 +9,7 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install; internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result) { - private readonly string? _componentSpec = result.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument); + private readonly string[] _componentSpecs = result.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument) ?? []; private readonly string? _installPath = result.GetValue(CommonOptions.InstallPathOption); private readonly bool? _setDefaultInstall = result.GetValue(CommonOptions.SetDefaultInstallOption); private readonly string? _manifestPath = result.GetValue(CommonOptions.ManifestPathOption); @@ -36,44 +36,69 @@ internal class RuntimeInstallCommand(ParseResult result) : CommandBase(result) protected override int ExecuteCore() { - // Parse the component spec to determine runtime type and version - var (component, versionOrChannel) = ParseComponentSpec(_componentSpec); + // If no specs provided, default to installing latest core runtime + var specs = _componentSpecs.Length > 0 ? (string?[])_componentSpecs : [null]; - // Windows Desktop Runtime is only available on Windows - if (component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows()) + // Parse and validate all specs up front before installing any + var parsed = new List<(InstallComponent Component, string? VersionOrChannel, string Description)>(); + foreach (var spec in specs) { - throw new DotnetInstallException( - DotnetInstallErrorCode.PlatformNotSupported, - $"Windows Desktop Runtime is only available on Windows. Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}"); - } + var (component, versionOrChannel) = ParseComponentSpec(spec); - // SDK versions and feature bands (like 9.0.103, 9.0.1xx) are SDK-specific and not valid for runtimes - if (!string.IsNullOrEmpty(versionOrChannel) && new UpdateChannel(versionOrChannel).IsSdkVersionOrFeatureBand()) - { - throw new DotnetInstallException( - DotnetInstallErrorCode.InvalidChannel, - $"'{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations. " - + "Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'."); - } + if (component == InstallComponent.WindowsDesktop && !OperatingSystem.IsWindows()) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.PlatformNotSupported, + $"Windows Desktop Runtime is only available on Windows. Valid component types for this platform are: {string.Join(", ", GetValidRuntimeTypes())}"); + } + + if (!string.IsNullOrEmpty(versionOrChannel) && new UpdateChannel(versionOrChannel).IsSdkVersionOrFeatureBand()) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InvalidChannel, + $"'{versionOrChannel}' looks like an SDK version or feature band, which is not valid for runtime installations. " + + "Use a version channel like '9.0', 'latest', 'lts', or a specific runtime version like '9.0.12'."); + } - // Use GetDisplayName() from InstallComponentExtensions for consistent descriptions - string componentDescription = component.GetDisplayName(); + parsed.Add((component, versionOrChannel, component.GetDisplayName())); + } InstallWorkflow workflow = new(_dotnetInstaller, _channelVersionResolver); - InstallWorkflow.InstallWorkflowOptions options = new( - versionOrChannel, - _installPath, - _setDefaultInstall, - _manifestPath, - _interactive, - _noProgress, - component, - componentDescription, - RequireMuxerUpdate: _requireMuxerUpdate, - Untracked: _untracked); - - workflow.Execute(options); + if (parsed.Count == 1) + { + var (component, versionOrChannel, componentDescription) = parsed[0]; + InstallWorkflow.InstallWorkflowOptions options = new( + versionOrChannel, + _installPath, + _setDefaultInstall, + _manifestPath, + _interactive, + _noProgress, + component, + componentDescription, + RequireMuxerUpdate: _requireMuxerUpdate, + Untracked: _untracked); + + workflow.Execute(options); + } + else + { + var optionsList = parsed.Select(p => new InstallWorkflow.InstallWorkflowOptions( + p.VersionOrChannel, + _installPath, + _setDefaultInstall, + _manifestPath, + _interactive, + _noProgress, + p.Component, + p.Description, + RequireMuxerUpdate: _requireMuxerUpdate, + Untracked: _untracked)).ToList(); + + workflow.ExecuteMultiple(optionsList); + } + return 0; } diff --git a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs index 10a708fdc296..92abf2b3634f 100644 --- a/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs +++ b/src/Installer/dotnetup/Commands/Runtime/Install/RuntimeInstallCommandParser.cs @@ -7,8 +7,8 @@ namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Runtime.Install; internal static class RuntimeInstallCommandParser { - public static readonly Argument ComponentSpecArgument = - CommonOptions.CreateRuntimeComponentSpecArgument(required: false, actionVerb: "install"); + public static readonly Argument ComponentSpecArgument = + CommonOptions.CreateMultipleRuntimeComponentSpecArgument(actionVerb: "install"); private static readonly Command s_command = ConstructCommand(); diff --git a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs index 6c3eee013b1a..13f47d74b091 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallExecutor.cs @@ -4,6 +4,7 @@ using System.Globalization; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; @@ -99,6 +100,47 @@ public static InstallResult ExecuteInstall( return new InstallResult(orchestratorResult.Install, orchestratorResult.WasAlreadyInstalled); } + /// + /// Executes multiple installations with concurrent downloads and serialized extraction. + /// Each component gets its own progress bar during download. + /// + /// The list of resolved install requests to execute. + /// Whether to suppress progress display. + /// The installation results in the same order as the requests. + public static IReadOnlyList ExecuteInstallMultiple( + IReadOnlyList requests, + bool noProgress) + { + var installRequests = requests.Select(r => r.Request).ToList(); + var descriptions = requests.Select(r => r.Request.Component.GetDisplayName()).ToList(); + +#pragma warning disable CA1305 // Spectre.Console API does not accept IFormatProvider + for (int i = 0; i < requests.Count; i++) + { + SpectreAnsiConsole.MarkupLineInterpolated($"Installing {descriptions[i]} [blue]{requests[i].ResolvedVersion}[/] to [blue]{requests[i].Request.InstallRoot.Path}[/]..."); + } +#pragma warning restore CA1305 + + var orchestratorResults = InstallerOrchestratorSingleton.Instance.InstallMultiple(installRequests, noProgress); + + var results = new List(); + for (int i = 0; i < orchestratorResults.Count; i++) + { + var orchResult = orchestratorResults[i]; + if (orchResult.WasAlreadyInstalled) + { + SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]{descriptions[i]} {orchResult.Install.Version} is already installed at {orchResult.Install.InstallRoot}[/]"); + } + else + { + SpectreAnsiConsole.MarkupLineInterpolated(CultureInfo.InvariantCulture, $"[green]Installed {descriptions[i]} {orchResult.Install.Version}, available via {orchResult.Install.InstallRoot}[/]"); + } + results.Add(new InstallResult(orchResult.Install, orchResult.WasAlreadyInstalled)); + } + + return results; + } + /// /// Executes the installation of additional versions of a .NET component. /// @@ -162,6 +204,19 @@ public static void ConfigureDefaultInstallIfRequested( if (setDefaultInstall) { dotnetInstaller.ConfigureInstallType(InstallType.User, installPath); + + // On non-Windows, print the activation command for the current terminal + if (!OperatingSystem.IsWindows()) + { + var dotnetupPath = Environment.ProcessPath; + IEnvShellProvider? shellProvider = ShellDetection.GetCurrentShellProvider(); + if (dotnetupPath is not null && shellProvider is not null) + { + SpectreAnsiConsole.WriteLine(); + SpectreAnsiConsole.WriteLine("To start using .NET in this terminal, run:"); + SpectreAnsiConsole.WriteLine($" {shellProvider.GenerateActivationCommand(dotnetupPath)}"); + } + } } } diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs index fcfaef1f7123..b06795a00ffc 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWalkthrough.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using Microsoft.Deployment.DotNet.Releases; using Microsoft.Dotnet.Installation.Internal; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Microsoft.DotNet.Tools.Bootstrapper.Telemetry; using Spectre.Console; using SpectreAnsiConsole = Spectre.Console.AnsiConsole; @@ -163,7 +164,16 @@ public bool ResolveSetDefaultInstall( // If global.json specified an install path, we don't prompt for setting the default install path (since you probably don't want to do that for a repo-local path) if (_options.Interactive && !installPathCameFromGlobalJson) { - if (currentDotnetInstallRoot == null) + // On non-Windows, configuring the default install requires modifying shell profiles. + // If we can't detect a supported shell, skip the prompt — there's nothing useful we can do. + if (!OperatingSystem.IsWindows() && ShellDetection.GetCurrentShellProvider() is null) + { + var shellEnv = Environment.GetEnvironmentVariable("SHELL") ?? "(not set)"; + SpectreAnsiConsole.MarkupLine($"[yellow]Shell '{shellEnv}' is not supported for automatic environment configuration. Skipping default install setup.[/]"); + SpectreAnsiConsole.MarkupLine($"[yellow]Supported shells: {string.Join(", ", ShellDetection.s_supportedShells.Select(s => s.ArgumentName))}[/]"); + resolvedSetDefaultInstall = false; + } + else if (currentDotnetInstallRoot == null) { resolvedSetDefaultInstall = SpectreAnsiConsole.Confirm( $"Do you want to set the install path ({resolvedInstallPath}) as the default dotnet install? This will update the PATH and DOTNET_ROOT environment variables.", diff --git a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs index 25ecb7b99a96..8b8e22e3e76f 100644 --- a/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs +++ b/src/Installer/dotnetup/Commands/Shared/InstallWorkflow.cs @@ -226,4 +226,86 @@ private void ApplyPostInstallConfiguration(WorkflowContext context, InstallExecu } } + /// + /// Executes multiple installations with concurrent downloads. Context resolution (install path, + /// default install prompting) happens once for the first spec's options. Each spec's component + /// and version are resolved independently, then downloads run in parallel and installs are + /// serialized. + /// + public void ExecuteMultiple(IReadOnlyList optionsList) + { + if (optionsList.Count == 0) + { + return; + } + + // For a single spec, use the standard path + if (optionsList.Count == 1) + { + Execute(optionsList[0]); + return; + } + + // Use the first spec's options for shared context (install path, interactive, etc.) + var baseOptions = optionsList[0]; + + // Record telemetry for the batch + Activity.Current?.SetTag(TelemetryTagNames.InstallComponent, "multi"); + Activity.Current?.SetTag("install.count", optionsList.Count); + + // Resolve shared context once (install path, default install decision) + var context = ResolveWorkflowContext(baseOptions, out string? error); + if (context is null) + { + throw new DotnetInstallException(DotnetInstallErrorCode.ContextResolutionFailed, error ?? "Failed to resolve workflow context."); + } + + if (File.Exists(context.InstallPath)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallPathIsFile, + $"The install path '{context.InstallPath}' is an existing file, not a directory. " + + "Please specify a directory path for the installation."); + } + + if (InstallExecutor.IsAdminInstallPath(context.InstallPath)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.AdminPathBlocked, + $"The install path '{context.InstallPath}' is a system-managed .NET location. " + + "dotnetup cannot install to the default system .NET directory. " + + "Use your system package manager or choose a different path."); + } + + // Create install requests for all specs + var resolvedRequests = new List(); + foreach (var options in optionsList) + { + string channel = options.VersionOrChannel ?? "latest"; + + var resolved = InstallExecutor.CreateAndResolveRequest( + context.InstallPath, + channel, + options.Component, + options.ManifestPath, + _channelVersionResolver, + options.RequireMuxerUpdate, + untracked: options.Untracked); + + resolvedRequests.Add(resolved); + } + + // Execute all installations with concurrent downloads + InstallExecutor.ExecuteInstallMultiple(resolvedRequests, baseOptions.NoProgress); + + // Apply post-install configuration once + if (context.SetDefaultInstall) + { + InstallExecutor.ConfigureDefaultInstallIfRequested(_dotnetInstaller, context.SetDefaultInstall, context.InstallPath); + } + + Activity.Current?.SetTag(TelemetryTagNames.InstallResult, "installed"); + InstallExecutor.DisplayComplete(); + } + } diff --git a/src/Installer/dotnetup/CommonOptions.cs b/src/Installer/dotnetup/CommonOptions.cs index 23bb9116de37..1493ec56685e 100644 --- a/src/Installer/dotnetup/CommonOptions.cs +++ b/src/Installer/dotnetup/CommonOptions.cs @@ -122,6 +122,24 @@ internal class CommonOptions }; } + /// + /// Creates a positional argument that accepts one or more runtime component specifications. + /// Used by runtime install to support installing multiple runtimes in a single invocation. + /// + /// Verb for the description (e.g., "install"). + public static Argument CreateMultipleRuntimeComponentSpecArgument(string actionVerb) + { + return new Argument("component-spec") + { + HelpName = "COMPONENT_SPEC", + Description = $"One or more version/channel (e.g., 10.0) or component@version (e.g., aspnetcore@10.0) to {actionVerb}. " + + "When only a version is provided, the core .NET runtime is targeted. " + + "Multiple specs can be provided to {actionVerb} several runtimes at once. " + + "Valid component types: " + string.Join(", ", RuntimeInstallCommand.GetValidRuntimeTypes()), + Arity = ArgumentArity.ZeroOrMore, + }; + } + private static bool IsCIEnvironmentOrRedirected() => new Cli.Telemetry.CIEnvironmentDetectorForTelemetry().IsCIEnvironment() || Console.IsOutputRedirected; } diff --git a/src/Installer/dotnetup/DotnetInstallManager.cs b/src/Installer/dotnetup/DotnetInstallManager.cs index 81927efa3b50..f3286dc621c5 100644 --- a/src/Installer/dotnetup/DotnetInstallManager.cs +++ b/src/Installer/dotnetup/DotnetInstallManager.cs @@ -1,10 +1,11 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; using Microsoft.Dotnet.Installation.Internal; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Tools.Bootstrapper.Commands.Shared; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; using Spectre.Console; namespace Microsoft.DotNet.Tools.Bootstrapper; @@ -158,13 +159,10 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n } else { - // Non-Windows platforms: use the simpler PATH-based approach - // Get current PATH - var path = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; - var pathEntries = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList(); - string exeName = "dotnet"; - // Remove only actual dotnet installation folders from PATH - pathEntries = [.. pathEntries.Where(p => !File.Exists(Path.Combine(p, exeName)))]; + // Non-Windows platforms: persist environment via shell profiles only. + // Environment.SetEnvironmentVariable with EnvironmentVariableTarget.User + // has no real persistent store on Unix, so shell profile entries are the + // sole persistence mechanism. switch (installType) { @@ -173,27 +171,27 @@ public void ConfigureInstallType(InstallType installType, string? dotnetRoot = n { throw new ArgumentNullException(nameof(dotnetRoot)); } - // Add dotnetRoot to PATH - pathEntries.Insert(0, dotnetRoot); - // Set DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", dotnetRoot, EnvironmentVariableTarget.User); + + // Persist to shell profiles + var dotnetupPath = Environment.ProcessPath; + var shellProvider = ShellDetection.GetCurrentShellProvider(); + if (dotnetupPath is not null && shellProvider is not null) + { + ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath); + } break; case InstallType.Admin: - if (string.IsNullOrEmpty(dotnetRoot)) + // Replace shell profile entries with dotnetup-only (no DOTNET_ROOT or dotnet PATH) + var adminDotnetupPath = Environment.ProcessPath; + var adminShellProvider = ShellDetection.GetCurrentShellProvider(); + if (adminDotnetupPath is not null && adminShellProvider is not null) { - throw new ArgumentNullException(nameof(dotnetRoot)); + ShellProfileManager.AddProfileEntries(adminShellProvider, adminDotnetupPath, dotnetupOnly: true); } - // Add dotnetRoot to PATH - pathEntries.Insert(0, dotnetRoot); - // Unset DOTNET_ROOT - Environment.SetEnvironmentVariable("DOTNET_ROOT", null, EnvironmentVariableTarget.User); 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/InstallerOrchestratorSingleton.cs b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs index bc3986924e74..c9f70229f163 100644 --- a/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs +++ b/src/Installer/dotnetup/InstallerOrchestratorSingleton.cs @@ -153,7 +153,182 @@ public InstallResult Install(DotnetInstallRequest installRequest, bool noProgres #pragma warning restore CA1822 /// - /// Records the install spec in the manifest, respecting Untracked and SkipInstallSpecRecording flags. + /// Installs multiple components with concurrent downloads and serialized extraction. + /// All pre-checks run first, then archives download in parallel with separate progress bars, + /// and finally extraction/commit happens one at a time. + /// +#pragma warning disable CA1822 // Intentionally an instance method on a singleton + public IReadOnlyList InstallMultiple(IReadOnlyList installRequests, bool noProgress = false) + { + if (installRequests.Count == 0) + { + return []; + } + + // For a single request, delegate to the existing path + if (installRequests.Count == 1) + { + return [Install(installRequests[0], noProgress)]; + } + + ReleaseManifest releaseManifest = new(); + + // Phase 1: Validate and resolve all requests, check for already-installed + var prepared = new List<(int OriginalIndex, DotnetInstallRequest Request, ReleaseVersion Version, DotnetInstall Install, string? ManifestPath)>(); + var results = new InstallResult?[installRequests.Count]; + + for (int i = 0; i < installRequests.Count; i++) + { + var request = installRequests[i]; + + if (!ChannelVersionResolver.IsValidChannelFormat(request.Channel.Name)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InvalidChannel, + $"'{request.Channel.Name}' is not a valid .NET version or channel. " + + $"Use a version like '9.0', '9.0.100', or a channel keyword: {string.Join(", ", ChannelVersionResolver.KnownChannelKeywords)}.", + version: null, + component: request.Component.ToString()); + } + + ReleaseVersion? version = request.ResolvedVersion + ?? new ChannelVersionResolver(releaseManifest).Resolve(request); + + if (version == null) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.VersionNotFound, + $"Could not find .NET version '{request.Channel.Name}'. The version may not exist or may not be supported.", + version: null, + component: request.Component.ToString()); + } + + DotnetInstall install = new(request.InstallRoot, version, request.Component); + string? customManifestPath = request.Options.ManifestPath; + + using (var finalizeLock = ModifyInstallStateMutex()) + { + var manifestData = request.Options.Untracked + ? new DotnetupManifestData() + : new DotnetupSharedManifest(customManifestPath).ReadManifest(); + + if (InstallAlreadyExists(manifestData, install)) + { + RecordInstallSpec(request, customManifestPath); + results[i] = new InstallResult(install, WasAlreadyInstalled: true); + continue; + } + + if (!request.Options.Untracked + && !IsRootInManifest(manifestData, request.InstallRoot) + && HasDotnetArtifacts(request.InstallRoot.Path)) + { + throw new DotnetInstallException( + DotnetInstallErrorCode.Unknown, + $"The install path '{request.InstallRoot.Path}' already contains a .NET installation that is not tracked by dotnetup. " + + "To avoid conflicts, use a different install path or remove the existing installation first.", + version: version.ToString(), + component: request.Component.ToString()); + } + + if (request.Options.RequireMuxerUpdate && request.InstallRoot.Path is not null) + { + MuxerHandler.EnsureMuxerIsWritable(request.InstallRoot.Path); + } + } + + prepared.Add((i, request, version, install, customManifestPath)); + } + + // If everything was already installed, return early + if (prepared.Count == 0) + { + return [.. results.Select(r => r!)]; + } + + // Phase 2: Download all archives concurrently with shared progress + IProgressTarget progressTarget = noProgress ? new NonUpdatingProgressTarget() : new SpectreProgressTarget(); + using var sharedReporter = progressTarget.CreateProgressReporter(); + + var extractors = new DotnetArchiveExtractor[prepared.Count]; + try + { + for (int i = 0; i < prepared.Count; i++) + { + extractors[i] = new DotnetArchiveExtractor( + prepared[i].Request, + prepared[i].Version, + releaseManifest, + progressTarget, + sharedReporter: sharedReporter); + } + + // Download concurrently + var downloadTasks = extractors.Select(e => Task.Run(() => e.Prepare())).ToArray(); + Task.WaitAll(downloadTasks); + + // Phase 3: Commit sequentially + for (int i = 0; i < prepared.Count; i++) + { + var (originalIndex, request, version, install, customManifestPath) = prepared[i]; + + using (var finalizeLock = ModifyInstallStateMutex()) + { + var manifestData = request.Options.Untracked + ? new DotnetupManifestData() + : new DotnetupSharedManifest(customManifestPath).ReadManifest(); + + if (InstallAlreadyExists(manifestData, install)) + { + results[originalIndex] = new InstallResult(install, WasAlreadyInstalled: true); + continue; + } + + extractors[i].Commit(); + + ArchiveInstallationValidator validator = new(); + if (validator.Validate(install, out string? validationFailure)) + { + RecordInstallSpec(request, customManifestPath); + + if (!request.Options.Untracked) + { + var manifestManager = new DotnetupSharedManifest(customManifestPath); + manifestManager.AddInstallation(request.InstallRoot, new Installation + { + Component = request.Component, + Version = version.ToString(), + Subcomponents = [.. extractors[i].ExtractedSubcomponents] + }); + } + } + else + { + throw new DotnetInstallException( + DotnetInstallErrorCode.InstallFailed, + $"Installation validation failed: {validationFailure}", + version: version.ToString(), + component: request.Component.ToString()); + } + } + + results[originalIndex] = new InstallResult(install, WasAlreadyInstalled: false); + } + } + finally + { + foreach (var extractor in extractors) + { + extractor?.Dispose(); + } + } + + return [.. results.Select(r => r!)]; + } +#pragma warning restore CA1822 + + /// + /// Records the install specin the manifest, respecting Untracked and SkipInstallSpecRecording flags. /// private static void RecordInstallSpec(DotnetInstallRequest installRequest, string? customManifestPath) { diff --git a/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs new file mode 100644 index 000000000000..6a65b29e6348 --- /dev/null +++ b/src/Installer/dotnetup/Shell/BashEnvShellProvider.cs @@ -0,0 +1,99 @@ +// 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 = null, bool includeDotnet = true) + { + var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); + + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; + } + else if (includeDotnet) + { + pathExport = $"export PATH='{escapedPath}':$PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':$PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + #!/usr/bin/env bash + # This script adds dotnetup to your PATH + {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null + """; + } + + return + $""" + #!/usr/bin/env bash + # This script configures the environment for .NET installed at {dotnetInstallPath} + + export DOTNET_ROOT='{escapedPath}' + {pathExport} + hash -d dotnet 2>/dev/null + hash -d dotnetup 2>/dev/null + """; + } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var paths = new List { Path.Combine(home, ".bashrc") }; + + // For login shells, use the first existing of .bash_profile / .profile. + // Never create .bash_profile — it would shadow an existing .profile. + string bashProfile = Path.Combine(home, ".bash_profile"); + string profile = Path.Combine(home, ".profile"); + + if (File.Exists(bashProfile)) + { + paths.Add(bashProfile); + } + else + { + // Use .profile (will be created if it doesn't exist) + paths.Add(profile); + } + + return paths; + } + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{escapedPath}' print-env-script --shell bash{flags})\""; + } +} diff --git a/src/Installer/dotnetup/Shell/IEnvShellProvider.cs b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs new file mode 100644 index 000000000000..f03fae93e77c --- /dev/null +++ b/src/Installer/dotnetup/Shell/IEnvShellProvider.cs @@ -0,0 +1,55 @@ +// 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 null to omit + /// When true, sets DOTNET_ROOT and adds dotnet to PATH. When false, only adds dotnetup to PATH. + /// A shell script that can be sourced to configure the environment + string GenerateEnvScript(string dotnetInstallPath, string? dotnetupDir = null, 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 line(s) to append to a shell profile that will eval dotnetup's env script. + /// Includes a marker comment for identification and removal. + /// + /// The full path to the dotnetup binary + /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). + string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false); + + /// + /// Generates a command that the user can paste into the current terminal to activate .NET. + /// + /// The full path to the dotnetup binary + /// When true, the command only adds dotnetup to PATH. + string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false); +} diff --git a/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs new file mode 100644 index 000000000000..ab82d39b7e11 --- /dev/null +++ b/src/Installer/dotnetup/Shell/PowerShellEnvShellProvider.cs @@ -0,0 +1,76 @@ +// 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 = null, bool includeDotnet = true) + { + var escapedPath = dotnetInstallPath.Replace("'", "''"); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "''"); + + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) + { + pathExport = $"$env:PATH = '{escapedDotnetupDir}' + [IO.Path]::PathSeparator + '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } + else if (includeDotnet) + { + pathExport = $"$env:PATH = '{escapedPath}' + [IO.Path]::PathSeparator + $env:PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"$env:PATH = '{escapedDotnetupDir}' + [IO.Path]::PathSeparator + $env:PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + # This script adds dotnetup to your PATH + {pathExport} + """; + } + + return + $""" + # This script configures the environment for .NET installed at {dotnetInstallPath} + + $env:DOTNET_ROOT = '{escapedPath}' + {pathExport} + """; + } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return [Path.Combine(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1")]; + } + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{ShellProfileManager.MarkerComment}\n& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"& '{escapedPath}' print-env-script --shell pwsh{flags} | Invoke-Expression"; + } +} diff --git a/src/Installer/dotnetup/Shell/ShellDetection.cs b/src/Installer/dotnetup/Shell/ShellDetection.cs new file mode 100644 index 000000000000..756057f77213 --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellDetection.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; + +/// +/// Detects the user's current shell and resolves the matching . +/// +public static class ShellDetection +{ + /// + /// The list of shell providers supported by dotnetup. + /// + internal static readonly IEnvShellProvider[] s_supportedShells = + [ + new BashEnvShellProvider(), + new ZshEnvShellProvider(), + new PowerShellEnvShellProvider() + ]; + + private static readonly Dictionary s_shellMap = + s_supportedShells.ToDictionary(s => s.ArgumentName, StringComparer.OrdinalIgnoreCase); + + /// + /// Looks up a shell provider by its argument name (e.g., "bash", "zsh", "pwsh"). + /// + internal static IEnvShellProvider? GetShellProvider(string shellName) + => s_shellMap.GetValueOrDefault(shellName); + + /// + /// Checks whether a shell name is supported. + /// + internal static bool IsSupported(string shellName) + => s_shellMap.ContainsKey(shellName); + + /// + /// Returns the for the user's current shell, + /// or null if the shell cannot be detected or is not supported. + /// + public static IEnvShellProvider? GetCurrentShellProvider() + { + if (OperatingSystem.IsWindows()) + { + return s_shellMap.GetValueOrDefault("pwsh"); + } + + var shellPath = Environment.GetEnvironmentVariable("SHELL"); + if (shellPath is null) + { + return null; + } + + var shellName = Path.GetFileName(shellPath); + return s_shellMap.GetValueOrDefault(shellName); + } +} diff --git a/src/Installer/dotnetup/Shell/ShellProfileManager.cs b/src/Installer/dotnetup/Shell/ShellProfileManager.cs new file mode 100644 index 000000000000..fd35b665a411 --- /dev/null +++ b/src/Installer/dotnetup/Shell/ShellProfileManager.cs @@ -0,0 +1,167 @@ +// 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 MarkerComment = "# dotnetup"; + private const string BackupSuffix = ".dotnetup-backup"; + + /// + /// Ensures the correct dotnetup profile entry is present in all profile files for the given shell provider. + /// If an entry already exists, it is replaced in-place. If no entry exists, one is appended. + /// Creates backups before modifying existing files. + /// + /// The shell provider to use. + /// The full path to the dotnetup binary. + /// When true, the profile entry only adds dotnetup to PATH (no DOTNET_ROOT or dotnet PATH). + /// The list of profile file paths that were modified. + public static IReadOnlyList AddProfileEntries(IEnvShellProvider provider, string dotnetupPath, bool dotnetupOnly = false) + { + var profilePaths = provider.GetProfilePaths(); + var entry = provider.GenerateProfileEntry(dotnetupPath, dotnetupOnly); + 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; + } + + /// + /// Checks whether a profile file already contains a dotnetup entry. + /// + public static bool HasProfileEntry(string profilePath) + { + if (!File.Exists(profilePath)) + { + return false; + } + + var content = File.ReadAllText(profilePath); + return content.Contains(MarkerComment, StringComparison.Ordinal); + } + + /// + /// Ensures the given entry is present in the file. If an existing dotnetup entry is found, + /// it is replaced in-place to preserve the user's ordering. Otherwise the entry is appended. + /// 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 — just write the entry + File.WriteAllText(profilePath, entry + Environment.NewLine); + return true; + } + + var lines = File.ReadAllLines(profilePath).ToList(); + var entryLines = entry.Split('\n', StringSplitOptions.None) + .Select(l => l.TrimEnd('\r')) + .ToArray(); + + // Look for an existing marker + int markerIndex = lines.FindIndex(l => l.TrimEnd() == MarkerComment); + + if (markerIndex >= 0) + { + // Determine how many lines the old entry spans (marker + command lines) + int oldEntryEnd = markerIndex + 1; + // The old entry is the marker line plus the next line (the eval/invoke line) + if (oldEntryEnd < lines.Count) + { + oldEntryEnd++; + } + + // Check if the existing entry already matches + var oldEntry = lines.GetRange(markerIndex, oldEntryEnd - markerIndex); + if (oldEntry.Count == entryLines.Length && + oldEntry.Zip(entryLines).All(pair => pair.First.TrimEnd() == pair.Second.TrimEnd())) + { + return false; // Already correct + } + + // Replace in-place + File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); + lines.RemoveRange(markerIndex, oldEntryEnd - markerIndex); + lines.InsertRange(markerIndex, entryLines); + File.WriteAllLines(profilePath, lines); + return true; + } + + // No existing entry — append + File.Copy(profilePath, profilePath + BackupSuffix, overwrite: true); + using var writer = File.AppendText(profilePath); + writer.WriteLine(); + writer.WriteLine(entry); + return true; + } + + private static bool RemoveEntryFromFile(string profilePath) + { + if (!File.Exists(profilePath)) + { + return false; + } + + var lines = File.ReadAllLines(profilePath).ToList(); + bool modified = false; + + for (int i = lines.Count - 1; i >= 0; i--) + { + if (lines[i].TrimEnd() == MarkerComment) + { + // Remove the marker line and the line after it (the eval/invoke line) + lines.RemoveAt(i); + if (i < lines.Count) + { + lines.RemoveAt(i); + } + modified = true; + } + } + + if (modified) + { + File.WriteAllLines(profilePath, lines); + } + + return modified; + } +} diff --git a/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs new file mode 100644 index 000000000000..0d98e11a4f26 --- /dev/null +++ b/src/Installer/dotnetup/Shell/ZshEnvShellProvider.cs @@ -0,0 +1,80 @@ +// 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 = null, bool includeDotnet = true) + { + var escapedPath = dotnetInstallPath.Replace("'", "'\\''"); + var escapedDotnetupDir = dotnetupDir?.Replace("'", "'\\''"); + + string pathExport; + if (includeDotnet && escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':'{escapedPath}':$PATH"; + } + else if (includeDotnet) + { + pathExport = $"export PATH='{escapedPath}':$PATH"; + } + else if (escapedDotnetupDir is not null) + { + pathExport = $"export PATH='{escapedDotnetupDir}':$PATH"; + } + else + { + pathExport = ""; + } + + if (!includeDotnet) + { + return + $""" + #!/usr/bin/env zsh + # This script adds dotnetup to your PATH + {pathExport} + rehash 2>/dev/null + """; + } + + return + $""" + #!/usr/bin/env zsh + # This script configures the environment for .NET installed at {dotnetInstallPath} + + export DOTNET_ROOT='{escapedPath}' + {pathExport} + rehash 2>/dev/null + """; + } + + public IReadOnlyList GetProfilePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return [Path.Combine(home, ".zshrc")]; + } + + public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"{ShellProfileManager.MarkerComment}\neval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + { + var escapedPath = dotnetupPath.Replace("'", "'\\''"); + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{escapedPath}' print-env-script --shell zsh{flags})\""; + } +} diff --git a/test/dotnetup.Tests/DnupE2Etest.cs b/test/dotnetup.Tests/DnupE2Etest.cs index 6e3649cd384a..650e7ef10cdd 100644 --- a/test/dotnetup.Tests/DnupE2Etest.cs +++ b/test/dotnetup.Tests/DnupE2Etest.cs @@ -342,13 +342,11 @@ private static void VerifyEnvScriptWorks(string shell, string installPath, strin pathLine.Should().NotBeNull($"PATH should be printed for {shell}"); dotnetRootLine.Should().NotBeNull($"DOTNET_ROOT should be printed for {shell}"); - // Verify PATH contains the install path (find first entry with 'dotnet' to handle shell startup files that may prepend entries) + // Verify PATH contains the install path var pathValue = pathLine!.Substring("PATH=".Length); var pathSeparator = OperatingSystem.IsWindows() ? ';' : ':'; var pathEntries = pathValue.Split(pathSeparator); - var dotnetPathEntries = pathEntries.Where(p => p.Contains("dotnet", StringComparison.OrdinalIgnoreCase)).ToList(); - var firstDotnetPathEntry = dotnetPathEntries.FirstOrDefault(); - firstDotnetPathEntry.Should().Be(installPath, $"First PATH entry containing 'dotnet' should be the dotnet install path for {shell}. Found dotnet entries: [{string.Join(", ", dotnetPathEntries)}]"); + pathEntries.Should().Contain(installPath, $"PATH should contain the dotnet install path for {shell}. PATH entries: [{string.Join(", ", pathEntries)}]"); // Verify DOTNET_ROOT matches install path var dotnetRootValue = dotnetRootLine!.Substring("DOTNET_ROOT=".Length); diff --git a/test/dotnetup.Tests/EnvShellProviderTests.cs b/test/dotnetup.Tests/EnvShellProviderTests.cs index f2bab4124d41..ba2f1ce1dfc4 100644 --- a/test/dotnetup.Tests/EnvShellProviderTests.cs +++ b/test/dotnetup.Tests/EnvShellProviderTests.cs @@ -1,7 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using Microsoft.DotNet.Tools.Bootstrapper.Commands.PrintEnvScript; +using Microsoft.DotNet.Tools.Bootstrapper; +using Microsoft.DotNet.Tools.Bootstrapper.Shell; namespace Microsoft.DotNet.Tools.Dotnetup.Tests; @@ -24,6 +25,26 @@ public void BashProvider_ShouldGenerateValidScript() script.Should().Contain($"export PATH='{installPath}':$PATH"); } + [Fact] + public void BashProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new BashEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); + } + + [Fact] + public void BashProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new BashEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("export PATH='/usr/local/bin':$PATH"); + script.Should().NotContain("'/test/dotnet'"); + } + [Fact] public void ZshProvider_ShouldGenerateValidScript() { @@ -41,6 +62,25 @@ public void ZshProvider_ShouldGenerateValidScript() script.Should().Contain($"export PATH='{installPath}':$PATH"); } + [Fact] + public void ZshProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new ZshEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("export PATH='/usr/local/bin':'/test/dotnet':$PATH"); + } + + [Fact] + public void ZshProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new ZshEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("export PATH='/usr/local/bin':$PATH"); + } + [Fact] public void PowerShellProvider_ShouldGenerateValidScript() { @@ -58,6 +98,25 @@ public void PowerShellProvider_ShouldGenerateValidScript() script.Should().Contain("[IO.Path]::PathSeparator"); } + [Fact] + public void PowerShellProvider_ShouldIncludeDotnetupDirInPath() + { + var provider = new PowerShellEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin"); + + script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + '/test/dotnet' + [IO.Path]::PathSeparator + $env:PATH"); + } + + [Fact] + public void PowerShellProvider_DotnetupOnly_ShouldNotSetDotnetRoot() + { + var provider = new PowerShellEnvShellProvider(); + var script = provider.GenerateEnvScript("/test/dotnet", "/usr/local/bin", includeDotnet: false); + + script.Should().NotContain("DOTNET_ROOT"); + script.Should().Contain("$env:PATH = '/usr/local/bin' + [IO.Path]::PathSeparator + $env:PATH"); + } + [Theory] [InlineData("bash")] [InlineData("zsh")] @@ -65,7 +124,7 @@ 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(); diff --git a/test/dotnetup.Tests/InstallerOrchestratorTests.cs b/test/dotnetup.Tests/InstallerOrchestratorTests.cs index 2e47d36909c4..ca956a85aa8c 100644 --- a/test/dotnetup.Tests/InstallerOrchestratorTests.cs +++ b/test/dotnetup.Tests/InstallerOrchestratorTests.cs @@ -176,6 +176,17 @@ public void HasDotnetArtifacts_ReturnsFalseForEmptyDirectory() #endregion + #region InstallMultiple + + [Fact] + public void InstallMultiple_EmptyList_ReturnsEmpty() + { + var results = InstallerOrchestratorSingleton.Instance.InstallMultiple([], noProgress: true); + results.Should().BeEmpty(); + } + + #endregion + #region Helpers private static DotnetInstall MakeInstall(string path, string version, InstallComponent component) diff --git a/test/dotnetup.Tests/RuntimeInstallTests.cs b/test/dotnetup.Tests/RuntimeInstallTests.cs index c49f5dbee14d..601fd638b850 100644 --- a/test/dotnetup.Tests/RuntimeInstallTests.cs +++ b/test/dotnetup.Tests/RuntimeInstallTests.cs @@ -138,5 +138,29 @@ public void Parser_RuntimeInstallWithValidComponentSpec_NoErrors(string componen parseResult.Errors.Should().BeEmpty(); } + [Fact] + public void Parser_RuntimeInstallWithMultipleSpecs_NoErrors() + { + var parseResult = Parser.Parse(["runtime", "install", "9.0", "10.0"]); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_RuntimeInstallWithMixedComponentSpecs_NoErrors() + { + var parseResult = Parser.Parse(["runtime", "install", "aspnetcore@9.0", "windowsdesktop@10.0", "8.0"]); + parseResult.Errors.Should().BeEmpty(); + } + + [Fact] + public void Parser_RuntimeInstallMultipleSpecs_ParsesAllValues() + { + var parseResult = Parser.Parse(["runtime", "install", "9.0", "10.0", "aspnetcore@8.0"]); + var specs = parseResult.GetValue(RuntimeInstallCommandParser.ComponentSpecArgument); + specs.Should().NotBeNull(); + specs.Should().HaveCount(3); + specs.Should().ContainInOrder("9.0", "10.0", "aspnetcore@8.0"); + } + #endregion } diff --git a/test/dotnetup.Tests/ShellProfileManagerTests.cs b/test/dotnetup.Tests/ShellProfileManagerTests.cs new file mode 100644 index 000000000000..b2d1f3dff05d --- /dev/null +++ b/test/dotnetup.Tests/ShellProfileManagerTests.cs @@ -0,0 +1,350 @@ +// 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; + +namespace Microsoft.DotNet.Tools.Dotnetup.Tests; + +public class ShellProfileManagerTests : IDisposable +{ + private readonly string _tempDir; + private const string FakeDotnetupPath = "/usr/local/bin/dotnetup"; + + public ShellProfileManagerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "dotnetup-profile-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { } + } + } + + [Fact] + public void AddProfileEntries_CreatesFileAndAddsEntry() + { + var provider = new TestShellProvider(_tempDir, "test.sh"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(modified[0]); + content.Should().Contain(ShellProfileManager.MarkerComment); + content.Should().Contain("print-env-script"); + } + + [Fact] + public void AddProfileEntries_AppendsToExistingFile() + { + var profilePath = Path.Combine(_tempDir, "existing.sh"); + File.WriteAllText(profilePath, "# existing config\nexport FOO=bar\n"); + var provider = new TestShellProvider(_tempDir, "existing.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var content = File.ReadAllText(profilePath); + content.Should().StartWith("# existing config"); + content.Should().Contain(ShellProfileManager.MarkerComment); + } + + [Fact] + public void AddProfileEntries_DoesNotDuplicateIfAlreadyPresent() + { + var profilePath = Path.Combine(_tempDir, "dup.sh"); + var provider = new TestShellProvider(_tempDir, "dup.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().BeEmpty(); + var lines = File.ReadAllLines(profilePath); + lines.Count(l => l.TrimEnd() == ShellProfileManager.MarkerComment).Should().Be(1); + } + + [Fact] + public void AddProfileEntries_CreatesBackupOfExistingFile() + { + var profilePath = Path.Combine(_tempDir, "backup.sh"); + var originalContent = "# original content\n"; + File.WriteAllText(profilePath, originalContent); + var provider = new TestShellProvider(_tempDir, "backup.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + var backupPath = profilePath + ".dotnetup-backup"; + File.Exists(backupPath).Should().BeTrue(); + File.ReadAllText(backupPath).Should().Be(originalContent); + } + + [Fact] + public void AddProfileEntries_CreatesParentDirectories() + { + var nestedDir = Path.Combine(_tempDir, "config", "powershell"); + var provider = new TestShellProvider(nestedDir, "profile.ps1"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(1); + File.Exists(Path.Combine(nestedDir, "profile.ps1")).Should().BeTrue(); + } + + [Fact] + public void RemoveProfileEntries_RemovesMarkerAndEvalLine() + { + var profilePath = Path.Combine(_tempDir, "remove.sh"); + var provider = new TestShellProvider(_tempDir, "remove.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + File.ReadAllText(profilePath).Should().Contain(ShellProfileManager.MarkerComment); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().HaveCount(1); + var content = File.ReadAllText(profilePath); + content.Should().NotContain(ShellProfileManager.MarkerComment); + content.Should().NotContain("print-env-script"); + } + + [Fact] + public void RemoveProfileEntries_LeavesOtherContentIntact() + { + var profilePath = Path.Combine(_tempDir, "partial.sh"); + File.WriteAllText(profilePath, "# my config\nexport FOO=bar\n"); + var provider = new TestShellProvider(_tempDir, "partial.sh"); + + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + ShellProfileManager.RemoveProfileEntries(provider); + + var content = File.ReadAllText(profilePath); + content.Should().Contain("# my config"); + content.Should().Contain("export FOO=bar"); + } + + [Fact] + public void RemoveProfileEntries_ReturnsEmptyForMissingFile() + { + var provider = new TestShellProvider(_tempDir, "nonexistent.sh"); + + var modified = ShellProfileManager.RemoveProfileEntries(provider); + + modified.Should().BeEmpty(); + } + + [Fact] + public void HasProfileEntry_ReturnsFalseForMissingFile() + { + ShellProfileManager.HasProfileEntry(Path.Combine(_tempDir, "missing.sh")).Should().BeFalse(); + } + + [Fact] + public void HasProfileEntry_ReturnsTrueWhenEntryPresent() + { + var profilePath = Path.Combine(_tempDir, "has.sh"); + var provider = new TestShellProvider(_tempDir, "has.sh"); + ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + ShellProfileManager.HasProfileEntry(profilePath).Should().BeTrue(); + } + + [Fact] + public void AddProfileEntries_ModifiesMultipleFiles() + { + var provider = new TestShellProvider(_tempDir, "file1.sh", "file2.sh"); + + var modified = ShellProfileManager.AddProfileEntries(provider, FakeDotnetupPath); + + modified.Should().HaveCount(2); + File.ReadAllText(Path.Combine(_tempDir, "file1.sh")).Should().Contain(ShellProfileManager.MarkerComment); + File.ReadAllText(Path.Combine(_tempDir, "file2.sh")).Should().Contain(ShellProfileManager.MarkerComment); + } + + [Fact] + public void 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_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"); + // Should only have one marker + content.Split('\n').Count(l => l.TrimEnd() == ShellProfileManager.MarkerComment).Should().Be(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().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("eval"); + entry.Should().Contain("--shell bash"); + entry.Should().NotContain("--dotnetup-only"); + } + + [Fact] + public void BashProvider_GenerateProfileEntry_DotnetupOnly() + { + var provider = new BashEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath, dotnetupOnly: true); + + entry.Should().Contain("--dotnetup-only"); + } + + [Fact] + public void ZshProvider_GenerateProfileEntry_ContainsEval() + { + var provider = new ZshEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("eval"); + entry.Should().Contain("--shell zsh"); + entry.Should().NotContain("--dotnetup-only"); + } + + [Fact] + public void PowerShellProvider_GenerateProfileEntry_ContainsInvokeExpression() + { + var provider = new PowerShellEnvShellProvider(); + var entry = provider.GenerateProfileEntry(FakeDotnetupPath); + + entry.Should().Contain(ShellProfileManager.MarkerComment); + entry.Should().Contain("Invoke-Expression"); + entry.Should().Contain("--shell pwsh"); + 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.MarkerComment); + } + + [Fact] + public void ZshProvider_GenerateActivationCommand_IsCorrect() + { + var provider = new ZshEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath); + + command.Should().Contain("eval"); + command.Should().Contain("--shell zsh"); + } + + [Fact] + public void PowerShellProvider_GenerateActivationCommand_IsCorrect() + { + var provider = new PowerShellEnvShellProvider(); + var command = provider.GenerateActivationCommand(FakeDotnetupPath); + + command.Should().Contain("Invoke-Expression"); + command.Should().Contain("--shell pwsh"); + } + + [Fact] + public void BashProvider_GetProfilePaths_ReturnsAtLeastBashrc() + { + var provider = new BashEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCountGreaterThanOrEqualTo(2); + paths[0].Should().EndWith(".bashrc"); + } + + [Fact] + public void ZshProvider_GetProfilePaths_ReturnsZshrc() + { + var provider = new ZshEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCount(1); + paths[0].Should().EndWith(".zshrc"); + } + + [Fact] + public void PowerShellProvider_GetProfilePaths_ReturnsProfilePs1() + { + var provider = new PowerShellEnvShellProvider(); + var paths = provider.GetProfilePaths(); + + paths.Should().HaveCount(1); + paths[0].Should().EndWith("Microsoft.PowerShell_profile.ps1"); + } + + /// + /// Test-only shell provider that targets files in the temp directory. + /// + private sealed class TestShellProvider : IEnvShellProvider + { + private readonly string[] _profilePaths; + + public TestShellProvider(string dir, params string[] fileNames) + { + _profilePaths = fileNames.Select(f => Path.Combine(dir, f)).ToArray(); + } + + public string ArgumentName => "test"; + public string Extension => "sh"; + public string? HelpDescription => null; + + public string GenerateEnvScript(string dotnetInstallPath, 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) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"# dotnetup\neval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + } + + public string GenerateActivationCommand(string dotnetupPath, bool dotnetupOnly = false) + { + var flags = dotnetupOnly ? " --dotnetup-only" : ""; + return $"eval \"$('{dotnetupPath}' print-env-script --shell test{flags})\""; + } + } +}