Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ dotnetup defaultinstall system
|-------|---------------|-----------|
| **bash** | `~/.bashrc` (always) + the first existing of `~/.bash_profile` / `~/.profile` (creates `~/.profile` if neither exists) | `.bashrc` covers Linux terminals (non-login shells). The login profile covers macOS Terminal and SSH sessions. We never create `~/.bash_profile` to avoid shadowing an existing `~/.profile`. |
| **zsh** | `$ZDOTDIR/.zshrc` when `ZDOTDIR` is set; otherwise `~/.zshrc` (created if needed) | Covers all interactive zsh sessions. `~/.zshenv` is avoided because on macOS, `/etc/zprofile` runs `path_helper` which resets PATH after `.zshenv` loads. |
| **pwsh** | `~/.config/powershell/Microsoft.PowerShell_profile.ps1` (creates directory and file if needed) | Standard PowerShell profile path on Unix. |
| **pwsh** (Unix) | `~/.config/powershell/profile.ps1` (creates directory and file if needed) | Standard PowerShell profile path on Unix. Targets the `CurrentUserAllHosts` slot so the entry applies to every host (terminal, VS Code, embedded). |
| **pwsh** (Windows) | `<Documents>\WindowsPowerShell\profile.ps1` and `<Documents>\PowerShell\profile.ps1` -- both flavors are written unconditionally (creates directories and files if needed). `<Documents>` honors OneDrive Known Folder redirection via `Environment.GetFolderPath(MyDocuments)`. | Windows PowerShell 5.1 ships in-box and PowerShell 7+ (pwsh) installs separately, with disjoint profile folders. We write `profile.ps1` (`CurrentUserAllHosts`) for both flavors so the configuration is future-proof: if pwsh is installed later, dotnet is already wired up. `profile.ps1` is a standard PowerShell file; creating it for a not-yet-installed flavor is harmless. |

The home directory used for these lookups comes from the user's current environment (`HOME`, or `USERPROFILE` / `Environment.SpecialFolder.UserProfile` as a fallback). dotnetup fails with a clear error if it cannot determine a writable profile location.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ public List<ResolvedInstallRequest> InitWalkthrough(

if (pathPreference is PathPreference.ShellProfile)
{
_dotnetEnvironment.ApplyTerminalProfileModifications(installRoot.Path, command.ShellProvider);
_dotnetEnvironment.ApplyTerminalProfileModifications(installRoot.Path, shellProvider: command.ShellProvider);
}

if (ShouldReplaceSystemConfiguration(pathPreference))
Expand Down
76 changes: 54 additions & 22 deletions src/Installer/dotnetup.Library/DotnetEnvironmentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ private static void TryAddPath(List<string> paths, string path)
}
}

/// <summary>
/// Applies machine/user-level environment configuration (PATH and DOTNET_ROOT environment
/// variables) to point to either the system (Program Files) or user dotnet install location.
/// </summary>
public void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null)
{
if (OperatingSystem.IsWindows())
Expand Down Expand Up @@ -313,46 +317,74 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne
}
}

public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null)
/// <summary>
/// Applies dotnetup's profile-file modifications for the current user's shell environment,
/// which set up the PATH and DOTNET_ROOT environment variables for the user's shell.
/// </summary>
public void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null)
{
ArgumentNullException.ThrowIfNull(dotnetRoot);

if (OperatingSystem.IsWindows())
{
// Not implemented yet on Windows
return;
ApplyTerminalProfileModificationsWindows(dotnetRoot, installType, shellProvider);
}
else
{
ConfigureInstallTypeUnix(InstallType.User, dotnetRoot, shellProvider);
ApplyTerminalProfileModificationsUnix(dotnetRoot, installType, shellProvider);
}
}

