Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 16, 2025

Implementation Plan for Windows PATH Management in dotnetup

  • Create WindowsPathHelper utility class to handle Windows-specific PATH operations
    • Read/write admin PATH from registry (HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment)
    • Support reading with/without environment variable expansion
    • Check if current process is elevated
    • Remove/Add Program Files dotnet path to admin PATH
    • Preserve unexpanded environment variables when modifying PATH
    • Implement IDisposable with log file lifecycle management
    • Progressive logging throughout operations
    • Proper exception handling - no swallowing exceptions
    • Broadcast WM_SETTINGCHANGE message after changes
    • Extract testable methods for PATH manipulation logic
    • Refactor orchestrating methods to encapsulate full logic
  • Create ElevatedAdminPathCommand and parser for dotnetup elevatedadminpath subcommand
    • Implement removedotnet subcommand
    • Implement adddotnet subcommand
    • Hide command from help output (internal command)
    • Simplified to use WindowsPathHelper orchestrating methods
  • Create SetInstallRootCommand and parser for dotnetup setinstallroot subcommand
    • Implement user subcommand that:
      • Checks if admin PATH needs to be changed
      • Shells out to dotnetup elevatedadminpath removedotnet if necessary
      • Adds user dotnet path to user PATH
    • Uses same WindowsPathHelper logic as ElevatedAdminPathCommand
  • Add tests for new commands
    • Parser tests
    • WindowsPathHelper tests (18 tests total)
    • Unit tests for testable PATH manipulation methods
  • Update Parser.cs to register new commands
  • Build successfully
  • Test the changes manually
  • Run code review and address feedback
  • Run CodeQL security checker
  • Format code

Summary

This PR implements Windows PATH management commands for dotnetup as specified in issue #52121. The implementation includes:

New Commands:

  1. dotnetup elevatedadminpath removedotnet - Removes Program Files dotnet path from machine-wide admin PATH (requires elevation, hidden from help)
  2. dotnetup elevatedadminpath adddotnet - Adds Program Files dotnet path to machine-wide admin PATH (requires elevation, hidden from help)
  3. dotnetup setinstallroot user - Sets up user installation root, handling admin PATH modification via elevation if needed

Key Features:

  • Windows-specific implementation with proper OS guards
  • Preserves unexpanded environment variables (e.g., %SystemRoot%, %USERPROFILE%) when modifying PATH
  • Uses expanded PATH for detection, unexpanded PATH for modification to avoid breaking existing environment variable references
  • Automatic elevation handling when needed
  • Progressive logging with disposable pattern - Log file created on WindowsPathHelper construction, messages written throughout operation, file closed on disposal
  • Proper exception handling - Exceptions rethrown with better messages, no silent failures
  • Log file lifecycle management - Opens on creation, auto-flushes for immediate writes
  • Environment change notification broadcasting
  • Comprehensive tests for new functionality (18 tests)
  • Testable PATH manipulation methods that don't require registry access for reliable unit testing
  • Refactored architecture - RemoveProgramFilesDotnetFromPath() takes both expanded and unexpanded paths, used by both registry operations and tests
  • Code reuse - PathContainsDotnet() method used consistently across the codebase
  • No code duplication - Commands simplified to just create helper and call methods
  • Graceful handling of non-Windows platforms
  • elevatedadminpath command is hidden from help as it's an internal command
Original prompt

This section details on the original issue you should resolve

<issue_title>dotnetup: Modify admin PATH on windows</issue_title>
<issue_description>As part of #52121, on Windows we want to modify the machine-wide admin PATH to remove the Program Files dotnet path.

Rough plan to do so:

  • Read the admin PATH from the registry (HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment) to check if it needs to be updated
  • If it does:
    • Check if current process is elevated
    • If not, then launch an elevated dotnetup process: dotnetup elevatedadminpath removedotnet to do the following:
      • Remove the Program Files dotnet path from the admin PATH
      • Write to a log (event log and/or a log in the %temp% folder) the previous and new values of the PATH
      • Broadcast a WM_SETTINGCHANGE message when done
    • If the process was already elevated, then it should do the same steps in-process</issue_description>

<agent_instructions>Start by adding a dotnetup elevatedadminpath removedotnet command as described. Also add a dotnetup elevatedadminpath adddotnet which will add the program files dotnet path back to the system PATH.

Then add a dotnetup setinstallroot user command which will check if the admin path needs to be changed, and if so modify it (via shelling out to dotnetup elevatedadminpath removedotnet if necessary). Then add the user dotnet path to the user PATH.</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@joeloff We should make sure that modifying the path is logged. It's a system change that can have impact, but otherwise this looks fine. @dsplaisted @joeloff Would the log need to be in a system-wide admin path or could it be a user log? If it needs to be system-wide then we'd probably have to launch our own process as elevated instead of relying on `setx`.

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI and others added 4 commits December 16, 2025 14:28
- 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.cs

Co-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 successfully

