From b73eea506a229dabc14301ac51a01bf364143682 Mon Sep 17 00:00:00 2001 From: Justin Chung Date: Thu, 13 Mar 2025 14:38:28 -0500 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 { From d3719793094517bd9872cc70214c2434b0da3ede Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:00:07 -0600 Subject: [PATCH 11/22] Move migration to GetPSContentPath API, added safety fallbacks to default PSContentPath location --- .../ExperimentalFeature.cs | 2 +- .../engine/Modules/ModuleIntrinsics.cs | 13 ++++- .../engine/PSConfiguration.cs | 53 +++++++++---------- .../engine/Utils.cs | 16 ++++-- .../engine/hostifaces/HostUtilities.cs | 10 ++++ 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index f24faec0ac9..42051d9e173 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -128,7 +128,7 @@ static ExperimentalFeature() 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." + description: "Moves PS content (modules, scripts, help, and profiles) 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 6e344352493..b74fbe4602b 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -965,7 +965,18 @@ internal static string GetModuleName(string path) /// Personal module path. internal static string GetPersonalModulePath() { - return Path.Combine(Utils.GetPSContentPath(), "Modules"); + string contentPath = Utils.GetPSContentPath(); + // GetPSContentPath should never return null when experimental feature is enabled, + // but add defensive check for safety + if (string.IsNullOrEmpty(contentPath)) + { +#if UNIX + contentPath = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + contentPath = Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } + return Path.Combine(contentPath, "Modules"); } /// diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index f4992e219a8..aa514adff98 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -65,8 +65,8 @@ internal sealed class PowerShellConfig private string perUserConfigFile; private string perUserConfigDirectory; - // Flag to track if migration has been checked - private bool migrationChecked = false; + // Flag to track if migration has been checked (lazy initialization to avoid circular dependency) + private int migrationChecked = 0; // Note: JObject and JsonSerializer are thread safe. // Root Json objects corresponding to the configuration file for 'AllUsers' and 'CurrentUser' respectively. @@ -99,6 +99,9 @@ private PowerShellConfig() serializer = JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None, MaxDepth = 10 }); fileLock = new ReaderWriterLockSlim(); + + // Note: PSContentPath user config migration is NOT performed here to avoid circular dependency with ExperimentalFeature. + // It will be performed lazily on first access to GetPSContentPath(). } private string GetConfigFilePath(ConfigScope scope) @@ -146,16 +149,22 @@ internal string GetModulePath(ConfigScope scope) /// /// Gets the PSContentPath from the configuration file. + /// If not configured, returns the default location without creating the config file. + /// This ensures PowerShell works on read-only file systems and avoids creating unnecessary files. /// - /// The configured PSContentPath if found, null otherwise. + /// The configured PSContentPath if found, otherwise the default location (never null). internal string GetPSContentPath() { + // Perform migration check on first call to avoid circular dependency with ExperimentalFeature + CheckAndPerformPSContentPathMigrationOnce(); + string contentPath = ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); if (!string.IsNullOrEmpty(contentPath)) { contentPath = Environment.ExpandEnvironmentVariables(contentPath); } - return contentPath; + // Returns default LocalAppData path if not configured + return contentPath ?? Platform.DefaultPSContentDirectory; } /// @@ -419,9 +428,6 @@ 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]; @@ -643,37 +649,20 @@ internal void MigrateUserConfig(string oldPath, string newPath) catch (Exception) { // Migration failed, but don't break the system - // Log the error if logging is available + // TODO:: What should we do when we fail? } } /// - /// Checks for migration on first configuration access to avoid circular dependencies. + /// Ensures migration is checked exactly once, using thread-safe lazy initialization. + /// This is called from GetPSContentPath() to avoid circular dependency with ExperimentalFeature. /// - private void CheckForMigrationOnFirstAccess() + private void CheckAndPerformPSContentPathMigrationOnce() { - if (migrationChecked) - { - return; - } - - migrationChecked = true; - - try + if (Interlocked.CompareExchange(ref migrationChecked, 1, 0) == 0) { - // 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 - } } /// @@ -683,6 +672,12 @@ private void CheckAndPerformPSContentPathMigration() { try { + // Only perform migration if PSContentPath experimental feature is enabled + if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + return; + } + string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 48be44e2085..f5dd812f731 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -709,23 +709,31 @@ internal static bool IsValidPSEditionValue(string editionValue) /// /// Gets the PSContent path from PowerShell.config.json or falls back to platform defaults. + /// When PSContentPath experimental feature is enabled, returns the configured path or default location. /// - /// The PSContent directory path + /// The PSContent directory path (never null). internal static string GetPSContentPath(bool setModulePath = false) { try { if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) { - return PowerShellConfig.Instance.GetPSContentPath(); + string contentPath = PowerShellConfig.Instance.GetPSContentPath(); + // GetPSContentPath now returns default location if not configured, + // so this should never be null, but add defensive check for safety + if (!string.IsNullOrEmpty(contentPath)) + { + return contentPath; + } } } catch (Exception) { - // On Startup there is a cirular dependency between PowerShellConfig and Utils. + // On startup there is a circular dependency between PowerShellConfig and Utils. + // Fall through to platform defaults below to avoid breaking PowerShell initialization. } - // Fall back to platform defaults + // Fall back to platform defaults when feature is disabled or during initialization if (setModulePath) { return PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) ?? diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 9aceca17d9d..970d3d1cba9 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -204,6 +204,16 @@ internal static string GetFullProfileFileName(string shellId, bool forCurrentUse if (forCurrentUser) { basePath = Utils.GetPSContentPath(); + // GetPSContentPath should always return a valid path when called + if (string.IsNullOrEmpty(basePath)) + { + // Defensive fallback - should not happen in normal operation +#if UNIX + basePath = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + basePath = Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; +#endif + } } else { From b207b5ca3c5765597d5c67e3cc0a66c79c689bf3 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:21:58 -0600 Subject: [PATCH 12/22] Add tests --- .../CoreCLR/CorePsPlatform.cs | 2 +- .../engine/InitialSessionState.cs | 8 +- .../engine/Modules/ModuleIntrinsics.cs | 20 +- .../engine/PSConfiguration.cs | 196 +++++++- .../engine/Utils.cs | 30 +- .../powershell/engine/PSContentPath.Tests.ps1 | 456 ++++++++++++++++++ 6 files changed, 647 insertions(+), 65 deletions(-) create mode 100644 test/powershell/engine/PSContentPath.Tests.ps1 diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs index 7a23fef96a6..e063b50ecce 100644 --- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs +++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs @@ -165,7 +165,7 @@ 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"); + internal static readonly string DefaultPSContentDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); #else // Gets the location for cache and config folders. internal static readonly string CacheDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Microsoft\PowerShell"; diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index c1c93b7a81f..fa80c38a656 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -5468,7 +5468,6 @@ 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) }, @@ -5490,7 +5489,6 @@ 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) }, @@ -5517,6 +5515,12 @@ private static void InitializeCoreCmdletsAndProviders( cmdlets.Add("Get-PSSubsystem", new SessionStateCmdletEntry("Get-PSSubsystem", typeof(Subsystem.GetPSSubsystemCommand), helpFile)); } + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + cmdlets.Add("Get-PSContentPath", new SessionStateCmdletEntry("Get-PSContentPath", typeof(GetPSContentPathCommand), helpFile)); + cmdlets.Add("Set-PSContentPath", new SessionStateCmdletEntry("Set-PSContentPath", typeof(SetPSContentPathCommand), helpFile)); + } + #if UNIX cmdlets.Add("Switch-Process", new SessionStateCmdletEntry("Switch-Process", typeof(SwitchProcessCommand), helpFile)); #endif diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index b74fbe4602b..6ce2a06820e 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -965,18 +965,7 @@ internal static string GetModuleName(string path) /// Personal module path. internal static string GetPersonalModulePath() { - string contentPath = Utils.GetPSContentPath(); - // GetPSContentPath should never return null when experimental feature is enabled, - // but add defensive check for safety - if (string.IsNullOrEmpty(contentPath)) - { -#if UNIX - contentPath = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); -#else - contentPath = Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; -#endif - } - return Path.Combine(contentPath, "Modules"); + return Path.Combine(Utils.GetPSContentPath, "Modules"); } /// @@ -1373,14 +1362,9 @@ private static string SetModulePath() } #endif string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers); - string personalModulePath = Utils.GetPSContentPath(true) ?? string.Empty; + string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) ?? GetPersonalModulePath(); 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 aa514adff98..c68fe0354dd 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Management.Automation.Internal; using System.Text; using System.Threading; @@ -68,6 +69,9 @@ internal sealed class PowerShellConfig // Flag to track if migration has been checked (lazy initialization to avoid circular dependency) private int migrationChecked = 0; + // Track the legacy config file path after migration, so we can keep it in sync + private string legacyConfigFile = null; + // 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. @@ -225,17 +229,68 @@ private static string GetExecutionPolicySettingKey(string shellId) /// /// Get the names of experimental features enabled in the config file. + /// + /// BOOTSTRAP PROBLEM SOLUTION: + /// This method reads from BOTH the current location (which may be LocalAppData or Documents) + /// AND the potential legacy location (Documents) to handle the bootstrap problem: + /// - We need to know if PSContentPath is enabled to know which config file to read + /// - But PSContentPath enabled state is stored IN the config file + /// + /// By reading both locations and merging (union), we ensure correct behavior: + /// - If PSContentPath is disabled: only Documents config exists, we read it correctly + /// - If PSContentPath is enabled: both configs should be in sync (via write path), union gives same result + /// - During re-enable after disable: Documents has new state, LocalAppData may be stale, union captures intent + /// - Edge case (manual edit): if either location has a feature enabled, we honor it (permissive approach) + /// + /// MIGRATION FLOW: + /// After this method determines the enabled features, if PSContentPath is enabled: + /// 1. CheckAndPerformPSContentPathMigration() switches to LocalAppData location + /// 2. SyncExperimentalFeaturesToNewLocation() updates LocalAppData config to match Documents + /// 3. Future writes keep both locations in sync via UpdateLegacyConfigFile() + /// + /// This ensures both config files converge to the same state within one PowerShell session restart. /// internal string[] GetExperimentalFeatures() { - string[] features = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + // Read from current location (might be LocalAppData or Documents depending on migration state) + string[] currentFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + + // Also check the potential legacy location if it's different from current + string[] legacyFeatures = Array.Empty(); + if (!string.IsNullOrEmpty(legacyConfigFile) && + !legacyConfigFile.Equals(perUserConfigFile, StringComparison.OrdinalIgnoreCase)) + { + // Temporarily swap to read from legacy location + string originalFile = perUserConfigFile; + JObject originalCache = configRoots[(int)ConfigScope.CurrentUser]; + + try + { + perUserConfigFile = legacyConfigFile; + configRoots[(int)ConfigScope.CurrentUser] = null; // Force re-read + legacyFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + } + finally + { + perUserConfigFile = originalFile; + configRoots[(int)ConfigScope.CurrentUser] = originalCache; + } + } + + // Merge features from both locations (union) - if a feature is enabled in either, it's enabled + var mergedFeatures = new HashSet(currentFeatures, StringComparer.OrdinalIgnoreCase); + foreach (string feature in legacyFeatures) + { + mergedFeatures.Add(feature); + } - if (features.Length == 0) + // If neither current nor legacy location has features, check AllUsers (system-wide) as fallback + if (mergedFeatures.Count == 0) { - features = ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); + return ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); } - return features; + return mergedFeatures.ToArray(); } /// @@ -615,6 +670,15 @@ private void WriteValueToFile(ConfigScope scope, string key, T value) } UpdateValueInFile(scope, key, value, true); + + // If we migrated from a legacy location, also update the legacy config to keep them in sync. + // This ensures that disabling features (like PSContentPath) updates both locations. + if (scope == ConfigScope.CurrentUser && + !string.IsNullOrEmpty(legacyConfigFile) && + File.Exists(legacyConfigFile)) + { + UpdateLegacyConfigFile(key, value, true); + } } /// @@ -653,6 +717,90 @@ internal void MigrateUserConfig(string oldPath, string newPath) } } + /// + /// Syncs the ExperimentalFeatures array from the old config location to the new location. + /// This ensures that when PSContentPath is re-enabled after being disabled, the new location + /// gets updated with the current experimental features state. + /// + /// Path to the old config file (Documents location) + /// Path to the new config file (LocalAppData location) + private void SyncExperimentalFeaturesToNewLocation(string oldPath, string newPath) + { + try + { + // Read experimental features from old location + string originalFile = perUserConfigFile; + JObject originalCache = configRoots[(int)ConfigScope.CurrentUser]; + + try + { + // Temporarily point to old location to read features + perUserConfigFile = oldPath; + configRoots[(int)ConfigScope.CurrentUser] = null; + string[] oldFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + + // Now point to new location to write features + perUserConfigFile = newPath; + configRoots[(int)ConfigScope.CurrentUser] = null; + + // Write the features to new location (this will also invalidate the cache) + if (oldFeatures.Length > 0) + { + UpdateValueInFile(ConfigScope.CurrentUser, "ExperimentalFeatures", oldFeatures, true); + } + } + finally + { + // Restore original state + perUserConfigFile = originalFile; + configRoots[(int)ConfigScope.CurrentUser] = originalCache; + } + } + catch + { + // Best-effort operation; don't fail PowerShell startup if sync fails + } + } + + /// + /// Updates the legacy config file to keep it in sync with the new location. + /// This is a best-effort operation that silently fails if there are any issues. + /// Reuses UpdateValueInFile by temporarily treating the legacy file as the current user config. + /// + /// The type of value + /// The string key of the value. + /// The value to set. + /// Whether the key-value pair should be added to or removed from the file. + private void UpdateLegacyConfigFile(string key, T value, bool addValue) + { + try + { + // Save the current cache for the CurrentUser scope + JObject savedCache = configRoots[(int)ConfigScope.CurrentUser]; + + // Temporarily swap to the legacy config file path and clear cache + string originalPerUserConfigFile = perUserConfigFile; + perUserConfigFile = legacyConfigFile; + configRoots[(int)ConfigScope.CurrentUser] = null; + + try + { + // Reuse the existing UpdateValueInFile logic + UpdateValueInFile(ConfigScope.CurrentUser, key, value, addValue); + } + finally + { + // Restore the original path and cache + perUserConfigFile = originalPerUserConfigFile; + configRoots[(int)ConfigScope.CurrentUser] = savedCache; + } + } + catch + { + // Best-effort operation; don't fail if we can't update the legacy config + } + } + /// /// Ensures migration is checked exactly once, using thread-safe lazy initialization. /// This is called from GetPSContentPath() to avoid circular dependency with ExperimentalFeature. @@ -667,6 +815,19 @@ private void CheckAndPerformPSContentPathMigrationOnce() /// /// Checks if PSContentPath migration is needed and performs it if the experimental feature is enabled. + /// + /// MIGRATION SCENARIOS: + /// 1. First enable: Copies Documents config to LocalAppData, switches to LocalAppData + /// 2. Already migrated: Just switches to LocalAppData (both configs exist and should be in sync) + /// 3. Re-enable after disable: Syncs experimental features from Documents to LocalAppData, then switches + /// + /// BIDIRECTIONAL SYNC: + /// After migration, legacyConfigFile points to Documents and perUserConfigFile points to LocalAppData. + /// All subsequent writes via WriteValueToFile() will update both locations via UpdateLegacyConfigFile(). + /// This ensures that: + /// - Enabling/disabling features updates both configs + /// - Disabling PSContentPath and restarting uses Documents with current state + /// - Re-enabling PSContentPath and restarting switches back to LocalAppData with current state /// private void CheckAndPerformPSContentPathMigration() { @@ -677,6 +838,7 @@ private void CheckAndPerformPSContentPathMigration() { return; } + string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); @@ -690,25 +852,25 @@ private void CheckAndPerformPSContentPathMigration() // 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; - } + // Update to use new location + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; - // If old config exists and needs migration, perform the migration - if (File.Exists(oldConfigFile)) + // If both configs exist, keep them in sync (legacyConfigFile already points to old location from constructor) + // If old config exists but new doesn't, perform migration + if (!File.Exists(newConfigFile) && File.Exists(oldConfigFile)) { MigrateUserConfig(oldConfigFile, newConfigFile); } - else + else if (File.Exists(oldConfigFile) && File.Exists(newConfigFile)) { - // No existing config, but still use new location going forward - perUserConfigDirectory = newConfigDir; - perUserConfigFile = newConfigFile; + // Both configs exist. Ensure they're in sync by copying the experimental features from old to new. + // This handles the case where PSContentPath was disabled, then re-enabled while running from Documents location. + // The Documents config now has PSContentPath enabled, but LocalAppData config still has it disabled (stale). + // We need to update LocalAppData to match so future operations see the correct state. + SyncExperimentalFeaturesToNewLocation(oldConfigFile, newConfigFile); } + // legacyConfigFile was set to oldConfigFile in constructor and will be used for sync } catch { diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index f5dd812f731..a4eaec23cf3 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -712,37 +712,13 @@ internal static bool IsValidPSEditionValue(string editionValue) /// When PSContentPath experimental feature is enabled, returns the configured path or default location. /// /// The PSContent directory path (never null). - internal static string GetPSContentPath(bool setModulePath = false) + internal static string GetPSContentPath() { - try + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) { - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { - string contentPath = PowerShellConfig.Instance.GetPSContentPath(); - // GetPSContentPath now returns default location if not configured, - // so this should never be null, but add defensive check for safety - if (!string.IsNullOrEmpty(contentPath)) - { - return contentPath; - } - } - } - catch (Exception) - { - // On startup there is a circular dependency between PowerShellConfig and Utils. - // Fall through to platform defaults below to avoid breaking PowerShell initialization. + return PowerShellConfig.Instance.GetPSContentPath(); } - // Fall back to platform defaults when feature is disabled or during initialization - 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 diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1 new file mode 100644 index 00000000000..c8736136805 --- /dev/null +++ b/test/powershell/engine/PSContentPath.Tests.ps1 @@ -0,0 +1,456 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { + BeforeAll { + if ($IsWindows) { + $powershell = "$PSHOME\pwsh.exe" + $userConfigPath = Join-Path $HOME "Documents\PowerShell\powershell.config.json" + $defaultContentPath = Join-Path $HOME "Documents\PowerShell" + $newContentPath = [System.IO.Path]::Combine($env:LOCALAPPDATA, "PowerShell") + } + else { + $powershell = "$PSHOME/pwsh" + $userConfigPath = "~/.config/powershell/powershell.config.json" + $defaultContentPath = [System.Management.Automation.Platform]::SelectProductNameForDirectory("USER_MODULES") + $defaultContentPath = Split-Path $defaultContentPath -Parent + $newContentPath = $defaultContentPath + } + + # Backup existing configs + if (Test-Path $userConfigPath) { + $userConfigExists = $true + Copy-Item $userConfigPath "$userConfigPath.backup.pscontentpath" -Force -ErrorAction Ignore + } + + if ($IsWindows) { + $newConfigPath = Join-Path $newContentPath "powershell.config.json" + if (Test-Path $newConfigPath) { + $newConfigExists = $true + Copy-Item $newConfigPath "$newConfigPath.backup.pscontentpath" -Force -ErrorAction Ignore + } + } + } + + AfterAll { + # Restore original configs + if ($userConfigExists) { + Move-Item "$userConfigPath.backup.pscontentpath" $userConfigPath -Force -ErrorAction Ignore + } + else { + Remove-Item "$userConfigPath" -Force -ErrorAction Ignore + } + + if ($IsWindows -and $newConfigExists) { + Move-Item "$newConfigPath.backup.pscontentpath" $newConfigPath -Force -ErrorAction Ignore + } + elseif ($IsWindows) { + Remove-Item "$newConfigPath" -Force -ErrorAction Ignore + } + } + + BeforeEach { + # Clean up config file before each test + Remove-Item "$userConfigPath" -Force -ErrorAction Ignore + if ($IsWindows) { + $newConfigPath = Join-Path $newContentPath "powershell.config.json" + Remove-Item "$newConfigPath" -Force -ErrorAction Ignore + } + } + + Context "Get-PSContentPath cmdlet" { + It "Get-PSContentPath cmdlet does not exist when feature is disabled" -Skip:$skipNoPwsh { + # Ensure feature is disabled + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null + + $result = & $powershell -noprofile -command 'Get-Command Get-PSContentPath -ErrorAction SilentlyContinue' + $result | Should -BeNullOrEmpty + } + + It "Get-PSContentPath cmdlet exists when feature is enabled" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $result = & $powershell -noprofile -command 'Get-Command Get-PSContentPath -ErrorAction SilentlyContinue' + $result | Should -Not -BeNullOrEmpty + } + + It "Get-PSContentPath returns current content path" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Not -BeNullOrEmpty + } + + It "Get-PSContentPath returns default path when not configured" -Skip:$skipNoPwsh { + # Run everything in ONE PowerShell session to ensure clean state + $script = @" + # Remove any existing configs + Remove-Item '$newConfigPath' -Force -ErrorAction Ignore + Remove-Item '$userConfigPath' -Force -ErrorAction Ignore + + # Verify they're gone + if (Test-Path '$newConfigPath') { Write-Error 'Failed to remove newConfigPath' } + if (Test-Path '$userConfigPath') { Write-Error 'Failed to remove userConfigPath' } + + # Disable feature (suppress warnings) + Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -ErrorAction Ignore -WarningAction Ignore + + # Clean again after disable + Remove-Item '$newConfigPath' -Force -ErrorAction Ignore + Remove-Item '$userConfigPath' -Force -ErrorAction Ignore + + # Enable feature with clean state (suppress warnings) + Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore + + # Get the path - should be default + Get-PSContentPath +"@ + + $result = & $powershell -noprofile -command $script + + if ($IsWindows) { + # When PSContentPath feature is enabled, returns LocalAppData path by default + $result | Should -Be $newContentPath + } + else { + $result | Should -Not -BeNullOrEmpty + } + } + + It "Get-PSContentPath expands environment variables" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Clean up first to ensure fresh state + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + Start-Sleep -Milliseconds 100 + + # Enable feature and set path with environment variable (note: single backslash) + & $powershell -noprofile -command "Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser; Set-PSContentPath -Path '%TEMP%\PowerShell'" + + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Not -Contain '%' + $result | Should -Be (Join-Path $env:TEMP "PowerShell") + + # Clean up after this test IN THE TEST SESSION to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + + It "Get-PSContentPath works when config file doesn't exist" -Skip:$skipNoPwsh { + # Ensure no config file and enable feature + Remove-Item $userConfigPath -Force -ErrorAction Ignore + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Not -BeNullOrEmpty + } + } + + Context "Set-PSContentPath cmdlet" { + It "Set-PSContentPath cmdlet does not exist when feature is disabled" -Skip:$skipNoPwsh { + # Ensure feature is disabled + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null + + $result = & $powershell -noprofile -command 'Get-Command Set-PSContentPath -ErrorAction SilentlyContinue' + $result | Should -BeNullOrEmpty + } + + It "Set-PSContentPath cmdlet exists when feature is enabled" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $result = & $powershell -noprofile -command 'Get-Command Set-PSContentPath -ErrorAction SilentlyContinue' + $result | Should -Not -BeNullOrEmpty + } + + It "Set-PSContentPath creates config file if it doesn't exist" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # When feature is enabled, config is in LocalAppData + if ($IsWindows) { + (Test-Path $newConfigPath) -or (Test-Path $userConfigPath) | Should -BeTrue + } + else { + Test-Path $userConfigPath | Should -BeTrue + } + } + + It "Set-PSContentPath updates the content path" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Be $customPath + } + + It "Set-PSContentPath updates existing config file" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + # Create initial config in LocalAppData (where feature writes to) + $configToCheck = if ($IsWindows) { $newConfigPath } else { $userConfigPath } + $config = @{ ExperimentalFeatures = @("PSNativeWindowsTildeExpansion") } | ConvertTo-Json + New-Item -Path (Split-Path $configToCheck) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $configToCheck -Value $config + + # Set custom path + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Small delay for file write + Start-Sleep -Milliseconds 100 + + # Verify existing settings are preserved + $updatedConfig = Get-Content $configToCheck -Raw | ConvertFrom-Json + $updatedConfig.ExperimentalFeatures | Should -Contain "PSNativeWindowsTildeExpansion" + $updatedConfig.PSObject.Properties.Name | Should -Contain "PSUserContentPath" + $updatedConfig.PSUserContentPath | Should -Be $customPath + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + + It "Set-PSContentPath accepts paths with environment variables" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + & $powershell -noprofile -command "Set-PSContentPath -Path '%LOCALAPPDATA%\PowerShell'" + + # Small delay for file write + Start-Sleep -Milliseconds 100 + + # Check the config file in LocalAppData (where it's written when feature enabled) + $configToCheck = if ($IsWindows) { $newConfigPath } else { $userConfigPath } + $config = Get-Content $configToCheck -Raw | ConvertFrom-Json + $config.PSObject.Properties.Name | Should -Contain "PSUserContentPath" + $config.PSUserContentPath | Should -Be '%LOCALAPPDATA%\PowerShell' + + # Get-PSContentPath should expand it + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Be $newContentPath + } + + It "Set-PSContentPath creates directory structure if it doesn't exist" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "NewFolder\PowerShell" + + # Directory doesn't exist yet + Test-Path $customPath | Should -BeFalse + + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Config should be created (in LocalAppData when feature enabled) + if ($IsWindows) { + (Test-Path $newConfigPath) -or (Test-Path $userConfigPath) | Should -BeTrue + } + else { + Test-Path $userConfigPath | Should -BeTrue + } + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + } + + Context "Integration with PSModulePath" { + It "Custom PSContentPath affects module path" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # The actual module path will be used in a new PowerShell session + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Be $customPath + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + } + + Context "Integration with Profile paths" { + It "Custom PSContentPath affects profile path" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Profile paths are constructed at startup + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Be $customPath + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + } + + Context "Error handling" { + It "Set-PSContentPath handles invalid paths gracefully" -Skip:$skipNoPwsh { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + # Very long path + $longPath = "C:\\" + ("a" * 300) + + # Should not throw - just accept the path with a warning + $result = & $powershell -noprofile -command "try { Set-PSContentPath -Path '$longPath' -WarningAction SilentlyContinue; 'Success' } catch { 'Failed' }" + $result | Should -Be 'Success' + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + + It "Set-PSContentPath handles paths with special characters" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $pathWithSpaces = Join-Path $TestDrive "Path With Spaces" + # Warnings are expected, just check it doesn't throw + & $powershell -noprofile -command "Set-PSContentPath -Path '$pathWithSpaces' -WarningAction SilentlyContinue" 2>$null + + $result = & $powershell -noprofile -command 'Get-PSContentPath' + $result | Should -Be $pathWithSpaces + + # Clean up after this test to not contaminate others + & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" + Start-Sleep -Milliseconds 200 + } + } +} + +Describe "PSContentPath experimental feature integration" -tags "Feature" { + BeforeAll { + if ($IsWindows) { + $powershell = "$PSHOME\pwsh.exe" + $userConfigPath = Join-Path $HOME "Documents\PowerShell\powershell.config.json" + $newConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json" + } + else { + $powershell = "$PSHOME/pwsh" + $userConfigPath = "~/.config/powershell/powershell.config.json" + $newConfigPath = $userConfigPath + } + + # Backup existing configs + $backupSuffix = ".backup.integration" + if (Test-Path $userConfigPath) { + Copy-Item $userConfigPath "$userConfigPath$backupSuffix" -Force -ErrorAction Ignore + } + if ($IsWindows -and (Test-Path $newConfigPath)) { + Copy-Item $newConfigPath "$newConfigPath$backupSuffix" -Force -ErrorAction Ignore + } + } + + AfterAll { + # Restore original configs + $backupSuffix = ".backup.integration" + if (Test-Path "$userConfigPath$backupSuffix") { + Move-Item "$userConfigPath$backupSuffix" $userConfigPath -Force -ErrorAction Ignore + } + else { + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + if ($IsWindows) { + if (Test-Path "$newConfigPath$backupSuffix") { + Move-Item "$newConfigPath$backupSuffix" $newConfigPath -Force -ErrorAction Ignore + } + else { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + } + } + } + + It "Config file migration preserves all settings" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Remove any existing config in new location first + Remove-Item $newConfigPath -Force -ErrorAction Ignore + + # Create a config with multiple settings in old location (Documents) + $config = @{ + ExperimentalFeatures = @("PSContentPath", "PSNativeWindowsTildeExpansion") + "Microsoft.PowerShell:ExecutionPolicy" = "RemoteSigned" + PSModulePath = "C:\\CustomModules" + } | ConvertTo-Json + + New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $userConfigPath -Value $config + + # Trigger migration by calling Get-PSContentPath in a new PowerShell session + # This will read the config, see PSContentPath is enabled, and perform migration + $result = & $powershell -noprofile -command 'Get-PSContentPath' + + # Small delay for migration to complete + Start-Sleep -Milliseconds 200 + + # Verify new config has all settings after migration + if (Test-Path $newConfigPath) { + $migratedConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json + $migratedConfig.ExperimentalFeatures | Should -Contain "PSContentPath" + $migratedConfig.ExperimentalFeatures | Should -Contain "PSNativeWindowsTildeExpansion" + + # Verify custom PSModulePath is preserved + $propertyNames = $migratedConfig.PSObject.Properties.Name + if ($propertyNames -contains 'PSModulePath') { + $migratedConfig.PSModulePath | Should -Be "C:\\CustomModules" + } + } + } + + It "Re-enabling feature after disable syncs correctly" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable, then disable, then re-enable (synchronously) + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null + + # Verify config files have the feature enabled (check new location since feature is enabled) + Start-Sleep -Milliseconds 100 # Small delay for file writes + + $configFound = $false + if (Test-Path $newConfigPath) { + $newConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json + if ($newConfig.ExperimentalFeatures -contains "PSContentPath") { + $configFound = $true + } + } + + if (!$configFound -and (Test-Path $userConfigPath)) { + $docConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json + if ($docConfig.ExperimentalFeatures -contains "PSContentPath") { + $configFound = $true + } + } + + $configFound | Should -BeTrue + } + + It "Bootstrap problem is solved - reads from both locations" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Create config only in Documents with PSContentPath enabled + $docConfig = @{ ExperimentalFeatures = @("PSContentPath") } | ConvertTo-Json + New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $userConfigPath -Value $docConfig + + # Remove LocalAppData config if it exists + Remove-Item $newConfigPath -Force -ErrorAction Ignore + + # PowerShell should still detect the feature is enabled (returns object, not just Enabled property) + $featureEnabled = & $powershell -noprofile -command '(Get-ExperimentalFeature -Name PSContentPath).Enabled' + $featureEnabled | Should -Be 'True' + } +} From eaaaeddbafb6c3ada13c5626f42e53b439295d73 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:41:50 -0600 Subject: [PATCH 13/22] Add initial test cases for PSContentPath --- build.psm1 | 2 +- .../GetSetPSContentPathCommand.cs | 42 +- .../engine/InitialSessionState.cs | 40 +- .../engine/Modules/ModuleIntrinsics.cs | 9 +- .../engine/PSConfiguration.cs | 145 +++--- .../engine/Utils.cs | 2 +- .../help/HelpUtils.cs | 12 +- .../Get-ExperimentalFeature.Tests.ps1 | 4 +- .../powershell/engine/PSContentPath.Tests.ps1 | 425 +++++++++++------- 9 files changed, 419 insertions(+), 262 deletions(-) diff --git a/build.psm1 b/build.psm1 index dd2cf0f351e..cc44c436e61 100644 --- a/build.psm1 +++ b/build.psm1 @@ -711,7 +711,7 @@ Fix steps: if ((Test-ShouldGenerateExperimentalFeatures -Runtime $Options.Runtime)) { Write-Verbose "Build experimental feature list by running 'Get-ExperimentalFeature'" -Verbose $json = & $publishPath\pwsh -noprofile -command { - $expFeatures = Get-ExperimentalFeature | ForEach-Object -MemberName Name + $expFeatures = Get-ExperimentalFeature | Where-Object { $_.Name -ne 'PSContentPath' } | ForEach-Object -MemberName Name ConvertTo-Json $expFeatures } } else { diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs index 894aa7af1d1..722122af575 100644 --- a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -12,15 +12,15 @@ namespace Microsoft.PowerShell.Commands /// /// Implements Get-PSContentPath cmdlet. /// - /// TODO:: Add helpURI - [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] + [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344910")] [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class GetPSContentPathCommand : PSCmdlet { /// - /// ProcessRecord method of this cmdlet. + /// EndProcessing method of this cmdlet. + /// Main logic is in EndProcessing to ensure all pipeline input is processed first. /// - protected override void ProcessRecord() + protected override void EndProcessing() { try { @@ -41,8 +41,7 @@ protected override void ProcessRecord() /// /// Implements Set-PSContentPath cmdlet. /// - /// TODO:: Add helpURI - [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=")] + [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344807")] [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class SetPSContentPathCommand : PSCmdlet { @@ -53,23 +52,40 @@ public class SetPSContentPathCommand : PSCmdlet [ValidateNotNullOrEmpty] public string Path { get; set; } + private string validatedPath = null; + /// /// ProcessRecord method of this cmdlet. + /// Validates each path from the pipeline and stores the last valid one. /// protected override void ProcessRecord() { - // Validate the path before processing - if (!ValidatePath(Path)) + // Validate the path from pipeline input + if (ValidatePath(Path)) + { + // Store the last valid path from pipeline + validatedPath = Path; + } + } + + /// + /// EndProcessing method of this cmdlet. + /// Main logic is in EndProcessing to use the last valid path from the pipeline. + /// + protected override void EndProcessing() + { + // If no valid path was found, exit early + if (validatedPath == null) { - return; // Error already written in ValidatePath + return; } - if (ShouldProcess($"PSContentPath = {Path}", "Set PSContentPath")) + if (ShouldProcess($"PSContentPath = {validatedPath}", "Set PSContentPath")) { try { - PowerShellConfig.Instance.SetPSContentPath(Path); - WriteVerbose($"Successfully set PSContentPath to '{Path}'"); + PowerShellConfig.Instance.SetPSContentPath(validatedPath); + WriteVerbose($"Successfully set PSContentPath to '{validatedPath}'"); WriteWarning("PSContentPath changes will take effect after restarting PowerShell."); } catch (Exception ex) @@ -78,7 +94,7 @@ protected override void ProcessRecord() ex, "SetPSContentPathFailed", ErrorCategory.WriteError, - Path)); + validatedPath)); } } } diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index 62308282d17..f921b26b495 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -1599,6 +1599,10 @@ public static InitialSessionState CreateDefault2() ss.Commands.Add(BuiltInAliases); ss.ImportCorePSSnapIn(); + + // Register experimental feature cmdlets conditionally + ss.RegisterExperimentalFeatureCmdlets(); + ss.LanguageMode = PSLanguageMode.FullLanguage; ss.AuthorizationManager = new Microsoft.PowerShell.PSAuthorizationManager(Utils.DefaultPowerShellShellID); @@ -3799,6 +3803,26 @@ internal PSSnapInInfo ImportCorePSSnapIn() return coreSnapin; } + /// + /// Register cmdlets that are only available when their associated experimental features are enabled. + /// + private void RegisterExperimentalFeatureCmdlets() + { + string helpFile = typeof(GetPSContentPathCommand).Assembly.Location + "-help.xml"; + + // Register PSContentPath cmdlets if the experimental feature is enabled + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) + { + var getPSContentPathEntry = new SessionStateCmdletEntry("Get-PSContentPath", typeof(GetPSContentPathCommand), helpFile); + getPSContentPathEntry.Visibility = this.DefaultCommandVisibility; + this.Commands.Add(getPSContentPathEntry); + + var setPSContentPathEntry = new SessionStateCmdletEntry("Set-PSContentPath", typeof(SetPSContentPathCommand), helpFile); + setPSContentPathEntry.Visibility = this.DefaultCommandVisibility; + this.Commands.Add(setPSContentPathEntry); + } + } + internal PSSnapInInfo ImportPSSnapIn(PSSnapInInfo psSnapInInfo, out PSSnapInException warning) { ArgumentNullException.ThrowIfNull(psSnapInInfo); @@ -5249,10 +5273,24 @@ internal static void AnalyzePSSnapInAssembly( } } - Diagnostics.Assert(cmdletsCheck.Count == cmdlets.Count, "new Cmdlet added to System.Management.Automation.dll - update InitializeCoreCmdletsAndProviders"); + // Exclude dynamically registered cmdlets (registered based on experimental features) + var dynamicCmdlets = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Get-PSContentPath", + "Set-PSContentPath" + }; + + var expectedCmdletCount = cmdletsCheck.Count - cmdletsCheck.Keys.Count(key => dynamicCmdlets.Contains(key)); + Diagnostics.Assert(expectedCmdletCount == cmdlets.Count, "new Cmdlet added to System.Management.Automation.dll - update InitializeCoreCmdletsAndProviders"); foreach (var pair in cmdletsCheck) { + // Skip dynamic cmdlets - they are registered conditionally based on experimental features + if (dynamicCmdlets.Contains(pair.Key)) + { + continue; + } + SessionStateCmdletEntry other; if (cmdlets.TryGetValue(pair.Key, out other)) { diff --git a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs index 6ce2a06820e..b7dc5c9cc3a 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleIntrinsics.cs @@ -965,7 +965,7 @@ internal static string GetModuleName(string path) /// Personal module path. internal static string GetPersonalModulePath() { - return Path.Combine(Utils.GetPSContentPath, "Modules"); + return Path.Combine(Utils.GetPSContentPath(), "Modules"); } /// @@ -1311,11 +1311,14 @@ internal static string GetWindowsPowerShellModulePath() // PowerShell specific paths including if set in powershell.config.json file we want to exclude var excludeModulePaths = new HashSet(StringComparer.OrdinalIgnoreCase) { - GetPersonalModulePath(), + GetPersonalModulePath(), // This returns the current user module path (Documents or LocalAppData based on PSContentPath) GetSharedModulePath(), GetPSHomeModulePath(), PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers), - PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) + PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser), + // Also exclude the default Documents location to handle migration scenarios + // where PSContentPath moved content to LocalAppData but Documents path might still be in PSModulePath + Path.Combine(Platform.ConfigDirectory, "Modules") }; var modulePathList = new List(); diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index dc2e1a5120d..5b0f1607329 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -99,6 +99,8 @@ private PowerShellConfig() if (!string.IsNullOrEmpty(perUserConfigDirectory)) { perUserConfigFile = Path.Combine(perUserConfigDirectory, ConfigFileName); + // Save the legacy config file path (Documents location) for bidirectional sync + legacyConfigFile = perUserConfigFile; } emptyConfig = new JObject(); @@ -170,7 +172,14 @@ internal string GetPSContentPath() { contentPath = Environment.ExpandEnvironmentVariables(contentPath); } - // Returns default LocalAppData path if not configured + + // When PSContentPath feature is enabled: + // - If user has configured a custom path, use that + // - Otherwise, use DefaultPSContentDirectory (LocalAppData\PowerShell on Windows) + // + // When PSContentPath feature is disabled: + // - This method is only called when feature is enabled (checked in Utils.GetPSContentPath) + // - So we can safely return the new default location return contentPath ?? Platform.DefaultPSContentDirectory; } @@ -233,67 +242,34 @@ private static string GetExecutionPolicySettingKey(string shellId) /// /// Get the names of experimental features enabled in the config file. /// - /// BOOTSTRAP PROBLEM SOLUTION: - /// This method reads from BOTH the current location (which may be LocalAppData or Documents) - /// AND the potential legacy location (Documents) to handle the bootstrap problem: - /// - We need to know if PSContentPath is enabled to know which config file to read - /// - But PSContentPath enabled state is stored IN the config file + /// BOOTSTRAP SOLUTION: + /// Always read from the Documents location first (Platform.ConfigDirectory). + /// This is the canonical source of truth for experimental features on startup. + /// We NEVER read from LocalAppData to determine enabled features because: + /// - LocalAppData configs are created BY the PSContentPath feature + /// - Reading from LocalAppData creates circular dependency + /// - Stale LocalAppData configs would contaminate fresh sessions /// - /// By reading both locations and merging (union), we ensure correct behavior: - /// - If PSContentPath is disabled: only Documents config exists, we read it correctly - /// - If PSContentPath is enabled: both configs should be in sync (via write path), union gives same result - /// - During re-enable after disable: Documents has new state, LocalAppData may be stale, union captures intent - /// - Edge case (manual edit): if either location has a feature enabled, we honor it (permissive approach) + /// The flow: + /// 1. Read experimental features from Documents config (this method) + /// 2. If PSContentPath is enabled, migration happens later in GetPSContentPath() + /// 3. Migration switches perUserConfigFile to LocalAppData + /// 4. Future writes update both Documents and LocalAppData (bidirectional sync) /// - /// MIGRATION FLOW: - /// After this method determines the enabled features, if PSContentPath is enabled: - /// 1. CheckAndPerformPSContentPathMigration() switches to LocalAppData location - /// 2. SyncExperimentalFeaturesToNewLocation() updates LocalAppData config to match Documents - /// 3. Future writes keep both locations in sync via UpdateLegacyConfigFile() - /// - /// This ensures both config files converge to the same state within one PowerShell session restart. + /// This ensures clean separation: Documents = source of truth, LocalAppData = migrated location. /// internal string[] GetExperimentalFeatures() { - // Read from current location (might be LocalAppData or Documents depending on migration state) + // Always read from the current location (Documents on startup, LocalAppData after migration) string[] currentFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); - // Also check the potential legacy location if it's different from current - string[] legacyFeatures = Array.Empty(); - if (!string.IsNullOrEmpty(legacyConfigFile) && - !legacyConfigFile.Equals(perUserConfigFile, StringComparison.OrdinalIgnoreCase)) - { - // Temporarily swap to read from legacy location - string originalFile = perUserConfigFile; - JObject originalCache = configRoots[(int)ConfigScope.CurrentUser]; - - try - { - perUserConfigFile = legacyConfigFile; - configRoots[(int)ConfigScope.CurrentUser] = null; // Force re-read - legacyFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); - } - finally - { - perUserConfigFile = originalFile; - configRoots[(int)ConfigScope.CurrentUser] = originalCache; - } - } - - // Merge features from both locations (union) - if a feature is enabled in either, it's enabled - var mergedFeatures = new HashSet(currentFeatures, StringComparer.OrdinalIgnoreCase); - foreach (string feature in legacyFeatures) - { - mergedFeatures.Add(feature); - } - - // If neither current nor legacy location has features, check AllUsers (system-wide) as fallback - if (mergedFeatures.Count == 0) + // If no features in current user config, check system-wide config + if (currentFeatures.Length == 0) { return ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); } - return mergedFeatures.ToArray(); + return currentFeatures; } /// @@ -315,6 +291,7 @@ internal void SetExperimentalFeatures(ConfigScope scope, string featureName, boo { features.Remove(featureName); WriteValueToFile(scope, "ExperimentalFeatures", features.ToArray()); + // Note: WriteValueToFile already handles syncing to legacy config if it exists } } @@ -707,22 +684,14 @@ private void RemoveValueFromFile(ConfigScope scope, string key) 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 - // TODO:: What should we do when we fail? - } + // Ensure new directory exists + Directory.CreateDirectory(Path.GetDirectoryName(newPath)); + + // Copy the config file (may throw) + File.Copy(oldPath, newPath); + + // Note: Path updates and cache invalidation are handled by the caller + // after this method successfully completes to avoid race conditions. } /// @@ -812,17 +781,29 @@ private void UpdateLegacyConfigFile(string key, T value, bool addValue) /// /// Ensures migration is checked exactly once, using thread-safe lazy initialization. /// This is called from GetPSContentPath() to avoid circular dependency with ExperimentalFeature. + /// + /// IMPORTANT: This only runs when PSContentPath feature is ENABLED. + /// When disabled, we use Platform.ConfigDirectory (Documents) without any migration checks. + /// This prevents test contamination in CI environments where the feature code exists but is disabled. /// private void CheckAndPerformPSContentPathMigrationOnce() { - if (Interlocked.CompareExchange(ref migrationChecked, 1, 0) == 0) + // Only perform migration if the feature is actually enabled + // This prevents CI test contamination where feature code exists but is disabled + if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) { - CheckAndPerformPSContentPathMigration(); + if (Interlocked.CompareExchange(ref migrationChecked, 1, 0) == 0) + { + CheckAndPerformPSContentPathMigration(); + } } } /// - /// Checks if PSContentPath migration is needed and performs it if the experimental feature is enabled. + /// Checks if PSContentPath migration is needed and performs it when the experimental feature is enabled. + /// + /// IMPORTANT: This method is ONLY called when the feature is enabled (checked in caller). + /// This prevents test contamination in CI where the feature code exists but is disabled. /// /// MIGRATION SCENARIOS: /// 1. First enable: Copies Documents config to LocalAppData, switches to LocalAppData @@ -841,13 +822,6 @@ private void CheckAndPerformPSContentPathMigration() { try { - // Only perform migration if PSContentPath experimental feature is enabled - if (!ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { - return; - } - - string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); @@ -856,14 +830,11 @@ private void CheckAndPerformPSContentPathMigration() { return; } + + // Feature is enabled (checked by caller) - perform migration to new location + // (Paths are different, already checked above) - // Always update to use the new location when experimental feature is enabled - string newConfigDir = Path.GetDirectoryName(newConfigFile); - - // Update to use new location - perUserConfigDirectory = newConfigDir; - perUserConfigFile = newConfigFile; - + // Perform file operations BEFORE updating paths to avoid race conditions // If both configs exist, keep them in sync (legacyConfigFile already points to old location from constructor) // If old config exists but new doesn't, perform migration if (!File.Exists(newConfigFile) && File.Exists(oldConfigFile)) @@ -878,6 +849,12 @@ private void CheckAndPerformPSContentPathMigration() // We need to update LocalAppData to match so future operations see the correct state. SyncExperimentalFeaturesToNewLocation(oldConfigFile, newConfigFile); } + + // Now that file operations completed successfully, update paths and invalidate cache + string newConfigDir = Path.GetDirectoryName(newConfigFile); + perUserConfigDirectory = newConfigDir; + perUserConfigFile = newConfigFile; + configRoots[(int)ConfigScope.CurrentUser] = null; // legacyConfigFile was set to oldConfigFile in constructor and will be used for sync } catch diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 86f1adccb6a..ac2fe97226a 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -721,7 +721,7 @@ internal static string GetPSContentPath() #if UNIX return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); #else - return Environment.GetFolderPath(Environment.SpecialFolder.Personal) + @"\PowerShell"; + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); #endif } diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs index 9316fd8e6ee..805fb61bac1 100644 --- a/src/System.Management.Automation/help/HelpUtils.cs +++ b/src/System.Management.Automation/help/HelpUtils.cs @@ -16,12 +16,18 @@ internal static class HelpUtils /// /// Get the path to $HOME. /// + /// + /// This path is cached for performance, but updated if the PSContentPath changes. + /// This ensures it reflects changes to the PSContentPath experimental feature or config. + /// internal static string GetUserHomeHelpSearchPath() { - if (userHomeHelpPath == null) + string expectedPath = Path.Combine(Utils.GetPSContentPath(), "Help"); + + // Update cache if path changed or not yet initialized + if (userHomeHelpPath != expectedPath) { - string userScopeRootPath = Utils.GetPSContentPath(); - userHomeHelpPath = Path.Combine(userScopeRootPath, "Help"); + userHomeHelpPath = expectedPath; } return userHomeHelpPath; diff --git a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 index 0c6f0d31195..71d8fc0af45 100644 --- a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 +++ b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 @@ -192,7 +192,9 @@ Describe "Default enablement of Experimental Features" -Tags CI { # is launched from another pwsh (with $PSHOME like C:\program files\powershell\7) # resulting in combined PSModulePath which is used by Get-ExperimentalFeature to enum module-scoped exp.features from both pwsh locations. # So we need to exclude parent's modules' exp.features from verification using filtering on $PSHOME. - if (($expFeature.Source -eq 'PSEngine') -or ($expFeature.Source.StartsWith($PSHOME, "InvariantCultureIgnoreCase"))) + # Also exclude PSContentPath as it's intentionally not auto-enabled in preview builds (see build.psm1 line 714) + if ((($expFeature.Source -eq 'PSEngine') -or ($expFeature.Source.StartsWith($PSHOME, "InvariantCultureIgnoreCase"))) -and + ($expFeature.Name -ne 'PSContentPath')) { "Checking $($expFeature.Name) experimental feature" | Write-Verbose -Verbose $expFeature.Enabled | Should -BeEnabled -Name $expFeature.Name diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1 index c8736136805..640c650be3b 100644 --- a/test/powershell/engine/PSContentPath.Tests.ps1 +++ b/test/powershell/engine/PSContentPath.Tests.ps1 @@ -5,9 +5,11 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { BeforeAll { if ($IsWindows) { $powershell = "$PSHOME\pwsh.exe" - $userConfigPath = Join-Path $HOME "Documents\PowerShell\powershell.config.json" - $defaultContentPath = Join-Path $HOME "Documents\PowerShell" + $documentsPath = [System.Environment]::GetFolderPath('MyDocuments') + $userConfigPath = Join-Path $documentsPath "PowerShell\powershell.config.json" + $defaultContentPath = Join-Path $documentsPath "PowerShell" $newContentPath = [System.IO.Path]::Combine($env:LOCALAPPDATA, "PowerShell") + $newConfigPath = Join-Path $newContentPath "powershell.config.json" } else { $powershell = "$PSHOME/pwsh" @@ -15,46 +17,51 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { $defaultContentPath = [System.Management.Automation.Platform]::SelectProductNameForDirectory("USER_MODULES") $defaultContentPath = Split-Path $defaultContentPath -Parent $newContentPath = $defaultContentPath + $newConfigPath = $userConfigPath } - # Backup existing configs + $script:userConfigPath = $userConfigPath + $script:newConfigPath = $newConfigPath + $script:defaultContentPath = $defaultContentPath + $script:newContentPath = $newContentPath + + # Backup original configs if (Test-Path $userConfigPath) { - $userConfigExists = $true - Copy-Item $userConfigPath "$userConfigPath.backup.pscontentpath" -Force -ErrorAction Ignore + $script:userConfigBackup = Get-Content $userConfigPath -Raw + } + if ($IsWindows -and (Test-Path $newConfigPath)) { + $script:newConfigBackup = Get-Content $newConfigPath -Raw } + # Create clean test config with feature disabled + Remove-Item $userConfigPath -Force -ErrorAction Ignore if ($IsWindows) { - $newConfigPath = Join-Path $newContentPath "powershell.config.json" - if (Test-Path $newConfigPath) { - $newConfigExists = $true - Copy-Item $newConfigPath "$newConfigPath.backup.pscontentpath" -Force -ErrorAction Ignore - } + Remove-Item $newConfigPath -Force -ErrorAction Ignore } + + $testConfig = @{ ExperimentalFeatures = @() } | ConvertTo-Json + New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $userConfigPath -Value $testConfig -Force } AfterAll { - # Restore original configs - if ($userConfigExists) { - Move-Item "$userConfigPath.backup.pscontentpath" $userConfigPath -Force -ErrorAction Ignore - } - else { - Remove-Item "$userConfigPath" -Force -ErrorAction Ignore - } + # Disable the feature + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' 2>$null - if ($IsWindows -and $newConfigExists) { - Move-Item "$newConfigPath.backup.pscontentpath" $newConfigPath -Force -ErrorAction Ignore - } - elseif ($IsWindows) { - Remove-Item "$newConfigPath" -Force -ErrorAction Ignore + # Remove test configs + Remove-Item $userConfigPath -Force -ErrorAction Ignore + if ($IsWindows) { + Remove-Item $newConfigPath -Force -ErrorAction Ignore } - } - BeforeEach { - # Clean up config file before each test - Remove-Item "$userConfigPath" -Force -ErrorAction Ignore - if ($IsWindows) { - $newConfigPath = Join-Path $newContentPath "powershell.config.json" - Remove-Item "$newConfigPath" -Force -ErrorAction Ignore + # Restore original configs + if ($null -ne $script:userConfigBackup) { + New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $userConfigPath -Value $script:userConfigBackup -Force + } + if ($IsWindows -and ($null -ne $script:newConfigBackup)) { + New-Item -Path (Split-Path $newConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $newConfigPath -Value $script:newConfigBackup -Force } } @@ -119,22 +126,17 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { } } - It "Get-PSContentPath expands environment variables" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Clean up first to ensure fresh state - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - Start-Sleep -Milliseconds 100 - - # Enable feature and set path with environment variable (note: single backslash) + It "Get-PSContentPath expands environment variables (%TEMP%)" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature and set path with environment variable & $powershell -noprofile -command "Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser; Set-PSContentPath -Path '%TEMP%\PowerShell'" $result = & $powershell -noprofile -command 'Get-PSContentPath' $result | Should -Not -Contain '%' - $result | Should -Be (Join-Path $env:TEMP "PowerShell") - # Clean up after this test IN THE TEST SESSION to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 + # Normalize paths for comparison (handles short path names like RUNNER~1) + $expectedPath = [System.IO.Path]::GetFullPath((Join-Path $env:TEMP "PowerShell")) + $actualPath = [System.IO.Path]::GetFullPath($result) + $actualPath | Should -Be $expectedPath } It "Get-PSContentPath works when config file doesn't exist" -Skip:$skipNoPwsh { @@ -148,6 +150,18 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { } Context "Set-PSContentPath cmdlet" { + BeforeEach { + # Ensure completely clean state for each test + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + # Clean up after each test + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + It "Set-PSContentPath cmdlet does not exist when feature is disabled" -Skip:$skipNoPwsh { # Ensure feature is disabled & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null @@ -191,43 +205,12 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { $result | Should -Be $customPath } - It "Set-PSContentPath updates existing config file" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - # Create initial config in LocalAppData (where feature writes to) - $configToCheck = if ($IsWindows) { $newConfigPath } else { $userConfigPath } - $config = @{ ExperimentalFeatures = @("PSNativeWindowsTildeExpansion") } | ConvertTo-Json - New-Item -Path (Split-Path $configToCheck) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $configToCheck -Value $config - - # Set custom path - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Small delay for file write - Start-Sleep -Milliseconds 100 - - # Verify existing settings are preserved - $updatedConfig = Get-Content $configToCheck -Raw | ConvertFrom-Json - $updatedConfig.ExperimentalFeatures | Should -Contain "PSNativeWindowsTildeExpansion" - $updatedConfig.PSObject.Properties.Name | Should -Contain "PSUserContentPath" - $updatedConfig.PSUserContentPath | Should -Be $customPath - - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 - } - It "Set-PSContentPath accepts paths with environment variables" -Skip:(!$IsWindows -or $skipNoPwsh) { # Enable feature & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' & $powershell -noprofile -command "Set-PSContentPath -Path '%LOCALAPPDATA%\PowerShell'" - # Small delay for file write - Start-Sleep -Milliseconds 100 - # Check the config file in LocalAppData (where it's written when feature enabled) $configToCheck = if ($IsWindows) { $newConfigPath } else { $userConfigPath } $config = Get-Content $configToCheck -Raw | ConvertFrom-Json @@ -257,14 +240,20 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { else { Test-Path $userConfigPath | Should -BeTrue } - - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 } } Context "Integration with PSModulePath" { + BeforeEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + It "Custom PSContentPath affects module path" -Skip:(!$IsWindows -or $skipNoPwsh) { # Enable feature & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' @@ -275,14 +264,20 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { # The actual module path will be used in a new PowerShell session $result = & $powershell -noprofile -command 'Get-PSContentPath' $result | Should -Be $customPath - - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 } } Context "Integration with Profile paths" { + BeforeEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + It "Custom PSContentPath affects profile path" -Skip:(!$IsWindows -or $skipNoPwsh) { # Enable feature & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' @@ -293,14 +288,124 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { # Profile paths are constructed at startup $result = & $powershell -noprofile -command 'Get-PSContentPath' $result | Should -Be $customPath + } + + It "Profile path uses custom PSContentPath location" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature and set custom path + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Get the current user profile path in a new session + $profilePath = & $powershell -noprofile -command '$PROFILE.CurrentUserCurrentHost' - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 + # Profile should be in the custom content path + $profilePath | Should -BeLike "$customPath*" + } + + It "Profile path uses default Documents location when feature is disabled" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Ensure feature is disabled + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null + + # Get the current user profile path in a new session + $profilePath = & $powershell -noprofile -command '$PROFILE.CurrentUserCurrentHost' + + # Profile should be in Documents\PowerShell + $profilePath | Should -BeLike "*Documents\PowerShell*" + } + } + + Context "Integration with Updatable Help" { + BeforeEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + It "Help path uses custom PSContentPath location" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature and set custom path + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Get the help save path (CurrentUser scope) + $script = @" + `$helpPaths = [System.Management.Automation.Internal.InternalTestHooks]::TestHelpSavePath + if (`$helpPaths) { `$helpPaths } else { + # Fallback: construct expected path + `$contentPath = Get-PSContentPath + Join-Path `$contentPath "Help" + } +"@ + $helpPath = & $powershell -noprofile -command $script + + # Help path should be in the custom content path + $helpPath | Should -BeLike "$customPath*" + } + + It "Help path uses default Documents location when feature is disabled" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Ensure feature is disabled + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null + + # Check what path Update-Help would use + $script = @" + # Get the default user help path + `$documentsPath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath('MyDocuments'), 'PowerShell') + Join-Path `$documentsPath "Help" +"@ + $expectedHelpPath = & $powershell -noprofile -command $script + + # Expected path should be in Documents\PowerShell + $expectedHelpPath | Should -BeLike "*Documents\PowerShell\Help" + } + + It "Update-Help with CurrentUser scope respects custom PSContentPath" -Skip:(!$IsWindows -or $skipNoPwsh) { + # Enable feature and set custom path + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + + $customPath = Join-Path $TestDrive "CustomPowerShell" + & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" + + # Create the custom help directory + $customHelpPath = Join-Path $customPath "Help" + New-Item -Path $customHelpPath -ItemType Directory -Force -ErrorAction Ignore + + # Try to save help (using -WhatIf to avoid actual download) + $script = @" + `$ErrorActionPreference = 'SilentlyContinue' + `$WarningPreference = 'SilentlyContinue' + Save-Help -Module Microsoft.PowerShell.Management -DestinationPath '$customHelpPath' -Force -WhatIf 2>&1 | Out-Null + # Just verify the path would be used + '$customHelpPath' +"@ + $result = & $powershell -noprofile -command $script + + # Verify the custom help path exists + Test-Path $customHelpPath | Should -BeTrue + $result | Should -Be $customHelpPath + + # Clean up custom help directory + Remove-Item $customHelpPath -Recurse -Force -ErrorAction Ignore } } Context "Error handling" { + BeforeEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + It "Set-PSContentPath handles invalid paths gracefully" -Skip:$skipNoPwsh { # Enable feature & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' @@ -311,26 +416,18 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { # Should not throw - just accept the path with a warning $result = & $powershell -noprofile -command "try { Set-PSContentPath -Path '$longPath' -WarningAction SilentlyContinue; 'Success' } catch { 'Failed' }" $result | Should -Be 'Success' - - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 } It "Set-PSContentPath handles paths with special characters" -Skip:(!$IsWindows -or $skipNoPwsh) { # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' - $pathWithSpaces = Join-Path $TestDrive "Path With Spaces" - # Warnings are expected, just check it doesn't throw - & $powershell -noprofile -command "Set-PSContentPath -Path '$pathWithSpaces' -WarningAction SilentlyContinue" 2>$null + # Set path with spaces + & $powershell -noprofile -command "Set-PSContentPath -Path '$TestDrive\Path With Spaces' -WarningAction SilentlyContinue" + # Verify it worked $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be $pathWithSpaces - - # Clean up after this test to not contaminate others - & $powershell -noprofile -command "Remove-Item '$newConfigPath' -Force -ErrorAction Ignore; Remove-Item '$userConfigPath' -Force -ErrorAction Ignore" - Start-Sleep -Milliseconds 200 + $result | Should -Be (Join-Path $TestDrive "Path With Spaces") } } } @@ -339,7 +436,8 @@ Describe "PSContentPath experimental feature integration" -tags "Feature" { BeforeAll { if ($IsWindows) { $powershell = "$PSHOME\pwsh.exe" - $userConfigPath = Join-Path $HOME "Documents\PowerShell\powershell.config.json" + $documentsPath = [System.Environment]::GetFolderPath('MyDocuments') + $userConfigPath = Join-Path $documentsPath "PowerShell\powershell.config.json" $newConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json" } else { @@ -348,93 +446,110 @@ Describe "PSContentPath experimental feature integration" -tags "Feature" { $newConfigPath = $userConfigPath } - # Backup existing configs - $backupSuffix = ".backup.integration" + # Backup original configs if (Test-Path $userConfigPath) { - Copy-Item $userConfigPath "$userConfigPath$backupSuffix" -Force -ErrorAction Ignore + $script:userConfigBackup = Get-Content $userConfigPath -Raw } if ($IsWindows -and (Test-Path $newConfigPath)) { - Copy-Item $newConfigPath "$newConfigPath$backupSuffix" -Force -ErrorAction Ignore + $script:newConfigBackup = Get-Content $newConfigPath -Raw } + + # Create clean test environment + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + BeforeEach { + # Remove all configs to ensure clean state before each test + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore + } + + AfterEach { + # Clean up after each test to prevent contamination + Remove-Item $newConfigPath -Force -ErrorAction Ignore + Remove-Item $userConfigPath -Force -ErrorAction Ignore } AfterAll { - # Restore original configs - $backupSuffix = ".backup.integration" - if (Test-Path "$userConfigPath$backupSuffix") { - Move-Item "$userConfigPath$backupSuffix" $userConfigPath -Force -ErrorAction Ignore - } - else { - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } + # Disable the feature + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' 2>$null + # Remove test configs + Remove-Item $userConfigPath -Force -ErrorAction Ignore if ($IsWindows) { - if (Test-Path "$newConfigPath$backupSuffix") { - Move-Item "$newConfigPath$backupSuffix" $newConfigPath -Force -ErrorAction Ignore - } - else { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - } + Remove-Item $newConfigPath -Force -ErrorAction Ignore + } + + # Restore original configs + if ($null -ne $script:userConfigBackup) { + New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $userConfigPath -Value $script:userConfigBackup -Force + } + if ($IsWindows -and ($null -ne $script:newConfigBackup)) { + New-Item -Path (Split-Path $newConfigPath) -ItemType Directory -Force -ErrorAction Ignore + Set-Content -Path $newConfigPath -Value $script:newConfigBackup -Force } } It "Config file migration preserves all settings" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Remove any existing config in new location first - Remove-Item $newConfigPath -Force -ErrorAction Ignore + # Verify clean state from BeforeEach + Test-Path $newConfigPath | Should -BeFalse "LocalAppData config should not exist before test" + Test-Path $userConfigPath | Should -BeFalse "Documents config should not exist before test" # Create a config with multiple settings in old location (Documents) + $originalModulePath = "C:\\CustomModules" $config = @{ - ExperimentalFeatures = @("PSContentPath", "PSNativeWindowsTildeExpansion") + ExperimentalFeatures = @("PSContentPath") + PSModulePath = $originalModulePath "Microsoft.PowerShell:ExecutionPolicy" = "RemoteSigned" - PSModulePath = "C:\\CustomModules" } | ConvertTo-Json New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $config + Set-Content -Path $userConfigPath -Value $config -Force - # Trigger migration by calling Get-PSContentPath in a new PowerShell session - # This will read the config, see PSContentPath is enabled, and perform migration - $result = & $powershell -noprofile -command 'Get-PSContentPath' + # Verify the original config was written correctly + $originalConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json + $originalConfig.PSModulePath | Should -Be $originalModulePath -Because "Original config should have PSModulePath" + $originalConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "Original config should have PSContentPath enabled" - # Small delay for migration to complete - Start-Sleep -Milliseconds 200 + # Trigger migration by accessing config in a new PowerShell session + & $powershell -noprofile -command 'Get-PSContentPath' | Out-Null - # Verify new config has all settings after migration - if (Test-Path $newConfigPath) { - $migratedConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json - $migratedConfig.ExperimentalFeatures | Should -Contain "PSContentPath" - $migratedConfig.ExperimentalFeatures | Should -Contain "PSNativeWindowsTildeExpansion" - - # Verify custom PSModulePath is preserved - $propertyNames = $migratedConfig.PSObject.Properties.Name - if ($propertyNames -contains 'PSModulePath') { - $migratedConfig.PSModulePath | Should -Be "C:\\CustomModules" - } - } + # After migration, BOTH configs should exist + Test-Path $newConfigPath | Should -BeTrue "LocalAppData config should exist after migration" + Test-Path $userConfigPath | Should -BeTrue "Documents config should still exist after migration" + + # Parse configs + $newConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json + $docConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json + + # Verify new LocalAppData config has all settings (should be exact copy) + $newConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "New config should have PSContentPath enabled" + $newConfig.PSModulePath | Should -Be $originalModulePath -Because "New config should have PSModulePath" + $newConfig."Microsoft.PowerShell:ExecutionPolicy" | Should -Be "RemoteSigned" -Because "New config should have ExecutionPolicy" + + # Verify original Documents config still has all settings (bidirectional sync) + $docConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "Doc config should have PSContentPath enabled" + $docConfig.PSModulePath | Should -Be $originalModulePath -Because "Doc config should have PSModulePath" + $docConfig."Microsoft.PowerShell:ExecutionPolicy" | Should -Be "RemoteSigned" -Because "Doc config should have ExecutionPolicy" } It "Re-enabling feature after disable syncs correctly" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable, then disable, then re-enable (synchronously) - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null | Out-Null - - # Verify config files have the feature enabled (check new location since feature is enabled) - Start-Sleep -Milliseconds 100 # Small delay for file writes + # Enable, disable, then re-enable to test sync behavior + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null + & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null + # Verify config files have the feature enabled $configFound = $false if (Test-Path $newConfigPath) { - $newConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json - if ($newConfig.ExperimentalFeatures -contains "PSContentPath") { - $configFound = $true - } + $config = Get-Content $newConfigPath -Raw | ConvertFrom-Json + $configFound = $config.ExperimentalFeatures -contains "PSContentPath" } - if (!$configFound -and (Test-Path $userConfigPath)) { - $docConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json - if ($docConfig.ExperimentalFeatures -contains "PSContentPath") { - $configFound = $true - } + $config = Get-Content $userConfigPath -Raw | ConvertFrom-Json + $configFound = $config.ExperimentalFeatures -contains "PSContentPath" } $configFound | Should -BeTrue @@ -446,10 +561,10 @@ Describe "PSContentPath experimental feature integration" -tags "Feature" { New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore Set-Content -Path $userConfigPath -Value $docConfig - # Remove LocalAppData config if it exists - Remove-Item $newConfigPath -Force -ErrorAction Ignore + # LocalAppData config should not exist (removed by BeforeEach) + Test-Path $newConfigPath | Should -BeFalse - # PowerShell should still detect the feature is enabled (returns object, not just Enabled property) + # PowerShell should still detect the feature is enabled (reads from both locations) $featureEnabled = & $powershell -noprofile -command '(Get-ExperimentalFeature -Name PSContentPath).Enabled' $featureEnabled | Should -Be 'True' } From eed1cf342aad9f7d721bac82e86bbbbc9fd5e356 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:07:46 -0600 Subject: [PATCH 14/22] Fix expanding env variable test --- .../powershell/engine/PSContentPath.Tests.ps1 | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1 index 640c650be3b..486a8fcb06c 100644 --- a/test/powershell/engine/PSContentPath.Tests.ps1 +++ b/test/powershell/engine/PSContentPath.Tests.ps1 @@ -127,15 +127,28 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { } It "Get-PSContentPath expands environment variables (%TEMP%)" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature and set path with environment variable - & $powershell -noprofile -command "Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser; Set-PSContentPath -Path '%TEMP%\PowerShell'" + # Run in single session to avoid migration interference + $script = @" + `$ErrorActionPreference = 'Stop' + Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore + Set-PSContentPath -Path '%TEMP%\PowerShell' -ErrorAction Stop + Get-PSContentPath +"@ + $result = & $powershell -noprofile -command $script 2>&1 - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Not -Contain '%' + # Filter out any error messages, just get the path + $pathResult = $result | Where-Object { $_ -is [string] -and $_ -notmatch '^Set-PSContentPath:' } | Select-Object -Last 1 + + if (-not $pathResult) { + Write-Host "Command output: $result" -ForegroundColor Red + throw "Set-PSContentPath or Get-PSContentPath failed" + } + + $pathResult | Should -Not -Contain '%' # Normalize paths for comparison (handles short path names like RUNNER~1) $expectedPath = [System.IO.Path]::GetFullPath((Join-Path $env:TEMP "PowerShell")) - $actualPath = [System.IO.Path]::GetFullPath($result) + $actualPath = [System.IO.Path]::GetFullPath($pathResult) $actualPath | Should -Be $expectedPath } From ec187858c0b35491e9272f032cbc783c31b93e6f Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:15:28 -0600 Subject: [PATCH 15/22] Separate commands for env var test --- .../powershell/engine/PSContentPath.Tests.ps1 | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1 index 486a8fcb06c..e7d98d322dc 100644 --- a/test/powershell/engine/PSContentPath.Tests.ps1 +++ b/test/powershell/engine/PSContentPath.Tests.ps1 @@ -127,28 +127,21 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { } It "Get-PSContentPath expands environment variables (%TEMP%)" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Run in single session to avoid migration interference - $script = @" - `$ErrorActionPreference = 'Stop' - Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore - Set-PSContentPath -Path '%TEMP%\PowerShell' -ErrorAction Stop - Get-PSContentPath -"@ - $result = & $powershell -noprofile -command $script 2>&1 + # Enable feature first + & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' - # Filter out any error messages, just get the path - $pathResult = $result | Where-Object { $_ -is [string] -and $_ -notmatch '^Set-PSContentPath:' } | Select-Object -Last 1 + # Set path with environment variable + & $powershell -noprofile -command "Set-PSContentPath -Path '%TEMP%\PowerShell'" - if (-not $pathResult) { - Write-Host "Command output: $result" -ForegroundColor Red - throw "Set-PSContentPath or Get-PSContentPath failed" - } + # Get the path - should be expanded + $result = & $powershell -noprofile -command 'Get-PSContentPath' - $pathResult | Should -Not -Contain '%' + # Verify no environment variable syntax remains + $result | Should -Not -Contain '%' # Normalize paths for comparison (handles short path names like RUNNER~1) $expectedPath = [System.IO.Path]::GetFullPath((Join-Path $env:TEMP "PowerShell")) - $actualPath = [System.IO.Path]::GetFullPath($pathResult) + $actualPath = [System.IO.Path]::GetFullPath($result) $actualPath | Should -Be $expectedPath } From c37e776c6e4a5aefca8b6007e7d40d8be0bbc10e Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:33:38 -0600 Subject: [PATCH 16/22] Initial redo to remove experimental feature aspect and point to OneDrive by default --- .../GetSetPSContentPathCommand.cs | 2 - .../ExperimentalFeature.cs | 4 - .../engine/InitialSessionState.cs | 28 +- .../engine/PSConfiguration.cs | 227 +------ .../engine/Utils.cs | 13 +- .../powershell/engine/PSContentPath.Tests.ps1 | 609 +++--------------- 6 files changed, 92 insertions(+), 791 deletions(-) diff --git a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs index 722122af575..3901146df0e 100644 --- a/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs +++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs @@ -13,7 +13,6 @@ namespace Microsoft.PowerShell.Commands /// Implements Get-PSContentPath cmdlet. /// [Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344910")] - [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class GetPSContentPathCommand : PSCmdlet { /// @@ -42,7 +41,6 @@ protected override void EndProcessing() /// Implements Set-PSContentPath cmdlet. /// [Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344807")] - [Experimental(ExperimentalFeature.PSContentPath, ExperimentAction.Show)] public class SetPSContentPathCommand : PSCmdlet { /// diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index e42c9c36e45..9f0d3d6e578 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -21,7 +21,6 @@ public class ExperimentalFeature internal const string EngineSource = "PSEngine"; internal const string PSSerializeJSONLongEnumAsNumber = nameof(PSSerializeJSONLongEnumAsNumber); - internal const string PSContentPath = "PSContentPath"; internal const string PSProfileDSCResource = "PSProfileDSCResource"; #endregion @@ -111,9 +110,6 @@ static ExperimentalFeature() 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."), - new ExperimentalFeature( - name: PSContentPath, - description: "Moves PS content (modules, scripts, help, and profiles) to the new default location in LocalAppData/PowerShell and allows users to specify the content path."), new ExperimentalFeature( name: PSProfileDSCResource, description: "DSC v3 resources for managing PowerShell profile.") diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index f921b26b495..b237bc82326 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -1600,9 +1600,6 @@ public static InitialSessionState CreateDefault2() ss.ImportCorePSSnapIn(); - // Register experimental feature cmdlets conditionally - ss.RegisterExperimentalFeatureCmdlets(); - ss.LanguageMode = PSLanguageMode.FullLanguage; ss.AuthorizationManager = new Microsoft.PowerShell.PSAuthorizationManager(Utils.DefaultPowerShellShellID); @@ -3803,26 +3800,6 @@ internal PSSnapInInfo ImportCorePSSnapIn() return coreSnapin; } - /// - /// Register cmdlets that are only available when their associated experimental features are enabled. - /// - private void RegisterExperimentalFeatureCmdlets() - { - string helpFile = typeof(GetPSContentPathCommand).Assembly.Location + "-help.xml"; - - // Register PSContentPath cmdlets if the experimental feature is enabled - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { - var getPSContentPathEntry = new SessionStateCmdletEntry("Get-PSContentPath", typeof(GetPSContentPathCommand), helpFile); - getPSContentPathEntry.Visibility = this.DefaultCommandVisibility; - this.Commands.Add(getPSContentPathEntry); - - var setPSContentPathEntry = new SessionStateCmdletEntry("Set-PSContentPath", typeof(SetPSContentPathCommand), helpFile); - setPSContentPathEntry.Visibility = this.DefaultCommandVisibility; - this.Commands.Add(setPSContentPathEntry); - } - } - internal PSSnapInInfo ImportPSSnapIn(PSSnapInInfo psSnapInInfo, out PSSnapInException warning) { ArgumentNullException.ThrowIfNull(psSnapInInfo); @@ -5276,8 +5253,7 @@ internal static void AnalyzePSSnapInAssembly( // Exclude dynamically registered cmdlets (registered based on experimental features) var dynamicCmdlets = new HashSet(StringComparer.OrdinalIgnoreCase) { - "Get-PSContentPath", - "Set-PSContentPath" + // No dynamic cmdlets currently }; var expectedCmdletCount = cmdletsCheck.Count - cmdletsCheck.Keys.Count(key => dynamicCmdlets.Contains(key)); @@ -5514,6 +5490,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) }, { "Get-PSSubsystem", new SessionStateCmdletEntry("Get-PSSubsystem", typeof(Subsystem.GetPSSubsystemCommand), helpFile) }, @@ -5536,6 +5513,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/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 5b0f1607329..e95a3d26b35 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -66,12 +66,6 @@ internal sealed class PowerShellConfig private string perUserConfigFile; private string perUserConfigDirectory; - // Flag to track if migration has been checked (lazy initialization to avoid circular dependency) - private int migrationChecked = 0; - - // Track the legacy config file path after migration, so we can keep it in sync - private string legacyConfigFile = null; - // 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. @@ -99,8 +93,6 @@ private PowerShellConfig() if (!string.IsNullOrEmpty(perUserConfigDirectory)) { perUserConfigFile = Path.Combine(perUserConfigDirectory, ConfigFileName); - // Save the legacy config file path (Documents location) for bidirectional sync - legacyConfigFile = perUserConfigFile; } emptyConfig = new JObject(); @@ -108,9 +100,6 @@ private PowerShellConfig() serializer = JsonSerializer.Create(new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.None, MaxDepth = 10 }); fileLock = new ReaderWriterLockSlim(); - - // Note: PSContentPath user config migration is NOT performed here to avoid circular dependency with ExperimentalFeature. - // It will be performed lazily on first access to GetPSContentPath(). } private string GetConfigFilePath(ConfigScope scope) @@ -158,29 +147,26 @@ internal string GetModulePath(ConfigScope scope) /// /// Gets the PSContentPath from the configuration file. - /// If not configured, returns the default location without creating the config file. + /// If not configured, returns the default OneDrive location (Documents\PowerShell) without creating the config file. /// This ensures PowerShell works on read-only file systems and avoids creating unnecessary files. /// - /// The configured PSContentPath if found, otherwise the default location (never null). + /// The configured PSContentPath if found, otherwise the default OneDrive location (never null). internal string GetPSContentPath() { - // Perform migration check on first call to avoid circular dependency with ExperimentalFeature - CheckAndPerformPSContentPathMigrationOnce(); - string contentPath = ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar); if (!string.IsNullOrEmpty(contentPath)) { contentPath = Environment.ExpandEnvironmentVariables(contentPath); + return contentPath; } - - // When PSContentPath feature is enabled: - // - If user has configured a custom path, use that - // - Otherwise, use DefaultPSContentDirectory (LocalAppData\PowerShell on Windows) - // - // When PSContentPath feature is disabled: - // - This method is only called when feature is enabled (checked in Utils.GetPSContentPath) - // - So we can safely return the new default location - return contentPath ?? Platform.DefaultPSContentDirectory; + + // Return default OneDrive location (Documents\PowerShell on Windows, XDG on Unix) +#if UNIX + return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); +#else + // Future Experimental Feature will make the default LocalAppData\PowerShell instead + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); +#endif } /// @@ -655,15 +641,6 @@ private void WriteValueToFile(ConfigScope scope, string key, T value) } UpdateValueInFile(scope, key, value, true); - - // If we migrated from a legacy location, also update the legacy config to keep them in sync. - // This ensures that disabling features (like PSContentPath) updates both locations. - if (scope == ConfigScope.CurrentUser && - !string.IsNullOrEmpty(legacyConfigFile) && - File.Exists(legacyConfigFile)) - { - UpdateLegacyConfigFile(key, value, true); - } } /// @@ -681,188 +658,6 @@ private void RemoveValueFromFile(ConfigScope scope, string key) UpdateValueInFile(scope, key, default(T), false); } } - - internal void MigrateUserConfig(string oldPath, string newPath) - { - // Ensure new directory exists - Directory.CreateDirectory(Path.GetDirectoryName(newPath)); - - // Copy the config file (may throw) - File.Copy(oldPath, newPath); - - // Note: Path updates and cache invalidation are handled by the caller - // after this method successfully completes to avoid race conditions. - } - - /// - /// Syncs the ExperimentalFeatures array from the old config location to the new location. - /// This ensures that when PSContentPath is re-enabled after being disabled, the new location - /// gets updated with the current experimental features state. - /// - /// Path to the old config file (Documents location) - /// Path to the new config file (LocalAppData location) - private void SyncExperimentalFeaturesToNewLocation(string oldPath, string newPath) - { - try - { - // Read experimental features from old location - string originalFile = perUserConfigFile; - JObject originalCache = configRoots[(int)ConfigScope.CurrentUser]; - - try - { - // Temporarily point to old location to read features - perUserConfigFile = oldPath; - configRoots[(int)ConfigScope.CurrentUser] = null; - string[] oldFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); - - // Now point to new location to write features - perUserConfigFile = newPath; - configRoots[(int)ConfigScope.CurrentUser] = null; - - // Write the features to new location (this will also invalidate the cache) - if (oldFeatures.Length > 0) - { - UpdateValueInFile(ConfigScope.CurrentUser, "ExperimentalFeatures", oldFeatures, true); - } - } - finally - { - // Restore original state - perUserConfigFile = originalFile; - configRoots[(int)ConfigScope.CurrentUser] = originalCache; - } - } - catch - { - // Best-effort operation; don't fail PowerShell startup if sync fails - } - } - - /// - /// Updates the legacy config file to keep it in sync with the new location. - /// This is a best-effort operation that silently fails if there are any issues. - /// Reuses UpdateValueInFile by temporarily treating the legacy file as the current user config. - /// - /// The type of value - /// The string key of the value. - /// The value to set. - /// Whether the key-value pair should be added to or removed from the file. - private void UpdateLegacyConfigFile(string key, T value, bool addValue) - { - try - { - // Save the current cache for the CurrentUser scope - JObject savedCache = configRoots[(int)ConfigScope.CurrentUser]; - - // Temporarily swap to the legacy config file path and clear cache - string originalPerUserConfigFile = perUserConfigFile; - perUserConfigFile = legacyConfigFile; - configRoots[(int)ConfigScope.CurrentUser] = null; - - try - { - // Reuse the existing UpdateValueInFile logic - UpdateValueInFile(ConfigScope.CurrentUser, key, value, addValue); - } - finally - { - // Restore the original path and cache - perUserConfigFile = originalPerUserConfigFile; - configRoots[(int)ConfigScope.CurrentUser] = savedCache; - } - } - catch - { - // Best-effort operation; don't fail if we can't update the legacy config - } - } - - /// - /// Ensures migration is checked exactly once, using thread-safe lazy initialization. - /// This is called from GetPSContentPath() to avoid circular dependency with ExperimentalFeature. - /// - /// IMPORTANT: This only runs when PSContentPath feature is ENABLED. - /// When disabled, we use Platform.ConfigDirectory (Documents) without any migration checks. - /// This prevents test contamination in CI environments where the feature code exists but is disabled. - /// - private void CheckAndPerformPSContentPathMigrationOnce() - { - // Only perform migration if the feature is actually enabled - // This prevents CI test contamination where feature code exists but is disabled - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { - if (Interlocked.CompareExchange(ref migrationChecked, 1, 0) == 0) - { - CheckAndPerformPSContentPathMigration(); - } - } - } - - /// - /// Checks if PSContentPath migration is needed and performs it when the experimental feature is enabled. - /// - /// IMPORTANT: This method is ONLY called when the feature is enabled (checked in caller). - /// This prevents test contamination in CI where the feature code exists but is disabled. - /// - /// MIGRATION SCENARIOS: - /// 1. First enable: Copies Documents config to LocalAppData, switches to LocalAppData - /// 2. Already migrated: Just switches to LocalAppData (both configs exist and should be in sync) - /// 3. Re-enable after disable: Syncs experimental features from Documents to LocalAppData, then switches - /// - /// BIDIRECTIONAL SYNC: - /// After migration, legacyConfigFile points to Documents and perUserConfigFile points to LocalAppData. - /// All subsequent writes via WriteValueToFile() will update both locations via UpdateLegacyConfigFile(). - /// This ensures that: - /// - Enabling/disabling features updates both configs - /// - Disabling PSContentPath and restarting uses Documents with current state - /// - Re-enabling PSContentPath and restarting switches back to LocalAppData with current state - /// - private void CheckAndPerformPSContentPathMigration() - { - try - { - string oldConfigFile = Path.Combine(Platform.ConfigDirectory, ConfigFileName); - string newConfigFile = Path.Combine(Platform.DefaultPSContentDirectory, ConfigFileName); - - // If paths are the same, no migration needed - if (string.Equals(oldConfigFile, newConfigFile, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - // Feature is enabled (checked by caller) - perform migration to new location - // (Paths are different, already checked above) - - // Perform file operations BEFORE updating paths to avoid race conditions - // If both configs exist, keep them in sync (legacyConfigFile already points to old location from constructor) - // If old config exists but new doesn't, perform migration - if (!File.Exists(newConfigFile) && File.Exists(oldConfigFile)) - { - MigrateUserConfig(oldConfigFile, newConfigFile); - } - else if (File.Exists(oldConfigFile) && File.Exists(newConfigFile)) - { - // Both configs exist. Ensure they're in sync by copying the experimental features from old to new. - // This handles the case where PSContentPath was disabled, then re-enabled while running from Documents location. - // The Documents config now has PSContentPath enabled, but LocalAppData config still has it disabled (stale). - // We need to update LocalAppData to match so future operations see the correct state. - SyncExperimentalFeaturesToNewLocation(oldConfigFile, newConfigFile); - } - - // Now that file operations completed successfully, update paths and invalidate cache - string newConfigDir = Path.GetDirectoryName(newConfigFile); - perUserConfigDirectory = newConfigDir; - perUserConfigFile = newConfigFile; - configRoots[(int)ConfigScope.CurrentUser] = null; - // legacyConfigFile was set to oldConfigFile in constructor and will be used for sync - } - 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 diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index ac2fe97226a..b8b149e1e10 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -708,21 +708,12 @@ internal static bool IsValidPSEditionValue(string editionValue) /// /// Gets the PSContent path from PowerShell.config.json or falls back to platform defaults. - /// When PSContentPath experimental feature is enabled, returns the configured path or default location. + /// Returns the configured custom path if set, otherwise returns the default OneDrive location (Documents\PowerShell). /// /// The PSContent directory path (never null). internal static string GetPSContentPath() { - if (ExperimentalFeature.IsEnabled(ExperimentalFeature.PSContentPath)) - { - return PowerShellConfig.Instance.GetPSContentPath(); - } - -#if UNIX - return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); -#else - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); -#endif + return PowerShellConfig.Instance.GetPSContentPath(); } internal static readonly ConfigScope[] SystemWideOnlyConfig = new[] { ConfigScope.AllUsers }; diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1 index e7d98d322dc..6b2490c5d4d 100644 --- a/test/powershell/engine/PSContentPath.Tests.ps1 +++ b/test/powershell/engine/PSContentPath.Tests.ps1 @@ -3,575 +3,118 @@ Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" { BeforeAll { - if ($IsWindows) { - $powershell = "$PSHOME\pwsh.exe" - $documentsPath = [System.Environment]::GetFolderPath('MyDocuments') - $userConfigPath = Join-Path $documentsPath "PowerShell\powershell.config.json" - $defaultContentPath = Join-Path $documentsPath "PowerShell" - $newContentPath = [System.IO.Path]::Combine($env:LOCALAPPDATA, "PowerShell") - $newConfigPath = Join-Path $newContentPath "powershell.config.json" - } - else { - $powershell = "$PSHOME/pwsh" - $userConfigPath = "~/.config/powershell/powershell.config.json" - $defaultContentPath = [System.Management.Automation.Platform]::SelectProductNameForDirectory("USER_MODULES") - $defaultContentPath = Split-Path $defaultContentPath -Parent - $newContentPath = $defaultContentPath - $newConfigPath = $userConfigPath - } - - $script:userConfigPath = $userConfigPath - $script:newConfigPath = $newConfigPath - $script:defaultContentPath = $defaultContentPath - $script:newContentPath = $newContentPath - - # Backup original configs - if (Test-Path $userConfigPath) { - $script:userConfigBackup = Get-Content $userConfigPath -Raw - } - if ($IsWindows -and (Test-Path $newConfigPath)) { - $script:newConfigBackup = Get-Content $newConfigPath -Raw + # Backup any existing config files + $documentsConfigPath = if ($IsWindows) { + Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell' 'powershell.config.json' + } else { + Join-Path $HOME '.config' 'powershell' 'powershell.config.json' } - # Create clean test config with feature disabled - Remove-Item $userConfigPath -Force -ErrorAction Ignore - if ($IsWindows) { - Remove-Item $newConfigPath -Force -ErrorAction Ignore + $configBackup = $null + if (Test-Path $documentsConfigPath) { + $configBackup = Get-Content $documentsConfigPath -Raw } - - $testConfig = @{ ExperimentalFeatures = @() } | ConvertTo-Json - New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $testConfig -Force } AfterAll { - # Disable the feature - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' 2>$null - - # Remove test configs - Remove-Item $userConfigPath -Force -ErrorAction Ignore - if ($IsWindows) { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - } - - # Restore original configs - if ($null -ne $script:userConfigBackup) { - New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $script:userConfigBackup -Force - } - if ($IsWindows -and ($null -ne $script:newConfigBackup)) { - New-Item -Path (Split-Path $newConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $newConfigPath -Value $script:newConfigBackup -Force + # Restore original config if it existed + if ($configBackup) { + Set-Content -Path $documentsConfigPath -Value $configBackup -Force + } elseif (Test-Path $documentsConfigPath) { + Remove-Item $documentsConfigPath -Force -ErrorAction SilentlyContinue } } - Context "Get-PSContentPath cmdlet" { - It "Get-PSContentPath cmdlet does not exist when feature is disabled" -Skip:$skipNoPwsh { - # Ensure feature is disabled - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null - - $result = & $powershell -noprofile -command 'Get-Command Get-PSContentPath -ErrorAction SilentlyContinue' - $result | Should -BeNullOrEmpty - } - - It "Get-PSContentPath cmdlet exists when feature is enabled" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $result = & $powershell -noprofile -command 'Get-Command Get-PSContentPath -ErrorAction SilentlyContinue' - $result | Should -Not -BeNullOrEmpty - } - - It "Get-PSContentPath returns current content path" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Not -BeNullOrEmpty - } - - It "Get-PSContentPath returns default path when not configured" -Skip:$skipNoPwsh { - # Run everything in ONE PowerShell session to ensure clean state - $script = @" - # Remove any existing configs - Remove-Item '$newConfigPath' -Force -ErrorAction Ignore - Remove-Item '$userConfigPath' -Force -ErrorAction Ignore - - # Verify they're gone - if (Test-Path '$newConfigPath') { Write-Error 'Failed to remove newConfigPath' } - if (Test-Path '$userConfigPath') { Write-Error 'Failed to remove userConfigPath' } - - # Disable feature (suppress warnings) - Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -ErrorAction Ignore -WarningAction Ignore - - # Clean again after disable - Remove-Item '$newConfigPath' -Force -ErrorAction Ignore - Remove-Item '$userConfigPath' -Force -ErrorAction Ignore - - # Enable feature with clean state (suppress warnings) - Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore - - # Get the path - should be default - Get-PSContentPath -"@ - - $result = & $powershell -noprofile -command $script - - if ($IsWindows) { - # When PSContentPath feature is enabled, returns LocalAppData path by default - $result | Should -Be $newContentPath - } - else { - $result | Should -Not -BeNullOrEmpty + AfterEach { + # Clean up any test config files created during tests + if (Test-Path $documentsConfigPath) { + if ($configBackup) { + Set-Content -Path $documentsConfigPath -Value $configBackup -Force + } else { + Remove-Item $documentsConfigPath -Force -ErrorAction SilentlyContinue } } - - It "Get-PSContentPath expands environment variables (%TEMP%)" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature first - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' - - # Set path with environment variable - & $powershell -noprofile -command "Set-PSContentPath -Path '%TEMP%\PowerShell'" - - # Get the path - should be expanded - $result = & $powershell -noprofile -command 'Get-PSContentPath' - - # Verify no environment variable syntax remains - $result | Should -Not -Contain '%' - - # Normalize paths for comparison (handles short path names like RUNNER~1) - $expectedPath = [System.IO.Path]::GetFullPath((Join-Path $env:TEMP "PowerShell")) - $actualPath = [System.IO.Path]::GetFullPath($result) - $actualPath | Should -Be $expectedPath - } - - It "Get-PSContentPath works when config file doesn't exist" -Skip:$skipNoPwsh { - # Ensure no config file and enable feature - Remove-Item $userConfigPath -Force -ErrorAction Ignore - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Not -BeNullOrEmpty - } } - Context "Set-PSContentPath cmdlet" { - BeforeEach { - # Ensure completely clean state for each test - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - # Clean up after each test - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - It "Set-PSContentPath cmdlet does not exist when feature is disabled" -Skip:$skipNoPwsh { - # Ensure feature is disabled - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null - - $result = & $powershell -noprofile -command 'Get-Command Set-PSContentPath -ErrorAction SilentlyContinue' - $result | Should -BeNullOrEmpty - } - - It "Set-PSContentPath cmdlet exists when feature is enabled" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $result = & $powershell -noprofile -command 'Get-Command Set-PSContentPath -ErrorAction SilentlyContinue' - $result | Should -Not -BeNullOrEmpty - } - - It "Set-PSContentPath creates config file if it doesn't exist" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # When feature is enabled, config is in LocalAppData - if ($IsWindows) { - (Test-Path $newConfigPath) -or (Test-Path $userConfigPath) | Should -BeTrue - } - else { - Test-Path $userConfigPath | Should -BeTrue + Context "Get-PSContentPath default behavior" { + It "Get-PSContentPath returns default Documents path when not configured" { + # This test only works if no config was present at session start + # Skip if a config already exists (indicates a custom path was set) + $skipTest = Test-Path $documentsConfigPath + + if (-not $skipTest) { + $result = Get-PSContentPath + + # Default should be Documents\PowerShell on Windows, XDG on Unix + if ($IsWindows) { + $expectedPath = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell' + $result | Should -Be $expectedPath + } else { + # On Unix, should return XDG_DATA_HOME or ~/.local/share/powershell + $result | Should -Not -BeNullOrEmpty + $result | Should -BeLike '*powershell' + } + } else { + Set-ItResult -Skipped -Because "Config file exists from previous test - PSContentPath is session-level" } } - It "Set-PSContentPath updates the content path" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be $customPath - } - - It "Set-PSContentPath accepts paths with environment variables" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - & $powershell -noprofile -command "Set-PSContentPath -Path '%LOCALAPPDATA%\PowerShell'" - - # Check the config file in LocalAppData (where it's written when feature enabled) - $configToCheck = if ($IsWindows) { $newConfigPath } else { $userConfigPath } - $config = Get-Content $configToCheck -Raw | ConvertFrom-Json - $config.PSObject.Properties.Name | Should -Contain "PSUserContentPath" - $config.PSUserContentPath | Should -Be '%LOCALAPPDATA%\PowerShell' - - # Get-PSContentPath should expand it - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be $newContentPath - } - - It "Set-PSContentPath creates directory structure if it doesn't exist" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "NewFolder\PowerShell" - - # Directory doesn't exist yet - Test-Path $customPath | Should -BeFalse - - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Config should be created (in LocalAppData when feature enabled) - if ($IsWindows) { - (Test-Path $newConfigPath) -or (Test-Path $userConfigPath) | Should -BeTrue + It "Get-PSContentPath returns path without creating config file" { + # Ensure no config exists + if (Test-Path $documentsConfigPath) { + Remove-Item $documentsConfigPath -Force } - else { - Test-Path $userConfigPath | Should -BeTrue - } - } - } - - Context "Integration with PSModulePath" { - BeforeEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - It "Custom PSContentPath affects module path" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # The actual module path will be used in a new PowerShell session - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be $customPath - } - } - - Context "Integration with Profile paths" { - BeforeEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - It "Custom PSContentPath affects profile path" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Profile paths are constructed at startup - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be $customPath - } - - It "Profile path uses custom PSContentPath location" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature and set custom path - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Get the current user profile path in a new session - $profilePath = & $powershell -noprofile -command '$PROFILE.CurrentUserCurrentHost' - - # Profile should be in the custom content path - $profilePath | Should -BeLike "$customPath*" - } - - It "Profile path uses default Documents location when feature is disabled" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Ensure feature is disabled - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null - - # Get the current user profile path in a new session - $profilePath = & $powershell -noprofile -command '$PROFILE.CurrentUserCurrentHost' - - # Profile should be in Documents\PowerShell - $profilePath | Should -BeLike "*Documents\PowerShell*" - } - } - - Context "Integration with Updatable Help" { - BeforeEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - It "Help path uses custom PSContentPath location" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature and set custom path - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Get the help save path (CurrentUser scope) - $script = @" - `$helpPaths = [System.Management.Automation.Internal.InternalTestHooks]::TestHelpSavePath - if (`$helpPaths) { `$helpPaths } else { - # Fallback: construct expected path - `$contentPath = Get-PSContentPath - Join-Path `$contentPath "Help" - } -"@ - $helpPath = & $powershell -noprofile -command $script - - # Help path should be in the custom content path - $helpPath | Should -BeLike "$customPath*" - } - - It "Help path uses default Documents location when feature is disabled" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Ensure feature is disabled - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' 2>$null - # Check what path Update-Help would use - $script = @" - # Get the default user help path - `$documentsPath = [System.IO.Path]::Combine([System.Environment]::GetFolderPath('MyDocuments'), 'PowerShell') - Join-Path `$documentsPath "Help" -"@ - $expectedHelpPath = & $powershell -noprofile -command $script - - # Expected path should be in Documents\PowerShell - $expectedHelpPath | Should -BeLike "*Documents\PowerShell\Help" - } - - It "Update-Help with CurrentUser scope respects custom PSContentPath" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature and set custom path - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - $customPath = Join-Path $TestDrive "CustomPowerShell" - & $powershell -noprofile -command "Set-PSContentPath -Path '$customPath'" - - # Create the custom help directory - $customHelpPath = Join-Path $customPath "Help" - New-Item -Path $customHelpPath -ItemType Directory -Force -ErrorAction Ignore - - # Try to save help (using -WhatIf to avoid actual download) - $script = @" - `$ErrorActionPreference = 'SilentlyContinue' - `$WarningPreference = 'SilentlyContinue' - Save-Help -Module Microsoft.PowerShell.Management -DestinationPath '$customHelpPath' -Force -WhatIf 2>&1 | Out-Null - # Just verify the path would be used - '$customHelpPath' -"@ - $result = & $powershell -noprofile -command $script - - # Verify the custom help path exists - Test-Path $customHelpPath | Should -BeTrue - $result | Should -Be $customHelpPath + $result = Get-PSContentPath + $result | Should -Not -BeNullOrEmpty - # Clean up custom help directory - Remove-Item $customHelpPath -Recurse -Force -ErrorAction Ignore + # Config file should NOT be created just by calling Get-PSContentPath + Test-Path $documentsConfigPath | Should -Be $false } } - Context "Error handling" { - BeforeEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - It "Set-PSContentPath handles invalid paths gracefully" -Skip:$skipNoPwsh { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser' - - # Very long path - $longPath = "C:\\" + ("a" * 300) + Context "Set-PSContentPath custom path" { + It "Set-PSContentPath creates config with custom path" { + # Ensure no config exists + if (Test-Path $documentsConfigPath) { + Remove-Item $documentsConfigPath -Force + } - # Should not throw - just accept the path with a warning - $result = & $powershell -noprofile -command "try { Set-PSContentPath -Path '$longPath' -WarningAction SilentlyContinue; 'Success' } catch { 'Failed' }" - $result | Should -Be 'Success' - } + $customPath = if ($IsWindows) { "$env:TEMP\CustomPowerShell" } else { "/tmp/CustomPowerShell" } - It "Set-PSContentPath handles paths with special characters" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable feature - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' + Set-PSContentPath -Path $customPath -WarningAction SilentlyContinue - # Set path with spaces - & $powershell -noprofile -command "Set-PSContentPath -Path '$TestDrive\Path With Spaces' -WarningAction SilentlyContinue" + # Config file should now exist + Test-Path $documentsConfigPath | Should -Be $true - # Verify it worked - $result = & $powershell -noprofile -command 'Get-PSContentPath' - $result | Should -Be (Join-Path $TestDrive "Path With Spaces") + # Verify custom path is stored + $config = Get-Content $documentsConfigPath -Raw | ConvertFrom-Json + $config.PSUserContentPath | Should -Be $customPath } - } -} -Describe "PSContentPath experimental feature integration" -tags "Feature" { - BeforeAll { - if ($IsWindows) { - $powershell = "$PSHOME\pwsh.exe" - $documentsPath = [System.Environment]::GetFolderPath('MyDocuments') - $userConfigPath = Join-Path $documentsPath "PowerShell\powershell.config.json" - $newConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json" - } - else { - $powershell = "$PSHOME/pwsh" - $userConfigPath = "~/.config/powershell/powershell.config.json" - $newConfigPath = $userConfigPath - } + It "Set-PSContentPath expands environment variables on Windows" -Skip:(!$IsWindows) { + Set-PSContentPath -Path '%TEMP%\PowerShell' -WarningAction SilentlyContinue - # Backup original configs - if (Test-Path $userConfigPath) { - $script:userConfigBackup = Get-Content $userConfigPath -Raw + $result = Get-PSContentPath + $result | Should -Be "$env:TEMP\PowerShell" + $result | Should -Not -BeLike '*%TEMP%*' } - if ($IsWindows -and (Test-Path $newConfigPath)) { - $script:newConfigBackup = Get-Content $newConfigPath -Raw - } - - # Create clean test environment - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - BeforeEach { - # Remove all configs to ensure clean state before each test - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterEach { - # Clean up after each test to prevent contamination - Remove-Item $newConfigPath -Force -ErrorAction Ignore - Remove-Item $userConfigPath -Force -ErrorAction Ignore - } - - AfterAll { - # Disable the feature - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' 2>$null - # Remove test configs - Remove-Item $userConfigPath -Force -ErrorAction Ignore - if ($IsWindows) { - Remove-Item $newConfigPath -Force -ErrorAction Ignore + It "Set-PSContentPath validates path input" { + { Set-PSContentPath -Path '' -WarningAction SilentlyContinue -ErrorAction Stop } | Should -Throw } - # Restore original configs - if ($null -ne $script:userConfigBackup) { - New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $script:userConfigBackup -Force - } - if ($IsWindows -and ($null -ne $script:newConfigBackup)) { - New-Item -Path (Split-Path $newConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $newConfigPath -Value $script:newConfigBackup -Force - } - } - - It "Config file migration preserves all settings" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Verify clean state from BeforeEach - Test-Path $newConfigPath | Should -BeFalse "LocalAppData config should not exist before test" - Test-Path $userConfigPath | Should -BeFalse "Documents config should not exist before test" - - # Create a config with multiple settings in old location (Documents) - $originalModulePath = "C:\\CustomModules" - $config = @{ - ExperimentalFeatures = @("PSContentPath") - PSModulePath = $originalModulePath - "Microsoft.PowerShell:ExecutionPolicy" = "RemoteSigned" - } | ConvertTo-Json - - New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $config -Force - - # Verify the original config was written correctly - $originalConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json - $originalConfig.PSModulePath | Should -Be $originalModulePath -Because "Original config should have PSModulePath" - $originalConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "Original config should have PSContentPath enabled" - - # Trigger migration by accessing config in a new PowerShell session - & $powershell -noprofile -command 'Get-PSContentPath' | Out-Null - - # After migration, BOTH configs should exist - Test-Path $newConfigPath | Should -BeTrue "LocalAppData config should exist after migration" - Test-Path $userConfigPath | Should -BeTrue "Documents config should still exist after migration" - - # Parse configs - $newConfig = Get-Content $newConfigPath -Raw | ConvertFrom-Json - $docConfig = Get-Content $userConfigPath -Raw | ConvertFrom-Json - - # Verify new LocalAppData config has all settings (should be exact copy) - $newConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "New config should have PSContentPath enabled" - $newConfig.PSModulePath | Should -Be $originalModulePath -Because "New config should have PSModulePath" - $newConfig."Microsoft.PowerShell:ExecutionPolicy" | Should -Be "RemoteSigned" -Because "New config should have ExecutionPolicy" + It "Set-PSContentPath supports WhatIf" { + if (Test-Path $documentsConfigPath) { + Remove-Item $documentsConfigPath -Force + } - # Verify original Documents config still has all settings (bidirectional sync) - $docConfig.ExperimentalFeatures | Should -Contain "PSContentPath" -Because "Doc config should have PSContentPath enabled" - $docConfig.PSModulePath | Should -Be $originalModulePath -Because "Doc config should have PSModulePath" - $docConfig."Microsoft.PowerShell:ExecutionPolicy" | Should -Be "RemoteSigned" -Because "Doc config should have ExecutionPolicy" - } + $customPath = if ($IsWindows) { "$env:TEMP\TestPath" } else { "/tmp/TestPath" } - It "Re-enabling feature after disable syncs correctly" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Enable, disable, then re-enable to test sync behavior - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null - & $powershell -noprofile -command 'Disable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null - & $powershell -noprofile -command 'Enable-ExperimentalFeature -Name PSContentPath -Scope CurrentUser -WarningAction Ignore' | Out-Null + Set-PSContentPath -Path $customPath -WhatIf - # Verify config files have the feature enabled - $configFound = $false - if (Test-Path $newConfigPath) { - $config = Get-Content $newConfigPath -Raw | ConvertFrom-Json - $configFound = $config.ExperimentalFeatures -contains "PSContentPath" + # Config file should NOT be created with -WhatIf + Test-Path $documentsConfigPath | Should -Be $false } - if (!$configFound -and (Test-Path $userConfigPath)) { - $config = Get-Content $userConfigPath -Raw | ConvertFrom-Json - $configFound = $config.ExperimentalFeatures -contains "PSContentPath" - } - - $configFound | Should -BeTrue - } - - It "Bootstrap problem is solved - reads from both locations" -Skip:(!$IsWindows -or $skipNoPwsh) { - # Create config only in Documents with PSContentPath enabled - $docConfig = @{ ExperimentalFeatures = @("PSContentPath") } | ConvertTo-Json - New-Item -Path (Split-Path $userConfigPath) -ItemType Directory -Force -ErrorAction Ignore - Set-Content -Path $userConfigPath -Value $docConfig - - # LocalAppData config should not exist (removed by BeforeEach) - Test-Path $newConfigPath | Should -BeFalse - - # PowerShell should still detect the feature is enabled (reads from both locations) - $featureEnabled = & $powershell -noprofile -command '(Get-ExperimentalFeature -Name PSContentPath).Enabled' - $featureEnabled | Should -Be 'True' } } From f01f00f96446066baa620b5529ebedc50ca64791 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:41:55 -0600 Subject: [PATCH 17/22] Switch default PSContentPath variable to OneDrive --- .../CoreCLR/CorePsPlatform.cs | 5 ++++- .../engine/PSConfiguration.cs | 11 ++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs index c41e49c896c..41df2886708 100644 --- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs +++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs @@ -168,7 +168,10 @@ public static bool IsStaSupported internal static readonly string DefaultPSContentDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); #else // Gets the location for cache and config folders. - internal static readonly string DefaultPSContentDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\PowerShell"; + // TODO: Future PR will change this to LocalAppData\PowerShell when we make that the default + internal static readonly string DefaultPSContentDirectory = SafeDeriveFromSpecialFolder( + Environment.SpecialFolder.Personal, + @"PowerShell"); internal static readonly string CacheDirectory = SafeDeriveFromSpecialFolder( Environment.SpecialFolder.LocalApplicationData, diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index e95a3d26b35..1e04cac4c2b 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -160,13 +160,10 @@ internal string GetPSContentPath() return contentPath; } - // Return default OneDrive location (Documents\PowerShell on Windows, XDG on Unix) -#if UNIX - return Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA); -#else - // Future Experimental Feature will make the default LocalAppData\PowerShell instead - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "PowerShell"); -#endif + // Return default location using Platform.DefaultPSContentDirectory + // - Windows: Documents\PowerShell (OneDrive location) + // - Unix: XDG_DATA_HOME/powershell (~/.local/share/powershell) + return Platform.DefaultPSContentDirectory; } /// From b56721854186395d7ae52dbf0f06686604b45bf6 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:46:26 -0600 Subject: [PATCH 18/22] Remove old PSUserContentPath code --- .../engine/InitialSessionState.cs | 16 +--------- .../engine/PSConfiguration.cs | 31 ++++--------------- .../engine/Utils.cs | 1 - .../Get-ExperimentalFeature.Tests.ps1 | 4 +-- 4 files changed, 8 insertions(+), 44 deletions(-) diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs index b237bc82326..72fa4df1ceb 100644 --- a/src/System.Management.Automation/engine/InitialSessionState.cs +++ b/src/System.Management.Automation/engine/InitialSessionState.cs @@ -1599,7 +1599,6 @@ public static InitialSessionState CreateDefault2() ss.Commands.Add(BuiltInAliases); ss.ImportCorePSSnapIn(); - ss.LanguageMode = PSLanguageMode.FullLanguage; ss.AuthorizationManager = new Microsoft.PowerShell.PSAuthorizationManager(Utils.DefaultPowerShellShellID); @@ -5250,23 +5249,10 @@ internal static void AnalyzePSSnapInAssembly( } } - // Exclude dynamically registered cmdlets (registered based on experimental features) - var dynamicCmdlets = new HashSet(StringComparer.OrdinalIgnoreCase) - { - // No dynamic cmdlets currently - }; + Diagnostics.Assert(cmdletsCheck.Count == cmdlets.Count, "new Cmdlet added to System.Management.Automation.dll - update InitializeCoreCmdletsAndProviders"); - var expectedCmdletCount = cmdletsCheck.Count - cmdletsCheck.Keys.Count(key => dynamicCmdlets.Contains(key)); - Diagnostics.Assert(expectedCmdletCount == cmdlets.Count, "new Cmdlet added to System.Management.Automation.dll - update InitializeCoreCmdletsAndProviders"); - foreach (var pair in cmdletsCheck) { - // Skip dynamic cmdlets - they are registered conditionally based on experimental features - if (dynamicCmdlets.Contains(pair.Key)) - { - continue; - } - SessionStateCmdletEntry other; if (cmdlets.TryGetValue(pair.Key, out other)) { diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs index 1e04cac4c2b..cca8858b4d8 100644 --- a/src/System.Management.Automation/engine/PSConfiguration.cs +++ b/src/System.Management.Automation/engine/PSConfiguration.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Management.Automation.Internal; using System.Text; using System.Threading; @@ -63,8 +62,8 @@ internal sealed class PowerShellConfig private string systemWideConfigDirectory; // The json file containing the per-user configuration settings. - private string perUserConfigFile; - private string perUserConfigDirectory; + private readonly string perUserConfigFile; + private readonly string perUserConfigDirectory; // Note: JObject and JsonSerializer are thread safe. // Root Json objects corresponding to the configuration file for 'AllUsers' and 'CurrentUser' respectively. @@ -224,35 +223,17 @@ private static string GetExecutionPolicySettingKey(string shellId) /// /// Get the names of experimental features enabled in the config file. - /// - /// BOOTSTRAP SOLUTION: - /// Always read from the Documents location first (Platform.ConfigDirectory). - /// This is the canonical source of truth for experimental features on startup. - /// We NEVER read from LocalAppData to determine enabled features because: - /// - LocalAppData configs are created BY the PSContentPath feature - /// - Reading from LocalAppData creates circular dependency - /// - Stale LocalAppData configs would contaminate fresh sessions - /// - /// The flow: - /// 1. Read experimental features from Documents config (this method) - /// 2. If PSContentPath is enabled, migration happens later in GetPSContentPath() - /// 3. Migration switches perUserConfigFile to LocalAppData - /// 4. Future writes update both Documents and LocalAppData (bidirectional sync) - /// - /// This ensures clean separation: Documents = source of truth, LocalAppData = migrated location. /// internal string[] GetExperimentalFeatures() { - // Always read from the current location (Documents on startup, LocalAppData after migration) - string[] currentFeatures = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); + string[] features = ReadValueFromFile(ConfigScope.CurrentUser, "ExperimentalFeatures", Array.Empty()); - // If no features in current user config, check system-wide config - if (currentFeatures.Length == 0) + if (features.Length == 0) { - return ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); + features = ReadValueFromFile(ConfigScope.AllUsers, "ExperimentalFeatures", Array.Empty()); } - return currentFeatures; + return features; } /// diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index b8b149e1e10..061a999e997 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -17,7 +17,6 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Security; -using System.Text.Json; #if !UNIX using System.Security.Principal; #endif diff --git a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 index 71d8fc0af45..0c6f0d31195 100644 --- a/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 +++ b/test/powershell/engine/ExperimentalFeature/Get-ExperimentalFeature.Tests.ps1 @@ -192,9 +192,7 @@ Describe "Default enablement of Experimental Features" -Tags CI { # is launched from another pwsh (with $PSHOME like C:\program files\powershell\7) # resulting in combined PSModulePath which is used by Get-ExperimentalFeature to enum module-scoped exp.features from both pwsh locations. # So we need to exclude parent's modules' exp.features from verification using filtering on $PSHOME. - # Also exclude PSContentPath as it's intentionally not auto-enabled in preview builds (see build.psm1 line 714) - if ((($expFeature.Source -eq 'PSEngine') -or ($expFeature.Source.StartsWith($PSHOME, "InvariantCultureIgnoreCase"))) -and - ($expFeature.Name -ne 'PSContentPath')) + if (($expFeature.Source -eq 'PSEngine') -or ($expFeature.Source.StartsWith($PSHOME, "InvariantCultureIgnoreCase"))) { "Checking $($expFeature.Name) experimental feature" | Write-Verbose -Verbose $expFeature.Enabled | Should -BeEnabled -Name $expFeature.Name From c70f885073846a1c8b99f55b0bbe6e36d5610cc7 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:07:33 -0600 Subject: [PATCH 19/22] Fix help test and add Get/Set to cmdlet test --- .../engine/Basic/DefaultCommands.Tests.ps1 | 2 ++ .../engine/Help/HelpSystem.Tests.ps1 | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 index 7a757e6eac4..4b08279a993 100644 --- a/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 +++ b/test/powershell/engine/Basic/DefaultCommands.Tests.ps1 @@ -330,6 +330,7 @@ Describe "Verify aliases and cmdlets" -Tags "CI" { "Cmdlet", "Get-Process", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-PSBreakpoint", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-PSCallStack", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" +"Cmdlet", "Get-PSContentPath", "", $( $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-PSDrive", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-PSHostProcessInfo", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Get-PSProvider", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" @@ -475,6 +476,7 @@ Describe "Verify aliases and cmdlets" -Tags "CI" { "Cmdlet", "Set-Location", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Set-MarkdownOption", "", $( $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Set-PSBreakpoint", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" +"Cmdlet", "Set-PSContentPath", "", $( $CoreWindows -or $CoreUnix), "", "", "Medium" "Cmdlet", "Set-PSDebug", "", $($FullCLR -or $CoreWindows -or $CoreUnix), "", "", "None" "Cmdlet", "Set-PSSessionConfiguration", "", $($FullCLR -or $CoreWindows ), "", "", "Medium" "Cmdlet", "Set-Service", "", $($FullCLR -or $CoreWindows ), "", "", "Medium" diff --git a/test/powershell/engine/Help/HelpSystem.Tests.ps1 b/test/powershell/engine/Help/HelpSystem.Tests.ps1 index a17394c1d9f..3dfb50c5ead 100644 --- a/test/powershell/engine/Help/HelpSystem.Tests.ps1 +++ b/test/powershell/engine/Help/HelpSystem.Tests.ps1 @@ -17,7 +17,9 @@ $script:cmdletsToSkip = @( "Enable-ExperimentalFeature", "Disable-ExperimentalFeature", "Get-PSSubsystem", - "Switch-Process" + "Switch-Process", + "Get-PSContentPath", # New cmdlet - help content not yet generated + "Set-PSContentPath" # New cmdlet - help content not yet generated ) function UpdateHelpFromLocalContentPath { @@ -35,13 +37,25 @@ function UpdateHelpFromLocalContentPath { } function GetCurrentUserHelpRoot { - if ([System.Management.Automation.Platform]::IsWindows) { - $userHelpRoot = Join-Path $HOME "Documents/PowerShell/Help/" - } else { - $userModulesRoot = [System.Management.Automation.Platform]::SelectProductNameForDirectory([System.Management.Automation.Platform+XDG_Type]::USER_MODULES) - $userHelpRoot = Join-Path $userModulesRoot -ChildPath ".." -AdditionalChildPath "Help" + # Try to get the actual configured PSContentPath + $contentPath = $null + try { + $contentPath = Get-PSContentPath -ErrorAction SilentlyContinue + } catch { + # Get-PSContentPath might not exist in older builds + } + + # Fall back to default if not configured + if ([string]::IsNullOrEmpty($contentPath)) { + if ([System.Management.Automation.Platform]::IsWindows) { + $contentPath = Join-Path $HOME "Documents/PowerShell" + } else { + $userModulesRoot = [System.Management.Automation.Platform]::SelectProductNameForDirectory([System.Management.Automation.Platform+XDG_Type]::USER_MODULES) + $contentPath = Join-Path $userModulesRoot -ChildPath ".." + } } + $userHelpRoot = Join-Path $contentPath "Help" return $userHelpRoot } From 1bc5dbcb64d2dbe34716f96097cc7951f51b0b12 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:18:46 -0600 Subject: [PATCH 20/22] Update tab completion tests with PSContentPath resolution --- .../TabCompletion/TabCompletion.Tests.ps1 | 42 ++++++++++++------- .../engine/Help/HelpSystem.Tests.ps1 | 2 +- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index f8762a63929..8d64e8ffcc4 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -51,7 +51,7 @@ Describe "TabCompletion" -Tags CI { New-ModuleManifest -Path "$($NewDir.FullName)\$ModuleName.psd1" -RootModule "$ModuleName.psm1" -FunctionsToExport "MyTestFunction" -ModuleVersion $NewDir.Name } - $env:PSModulePath += [System.IO.Path]::PathSeparator + $tempDir + $env:PSModulePath += [System.IO.Path]::PathSeparator + $tempDir $Res = TabExpansion2 -inputScript MyTestFunction $Res.CompletionMatches.Count | Should -Be 2 $SortedMatches = $Res.CompletionMatches.CompletionText | Sort-Object @@ -82,7 +82,7 @@ Describe "TabCompletion" -Tags CI { New-ModuleManifest -Path "$($NewDir.FullName)\$ModuleName.psd1" -RootModule "$ModuleName.psm1" -FunctionsToExport "MyTestFunction" -ModuleVersion $NewDir.Name } - $env:PSModulePath += [System.IO.Path]::PathSeparator + $tempDir + $env:PSModulePath += [System.IO.Path]::PathSeparator + $tempDir $Res = TabExpansion2 -inputScript 'Import-Module -Name TestModule' $Res.CompletionMatches.Count | Should -Be 1 $Res.CompletionMatches[0].CompletionText | Should -Be TestModule1 @@ -165,21 +165,21 @@ Describe "TabCompletion" -Tags CI { $res = TabExpansion2 -inputScript 'param($PS = $P' $res.CompletionMatches.Count | Should -BeGreaterThan 0 } - + It 'Should complete variable with description and value ' -TestCases @( @{ Value = 1; Expected = '[int]$VariableWithDescription - Variable description' } @{ Value = 'string'; Expected = '[string]$VariableWithDescription - Variable description' } @{ Value = $null; Expected = 'VariableWithDescription - Variable description' } ) { param ($Value, $Expected) - + New-Variable -Name VariableWithDescription -Value $Value -Description 'Variable description' -Force $res = TabExpansion2 -inputScript '$VariableWithDescription' $res.CompletionMatches.Count | Should -Be 1 $res.CompletionMatches[0].CompletionText | Should -BeExactly '$VariableWithDescription' $res.CompletionMatches[0].ToolTip | Should -BeExactly $Expected } - + It 'Should complete environment variable' { try { $env:PWSH_TEST_1 = 'value 1' @@ -226,7 +226,7 @@ Describe "TabCompletion" -Tags CI { @{ Value = $null; Expected = 'VariableWithDescription - Variable description' } ) { param ($Value, $Expected) - + New-Variable -Name VariableWithDescription -Value $Value -Description 'Variable description' -Force $res = TabExpansion2 -inputScript '$local:VariableWithDescription' $res.CompletionMatches.Count | Should -Be 1 @@ -1191,7 +1191,7 @@ param([ValidatePattern( [Parameter(ParameterSetName = 'SetWithoutHelp')] [string] $ParamWithHelp, - + [Parameter(ParameterSetName = 'SetWithHelp')] [switch] $ParamWithoutHelp @@ -1733,7 +1733,7 @@ param([ValidatePattern( $commaSeparators = "',' ', '" $semiColonSeparators = "';' '; '" - + $squareBracketFormatString = "'[{0}]'" $curlyBraceFormatString = "'{0:N2}'" } @@ -2408,7 +2408,7 @@ param ($Param1) $null = New-Item -Path $TestFile $res = TabExpansion2 -ast $scriptAst -tokens $tokens -positionOfCursor $cursorPosition Pop-Location - + $ExpectedPath = Join-Path -Path '.\' -ChildPath $ExpectedFileName $res.CompletionMatches.CompletionText | Where-Object {$_ -Like "*$ExpectedFileName"} | Should -Be $ExpectedPath } @@ -3563,12 +3563,26 @@ dir -Recurse ` Context "Tab completion help test" { BeforeAll { New-Item -ItemType File (Join-Path ${TESTDRIVE} "pwsh.xml") - if ($IsWindows) { - $userHelpRoot = Join-Path $HOME "Documents/PowerShell/Help/" - } else { - $userModulesRoot = [System.Management.Automation.Platform]::SelectProductNameForDirectory([System.Management.Automation.Platform+XDG_Type]::USER_MODULES) - $userHelpRoot = Join-Path $userModulesRoot -ChildPath ".." -AdditionalChildPath "Help" + + # Try to get the actual configured PSContentPath + $contentPath = $null + try { + $contentPath = Get-PSContentPath -ErrorAction SilentlyContinue + } catch { + # Get-PSContentPath might not exist in older builds } + + # Fall back to default if not configured + if ([string]::IsNullOrEmpty($contentPath)) { + if ($IsWindows) { + $contentPath = Join-Path $HOME "Documents/PowerShell" + } else { + $userModulesRoot = [System.Management.Automation.Platform]::SelectProductNameForDirectory([System.Management.Automation.Platform+XDG_Type]::USER_MODULES) + $contentPath = Join-Path $userModulesRoot -ChildPath ".." + } + } + + $userHelpRoot = Join-Path $contentPath "Help" } It 'Should complete about help topic' { diff --git a/test/powershell/engine/Help/HelpSystem.Tests.ps1 b/test/powershell/engine/Help/HelpSystem.Tests.ps1 index 3dfb50c5ead..a678311c0b7 100644 --- a/test/powershell/engine/Help/HelpSystem.Tests.ps1 +++ b/test/powershell/engine/Help/HelpSystem.Tests.ps1 @@ -42,7 +42,7 @@ function GetCurrentUserHelpRoot { try { $contentPath = Get-PSContentPath -ErrorAction SilentlyContinue } catch { - # Get-PSContentPath might not exist in older builds + Write-Warning "PSContentPath is not available: $_" } # Fall back to default if not configured From 2cefe89257952e8213b84cd14e8b94bca4e461e5 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:37:06 -0600 Subject: [PATCH 21/22] Add warning message in tabcompletion tests --- test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index 8d64e8ffcc4..95b6e33269f 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -3570,6 +3570,7 @@ dir -Recurse ` $contentPath = Get-PSContentPath -ErrorAction SilentlyContinue } catch { # Get-PSContentPath might not exist in older builds + Write-Warning "PSContentPath is not available: $_" } # Fall back to default if not configured From c639721f1a1dee3c033dc1400df98773372bdc40 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:59:54 -0600 Subject: [PATCH 22/22] Remove changes to build script --- build.psm1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.psm1 b/build.psm1 index cc44c436e61..dd2cf0f351e 100644 --- a/build.psm1 +++ b/build.psm1 @@ -711,7 +711,7 @@ Fix steps: if ((Test-ShouldGenerateExperimentalFeatures -Runtime $Options.Runtime)) { Write-Verbose "Build experimental feature list by running 'Get-ExperimentalFeature'" -Verbose $json = & $publishPath\pwsh -noprofile -command { - $expFeatures = Get-ExperimentalFeature | Where-Object { $_.Name -ne 'PSContentPath' } | ForEach-Object -MemberName Name + $expFeatures = Get-ExperimentalFeature | ForEach-Object -MemberName Name ConvertTo-Json $expFeatures } } else {