private void ApplyTerminalProfileModificationsWindows(string dotnetRoot, InstallType installType, IEnvShellProvider? shellProvider)
{
var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow();
// The current shell provider on Windows is always the PowerShell provider (see
// ShellDetection.GetCurrentShellProvider). We accept a caller-supplied provider for
// testability.
shellProvider ??= new PowerShellEnvShellProvider();

if (installType == InstallType.System)
{
// System install: dotnet is assumed to be on PATH already (configured by the system
// installer / admin). The profile entry only adds dotnetup to PATH.
ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true);
return;
}

// If the install root matches the default dotnetup-managed path, omit it from the profile
// entry so `print-env-script` falls back to its built-in default-detection logic. This
// keeps profile entries minimal and portable across user-resolved default paths.
string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath())
? null
: dotnetRoot;

ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot);
Comment thread
dsplaisted marked this conversation as resolved.
}

private void ConfigureInstallTypeUnix(InstallType installType, string? dotnetRoot, IEnvShellProvider? shellProvider)
private void ApplyTerminalProfileModificationsUnix(string dotnetRoot, InstallType installType, IEnvShellProvider? shellProvider)
{
var dotnetupPath = ShellProviderHelpers.GetDotnetupExecutablePathOrThrow();
shellProvider = ShellDetection.GetCurrentShellProviderOrThrow(shellProvider);

switch (installType)
if (installType == InstallType.System)
Comment thread
dsplaisted marked this conversation as resolved.
{
case InstallType.User:
if (string.IsNullOrEmpty(dotnetRoot))
{
throw new ArgumentNullException(nameof(dotnetRoot));
}
// System install: dotnet is assumed to be on PATH already (e.g. /usr/share/dotnet
// configured by the system package manager). The profile entry only adds dotnetup
// to PATH.
ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true);
return;
}

string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath())
? null
: dotnetRoot;

ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot);
break;
case InstallType.System:
ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetupOnly: true);
break;
default:
throw new ArgumentException($"Unknown install type: {installType}", nameof(installType));
if (string.IsNullOrEmpty(dotnetRoot))
{
throw new ArgumentNullException(nameof(dotnetRoot));
}

string? profileDotnetRoot = DotnetupUtilities.PathsEqual(dotnetRoot, GetDefaultDotnetInstallPath())
? null
: dotnetRoot;

ShellProfileManager.AddProfileEntries(shellProvider, dotnetupPath, dotnetInstallPath: profileDotnetRoot);
}

/// <inheritdoc />
Expand Down
13 changes: 12 additions & 1 deletion src/Installer/dotnetup.Library/IDotnetEnvironmentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,20 @@ internal interface IDotnetEnvironmentManager

List<DotnetInstall> GetExistingSystemInstalls();

/// <summary>
/// Applies machine/user-level environment configuration (PATH and DOTNET_ROOT environment
/// variables) to point to either the system (Program Files) or user dotnet install location.
/// </summary>
void ApplyEnvironmentModifications(InstallType installType, string? dotnetRoot = null);

void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null);
/// <summary>
/// Applies dotnetup's profile-file modifications for the current user's shell environment,
/// which set up the PATH and DOTNET_ROOT environment variables for the user's shell.
/// When <paramref name="installType"/> is <see cref="InstallType.System"/>, dotnet is
/// assumed to already be on PATH (set up outside the profile), so the entry only adds
/// dotnetup to PATH.
/// </summary>
void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null);

/// <summary>
/// Updates the global.json file to reflect the installed SDK version,
Expand Down
9 changes: 9 additions & 0 deletions src/Installer/dotnetup.Library/Shell/IEnvShellProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public interface IEnvShellProvider
/// </summary>
IReadOnlyList<string> GetProfilePaths();

/// <summary>
/// The encoding to use when creating a brand-new profile file. Existing files always
/// have their detected encoding (including BOM presence) preserved by
/// <see cref="ShellProfileManager"/>; this is only consulted when no file exists yet.
/// Defaults to UTF-8 without BOM, which is the safe choice for POSIX shells (bash/zsh
/// would otherwise execute the BOM bytes as a command).
/// </summary>
Encoding NewFileEncoding => new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

