Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
@@ -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.

using System.CommandLine;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath;

internal class ElevatedAdminPathCommand : CommandBase
{
private readonly string _operation;

public ElevatedAdminPathCommand(ParseResult result) : base(result)
{
_operation = result.GetValue(ElevatedAdminPathCommandParser.OperationArgument)!;
}

public override int Execute()
{
// This command only works on Windows
if (!OperatingSystem.IsWindows())
{
Console.Error.WriteLine("Error: The elevatedadminpath command is only supported on Windows.");
return 1;
}

// Check if running with elevated privileges
if (!WindowsPathHelper.IsElevated())
{
Console.Error.WriteLine("Error: This operation requires administrator privileges. Please run from an elevated command prompt.");
return 1;
}

return _operation.ToLowerInvariant() switch
{
"removedotnet" => RemoveDotnet(),
"adddotnet" => AddDotnet(),
_ => throw new InvalidOperationException($"Unknown operation: {_operation}")
};
}

[SupportedOSPlatform("windows")]
private int RemoveDotnet()
{
using var pathHelper = new WindowsPathHelper();
return pathHelper.RemoveDotnetFromAdminPath();
}

[SupportedOSPlatform("windows")]
private int AddDotnet()
{
using var pathHelper = new WindowsPathHelper();
return pathHelper.AddDotnetToAdminPath();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath;

internal static class ElevatedAdminPathCommandParser
{
public static readonly Argument<string> OperationArgument = new("operation")
{
HelpName = "OPERATION",
Description = "The operation to perform: 'removedotnet' or 'adddotnet'",
Arity = ArgumentArity.ExactlyOne,
};

private static readonly Command ElevatedAdminPathCommand = ConstructCommand();

public static Command GetCommand()
{
return ElevatedAdminPathCommand;
}

private static Command ConstructCommand()
{
Command command = new("elevatedadminpath", "Modifies the machine-wide admin PATH (requires elevated privileges)");
command.Hidden = true;

command.Arguments.Add(OperationArgument);

command.SetAction(parseResult => new ElevatedAdminPathCommand(parseResult).Execute());

return command;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot;

internal class SetInstallRootCommand : CommandBase
{
private readonly string _installType;
private readonly IDotnetInstallManager _dotnetInstaller;

public SetInstallRootCommand(ParseResult result, IDotnetInstallManager? dotnetInstaller = null) : base(result)
{
_installType = result.GetValue(SetInstallRootCommandParser.InstallTypeArgument)!;
_dotnetInstaller = dotnetInstaller ?? new DotnetInstallManager();
}

public override int Execute()
{
return _installType.ToLowerInvariant() switch
{
"user" => SetUserInstallRoot(),
_ => throw new InvalidOperationException($"Unknown install type: {_installType}")
};
}

[SupportedOSPlatform("windows")]
private void HandleWindowsAdminPath()
{
try
{
// Check if admin PATH needs to be changed
if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an overload to this method that returns (via an out parameter) the list of dotnet paths that are in the admin PATH. Then display those to the user before doing the operation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit bd56b89. Added an overload AdminPathContainsProgramFilesDotnet(out List<string> foundDotnetPaths) that returns the list of dotnet paths found in the admin PATH. Updated HandleWindowsAdminPath() to use this overload and display the paths to the user before removing them:

Program Files dotnet path(s) found in admin PATH:
  - C:\Program Files\dotnet
  - C:\Program Files (x86)\dotnet
Removing them...

The original parameterless method now delegates to the new overload with a discard parameter.

{
Console.WriteLine("Program Files dotnet path found in admin PATH. Removing it...");

if (WindowsPathHelper.IsElevated())
{
// We're already elevated, modify the admin PATH directly
Console.WriteLine("Running with elevated privileges. Modifying admin PATH...");
using var pathHelper = new WindowsPathHelper();
pathHelper.RemoveDotnetFromAdminPath();
}
else
{
// Not elevated, shell out to elevated process
Console.WriteLine("Launching elevated process to modify admin PATH...");
int exitCode = WindowsPathHelper.StartElevatedProcess("elevatedadminpath removedotnet");

if (exitCode != 0)
{
Console.Error.WriteLine("Warning: Failed to modify admin PATH. You may need to manually remove the Program Files dotnet path from the system PATH.");
// Continue anyway - we can still set up the user PATH
}
}
}
else
{
Console.WriteLine("Admin PATH does not contain Program Files dotnet path. No changes needed.");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: Error while checking/modifying admin PATH: {ex.Message}");
Console.Error.WriteLine("Continuing with user PATH setup...");
}
}

private int SetUserInstallRoot()
{
// Get the default user dotnet installation path
string userDotnetPath = _dotnetInstaller.GetDefaultDotnetInstallPath();

Console.WriteLine($"Setting up user install root at: {userDotnetPath}");

// On Windows, check if we need to modify the admin PATH
if (OperatingSystem.IsWindows())
{
HandleWindowsAdminPath();
}

// Add the user dotnet path to the user PATH
try
{
Console.WriteLine($"Adding {userDotnetPath} to user PATH...");

var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty;
var pathEntries = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries).ToList();

// Check if the user dotnet path is already in the user PATH
bool alreadyExists = pathEntries.Any(entry =>
Path.TrimEndingDirectorySeparator(entry).Equals(
Path.TrimEndingDirectorySeparator(userDotnetPath),
StringComparison.OrdinalIgnoreCase));

if (!alreadyExists)
{
// Add to the beginning of PATH
pathEntries.Insert(0, userDotnetPath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to read the unexpanded user PATH value from the registry and modify that. Use helper methods from WindowsPathHelper as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit b182662. Added ReadUserPath() and WriteUserPath() methods to WindowsPathHelper for registry-based user PATH access. Created generic AddPathEntry() helper that:

  • Takes both unexpanded and expanded PATH
  • Uses expanded PATH for detection
  • Modifies unexpanded PATH to preserve environment variables

Updated SetUserInstallRoot() to use these new methods on Windows, with a fallback to Environment API for non-Windows platforms.

var newUserPath = string.Join(Path.PathSeparator, pathEntries);
Environment.SetEnvironmentVariable("PATH", newUserPath, EnvironmentVariableTarget.User);
Console.WriteLine($"Successfully added {userDotnetPath} to user PATH.");
}
else
{
Console.WriteLine($"User dotnet path is already in user PATH.");
}

// Set DOTNET_ROOT for user
Environment.SetEnvironmentVariable("DOTNET_ROOT", userDotnetPath, EnvironmentVariableTarget.User);
Console.WriteLine($"Set DOTNET_ROOT to {userDotnetPath}");

Console.WriteLine("User install root configured successfully.");
Console.WriteLine("Note: You may need to restart your terminal or application for the changes to take effect.");

return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error: Failed to configure user install root: {ex.Message}");
return 1;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;

namespace Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot;

internal static class SetInstallRootCommandParser
{
public static readonly Argument<string> InstallTypeArgument = new("installtype")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call the AcceptOnlyFromAmong method here to list the valid argument values. They should be defined as constants in this file. Add "admin" in addition to "user" as a valid value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 8aa862f. Added constants UserInstallType = "user" and AdminInstallType = "admin" to SetInstallRootCommandParser. Updated the argument to use AcceptOnlyFromAmong(UserInstallType, AdminInstallType) for validation. The help now shows:

Arguments:
  <INSTALL_TYPE>  The type of installation root to set: 'user' or 'admin'

{
HelpName = "INSTALL_TYPE",
Description = "The type of installation root to set: 'user'",
Arity = ArgumentArity.ExactlyOne,
};

private static readonly Command SetInstallRootCommand = ConstructCommand();

public static Command GetCommand()
{
return SetInstallRootCommand;
}

private static Command ConstructCommand()
{
Command command = new("setinstallroot", "Sets the dotnet installation root");

command.Arguments.Add(InstallTypeArgument);

command.SetAction(parseResult => new SetInstallRootCommand(parseResult).Execute());

return command;
}
}
4 changes: 4 additions & 0 deletions src/Installer/dotnetup/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Install;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.Sdk.Update;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.ElevatedAdminPath;
using Microsoft.DotNet.Tools.Bootstrapper.Commands.SetInstallRoot;

namespace Microsoft.DotNet.Tools.Bootstrapper
{
Expand Down Expand Up @@ -38,6 +40,8 @@ private static RootCommand ConfigureCommandLine(RootCommand rootCommand)
rootCommand.Subcommands.Add(SdkCommandParser.GetCommand());
rootCommand.Subcommands.Add(SdkInstallCommandParser.GetRootInstallCommand());
rootCommand.Subcommands.Add(SdkUpdateCommandParser.GetRootUpdateCommand());
rootCommand.Subcommands.Add(ElevatedAdminPathCommandParser.GetCommand());
rootCommand.Subcommands.Add(SetInstallRootCommandParser.GetCommand());

return rootCommand;
}
Expand Down
Loading