- Notifications
You must be signed in to change notification settings - Fork1.2k
Add Windows admin PATH management commands to dotnetup#52181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:release/dnup
Are you sure you want to change the base?
Conversation
- Created WindowsPathHelper utility class for Windows-specific PATH operations- Added ElevatedAdminPathCommand for 'dotnetup elevatedadminpath removedotnet/adddotnet'- Added SetInstallRootCommand for 'dotnetup setinstallroot user'- Registered new commands in Parser.csCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
- Added parser tests for elevatedadminpath and setinstallroot commands- Added unit tests for WindowsPathHelper utility methods- All tests pass successfullyCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
- Extract PATH splitting logic to SplitPath helper method- Remove extra blank lines in WindowsPathHelper.cs- All tests still passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
dsplaisted commentedDec 16, 2025
@copilot I forgot to tell you that the elevatedadminpath command should be hidden so it doesn't show up on the command line help, since it's meant to be an internal command not invoked by users. Can you fix that? |
The elevatedadminpath command is now hidden from the main help output as it's an internal command not meant to be invoked directly by users. The command is still functional when invoked explicitly.Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Done in commit3d163b5. The Before: After: The command still works when called directly: |
dsplaisted commentedDec 16, 2025
@copilot Next, let's change the logic for setting the admin PATH so that we don't expand variables embedded in it. Change the WindowsPathHelper.ReadAdminPath method to take an argument controlling whether it should expand the registry value. We should use the expanded value when checking to see if the PATH needs to be changed. When modifying the path, we should find the indices of the paths for dotnet in the expanded value, but then get the unexpanded value, delete those indices, and set the admin PATH to the new unexpanded value. We should also factor the manipulation logic into methods that don't require actually reading or writing the registry so we can write unit tests for that logic. |
- Modified ReadAdminPath to accept expand parameter controlling variable expansion- Created testable methods (FindDotnetPathIndices, RemovePathEntriesByIndices, PathContainsDotnet)- Added RemoveProgramFilesDotnetFromAdminPath that uses expanded PATH for detection but modifies unexpanded PATH- Updated AdminPathContainsProgramFilesDotnet to use expanded PATH for accurate detection- Updated commands to use new methods- Added 7 new unit tests for the testable methods- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Done in commit 5585351. I've refactored the PATH manipulation logic as requested: Changes made:
All 18 tests pass successfully. |
dsplaisted commentedDec 16, 2025
@copilot Refactor the functionality of AddDotnet and RemoveDotnet from the ElevatedAdminPathCommand into WindowsPathHelper, and then use the same logic from SetInstallRootCommand so that the logic isn't duplicated. Also, in WindowsPathHelper, create the log file when the class is created and close it when the class is disposed. Then write progress to it as it happens instead of all at the end. |
- Convert WindowsPathHelper from static to instance class implementing IDisposable- Add constructor that creates and opens log file on creation- Add Dispose method that closes log file and displays path- Add LogMessage method for progressive logging throughout operations- Create RemoveDotnetFromAdminPath() and AddDotnetToAdminPath() orchestrating methods- These methods encapsulate full logic: read, check, modify, write, log, broadcast- Simplify ElevatedAdminPathCommand to just create helper and call methods- Simplify SetInstallRootCommand to use the same logic- Log messages now written progressively during operations- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Done in commit1d91179. I've refactored the code as requested: Changes made:
All 18 tests pass successfully. |
Uh oh!
There was an error while loading.Please reload this page.
| { | ||
| _logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}]{message}"); | ||
| } | ||
| catch(Exceptionex) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Don't catch an exception here, let it fail.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitbbd3d5c. Removed the try-catch block from LogMessage method - it now lets exceptions propagate.
| _logWriter?.Dispose(); | ||
| if(_logFilePath!=null) | ||
| { | ||
| Console.WriteLine($"PATH changes logged to:{_logFilePath}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Remove console output such as this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitbbd3d5c. Removed the console output from Dispose method.
| /// Removes the Program Files dotnet path from the given PATH string. | ||
| /// This is a convenience method that uses the expanded PATH for detection. | ||
| /// </summary> | ||
| publicstaticstringRemoveProgramFilesDotnetFromPath(stringpath) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
This method is currently only called by tests, and it has similar but different logic to the real logic. Change this so that it takes both an unexpanded and expanded path as a parameter, and does the logic currently in RemoveProgramFilesDotnetFromAdminPath. Then that method can call this one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitbbd3d5c. RefactoredRemoveProgramFilesDotnetFromPath to take bothunexpandedPath andexpandedPath as parameters:
publicstaticstringRemoveProgramFilesDotnetFromPath(stringunexpandedPath,stringexpandedPath)
The method now does the logic that was inRemoveProgramFilesDotnetFromAdminPath, which now just calls this method. Tests updated to pass both parameters.
| } | ||
| // Check if any Program Files dotnet path is already in PATH | ||
| boolalreadyExists=pathEntries.Any(entry=> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Instead of this logic, use the PathContainsDotnet method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitbbd3d5c. Replaced the custom logic with a call toPathContainsDotnet:
boolalreadyExists=PathContainsDotnet(pathEntries,programFilesDotnetPaths);
- Don't swallow exception in constructor, rethrow with better message preserving inner exception- Remove try-catch from LogMessage method, let it fail if there are issues- Remove console output from Dispose method- Refactor RemoveProgramFilesDotnetFromPath to take both unexpanded and expanded paths as parameters- Update RemoveProgramFilesDotnetFromAdminPath to call the refactored method- Replace custom logic in AddProgramFilesDotnetToPath with PathContainsDotnet method call- Update tests to pass both parameters to RemoveProgramFilesDotnetFromPath- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
| /// Removes the Program Files dotnet path from the admin PATH while preserving unexpanded environment variables. | ||
| /// </summary> | ||
| /// <returns>The modified unexpanded PATH string.</returns> | ||
| publicstaticstringRemoveProgramFilesDotnetFromAdminPath() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Let's rename this to GetAdminPathWithProgramFilesDotnetRemoved() to make it clearer that it doesn't actually update the saved value of the PATH in the registry.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitf4e9278. Renamed toGetAdminPathWithProgramFilesDotnetRemoved() to make it clear the method doesn't modify the registry, only returns the modified PATH string.
| /// This is the main orchestrating method that should be called by commands. | ||
| /// </summary> | ||
| /// <returns>0 on success, 1 on failure.</returns> | ||
| publicintRemoveDotnetFromAdminPath() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Remove the console output from this method and from AddDotnetToAdminPath (except in the case of logging a failure to Console.Error).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitf4e9278. Removed allConsole.WriteLine statements from bothRemoveDotnetFromAdminPath() andAddDotnetToAdminPath(). OnlyConsole.Error.WriteLine remains for error reporting. All progress is now logged to the log file only.
| LogMessage($"ERROR:{ex.Message}"); | ||
| LogMessage($"Stack trace:{ex.StackTrace}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
In this method and the next one, rely on the Exception ToString() method. This should include more complete information (such as inner exceptions and their stack traces) in the logs.
| LogMessage($"ERROR:{ex.Message}"); | |
| LogMessage($"Stack trace:{ex.StackTrace}"); | |
| LogMessage($"ERROR:{ex.ToString()}"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitf4e9278. Changed to useex.ToString() in both methods:
LogMessage($"ERROR:{ex.ToString()}");
This includes complete information with inner exceptions and their stack traces.
| /// <summary> | ||
| /// Broadcasts a WM_SETTINGCHANGE message to notify other applications that the environment has changed. | ||
| /// </summary> | ||
| privatevoidBroadcastEnvironmentChange() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Don't write to the Console in this method, write to the log instead. Make sure to use ToString() on the exception when writing it to the log.
In methods that call this method, don't log a message saying that the change was broadcasted, since that will now be logged in this method.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitf4e9278. UpdatedBroadcastEnvironmentChange() to write to log instead of console:
LogMessage("Environment change notification broadcasted");// and on error:LogMessage($"WARNING: Failed to broadcast environment change:{ex.ToString()}");
Removed the "Environment change broadcasted" log messages from the calling methods since it's now logged inBroadcastEnvironmentChange().
| /// <summary> | ||
| /// Starts an elevated process with the given arguments. | ||
| /// </summary> | ||
| publicstaticintStartElevatedProcess(stringarguments) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Change this method to return a bool which should be true if the elevation succeeded and the process exit code was zero. It should return false if the elevation was cancelled. If the process returned a non-zero exit code, throw an exception with the process STDOUT and STDERR (which will need to be captured).
If there's some other error starting the process, let the exception propagate (or throw an exception if the process is null, for example).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitf4e9278. RefactoredStartElevatedProcess() to:
- Return
bool(true if succeeded, false if cancelled) - Capture STDOUT and STDERR with
RedirectStandardOutput/Error = true - Throw
InvalidOperationExceptionwith captured output on non-zero exit code - Return false on Win32Exception with error code 1223 (user cancelled UAC)
- Let other exceptions propagate
UpdatedSetInstallRootCommand to handle the new return type with proper error handling.
- Rename RemoveProgramFilesDotnetFromAdminPath to GetAdminPathWithProgramFilesDotnetRemoved for clarity- Remove all Console.WriteLine statements from RemoveDotnetFromAdminPath and AddDotnetToAdminPath (keep only Console.Error)- Use ex.ToString() for complete exception logging including inner exceptions and stack traces- Update BroadcastEnvironmentChange to log instead of console output- Refactor StartElevatedProcess to return bool (true on success, false if cancelled)- Capture STDOUT and STDERR in StartElevatedProcess and throw exception with details on non-zero exit- Update SetInstallRootCommand to handle new StartElevatedProcess return type- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
JeremyKuhne left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
@dsplaisted I don't think you need much if any of the interop.Environment already has this support.
| /// <summary> | ||
| /// Checks if the current process is running with elevated (administrator) privileges. | ||
| /// </summary> | ||
| public static bool IsElevated() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
I thinkEnvironment.IsPrivilegedProcess will do what you want here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
@copilot Delete this method and switch to Environment.IsPrivilegedProcess in places that call it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commit8aa862f. Deleted theIsElevated() method and replaced all calls withEnvironment.IsPrivilegedProcess. Also removed the unusedSystem.Security.Principal import.
| /// Reads the machine-wide PATH environment variable from the registry. | ||
| /// </summary> | ||
| /// <param name="expand">If true, expands environment variables in the PATH value.</param> | ||
| public static string ReadAdminPath(bool expand = false) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Environment.Get/SetEnvironmentVariable allows you to target machine-wide.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Is there a way with .NET APIs to get the unexpanded value from the registry? My understanding is that the path could have something like%USERPROFILE%\MyApp\ in it, and we don't want to replace that with the expanded value if we're modifying other parts of the PATH. I think we hit this with the installers at one point. /cc@joeloff
| internalstaticclassSetInstallRootCommandParser | ||
| { | ||
| publicstaticreadonlyArgument<string>InstallTypeArgument=new("installtype") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commit8aa862f. Added constantsUserInstallType = "user" andAdminInstallType = "admin" toSetInstallRootCommandParser. Updated the argument to useAcceptOnlyFromAmong(UserInstallType, AdminInstallType) for validation. The help now shows:
Arguments: <INSTALL_TYPE> The type of installation root to set: 'user' or 'admin'| /// <summary> | ||
| /// Checks if the current process is running with elevated (administrator) privileges. | ||
| /// </summary> | ||
| public static bool IsElevated() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
@copilot Delete this method and switch to Environment.IsPrivilegedProcess in places that call it.
| /// Helper class for Windows-specific PATH management operations. | ||
| /// </summary> | ||
| [SupportedOSPlatform("windows")] | ||
| internal sealed class WindowsPathHelper : IDisposable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Add implementation for 'dotnet setinstallroot admin` to this class.
This should add the Program Files dotnet path back to the admin PATH, remove the user dotnet path from the user PATH, and unset the user DOTNET_ROOT environment variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commit8aa862f. AddedSetAdminInstallRoot() method toSetInstallRootCommand that:
- Adds the Program Files dotnet path back to the admin PATH (with elevation if needed via
elevatedadminpath adddotnet) - Removes the user dotnet path from the user PATH
- Unsets the user DOTNET_ROOT environment variable
Theadmin install type is now accepted and handled by the command.
… install root- Remove custom IsElevated() method and use Environment.IsPrivilegedProcess instead- Remove unused System.Security.Principal import- Add constants for install types (UserInstallType, AdminInstallType) in SetInstallRootCommandParser- Use AcceptOnlyFromAmong to validate install type arguments- Add SetAdminInstallRoot() method to restore admin installation: - Adds Program Files dotnet path back to admin PATH (with elevation if needed) - Removes user dotnet path from user PATH - Unsets user DOTNET_ROOT environment variable- Update SetInstallRootCommand.Execute() to handle both user and admin install types- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
| try | ||
| { | ||
| // Check if admin PATH needs to be changed | ||
| if (WindowsPathHelper.AdminPathContainsProgramFilesDotnet()) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitbd56b89. Added an overloadAdminPathContainsProgramFilesDotnet(out List<string> foundDotnetPaths) that returns the list of dotnet paths found in the admin PATH. UpdatedHandleWindowsAdminPath() 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)\dotnetRemoving them...The original parameterless method now delegates to the new overload with a discard parameter.
- Add AdminPathContainsProgramFilesDotnet(out List<string> foundDotnetPaths) overload- The new overload returns the list of dotnet paths found in the admin PATH- Update HandleWindowsAdminPath to use the overload and display paths before removal- The original parameterless method now delegates to the new overload- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
| /// <summary> | ||
| /// Adds the Program Files dotnet path to the given PATH string if it's not already present. | ||
| /// </summary> | ||
| publicstaticstringAddProgramFilesDotnetToPath(stringpath) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
This process needs to be changed so that if the PATH needs to be modified, it reads the unexpanded path and then adds to that.
Reuse existing methods for similar functionality if possible. Refactor if you need to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitb182662. RefactoredAddProgramFilesDotnetToPath() to take bothunexpandedPath andexpandedPath parameters. The method now:
- Uses the expanded PATH for detection (to check if dotnet is already present)
- Modifies the unexpanded PATH (to preserve environment variables like
%ProgramFiles%,%USERPROFILE%, etc.)
UpdatedAddDotnetToAdminPath() to read both expanded and unexpanded admin PATH and pass both to the helper method.
| if(!alreadyExists) | ||
| { | ||
| // Add to the beginning of PATH | ||
| pathEntries.Insert(0,userDotnetPath); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitb182662. AddedReadUserPath() andWriteUserPath() methods to WindowsPathHelper for registry-based user PATH access. Created genericAddPathEntry() helper that:
- Takes both unexpanded and expanded PATH
- Uses expanded PATH for detection
- Modifies unexpanded PATH to preserve environment variables
UpdatedSetUserInstallRoot() to use these new methods on Windows, with a fallback to Environment API for non-Windows platforms.
| // Remove user dotnet path from user PATH | ||
| Console.WriteLine($"Removing{userDotnetPath} from user PATH..."); | ||
| varuserPath=Environment.GetEnvironmentVariable("PATH",EnvironmentVariableTarget.User)??string.Empty; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Same here, we need to get the unexpanded path and modify that (which is what we do for the admin path in WindowsPathHelper).
Reuse existing helper methods and refactor them as necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others.Learn more.
Fixed in commitb182662. Created genericRemovePathEntry() helper method that:
- Takes both unexpanded and expanded PATH
- Finds indices to remove using the expanded PATH
- Removes those indices from the unexpanded PATH to preserve environment variables
UpdatedSetAdminInstallRoot() to useReadUserPath(),RemovePathEntry(), andWriteUserPath() for registry-based operations that preserve unexpanded environment variables.
- Add ReadUserPath and WriteUserPath methods for user PATH registry access- Refactor AddProgramFilesDotnetToPath to take both unexpanded and expanded paths- Add new helper methods: AddPathEntry and RemovePathEntry for generic path manipulation- Update AddDotnetToAdminPath to read both expanded and unexpanded paths- Update SetUserInstallRoot to use WindowsPathHelper methods for registry operations on Windows- Update SetAdminInstallRoot to use WindowsPathHelper methods for user PATH removal- Add OS guards to ensure Windows-specific code only runs on Windows- Update tests to pass both parameters to AddProgramFilesDotnetToPath- All 18 tests passCo-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Uh oh!
There was an error while loading.Please reload this page.
Implementation Plan for Windows PATH Management in dotnetup
WindowsPathHelperutility class to handle Windows-specific PATH operationsHKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment)ElevatedAdminPathCommandand parser fordotnetup elevatedadminpathsubcommandremovedotnetsubcommandadddotnetsubcommandSetInstallRootCommandand parser fordotnetup setinstallrootsubcommandusersubcommand that:dotnetup elevatedadminpath removedotnetif necessaryadminsubcommand that:Summary
This PR implements Windows PATH management commands for dotnetup as specified in issue#52121. The implementation includes:
New Commands:
dotnetup elevatedadminpath removedotnet- Removes Program Files dotnet path from machine-wide admin PATH (requires elevation, hidden from help)dotnetup elevatedadminpath adddotnet- Adds Program Files dotnet path to machine-wide admin PATH (requires elevation, hidden from help)dotnetup setinstallroot user- Sets up user installation root, handling admin PATH modification via elevation if neededdotnetup setinstallroot admin- Restores admin installation root by adding Program Files dotnet back to admin PATH and removing user settingsKey Features:
%SystemRoot%,%USERPROFILE%) when modifying both admin AND user PATHGetAdminPathWithProgramFilesDotnetRemoved()makes it explicit that it doesn't modify the registryAddPathEntry()andRemovePathEntry()methods for reusable PATH manipulationReadUserPath()andWriteUserPath()methods preserve unexpanded environment variablesPathContainsDotnet()method used consistently across the codebaseelevatedadminpathcommand is hidden from help as it's an internal commandOriginal prompt
✨ Let Copilot coding agentset things up for you — coding agent works faster and does higher quality work when set up for your repo.