/// <summary>
/// Generates the shell command block to append to a shell profile that will eval dotnetup's env script.
/// <see cref="ShellProfileManager"/> adds the surrounding marker comments when it writes the block.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ public class PowerShellEnvShellProvider : IEnvShellProvider

public override string ToString() => ArgumentName;

// Subfolder names under the user's Documents folder that hold each flavor's profile files.
internal const string WindowsPowerShellProfileFolder = "WindowsPowerShell";
internal const string PowerShellCoreProfileFolder = "PowerShell";

// CurrentUserAllHosts profile filename. Used for all flavors and on all platforms.
internal const string ProfileFileName = "profile.ps1";

public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "", bool includeDotnet = true)
{
var escapedPath = ShellProviderHelpers.EscapePowerShellPath(dotnetInstallPath);
Expand All @@ -38,8 +45,63 @@ public string GenerateEnvScript(string dotnetInstallPath, string dotnetupDir = "

public IReadOnlyList<string> GetProfilePaths()
{
if (OperatingSystem.IsWindows())
{
var documentsFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return GetWindowsProfilePaths(documentsFolder);
}

var profileDir = ShellProviderHelpers.GetPowerShellProfileDirectoryOrThrow();
return [Path.Combine(profileDir, "Microsoft.PowerShell_profile.ps1")];
return [Path.Combine(profileDir, ProfileFileName)];
}

// Windows PowerShell 5.1 reads .ps1 files without a BOM as the system ANSI code page,
// so a BOM-less profile containing non-ASCII characters (e.g. an install path under a
// username with accented or CJK characters) would be misinterpreted and likely fail
// to invoke dotnetup. On Windows we therefore create new PowerShell profile files as
// UTF-8 with BOM. PowerShell 7+ handles a BOM transparently, so it's safe to use the
// same encoding for the pwsh profile location too. On non-Windows we keep the BOM-less
// default — PowerShell 7 on Linux/macOS treats BOM-less .ps1 files as UTF-8.
//
// Existing profile files (regardless of where they live) keep their detected encoding;
// see ShellProfileManager.ReadProfileFile.
public Encoding NewFileEncoding => OperatingSystem.IsWindows()
? new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)
: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

// Write to profile.ps1, which applies to all PowerShell hosts, rather than
// Microsoft.PowerShell_profile.ps1, which only applies to the console host.
//
// We want dotnetup to be available "everywhere". On Windows we would modify the
// PATH if we could and that would apply to cmd also, but there's not a good way
// to override the Admin PATH that the MSI / Program Files installers set.
//
// Worth noting:
// * Most install scripts (rustup, conda init, oh-my-posh, dotnet-install) write to the
// host-specific Microsoft.PowerShell_profile.ps1. Users may have learned to look there.
// * $PROFILE (bare) points to Microsoft.PowerShell_profile.ps1. A user
// running `notepad $PROFILE` won't see our entry -- they have to know to open
// $PROFILE.CurrentUserAllHosts.
// * profile.ps1 runs in non-interactive embedded hosts too. Our entry shells out
// (& '...\dotnetup.exe' print-env-script | Out-String) every time PowerShell starts.
//
// We write to both Windows PowerShell (5.1) and PowerShell 7+ profile locations
// unconditionally, regardless of whether each flavor is currently installed. This
// means that the user installs pwsh later, dotnet is already wired up.
internal static IReadOnlyList<string> GetWindowsProfilePaths(string documentsFolder)
{
if (string.IsNullOrEmpty(documentsFolder))
{
throw new DotnetInstallException(
DotnetInstallErrorCode.ContextResolutionFailed,
"Unable to locate the current user's Documents folder.");
}

return
[
Path.Combine(documentsFolder, WindowsPowerShellProfileFolder, ProfileFileName),
Comment thread
dsplaisted marked this conversation as resolved.
Path.Combine(documentsFolder, PowerShellCoreProfileFolder, ProfileFileName),
];
}

public string GenerateProfileEntry(string dotnetupPath, bool dotnetupOnly = false, string? dotnetInstallPath = null)
Expand Down
6 changes: 3 additions & 3 deletions src/Installer/dotnetup.Library/Shell/ShellProfileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static IReadOnlyList<string> AddProfileEntries(

foreach (var profilePath in profilePaths)
{
if (EnsureEntryInFile(profilePath, entry))
if (EnsureEntryInFile(profilePath, entry, provider.NewFileEncoding))
{
modifiedFiles.Add(profilePath);
}
Expand Down Expand Up @@ -75,7 +75,7 @@ public static IReadOnlyList<string> RemoveProfileEntries(IEnvShellProvider provi
/// 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.
/// </summary>
private static bool EnsureEntryInFile(string profilePath, string entry)
private static bool EnsureEntryInFile(string profilePath, string entry, Encoding newFileEncoding)
{
var directory = Path.GetDirectoryName(profilePath);
if (!string.IsNullOrEmpty(directory))
Expand All @@ -88,7 +88,7 @@ private static bool EnsureEntryInFile(string profilePath, string entry)
// New file — write the managed block using a consistent newline style.
var newFileState = new ProfileFileState(
[.. GetWrappedEntryLines(entry)],
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
newFileEncoding,
Environment.NewLine,
EndsWithTrailingNewLine: true);
WriteProfileFile(profilePath, newFileState);
Expand Down
2 changes: 1 addition & 1 deletion test/dotnetup.Tests/DefaultInstallCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void DefaultInstallUser_DoesNotPassDefaultInstallPathToPwshProfileOnUnix(

exitCode.Should().Be(0, output);

string profilePath = Path.Combine(_tempHome, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
string profilePath = Path.Combine(_tempHome, ".config", "powershell", "profile.ps1");
File.Exists(profilePath).Should().BeTrue();
var profileContents = File.ReadAllText(profilePath);
profileContents.Should().Contain("print-env-script --shell pwsh");
Expand Down
16 changes: 16 additions & 0 deletions test/dotnetup.Tests/EnvShellProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ public void PowerShellProvider_ShouldGenerateValidScript()
script.Should().Contain("[IO.Path]::PathSeparator");
}

[Fact]
public void PowerShellProvider_NewFileEncoding_IsBomFullOnWindowsBomLessElsewhere()
{
// IEnvShellProvider's NewFileEncoding is consulted only when creating brand-new
// profile files; existing files always keep their detected encoding. On Windows,
// Windows PowerShell 5.1 reads BOM-less .ps1 files as the system ANSI code page
// and would mis-decode profiles containing non-ASCII paths, so the PowerShell
// provider opts into UTF-8 with BOM. On other OSes BOM-less UTF-8 is the
// PowerShell 7+ default.
IEnvShellProvider provider = new PowerShellEnvShellProvider();
var encoding = provider.NewFileEncoding;

encoding.Should().BeOfType<UTF8Encoding>();
encoding.GetPreamble().Length.Should().Be(OperatingSystem.IsWindows() ? 3 : 0);
}

[Fact]
public void PowerShellProvider_ShouldIncludeDotnetupDirInPath()
{
Expand Down
2 changes: 1 addition & 1 deletion test/dotnetup.Tests/MockDotnetInstallManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void ApplyEnvironmentModifications(InstallType installType, string? dotne
LastDotnetRootForEnvironmentModifications = dotnetRoot;
}

public void ApplyTerminalProfileModifications(string dotnetRoot, IEnvShellProvider? shellProvider = null)
public void ApplyTerminalProfileModifications(string dotnetRoot, InstallType installType = InstallType.User, IEnvShellProvider? shellProvider = null)
{
ApplyTerminalProfileModificationsCallCount++;
LastDotnetRootForTerminalProfileModifications = dotnetRoot;
Expand Down
Loading
Loading