From b73eea506a229dabc14301ac51a01bf364143682 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 13 Mar 2025 14:38:28 -0500 Subject: [PATCH 01/10] Testing --- .../engine/Modules/ModuleIntrinsics.cs | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index b687e502763..3d0caeb6507 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -967,11 +967,44 @@ internal static string GetPersonalModulePath() #if UNIX return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); #else - string myDocumentsPath = InternalTestHooks.SetMyDocumentsSpecialFolderToBlank ? string.Empty : Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + string myDocumentsPath + if (InternalTestHooks.SetMyDocumentsSpecialFolderToBlank) + { + myDocumentsPath = string.Empty; + } + else + { + if (!userPrompted) + { + Console.WriteLine("Would you like to switch the user profile to a different directory?"); + Console.WriteLine("Type 'Y' to switch to a different directory, 'N' to continue with the current directory"); + userChoice = input.Equals("Y", StringComparison.OrdinalIgnoreCase); + userPrompted = true; + if(userChoice) + { + Console.Writeline("Please enter the new directory path: ") + myDocumentsPath = Console.ReadLine(); + // test the path + while (!Directory.Exists(myDocumentsPath)) + { + Console.WriteLine("The directory does not exist. Please enter a valid directory path: "); + myDocumentsPath = Console.ReadLine(); + } + } + } + else + { + myDocumentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + + } + } return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory); #endif } + private static bool userPrompted = false; + private static bool userChoice = false; + /// /// Gets the PSHome module path, as known as the "system wide module path" in windows powershell. /// From 805fd0e32e06c05640c7ebedb36efe963a1b3417 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Mon, 17 Mar 2025 10:51:24 -0500 Subject: [PATCH 02/10] Update PSModulePath --- .../engine/Modules/ModuleIntrinsics.cs | 70 ++++++++++++++++--- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 3d0caeb6507..9266b73ddcb 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -967,22 +967,19 @@ internal static string GetPersonalModulePath() #if UNIX return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); #else - string myDocumentsPath - if (InternalTestHooks.SetMyDocumentsSpecialFolderToBlank) - { - myDocumentsPath = string.Empty; - } - else + string myDocumentsPath = string.Empty; + if (!InternalTestHooks.SetMyDocumentsSpecialFolderToBlank) { if (!userPrompted) { Console.WriteLine("Would you like to switch the user profile to a different directory?"); Console.WriteLine("Type 'Y' to switch to a different directory, 'N' to continue with the current directory"); + string input = Console.ReadLine(); userChoice = input.Equals("Y", StringComparison.OrdinalIgnoreCase); userPrompted = true; - if(userChoice) + if (userChoice) { - Console.Writeline("Please enter the new directory path: ") + Console.WriteLine("Please enter the new directory path: "); myDocumentsPath = Console.ReadLine(); // test the path while (!Directory.Exists(myDocumentsPath)) @@ -990,20 +987,73 @@ string myDocumentsPath Console.WriteLine("The directory does not exist. Please enter a valid directory path: "); myDocumentsPath = Console.ReadLine(); } + userModulePath = myDocumentsPath; + UpdatePSModulePath(userModulePath); } + } + else { - myDocumentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - + if (userChoice && !string.IsNullOrEmpty(userModulePath)) + { + myDocumentsPath = userModulePath; + } + + else + { + myDocumentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + } } } + return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory); #endif } private static bool userPrompted = false; private static bool userChoice = false; + private static string userModulePath; + + internal static void UpdatePSModulePath(string newPath) + { + string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); + if (string.IsNullOrEmpty(psModulePath)) + { + return; + } + + string[] paths = psModulePath.Split(Path.PathSeparator); + string oneDrivePath = paths.FirstOrDefault(p => p.Contains("OneDrive - Microsoft")); + + if (!string.IsNullOrEmpty(oneDrivePath)) + { + // Ensure the new path exists + if (!Directory.Exists(newPath)) + { + Directory.CreateDirectory(Path.Combine(newPath, Utils.ModuleDirectory)); + } + + string destDir = Path.Combine(newPath, Utils.ModuleDirectory); + + // Create all directories + foreach (string dir in Directory.GetDirectories(oneDrivePath, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destDir, Path.GetRelativePath(oneDrivePath, dir))); + } + + // Copy all files + foreach (string file in Directory.GetFiles(oneDrivePath, "*", SearchOption.AllDirectories)) + { + string destFile = Path.Combine(destDir, Path.GetRelativePath(oneDrivePath, file)); + File.Copy(file, destFile, true); + } + + // Remove the "OneDrive - Microsoft" path from PSModulePath + List updatedPaths = paths.Where(p => !p.Contains("OneDrive - Microsoft")).ToList(); + Environment.SetEnvironmentVariable("PSModulePath", string.Join(Path.PathSeparator.ToString(), updatedPaths), EnvironmentVariableTarget.User); + } + } /// /// Gets the PSHome module path, as known as the "system wide module path" in windows powershell. From 6dc662d224009f91a6f3c3c3af2d516e8dfbef17 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:56:01 -0500 Subject: [PATCH 03/10] Add Experimental feature for PSContent --- .../ExperimentalFeature.cs | 6 +- .../engine/Modules/ModuleIntrinsics.cs | 69 ++++++++----------- 2 files changed, 34 insertions(+), 41 deletions(-) diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index dd26e609641..f24faec0ac9 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -25,6 +25,7 @@ public class ExperimentalFeature internal const string PSNativeWindowsTildeExpansion = nameof(PSNativeWindowsTildeExpansion); internal const string PSRedirectToVariable = "PSRedirectToVariable"; internal const string PSSerializeJSONLongEnumAsNumber = nameof(PSSerializeJSONLongEnumAsNumber); + internal const string PSContentPath = "PSContentPath"; #endregion @@ -124,7 +125,10 @@ static ExperimentalFeature() description: "Add support for redirecting to the variable drive"), new ExperimentalFeature( name: PSSerializeJSONLongEnumAsNumber, - description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json." + description: "Serialize enums based on long or ulong as an numeric value rather than the string representation when using ConvertTo-Json."), + new ExperimentalFeature( + name: PSContentPath, + description: "Moves PS content to the new default location in LocalAppData/PowerShell and allows users to specify the content path." ) }; diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 9266b73ddcb..5e1bc83ca36 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -967,50 +967,39 @@ internal static string GetPersonalModulePath() #if UNIX return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); #else - string myDocumentsPath = string.Empty; - if (!InternalTestHooks.SetMyDocumentsSpecialFolderToBlank) - { - if (!userPrompted) - { - Console.WriteLine("Would you like to switch the user profile to a different directory?"); - Console.WriteLine("Type 'Y' to switch to a different directory, 'N' to continue with the current directory"); - string input = Console.ReadLine(); - userChoice = input.Equals("Y", StringComparison.OrdinalIgnoreCase); - userPrompted = true; - if (userChoice) - { - Console.WriteLine("Please enter the new directory path: "); - myDocumentsPath = Console.ReadLine(); - // test the path - while (!Directory.Exists(myDocumentsPath)) - { - Console.WriteLine("The directory does not exist. Please enter a valid directory path: "); - myDocumentsPath = Console.ReadLine(); - } - userModulePath = myDocumentsPath; - UpdatePSModulePath(userModulePath); - } - - } - - else - { - if (userChoice && !string.IsNullOrEmpty(userModulePath)) - { - myDocumentsPath = userModulePath; - } - - else - { - myDocumentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - } - } - } - + string myDocumentsPath = InternalTestHooks.SetMyDocumentsSpecialFolderToBlank ? string.Empty : Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory); #endif } + /// + /// Gets the PS content path when PSContentPath experimental feature is enabled, + /// otherwise returns the personal module path. + /// + /// PS content path or personal module path. + internal static string GetPSContentPath() + { + // Check if PSContentPath experimental feature is enabled + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { +#if UNIX + // On Unix, use XDG standard for content path + return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); +#else + // On Windows, use LOCALAPPDATA\PowerShell + string localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return string.IsNullOrEmpty(localAppDataPath) ? null : Path.Combine(localAppDataPath, "PowerShell"); +#endif + } + else + { + // Fall back to existing GetPersonalModulePath behavior + return GetPersonalModulePath(); + } + } + + + private static bool userPrompted = false; private static bool userChoice = false; private static string userModulePath; From 4a7bb3d7601f1b8e396b8ccae0352b6948215646 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 6 Aug 2025 16:43:47 -0500 Subject: [PATCH 04/10] Removed unused variables --- .../engine/Modules/ModuleIntrinsics.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 5e1bc83ca36..630696cd10d 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -998,12 +998,6 @@ internal static string GetPSContentPath() } } - - - private static bool userPrompted = false; - private static bool userChoice = false; - private static string userModulePath; - internal static void UpdatePSModulePath(string newPath) { string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); From 00b6307a1fe7150ee7dac08aed6e0b84f585e997 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 20 Aug 2025 13:46:12 -0500 Subject: [PATCH 05/10] Switch to default PSContentPath LOCALAPPData, cmdlets added --- .../CoreCLR/CorePsPlatform.cs | 3 +- .../GetSetPSContentPathCommand.cs | 165 ++++++++++++++++++ .../engine/InitialSessionState.cs | 2 + .../engine/Modules/ModuleIntrinsics.cs | 75 +------- .../engine/PSConfiguration.cs | 49 +++++- .../engine/Utils.cs | 27 +++ .../engine/hostifaces/HostUtilities.cs | 2 +- .../help/HelpUtils.cs | 7 +- 8 files changed, 247 insertions(+), 83 deletions(-) create mode 100644 src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs index dc5db5f2c48..7a23fef96a6 100644 --- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs +++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs @@ -165,11 +165,12 @@ public static bool IsStaSupported // Gets the location for cache and config folders. internal static readonly string CacheDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.CACHE); internal static readonly string ConfigDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.CONFIG); + internal static readonly string DefaultPSContentDirectory = Path.Combine(Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA), "PowerShell"); #else // Gets the location for cache and config folders. internal static readonly string CacheDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Microsoft\PowerShell"; internal static readonly string ConfigDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; - + internal static readonly string DefaultPSContentDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\PowerShell"; private static readonly Lazy _isStaSupported = new Lazy(() => { int result = Interop.Windows.CoInitializeEx(IntPtr.Zero, Interop.Windows.COINIT_APARTMENTTHREADED); diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs new file mode 100644 index 00000000000..de8fdbf3b9c --- /dev/null +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Management.Automation; +using System.Management.Automation.Configuration; +using System.Management.Automation.Internal; + +namespace Microsoft.PowerShell.Commands +{ + /// + /// Implements Get-PSContentPath cmdlet. + /// + [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + public class GetPSContentPathCommand : PSCmdlet + { + /// + /// ProcessRecord method of this cmdlet. + /// + protected override void ProcessRecord() + { + try + { + var psContentPath = Utils.GetPSContentPath(); + WriteObject(psContentPath); + } + catch (Exception ex) + { + WriteError(new ErrorRecord( + ex, + "GetPSContentPathFailed", + ErrorCategory.ReadError, + null)); + } + } + } + + /// + /// Implements Set-PSContentPath cmdlet. + /// + [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + public class SetPSContentPathCommand : PSCmdlet + { + /// + /// Gets or sets the PSContentPath to configure. + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + [ValidateNotNullOrEmpty] + public string Path { get; set; } + + /// + /// ProcessRecord method of this cmdlet. + /// + protected override void ProcessRecord() + { + // Validate the path before processing + if (!ValidatePath(Path)) + { + return; // Error already written in ValidatePath + } + + if (ShouldProcess($"PSContentPath = {Path}", "Set PSContentPath")) + { + try + { + PowerShellConfig.Instance.SetPSContentPath(Path); + WriteVerbose($"Successfully set PSContentPath to '{Path}'"); + WriteWarning("PSContentPath changes will take effect after restarting PowerShell."); + } + catch (Exception ex) + { + WriteError(new ErrorRecord( + ex, + "SetPSContentPathFailed", + ErrorCategory.WriteError, + Path)); + } + } + } + + /// + /// Validates that the provided path is a valid directory path. + /// + /// The path to validate. + /// True if the path is valid, false otherwise. + private bool ValidatePath(string path) + { + try + { + // Expand environment variables if present + string expandedPath = Environment.ExpandEnvironmentVariables(path); + + // Check if the path contains invalid characters using PowerShell's existing utility + if (PathUtils.ContainsInvalidPathChars(expandedPath)) + { + WriteError(new ErrorRecord( + new ArgumentException($"The path '{path}' contains invalid characters."), + "InvalidPathCharacters", + ErrorCategory.InvalidArgument, + path)); + return false; + } + + // Check if the path is rooted (absolute path) + if (!Path.IsPathRooted(expandedPath)) + { + WriteError(new ErrorRecord( + new ArgumentException($"The path '{path}' must be an absolute path."), + "RelativePathNotAllowed", + ErrorCategory.InvalidArgument, + path)); + return false; + } + + // Try to get the full path to validate format + string fullPath = Path.GetFullPath(expandedPath); + + // Warn if the directory doesn't exist, but don't fail + if (!Directory.Exists(fullPath)) + { + WriteWarning($"The directory '{fullPath}' does not exist. It will be created when needed."); + } + + return true; + } + catch (ArgumentException ex) + { + WriteError(new ErrorRecord( + ex, + "InvalidPathFormat", + ErrorCategory.InvalidArgument, + path)); + return false; + } + catch (System.Security.SecurityException ex) + { + WriteError(new ErrorRecord( + ex, + "PathAccessDenied", + ErrorCategory.PermissionDenied, + path)); + return false; + } + catch (NotSupportedException ex) + { + WriteError(new ErrorRecord( + ex, + "PathNotSupported", + ErrorCategory.InvalidArgument, + path)); + return false; + } + catch (PathTooLongException ex) + { + WriteError(new ErrorRecord( + ex, + "PathTooLong", + ErrorCategory.InvalidArgument, + path)); + return false; + } + } + } +} diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index 84f513d8450..c1c93b7a81f 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -5468,6 +5468,7 @@ private static void InitializeCoreCmdletsAndProviders( { "Get-History", new SessionStateCmdletEntry("Get-History", typeof(GetHistoryCommand), helpFile) }, { "Get-Job", new SessionStateCmdletEntry("Get-Job", typeof(GetJobCommand), helpFile) }, { "Get-Module", new SessionStateCmdletEntry("Get-Module", typeof(GetModuleCommand), helpFile) }, + { "Get-PSContentPath", new SessionStateCmdletEntry("Get-PSContentPath", typeof(GetPSContentPathCommand), helpFile) }, { "Get-PSHostProcessInfo", new SessionStateCmdletEntry("Get-PSHostProcessInfo", typeof(GetPSHostProcessInfoCommand), helpFile) }, { "Get-PSSession", new SessionStateCmdletEntry("Get-PSSession", typeof(GetPSSessionCommand), helpFile) }, { "Import-Module", new SessionStateCmdletEntry("Import-Module", typeof(ImportModuleCommand), helpFile) }, @@ -5489,6 +5490,7 @@ private static void InitializeCoreCmdletsAndProviders( { "Remove-Module", new SessionStateCmdletEntry("Remove-Module", typeof(RemoveModuleCommand), helpFile) }, { "Remove-PSSession", new SessionStateCmdletEntry("Remove-PSSession", typeof(RemovePSSessionCommand), helpFile) }, { "Save-Help", new SessionStateCmdletEntry("Save-Help", typeof(SaveHelpCommand), helpFile) }, + { "Set-PSContentPath", new SessionStateCmdletEntry("Set-PSContentPath", typeof(SetPSContentPathCommand), helpFile) }, { "Set-PSDebug", new SessionStateCmdletEntry("Set-PSDebug", typeof(SetPSDebugCommand), helpFile) }, { "Set-StrictMode", new SessionStateCmdletEntry("Set-StrictMode", typeof(SetStrictModeCommand), helpFile) }, { "Start-Job", new SessionStateCmdletEntry("Start-Job", typeof(StartJobCommand), helpFile) }, diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 630696cd10d..e31a6889c63 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -490,7 +490,7 @@ internal static bool IsModuleMatchingConstraints( /// The required version of the expected module. /// The minimum required version of the expected module. /// The maximum required version of the expected module. - /// True if the module info object matches all given constraints, false otherwise. + /// True if the module info object matches all the constraints on the module specification, false otherwise. internal static bool IsModuleMatchingConstraints( out ModuleMatchFailure matchFailureReason, PSModuleInfo moduleInfo, @@ -964,78 +964,7 @@ internal static string GetModuleName(string path) /// Personal module path. internal static string GetPersonalModulePath() { -#if UNIX - return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); -#else - string myDocumentsPath = InternalTestHooks.SetMyDocumentsSpecialFolderToBlank ? string.Empty : Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory); -#endif - } - - /// - /// Gets the PS content path when PSContentPath experimental feature is enabled, - /// otherwise returns the personal module path. - /// - /// PS content path or personal module path. - internal static string GetPSContentPath() - { - // Check if PSContentPath experimental feature is enabled - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { -#if UNIX - // On Unix, use XDG standard for content path - return Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); -#else - // On Windows, use LOCALAPPDATA\PowerShell - string localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return string.IsNullOrEmpty(localAppDataPath) ? null : Path.Combine(localAppDataPath, "PowerShell"); -#endif - } - else - { - // Fall back to existing GetPersonalModulePath behavior - return GetPersonalModulePath(); - } - } - - internal static void UpdatePSModulePath(string newPath) - { - string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); - if (string.IsNullOrEmpty(psModulePath)) - { - return; - } - - string[] paths = psModulePath.Split(Path.PathSeparator); - string oneDrivePath = paths.FirstOrDefault(p => p.Contains("OneDrive - Microsoft")); - - if (!string.IsNullOrEmpty(oneDrivePath)) - { - // Ensure the new path exists - if (!Directory.Exists(newPath)) - { - Directory.CreateDirectory(Path.Combine(newPath, Utils.ModuleDirectory)); - } - - string destDir = Path.Combine(newPath, Utils.ModuleDirectory); - - // Create all directories - foreach (string dir in Directory.GetDirectories(oneDrivePath, "*", SearchOption.AllDirectories)) - { - Directory.CreateDirectory(Path.Combine(destDir, Path.GetRelativePath(oneDrivePath, dir))); - } - - // Copy all files - foreach (string file in Directory.GetFiles(oneDrivePath, "*", SearchOption.AllDirectories)) - { - string destFile = Path.Combine(destDir, Path.GetRelativePath(oneDrivePath, file)); - File.Copy(file, destFile, true); - } - - // Remove the "OneDrive - Microsoft" path from PSModulePath - List updatedPaths = paths.Where(p => !p.Contains("OneDrive - Microsoft")).ToList(); - Environment.SetEnvironmentVariable("PSModulePath", string.Join(Path.PathSeparator.ToString(), updatedPaths), EnvironmentVariableTarget.User); - } + return Path.Combine(Utils.GetPSContentPath(), "Modules"); } /// diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index e321423f768..d78b063fbfe 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -62,8 +62,8 @@ internal sealed class PowerShellConfig private string systemWideConfigDirectory; // The json file containing the per-user configuration settings. - private readonly string perUserConfigFile; - private readonly string perUserConfigDirectory; + private string perUserConfigFile; + private string perUserConfigDirectory; // Note: JObject and JsonSerializer are thread safe. // Root Json objects corresponding to the configuration file for 'AllUsers' and 'CurrentUser' respectively. @@ -141,6 +141,31 @@ internal string GetModulePath(ConfigScope scope) return modulePath; } + /// + /// Gets the PSContentPath from the configuration file. + /// + /// The configured PSContentPath if found, null otherwise. + internal string GetPSContentPath() + { + return ReadValueFromFile(ConfigScope.CurrentUser, "UserPSContentPath", Platform.DefaultPSContentDirectory); + } + + /// + /// Sets the PSContentPath in the configuration file. + /// + /// The path to set as PSContentPath. + internal void SetPSContentPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + RemoveValueFromFile(ConfigScope.CurrentUser, "UserPSContentPath"); + } + else + { + WriteValueToFile(ConfigScope.CurrentUser, "UserPSContentPath", path); + } + } + /// /// Existing Key = HKCU and HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell /// Proposed value = Existing default execution policy if not already specified @@ -590,6 +615,26 @@ private void RemoveValueFromFile(ConfigScope scope, string key) UpdateValueInFile(scope, key, default(T), false); } } + + internal void MigrateUserConfig(string oldPath, string newPath) + { + try + { + // Ensure new directory exists + Directory.CreateDirectory(Path.GetDirectoryName(newPath)); + + // Copy the config file + File.Copy(oldPath, newPath); + + perUserConfigDirectory = Path.GetDirectoryName(newPath); + perUserConfigFile = newPath; + } + catch (Exception) + { + // Migration failed, but don't break the system + // Log the error if logging is available + } + } } #region GroupPolicy Configs diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 2a2091af31a..a5cd1d33aeb 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -17,6 +17,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Security; +using System.Text.Json; #if !UNIX using System.Security.Principal; #endif @@ -706,6 +707,32 @@ internal static bool IsValidPSEditionValue(string editionValue) /// internal static readonly string ModuleDirectory = Path.Combine(ProductNameForDirectory, "Modules"); + /// + /// Gets the PSContent path from PowerShell.config.json or falls back to platform defaults. + /// + /// The PSContent directory path + internal static string GetPSContentPath() + { + try + { + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + return PowerShellConfig.Instance.GetPSContentPath(); + } + } + catch (Exception) + { + // On Startup there is a cirular dependency between PowerShellConfig and Utils. + } + + // Fall back to platform defaults +#if UNIX + return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + return Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } + internal static readonly ConfigScope[] SystemWideOnlyConfig = new[] { ConfigScope.AllUsers }; internal static readonly ConfigScope[] CurrentUserOnlyConfig = new[] { ConfigScope.CurrentUser }; internal static readonly ConfigScope[] SystemWideThenCurrentUserConfig = new[] { ConfigScope.AllUsers, ConfigScope.CurrentUser }; diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 003625791b1..9aceca17d9d 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -203,7 +203,7 @@ internal static string GetFullProfileFileName(string shellId, bool forCurrentUse if (forCurrentUser) { - basePath = Platform.ConfigDirectory; + basePath = Utils.GetPSContentPath(); } else { diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs index ab4a39f362a..9316fd8e6ee 100644 --- a/src/System.Management.Automation/help/HelpUtils.cs +++ b/src/System.Management.Automation/help/HelpUtils.cs @@ -20,12 +20,7 @@ internal static string GetUserHomeHelpSearchPath() { if (userHomeHelpPath == null) { -#if UNIX - var userModuleFolder = Platform.SelectProductNameForDirectory(Platform.XDG_Type.USER_MODULES); - string userScopeRootPath = System.IO.Path.GetDirectoryName(userModuleFolder); -#else - string userScopeRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); -#endif + string userScopeRootPath = Utils.GetPSContentPath(); userHomeHelpPath = Path.Combine(userScopeRootPath, "Help"); } From 7e5802b79f170396fc3129aa7b5154be3f0a5b78 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:19:56 -0500 Subject: [PATCH 06/10] Add lazy migration --- .../GetSetPSContentPathCommand.cs | 6 +- .../engine/PSConfiguration.cs | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs index de8fdbf3b9c..7bf113988d1 100644 --- a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -13,6 +13,7 @@ namespace Microsoft.PowerShell.Commands /// Implements Get-PSContentPath cmdlet. /// [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class GetPSContentPathCommand : PSCmdlet { /// @@ -40,6 +41,7 @@ protected override void ProcessRecord() /// Implements Set-PSContentPath cmdlet. /// [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class SetPSContentPathCommand : PSCmdlet { /// @@ -103,7 +105,7 @@ private bool ValidatePath(string path) } // Check if the path is rooted (absolute path) - if (!Path.IsPathRooted(expandedPath)) + if (!System.IO.Path.IsPathRooted(expandedPath)) { WriteError(new ErrorRecord( new ArgumentException($"The path '{path}' must be an absolute path."), @@ -114,7 +116,7 @@ private bool ValidatePath(string path) } // Try to get the full path to validate format - string fullPath = Path.GetFullPath(expandedPath); + string fullPath = System.IO.Path.GetFullPath(expandedPath); // Warn if the directory doesn't exist, but don't fail if (!Directory.Exists(fullPath)) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index d78b063fbfe..b01e1319b45 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -65,6 +65,9 @@ internal sealed class PowerShellConfig private string perUserConfigFile; private string perUserConfigDirectory; + // Flag to track if migration has been checked + private bool migrationChecked = false; + // Note: JObject and JsonSerializer are thread safe. // Root Json objects corresponding to the configuration file for 'AllUsers' and 'CurrentUser' respectively. // They are used as a cache to avoid hitting the disk for every read operation. @@ -411,6 +414,9 @@ internal PSKeyword GetLogKeywords() /// The default value to return if the key is not present. private T ReadValueFromFile(ConfigScope scope, string key, T defaultValue = default) { + // Check for PSContentPath migration on first config access + CheckForMigrationOnFirstAccess(); + string fileName = GetConfigFilePath(scope); JObject configData = configRoots[(int)scope]; @@ -635,6 +641,68 @@ internal void MigrateUserConfig(string oldPath, string newPath) // Log the error if logging is available } } + + /// + /// Checks for migration on first configuration access to avoid circular dependencies. + /// + private void CheckForMigrationOnFirstAccess() + { + if (migrationChecked) + { + return; + } + + migrationChecked = true; + + try + { + // Only perform migration if PSContentPath experimental feature is enabled + // This is safe to call here because ExperimentalFeature will be initialized by now + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + return; + } + + CheckAndPerformPSContentPathMigration(); + } + catch + { + // Migration is best-effort; don't fail config access if it fails + } + } + + /// + /// Checks if PSContentPath migration is needed and performs it if the experimental feature is enabled. + /// + private void CheckAndPerformPSContentPathMigration() + { + try + { + string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); + string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); + + if (!File.Exists(oldConfigFile)) + { + return; // Nothing to migrate + } + + // Check if we need to migrate from old location to new location + // Only migrate if: + // 1. Old config directory is different from new default directory + // 2. New config file doesn't already exist (avoid overwriting) + // 3. Old config file exists + if (!string.Equals(oldConfigFile, newConfigFile, StringComparison.OrdinalIgnoreCase) && + !File.Exists(newConfigFile)) + { + MigrateUserConfig(oldConfigFile, newConfigFile); + } + } + catch + { + // Migration is best-effort; don't fail PowerShell startup if it fails + // The user can manually copy the file if needed + } + } } #region GroupPolicy Configs From 830016e31f5209d0bfcb7df160d53cd9e4b60933 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:14:31 -0500 Subject: [PATCH 07/10] Add null checks incase PSUserContentPath fails to get any value --- .../engine/Modules/ModuleIntrinsics.cs | 8 +++++++- .../engine/PSConfiguration.cs | 6 +++--- src/System.Management.Automation/engine/Utils.cs | 11 ++++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index e31a6889c63..6e344352493 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -21,6 +21,7 @@ namespace System.Management.Automation internal static class Constants { public const string PSModulePathEnvVar = "PSModulePath"; + public const string PSUserContentPathEnvVar = "PSUserContentPath"; } /// @@ -1361,9 +1362,14 @@ private static string SetModulePath() } #endif string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers); - string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser); + string personalModulePath = Utils.GetPSContentPath(true) ?? string.Empty; string newModulePathString = GetModulePath(currentModulePath, allUsersModulePath, personalModulePath); + if (!string.IsNullOrEmpty(personalModulePath)) + { + Environment.SetEnvironmentVariable(Constants.PSUserContentPathEnvVar, personalModulePath); + } + if (!string.IsNullOrEmpty(newModulePathString)) { Environment.SetEnvironmentVariable(Constants.PSModulePathEnvVar, newModulePathString); diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index b01e1319b45..5e98abff1a8 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -150,7 +150,7 @@ internal string GetModulePath(ConfigScope scope) /// The configured PSContentPath if found, null otherwise. internal string GetPSContentPath() { - return ReadValueFromFile(ConfigScope.CurrentUser, "UserPSContentPath", Platform.DefaultPSContentDirectory); + return ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); } /// @@ -161,11 +161,11 @@ internal void SetPSContentPath(string path) { if (string.IsNullOrEmpty(path)) { - RemoveValueFromFile(ConfigScope.CurrentUser, "UserPSContentPath"); + RemoveValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); } else { - WriteValueToFile(ConfigScope.CurrentUser, "UserPSContentPath", path); + WriteValueToFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar, path); } } diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index a5cd1d33aeb..48be44e2085 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -711,7 +711,7 @@ internal static bool IsValidPSEditionValue(string editionValue) /// Gets the PSContent path from PowerShell.config.json or falls back to platform defaults. /// /// The PSContent directory path - internal static string GetPSContentPath() + internal static string GetPSContentPath(bool setModulePath = false) { try { @@ -726,6 +726,15 @@ internal static string GetPSContentPath() } // Fall back to platform defaults + if (setModulePath) + { + return PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) ?? +#if UNIX + Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } #if UNIX return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); #else From 56acf2d38c73c4babcae035837817164e74b5361 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:38:41 -0500 Subject: [PATCH 08/10] Able to use expanded environmental variables --- src/System.Management.Automation/engine/PSConfiguration.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 5e98abff1a8..3a356c296ee 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -150,7 +150,12 @@ internal string GetModulePath(ConfigScope scope) /// The configured PSContentPath if found, null otherwise. internal string GetPSContentPath() { - return ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); + string contentPath = ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); + if (!string.IsNullOrEmpty(contentPath)) + { + contentPath = Environment.ExpandEnvironmentVariables(contentPath); + } + return contentPath; } /// From 3f5709d4ffd5d6ba57e9588b52b1db9b73f92fc3 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:49:09 -0500 Subject: [PATCH 09/10] Remove help URI --- .../engine/Configuration/GetSetPSContentPathCommand.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs index 7bf113988d1..894aa7af1d1 100644 --- a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -12,7 +12,8 @@ namespace Microsoft.PowerShell.Commands /// /// Implements Get-PSContentPath cmdlet. /// - [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + /// TODO:: Add helpURI + [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class GetPSContentPathCommand : PSCmdlet { @@ -40,7 +41,8 @@ protected override void ProcessRecord() /// /// Implements Set-PSContentPath cmdlet. /// - [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2096787")] + /// TODO:: Add helpURI + [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class SetPSContentPathCommand : PSCmdlet { From 9e516fab99de574077d99769abe19c81b279a540 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Tue, 14 Oct 2025 15:16:02 -0500 Subject: [PATCH 10/10] Reassign perUserConfigDirectory if experimental feature is enabled whether or not migration took place --- .../engine/PSConfiguration.cs | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 3a356c296ee..f4992e219a8 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -686,21 +686,34 @@ private void CheckAndPerformPSContentPathMigration() string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); - if (!File.Exists(oldConfigFile)) + // If paths are the same, no migration needed + if (string.Equals(oldConfigFile, newConfigFile, StringComparison.OrdinalIgnoreCase)) { - return; // Nothing to migrate + return; } - // Check if we need to migrate from old location to new location - // Only migrate if: - // 1. Old config directory is different from new default directory - // 2. New config file doesn't already exist (avoid overwriting) - // 3. Old config file exists - if (!string.Equals(oldConfigFile, newConfigFile, StringComparison.OrdinalIgnoreCase) && - !File.Exists(newConfigFile)) + // Always update to use the new location when experimental feature is enabled + string newConfigDir = Path.GetDirectoryName(newConfigFile); + + // If migration was already completed (new config exists), just update paths + if (File.Exists(newConfigFile)) + { + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; + return; + } + + // If old config exists and needs migration, perform the migration + if (File.Exists(oldConfigFile)) { MigrateUserConfig(oldConfigFile, newConfigFile); } + else + { + // No existing config, but still use new location going forward + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; + } } catch {