Co-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 pass

Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Copilot AI changed the title [WIP] Modify admin PATH on Windows to remove dotnet path Add Windows admin PATH management commands to dotnetup Dec 16, 2025
Copilot AI requested a review from dsplaisted December 16, 2025 14:36
@dsplaisted
Copy link
Member

@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>
Copy link
Contributor Author

Copilot AI commented Dec 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 ...

Done in commit 3d163b5. The elevatedadminpath command is now hidden from the help output by setting command.Hidden = true. The command remains functional when invoked explicitly.

Before:

Commands:
  sdk                            Manage sdk installations
  install <CHANNEL>              Installs the .NET SDK
  update                         Updates the .NET SDK
  elevatedadminpath <OPERATION>  Modifies the machine-wide admin PATH (requires elevated privileges)
  setinstallroot <INSTALL_TYPE>  Sets the dotnet installation root

After:

Commands:
  sdk                            Manage sdk installations
  install <CHANNEL>              Installs the .NET SDK
  update                         Updates the .NET SDK
  setinstallroot <INSTALL_TYPE>  Sets the dotnet installation root

The command still works when called directly: dotnetup elevatedadminpath removedotnet

@dsplaisted
Copy link
Member

@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 pass

Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Dec 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 ...

Done in commit 5585351. I've refactored the PATH manipulation logic as requested:

Changes made:

  1. ReadAdminPath now takes an expand parameter - Controls whether environment variables are expanded via RegistryValueOptions

  2. New testable methods without registry access:

    • FindDotnetPathIndices(pathEntries, dotnetPaths) - Finds indices of dotnet paths
    • RemovePathEntriesByIndices(path, indices) - Removes entries at specified indices
    • PathContainsDotnet(pathEntries, dotnetPaths) - Checks if dotnet exists
  3. New RemoveProgramFilesDotnetFromAdminPath method:

    • Reads both expanded and unexpanded PATH from registry
    • Finds indices using the expanded PATH
    • Removes those indices from the unexpanded PATH
    • Preserves environment variables like %SystemRoot%, %USERPROFILE%, etc.
  4. Updated detection to use expanded PATH:

    • AdminPathContainsProgramFilesDotnet() now uses ReadAdminPath(expand: true)
  5. Added 7 new unit tests including:

    • RemovePathEntriesByIndices_PreservesUnexpandedVariables() - Verifies %SystemRoot% and %USERPROFILE% are preserved

All 18 tests pass successfully.

@dsplaisted
Copy link
Member

@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 pass

Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Dec 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...

Done in commit 1d91179. I've refactored the code as requested:

Changes made:

  1. Converted WindowsPathHelper to disposable instance class:

    • Constructor creates and opens log file on creation
    • Dispose method closes log file and displays the path to user
    • Log file auto-flushes for immediate write
  2. Created orchestrating methods in WindowsPathHelper:

    • RemoveDotnetFromAdminPath() - Full logic: read, check, modify, write, log, broadcast
    • AddDotnetToAdminPath() - Full logic: read, check, modify, write, log, broadcast
  3. Simplified ElevatedAdminPathCommand:

    • RemoveDotnet() now just creates WindowsPathHelper and calls method
    • AddDotnet() now just creates WindowsPathHelper and calls method
    • Reduced from ~70 lines to ~6 lines
  4. Updated SetInstallRootCommand to use same logic:

    • Now uses the same RemoveDotnetFromAdminPath() method
    • No duplication between commands
  5. Progressive logging implemented:

    • Log messages written throughout operation (start, read, modify, write, broadcast, complete)
    • Errors logged with stack traces
    • Session start/end markers in log

All 18 tests pass successfully.

_logWriter.AutoFlush = true;
LogMessage($"=== WindowsPathHelper session started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ===");
}
catch (Exception ex)
Copy link
Member

Choose a reason for hiding this comment

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

Don't swallow this exception here. We can rethrow it to add a better message as long as we preserve the inner exception.

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 bbd3d5c. The exception is now rethrown with a better message while preserving the inner exception:

throw new InvalidOperationException("Failed to create log file for PATH changes.", ex);

{
_logWriter?.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}");
}
catch (Exception ex)
Copy link
Member

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.

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 bbd3d5c. 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}");
Copy link
Member

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.

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 bbd3d5c. 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>
public static string RemoveProgramFilesDotnetFromPath(string path)
Copy link
Member

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.

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 bbd3d5c. Refactored RemoveProgramFilesDotnetFromPath to take both unexpandedPath and expandedPath as parameters:

public static string RemoveProgramFilesDotnetFromPath(string unexpandedPath, string expandedPath)

The method now does the logic that was in RemoveProgramFilesDotnetFromAdminPath, which now just calls this method. Tests updated to pass both parameters.

}

// Check if any Program Files dotnet path is already in PATH
bool alreadyExists = pathEntries.Any(entry =>
Copy link
Member

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.

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 bbd3d5c. Replaced the custom logic with a call to PathContainsDotnet:

bool alreadyExists = 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 pass

Co-authored-by: dsplaisted <145043+dsplaisted@users.noreply.github.com>
Copilot AI requested a review from dsplaisted December 17, 2025 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants