From 5f00798223cf8c2072f26b62eb764cd2a686b836 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:10:28 -0600 Subject: [PATCH 1/7] Add experimental feature check and add PSContentPath to standard platform paths --- src/code/Utils.cs | 147 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 4 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index ae540f9e4..4570bbf84 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1049,14 +1049,14 @@ public static List GetPathsFromEnvVarAndScope( { GetStandardPlatformPaths( psCmdlet, - out string myDocumentsPath, + out string psUserContentPath, out string programFilesPath); List resourcePaths = new List(); if (scope is null || scope.Value is ScopeType.CurrentUser) { - resourcePaths.Add(Path.Combine(myDocumentsPath, "Modules")); - resourcePaths.Add(Path.Combine(myDocumentsPath, "Scripts")); + resourcePaths.Add(Path.Combine(psUserContentPath, "Modules")); + resourcePaths.Add(Path.Combine(psUserContentPath, "Scripts")); } if (scope.Value is ScopeType.AllUsers) @@ -1156,6 +1156,112 @@ private static string GetHomeOrCreateTempHome() } private readonly static Version PSVersion6 = new Version(6, 0); + + /// + /// Checks if a PowerShell experimental feature is enabled by reading the PowerShell configuration file. + /// Returns false if the configuration file doesn't exist or if the feature is not enabled. + /// + private static bool IsExperimentalFeatureEnabled(PSCmdlet psCmdlet, string featureName) + { + try + { + // PowerShell configuration file location + string configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "powershell", + "powershell.config.json" + ); + + if (!File.Exists(configPath)) + { + psCmdlet.WriteVerbose("PowerShell configuration file not found, experimental features not enabled"); + return false; + } + + string jsonContent = File.ReadAllText(configPath); + + // Parse JSON to check for experimental features + // Look for "ExperimentalFeatures": ["FeatureName"] in the config + if (jsonContent.Contains($"\"{featureName}\"")) + { + psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); + return true; + } + + psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' not found in configuration file", featureName)); + return false; + } + catch (Exception ex) + { + psCmdlet.WriteVerbose(string.Format("Error reading PowerShell configuration file: {0}", ex.Message)); + return false; + } + } + + /// + /// Gets the custom PSUserContentPath from environment variable or PowerShell configuration file. + /// Environment variable takes precedence over the configuration file setting. + /// Returns null if neither is set or configured. + /// + private static string GetPSUserContentPath(PSCmdlet psCmdlet) + { + try + { + // First check the environment variable (takes precedence) + string envPSUserContentPath = Environment.GetEnvironmentVariable("PSUserContentPath"); + if (!string.IsNullOrEmpty(envPSUserContentPath)) + { + psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath from environment variable: {0}", envPSUserContentPath)); + return envPSUserContentPath; + } + + // If environment variable not set, check the configuration file + string configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "powershell", + "powershell.config.json" + ); + + if (!File.Exists(configPath)) + { + psCmdlet.WriteVerbose("PowerShell configuration file not found"); + return null; + } + + string jsonContent = File.ReadAllText(configPath); + + // Simple JSON parsing to find PSUserContentPath + // Format: "PSUserContentPath": "C:\\CustomPath" + int userPathIndex = jsonContent.IndexOf("\"PSUserContentPath\"", StringComparison.OrdinalIgnoreCase); + if (userPathIndex >= 0) + { + int colonIndex = jsonContent.IndexOf(':', userPathIndex); + if (colonIndex >= 0) + { + int firstQuote = jsonContent.IndexOf('"', colonIndex + 1); + int secondQuote = jsonContent.IndexOf('"', firstQuote + 1); + + if (firstQuote >= 0 && secondQuote > firstQuote) + { + string customPath = jsonContent.Substring(firstQuote + 1, secondQuote - firstQuote - 1); + // Unescape JSON string (handle \\) + customPath = customPath.Replace("\\\\", "\\"); + psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", customPath)); + return customPath; + } + } + } + + psCmdlet.WriteVerbose("PSUserContentPath not configured in PowerShell configuration file or environment variable"); + return null; + } + catch (Exception ex) + { + psCmdlet.WriteVerbose(string.Format("Error reading PSUserContentPath: {0}", ex.Message)); + return null; + } + } + private static void GetStandardPlatformPaths( PSCmdlet psCmdlet, out string localUserDir, @@ -1164,7 +1270,40 @@ private static void GetStandardPlatformPaths( if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + + // Check if PSContentPath experimental feature is enabled + bool usePSContentPath = IsExperimentalFeatureEnabled(psCmdlet, "PSContentPath"); + + if (usePSContentPath) + { + psCmdlet.WriteVerbose("PSContentPath experimental feature is enabled"); + + // Check environment variable and config file for custom PSUserContentPath + string customPSUserContentPath = GetPSUserContentPath(psCmdlet); + + if (!string.IsNullOrEmpty(customPSUserContentPath) && Directory.Exists(customPSUserContentPath)) + { + // Use custom configured path + localUserDir = customPSUserContentPath; + psCmdlet.WriteVerbose($"Using custom PSUserContentPath: {localUserDir}"); + } + else + { + // Use default LocalApplicationData location when PSContentPath is enabled + localUserDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + powerShellType + ); + psCmdlet.WriteVerbose($"Using default PSContentPath location: {localUserDir}"); + } + } + else + { + // PSContentPath not enabled, use legacy Documents folder + localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + psCmdlet.WriteVerbose($"Using legacy Documents folder: {localUserDir}"); + } + allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); } else From 7d033768ea063f0e05d9fdaa6b137e8b54276a7a Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:07:26 -0600 Subject: [PATCH 2/7] Use Json.net to parse instead --- src/code/Utils.cs | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 4570bbf84..0c9993ce6 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1179,13 +1179,20 @@ private static bool IsExperimentalFeatureEnabled(PSCmdlet psCmdlet, string featu } string jsonContent = File.ReadAllText(configPath); + var config = Newtonsoft.Json.Linq.JObject.Parse(jsonContent); - // Parse JSON to check for experimental features // Look for "ExperimentalFeatures": ["FeatureName"] in the config - if (jsonContent.Contains($"\"{featureName}\"")) + var experimentalFeatures = config["ExperimentalFeatures"] as Newtonsoft.Json.Linq.JArray; + if (experimentalFeatures != null) { - psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); - return true; + foreach (var feature in experimentalFeatures) + { + if (string.Equals(feature.ToString(), featureName, StringComparison.OrdinalIgnoreCase)) + { + psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); + return true; + } + } } psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' not found in configuration file", featureName)); @@ -1229,27 +1236,14 @@ private static string GetPSUserContentPath(PSCmdlet psCmdlet) } string jsonContent = File.ReadAllText(configPath); + var config = Newtonsoft.Json.Linq.JObject.Parse(jsonContent); - // Simple JSON parsing to find PSUserContentPath - // Format: "PSUserContentPath": "C:\\CustomPath" - int userPathIndex = jsonContent.IndexOf("\"PSUserContentPath\"", StringComparison.OrdinalIgnoreCase); - if (userPathIndex >= 0) + // Look for PSUserContentPath in the config + var psUserContentPath = config["PSUserContentPath"]?.ToString(); + if (!string.IsNullOrEmpty(psUserContentPath)) { - int colonIndex = jsonContent.IndexOf(':', userPathIndex); - if (colonIndex >= 0) - { - int firstQuote = jsonContent.IndexOf('"', colonIndex + 1); - int secondQuote = jsonContent.IndexOf('"', firstQuote + 1); - - if (firstQuote >= 0 && secondQuote > firstQuote) - { - string customPath = jsonContent.Substring(firstQuote + 1, secondQuote - firstQuote - 1); - // Unescape JSON string (handle \\) - customPath = customPath.Replace("\\\\", "\\"); - psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", customPath)); - return customPath; - } - } + psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", psUserContentPath)); + return psUserContentPath; } psCmdlet.WriteVerbose("PSUserContentPath not configured in PowerShell configuration file or environment variable"); From bbb71488df2a2016e821176249161db06a358aff Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:29:10 -0600 Subject: [PATCH 3/7] Add support in Linux, MacOS and refactor --- src/code/Utils.cs | 132 ++++++++++++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 46 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 0c9993ce6..14ce9a6c1 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -22,6 +22,7 @@ using Azure.Identity; using System.Text.RegularExpressions; using System.Threading; +using System.Text.Json; using System.Threading.Tasks; using System.Xml; @@ -1157,6 +1158,42 @@ private static string GetHomeOrCreateTempHome() private readonly static Version PSVersion6 = new Version(6, 0); + /// + /// Gets the user content directory path based on PSContentPath experimental feature settings. + /// Checks if PSContentPath is enabled and returns the appropriate path (custom, default, or legacy). + /// + private static string GetUserContentPath(PSCmdlet psCmdlet, string defaultPSContentPath, string legacyPath) + { + bool usePSContentPath = IsExperimentalFeatureEnabled(psCmdlet, "PSContentPath"); + + if (usePSContentPath) + { + psCmdlet.WriteVerbose("PSContentPath experimental feature is enabled"); + + // Check environment variable and config file for custom PSUserContentPath + string customPSUserContentPath = GetPSUserContentPath(psCmdlet); + + if (!string.IsNullOrEmpty(customPSUserContentPath) && Directory.Exists(customPSUserContentPath)) + { + // Use custom configured path + psCmdlet.WriteVerbose($"Using custom PSUserContentPath: {customPSUserContentPath}"); + return customPSUserContentPath; + } + else + { + // Use default PSContentPath location when feature is enabled + psCmdlet.WriteVerbose($"Using default PSContentPath location: {defaultPSContentPath}"); + return defaultPSContentPath; + } + } + else + { + // PSContentPath not enabled, use legacy location + psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}"); + return legacyPath; + } + } + /// /// Checks if a PowerShell experimental feature is enabled by reading the PowerShell configuration file. /// Returns false if the configuration file doesn't exist or if the feature is not enabled. @@ -1179,18 +1216,19 @@ private static bool IsExperimentalFeatureEnabled(PSCmdlet psCmdlet, string featu } string jsonContent = File.ReadAllText(configPath); - var config = Newtonsoft.Json.Linq.JObject.Parse(jsonContent); - - // Look for "ExperimentalFeatures": ["FeatureName"] in the config - var experimentalFeatures = config["ExperimentalFeatures"] as Newtonsoft.Json.Linq.JArray; - if (experimentalFeatures != null) + using (var jsonDoc = JsonDocument.Parse(jsonContent)) { - foreach (var feature in experimentalFeatures) + // Look for "ExperimentalFeatures": ["FeatureName"] in the config + if (jsonDoc.RootElement.TryGetProperty("ExperimentalFeatures", out var experimentalFeatures) && + experimentalFeatures.ValueKind == JsonValueKind.Array) { - if (string.Equals(feature.ToString(), featureName, StringComparison.OrdinalIgnoreCase)) + foreach (var feature in experimentalFeatures.EnumerateArray()) { - psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); - return true; + if (string.Equals(feature.GetString(), featureName, StringComparison.OrdinalIgnoreCase)) + { + psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); + return true; + } } } } @@ -1236,14 +1274,18 @@ private static string GetPSUserContentPath(PSCmdlet psCmdlet) } string jsonContent = File.ReadAllText(configPath); - var config = Newtonsoft.Json.Linq.JObject.Parse(jsonContent); - - // Look for PSUserContentPath in the config - var psUserContentPath = config["PSUserContentPath"]?.ToString(); - if (!string.IsNullOrEmpty(psUserContentPath)) + using (var jsonDoc = JsonDocument.Parse(jsonContent)) { - psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", psUserContentPath)); - return psUserContentPath; + // Look for PSUserContentPath in the config + if (jsonDoc.RootElement.TryGetProperty("PSUserContentPath", out var pathElement)) + { + string psUserContentPath = pathElement.GetString(); + if (!string.IsNullOrEmpty(psUserContentPath)) + { + psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", psUserContentPath)); + return psUserContentPath; + } + } } psCmdlet.WriteVerbose("PSUserContentPath not configured in PowerShell configuration file or environment variable"); @@ -1265,37 +1307,25 @@ private static void GetStandardPlatformPaths( { string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; - // Check if PSContentPath experimental feature is enabled - bool usePSContentPath = IsExperimentalFeatureEnabled(psCmdlet, "PSContentPath"); - - if (usePSContentPath) + // Windows PowerShell doesn't support experimental features or PSContentPath + if (powerShellType == "WindowsPowerShell") { - psCmdlet.WriteVerbose("PSContentPath experimental feature is enabled"); - - // Check environment variable and config file for custom PSUserContentPath - string customPSUserContentPath = GetPSUserContentPath(psCmdlet); - - if (!string.IsNullOrEmpty(customPSUserContentPath) && Directory.Exists(customPSUserContentPath)) - { - // Use custom configured path - localUserDir = customPSUserContentPath; - psCmdlet.WriteVerbose($"Using custom PSUserContentPath: {localUserDir}"); - } - else - { - // Use default LocalApplicationData location when PSContentPath is enabled - localUserDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - powerShellType - ); - psCmdlet.WriteVerbose($"Using default PSContentPath location: {localUserDir}"); - } + // Use legacy Documents folder for Windows PowerShell + localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); + psCmdlet.WriteVerbose($"Using Windows PowerShell Documents folder: {localUserDir}"); } else { - // PSContentPath not enabled, use legacy Documents folder - localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType); - psCmdlet.WriteVerbose($"Using legacy Documents folder: {localUserDir}"); + string defaultPSContentPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + powerShellType + ); + string legacyPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + powerShellType + ); + + localUserDir = GetUserContentPath(psCmdlet, defaultPSContentPath, legacyPath); } allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); @@ -1303,14 +1333,24 @@ private static void GetStandardPlatformPaths( else { // paths are the same for both Linux and macOS - localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); - // Create the default data directory if it doesn't exist. + string xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); + if (string.IsNullOrEmpty(xdgDataHome)) + { + xdgDataHome = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share"); + } + + string defaultPSContentPath = Path.Combine(xdgDataHome, "powershell"); + string legacyPath = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); + + localUserDir = GetUserContentPath(psCmdlet, defaultPSContentPath, legacyPath); + + // Create the default data directory if it doesn't exist if (!Directory.Exists(localUserDir)) { Directory.CreateDirectory(localUserDir); } - allUsersDir = System.IO.Path.Combine("/usr", "local", "share", "powershell"); + allUsersDir = Path.Combine("/usr", "local", "share", "powershell"); } } From d53dd080e74b8124996ec71164fd0015b45c8555 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 20 Nov 2025 12:47:36 -0600 Subject: [PATCH 4/7] Use PowerShell GetPSModulePath API instead --- src/code/Utils.cs | 180 ++++++++++++---------------------------------- 1 file changed, 46 insertions(+), 134 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 14ce9a6c1..5fb5ba724 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1157,145 +1157,62 @@ private static string GetHomeOrCreateTempHome() } private readonly static Version PSVersion6 = new Version(6, 0); + private readonly static Version PSVersion7_7 = new Version(7, 7, 0); /// - /// Gets the user content directory path based on PSContentPath experimental feature settings. - /// Checks if PSContentPath is enabled and returns the appropriate path (custom, default, or legacy). + /// Gets the user content directory path using PowerShell's GetPSModulePath API. + /// Falls back to legacy path if the API is not available or PowerShell version is below 7.7.0. /// - private static string GetUserContentPath(PSCmdlet psCmdlet, string defaultPSContentPath, string legacyPath) + private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath) { - bool usePSContentPath = IsExperimentalFeatureEnabled(psCmdlet, "PSContentPath"); - - if (usePSContentPath) - { - psCmdlet.WriteVerbose("PSContentPath experimental feature is enabled"); - - // Check environment variable and config file for custom PSUserContentPath - string customPSUserContentPath = GetPSUserContentPath(psCmdlet); - - if (!string.IsNullOrEmpty(customPSUserContentPath) && Directory.Exists(customPSUserContentPath)) - { - // Use custom configured path - psCmdlet.WriteVerbose($"Using custom PSUserContentPath: {customPSUserContentPath}"); - return customPSUserContentPath; - } - else - { - // Use default PSContentPath location when feature is enabled - psCmdlet.WriteVerbose($"Using default PSContentPath location: {defaultPSContentPath}"); - return defaultPSContentPath; - } - } - else - { - // PSContentPath not enabled, use legacy location - psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}"); - return legacyPath; - } - } + // Get PowerShell engine version from $PSVersionTable.PSVersion + Version psVersion = psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") is Hashtable versionTable + && versionTable["PSVersion"] is Version version + ? version + : new Version(5, 1); - /// - /// Checks if a PowerShell experimental feature is enabled by reading the PowerShell configuration file. - /// Returns false if the configuration file doesn't exist or if the feature is not enabled. - /// - private static bool IsExperimentalFeatureEnabled(PSCmdlet psCmdlet, string featureName) - { - try + // Only use GetPSModulePath API if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available) + if (psVersion >= PSVersion7_7) { - // PowerShell configuration file location - string configPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "powershell", - "powershell.config.json" - ); - - if (!File.Exists(configPath)) - { - psCmdlet.WriteVerbose("PowerShell configuration file not found, experimental features not enabled"); - return false; - } - - string jsonContent = File.ReadAllText(configPath); - using (var jsonDoc = JsonDocument.Parse(jsonContent)) + // Try to use PowerShell's GetPSModulePath API via reflection + // This API automatically respects PSContentPath settings + try { - // Look for "ExperimentalFeatures": ["FeatureName"] in the config - if (jsonDoc.RootElement.TryGetProperty("ExperimentalFeatures", out var experimentalFeatures) && - experimentalFeatures.ValueKind == JsonValueKind.Array) + var moduleIntrinsicsType = typeof(PSModuleInfo).Assembly.GetType("System.Management.Automation.ModuleIntrinsics"); + var scopeEnumType = typeof(PSModuleInfo).Assembly.GetType("System.Management.Automation.PSModulePathScope"); + + if (moduleIntrinsicsType != null && scopeEnumType != null) { - foreach (var feature in experimentalFeatures.EnumerateArray()) + var getPSModulePathMethod = moduleIntrinsicsType.GetMethod("GetPSModulePath", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + + if (getPSModulePathMethod != null) { - if (string.Equals(feature.GetString(), featureName, StringComparison.OrdinalIgnoreCase)) + // PSModulePathScope.User = 0 + object userScope = Enum.ToObject(scopeEnumType, 0); + string userModulePath = (string)getPSModulePathMethod.Invoke(null, new object[] { userScope }); + + if (!string.IsNullOrEmpty(userModulePath)) { - psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' found in configuration file", featureName)); - return true; + string userContentPath = Path.GetDirectoryName(userModulePath); + psCmdlet.WriteVerbose($"User content path from GetPSModulePath API: {userContentPath}"); + return userContentPath; } } } } - - psCmdlet.WriteVerbose(string.Format("Experimental feature '{0}' not found in configuration file", featureName)); - return false; - } - catch (Exception ex) - { - psCmdlet.WriteVerbose(string.Format("Error reading PowerShell configuration file: {0}", ex.Message)); - return false; - } - } - - /// - /// Gets the custom PSUserContentPath from environment variable or PowerShell configuration file. - /// Environment variable takes precedence over the configuration file setting. - /// Returns null if neither is set or configured. - /// - private static string GetPSUserContentPath(PSCmdlet psCmdlet) - { - try - { - // First check the environment variable (takes precedence) - string envPSUserContentPath = Environment.GetEnvironmentVariable("PSUserContentPath"); - if (!string.IsNullOrEmpty(envPSUserContentPath)) - { - psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath from environment variable: {0}", envPSUserContentPath)); - return envPSUserContentPath; - } - - // If environment variable not set, check the configuration file - string configPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "powershell", - "powershell.config.json" - ); - - if (!File.Exists(configPath)) - { - psCmdlet.WriteVerbose("PowerShell configuration file not found"); - return null; - } - - string jsonContent = File.ReadAllText(configPath); - using (var jsonDoc = JsonDocument.Parse(jsonContent)) + catch (Exception ex) { - // Look for PSUserContentPath in the config - if (jsonDoc.RootElement.TryGetProperty("PSUserContentPath", out var pathElement)) - { - string psUserContentPath = pathElement.GetString(); - if (!string.IsNullOrEmpty(psUserContentPath)) - { - psCmdlet.WriteVerbose(string.Format("Found PSUserContentPath in config file: {0}", psUserContentPath)); - return psUserContentPath; - } - } + psCmdlet.WriteVerbose($"GetPSModulePath API not available: {ex.Message}"); } - - psCmdlet.WriteVerbose("PSUserContentPath not configured in PowerShell configuration file or environment variable"); - return null; } - catch (Exception ex) + else { - psCmdlet.WriteVerbose(string.Format("Error reading PSUserContentPath: {0}", ex.Message)); - return null; + psCmdlet.WriteVerbose($"PowerShell version {psVersion} is below 7.7.0, using legacy location"); } + + // Fallback to legacy location + psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}"); + return legacyPath; } private static void GetStandardPlatformPaths( @@ -1305,7 +1222,13 @@ private static void GetStandardPlatformPaths( { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; + // Get PowerShell engine version from $PSVersionTable.PSVersion + Version psVersion = psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") is Hashtable versionTable + && versionTable["PSVersion"] is Version version + ? version + : new Version(5, 1); // Default to Windows PowerShell version if unable to determine + + string powerShellType = (psVersion >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; // Windows PowerShell doesn't support experimental features or PSContentPath if (powerShellType == "WindowsPowerShell") @@ -1316,16 +1239,12 @@ private static void GetStandardPlatformPaths( } else { - string defaultPSContentPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - powerShellType - ); string legacyPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType ); - localUserDir = GetUserContentPath(psCmdlet, defaultPSContentPath, legacyPath); + localUserDir = GetUserContentPath(psCmdlet, legacyPath); } allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); @@ -1333,16 +1252,9 @@ private static void GetStandardPlatformPaths( else { // paths are the same for both Linux and macOS - string xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); - if (string.IsNullOrEmpty(xdgDataHome)) - { - xdgDataHome = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share"); - } - - string defaultPSContentPath = Path.Combine(xdgDataHome, "powershell"); string legacyPath = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); - localUserDir = GetUserContentPath(psCmdlet, defaultPSContentPath, legacyPath); + localUserDir = GetUserContentPath(psCmdlet, legacyPath); // Create the default data directory if it doesn't exist if (!Directory.Exists(localUserDir)) From a454cb389e0dfcad46e3db7b6e420c38dda8bedb Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:05:50 -0600 Subject: [PATCH 5/7] Use runspace instead of reflection --- src/code/Utils.cs | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 5fb5ba724..278d71c02 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1160,8 +1160,8 @@ private static string GetHomeOrCreateTempHome() private readonly static Version PSVersion7_7 = new Version(7, 7, 0); /// - /// Gets the user content directory path using PowerShell's GetPSModulePath API. - /// Falls back to legacy path if the API is not available or PowerShell version is below 7.7.0. + /// Gets the user content directory path using PowerShell's Get-PSContentPath cmdlet. + /// Falls back to legacy path if the cmdlet is not available or PowerShell version is below 7.7.0. /// private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath) { @@ -1171,38 +1171,41 @@ private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath) ? version : new Version(5, 1); - // Only use GetPSModulePath API if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available) + // Only use Get-PSContentPath cmdlet if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available) if (psVersion >= PSVersion7_7) { - // Try to use PowerShell's GetPSModulePath API via reflection - // This API automatically respects PSContentPath settings + // Try to use PowerShell's Get-PSContentPath cmdlet + // This cmdlet automatically respects PSContentPath settings try { - var moduleIntrinsicsType = typeof(PSModuleInfo).Assembly.GetType("System.Management.Automation.ModuleIntrinsics"); - var scopeEnumType = typeof(PSModuleInfo).Assembly.GetType("System.Management.Automation.PSModulePathScope"); - - if (moduleIntrinsicsType != null && scopeEnumType != null) + using (System.Management.Automation.PowerShell pwsh = System.Management.Automation.PowerShell.Create()) { - var getPSModulePathMethod = moduleIntrinsicsType.GetMethod("GetPSModulePath", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + pwsh.AddCommand("Get-PSContentPath"); + var results = pwsh.Invoke(); - if (getPSModulePathMethod != null) + if (!pwsh.HadErrors && results != null && results.Count > 0) { - // PSModulePathScope.User = 0 - object userScope = Enum.ToObject(scopeEnumType, 0); - string userModulePath = (string)getPSModulePathMethod.Invoke(null, new object[] { userScope }); - - if (!string.IsNullOrEmpty(userModulePath)) + // Get-PSContentPath returns a PSObject, extract the path string + string userContentPath = results[0]?.ToString(); + if (!string.IsNullOrEmpty(userContentPath)) { - string userContentPath = Path.GetDirectoryName(userModulePath); - psCmdlet.WriteVerbose($"User content path from GetPSModulePath API: {userContentPath}"); + psCmdlet.WriteVerbose($"User content path from Get-PSContentPath: {userContentPath}"); return userContentPath; } } + + if (pwsh.HadErrors) + { + foreach (var error in pwsh.Streams.Error) + { + psCmdlet.WriteVerbose($"Get-PSContentPath error: {error}"); + } + } } } catch (Exception ex) { - psCmdlet.WriteVerbose($"GetPSModulePath API not available: {ex.Message}"); + psCmdlet.WriteVerbose($"Get-PSContentPath cmdlet not available: {ex.Message}"); } } else From 94a156386c7e00e636b265600a6d08110e5a5772 Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Thu, 20 Nov 2025 20:44:14 -0600 Subject: [PATCH 6/7] Use current runspace and only get PSVersion once --- src/code/Utils.cs | 62 +++++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 278d71c02..d8b61757f 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1157,49 +1157,32 @@ private static string GetHomeOrCreateTempHome() } private readonly static Version PSVersion6 = new Version(6, 0); - private readonly static Version PSVersion7_7 = new Version(7, 7, 0); + private readonly static Version PSVersion7_7 = new Version(7, 7); /// /// Gets the user content directory path using PowerShell's Get-PSContentPath cmdlet. /// Falls back to legacy path if the cmdlet is not available or PowerShell version is below 7.7.0. /// - private static string GetUserContentPath(PSCmdlet psCmdlet, string legacyPath) + private static string GetUserContentPath(PSCmdlet psCmdlet, Version psVersion, string legacyPath) { - // Get PowerShell engine version from $PSVersionTable.PSVersion - Version psVersion = psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") is Hashtable versionTable - && versionTable["PSVersion"] is Version version - ? version - : new Version(5, 1); // Only use Get-PSContentPath cmdlet if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available) if (psVersion >= PSVersion7_7) { - // Try to use PowerShell's Get-PSContentPath cmdlet - // This cmdlet automatically respects PSContentPath settings + // Try to use PowerShell's Get-PSContentPath cmdlet in the current runspace + // This cmdlet is only available if experimental feature PSContentPath is enabled try { - using (System.Management.Automation.PowerShell pwsh = System.Management.Automation.PowerShell.Create()) + var results = psCmdlet.InvokeCommand.InvokeScript("Get-PSContentPath"); + + if (results != null && results.Count > 0) { - pwsh.AddCommand("Get-PSContentPath"); - var results = pwsh.Invoke(); - - if (!pwsh.HadErrors && results != null && results.Count > 0) + // Get-PSContentPath returns a PSObject, extract the path string + string userContentPath = results[0]?.ToString(); + if (!string.IsNullOrEmpty(userContentPath)) { - // Get-PSContentPath returns a PSObject, extract the path string - string userContentPath = results[0]?.ToString(); - if (!string.IsNullOrEmpty(userContentPath)) - { - psCmdlet.WriteVerbose($"User content path from Get-PSContentPath: {userContentPath}"); - return userContentPath; - } - } - - if (pwsh.HadErrors) - { - foreach (var error in pwsh.Streams.Error) - { - psCmdlet.WriteVerbose($"Get-PSContentPath error: {error}"); - } + psCmdlet.WriteVerbose($"User content path from Get-PSContentPath: {userContentPath}"); + return userContentPath; } } } @@ -1223,14 +1206,19 @@ private static void GetStandardPlatformPaths( out string localUserDir, out string allUsersDir) { + // Get PowerShell engine version from $PSVersionTable.PSVersion + Version psVersion = new Version(5, 1); + try + { + dynamic psVersionObj = (psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") as Hashtable)?["PSVersion"]; + if (psVersionObj != null) psVersion = new Version((int)psVersionObj.Major, (int)psVersionObj.Minor); + } + catch { + // Fallback if dynamic access fails + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Get PowerShell engine version from $PSVersionTable.PSVersion - Version psVersion = psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") is Hashtable versionTable - && versionTable["PSVersion"] is Version version - ? version - : new Version(5, 1); // Default to Windows PowerShell version if unable to determine - string powerShellType = (psVersion >= PSVersion6) ? "PowerShell" : "WindowsPowerShell"; // Windows PowerShell doesn't support experimental features or PSContentPath @@ -1247,7 +1235,7 @@ private static void GetStandardPlatformPaths( powerShellType ); - localUserDir = GetUserContentPath(psCmdlet, legacyPath); + localUserDir = GetUserContentPath(psCmdlet, psVersion, legacyPath); } allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType); @@ -1257,7 +1245,7 @@ private static void GetStandardPlatformPaths( // paths are the same for both Linux and macOS string legacyPath = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell"); - localUserDir = GetUserContentPath(psCmdlet, legacyPath); + localUserDir = GetUserContentPath(psCmdlet, psVersion, legacyPath); // Create the default data directory if it doesn't exist if (!Directory.Exists(localUserDir)) From 26c5bf8c250c434a2b03201250fe09a0ceea3fdb Mon Sep 17 00:00:00 2001 From: Justin Chung <124807742+jshigetomi@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:10:24 -0600 Subject: [PATCH 7/7] Add tests and test hooks --- src/code/InternalHooks.cs | 16 ++++ src/code/Utils.cs | 7 +- test/PSContentPath.Tests.ps1 | 171 +++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 test/PSContentPath.Tests.ps1 diff --git a/src/code/InternalHooks.cs b/src/code/InternalHooks.cs index 99d6104ce..a86ab21d5 100644 --- a/src/code/InternalHooks.cs +++ b/src/code/InternalHooks.cs @@ -17,12 +17,28 @@ public class InternalHooks internal static string MARPrefix; + // PSContentPath testing hooks + internal static string LastUserContentPathSource; + internal static string LastUserContentPath; + public static void SetTestHook(string property, object value) { var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); fieldInfo?.SetValue(null, value); } + public static object GetTestHook(string property) + { + var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic); + return fieldInfo?.GetValue(null); + } + + public static void ClearPSContentPathHooks() + { + LastUserContentPathSource = null; + LastUserContentPath = null; + } + public static string GetUserString() { return Microsoft.PowerShell.PSResourceGet.Cmdlets.UserAgentInfo.UserAgentString(); diff --git a/src/code/Utils.cs b/src/code/Utils.cs index d8b61757f..8fa94d13a 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -1182,6 +1182,8 @@ private static string GetUserContentPath(PSCmdlet psCmdlet, Version psVersion, s if (!string.IsNullOrEmpty(userContentPath)) { psCmdlet.WriteVerbose($"User content path from Get-PSContentPath: {userContentPath}"); + InternalHooks.LastUserContentPathSource = "Get-PSContentPath"; + InternalHooks.LastUserContentPath = userContentPath; return userContentPath; } } @@ -1198,6 +1200,8 @@ private static string GetUserContentPath(PSCmdlet psCmdlet, Version psVersion, s // Fallback to legacy location psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}"); + InternalHooks.LastUserContentPathSource = "Legacy"; + InternalHooks.LastUserContentPath = legacyPath; return legacyPath; } @@ -1215,6 +1219,7 @@ private static void GetStandardPlatformPaths( } catch { // Fallback if dynamic access fails + psCmdlet.WriteWarning("Unable to determine PowerShell version from $PSVersionTable"); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -1253,7 +1258,7 @@ private static void GetStandardPlatformPaths( Directory.CreateDirectory(localUserDir); } - allUsersDir = Path.Combine("/usr", "local", "share", "powershell"); + allUsersDir = Path.Combine("/", "usr", "local", "share", "powershell"); } } diff --git a/test/PSContentPath.Tests.ps1 b/test/PSContentPath.Tests.ps1 new file mode 100644 index 000000000..32f3919fd --- /dev/null +++ b/test/PSContentPath.Tests.ps1 @@ -0,0 +1,171 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +Param() + +$ProgressPreference = "SilentlyContinue" +$modPath = "$psscriptroot/PSGetTestUtils.psm1" +Import-Module $modPath -Force + +Describe 'PSUserContentPath/PSContentPath - End-to-End Install Location' -Tags 'CI' { + BeforeAll { + $script:originalPSModulePath = $env:PSModulePath + $script:actualConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json" + $script:configBackup = $null + + # Detect if Get-PSContentPath cmdlet is available (requires PSContentPath experimental feature) + $script:getPSContentPathAvailable = $false + $script:isPSContentPathEnabled = $false + $script:sessionContentPath = $null + try { + # Check if Get-PSContentPath cmdlet exists + $null = Get-Command Get-PSContentPath -ErrorAction Stop + $script:getPSContentPathAvailable = $true + + # Get the actual session path + $script:sessionContentPath = Get-PSContentPath + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $documentsPS = Join-Path $documentsPath "PowerShell" + + # If Get-PSContentPath returns something other than Documents, the feature is enabled + $script:isPSContentPathEnabled = $script:sessionContentPath -ne $documentsPS + } catch { + # Get-PSContentPath not available (feature disabled) + } + + # Backup existing config if it exists + if (Test-Path $script:actualConfigPath) { + $script:configBackup = Get-Content $script:actualConfigPath -Raw + } + + $localRepo = "psgettestlocal" + $testModuleName = "PSContentPathTestModule" + Get-NewPSResourceRepositoryFile + Register-LocalRepos + + # Create a test module + New-TestModule -moduleName $testModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags @() + } + + AfterEach { + # Restore PSModulePath + $env:PSModulePath = $script:originalPSModulePath + # Clean up installed test modules + Uninstall-PSResource $testModuleName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue + # Clear testing hooks + [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::ClearPSContentPathHooks() + } + + AfterAll { + # Restore original config + if ($null -ne $script:configBackup) { + Set-Content -Path $script:actualConfigPath -Value $script:configBackup -Force + } + Get-RevertPSResourceRepositoryFile + } + + Context "PSResourceGet behavior on PS 7.7+" { + It "Should use Get-PSContentPath when available, Legacy when not" { + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + if ($script:getPSContentPathAvailable) { + # When Get-PSContentPath cmdlet exists, PSResourceGet should use it + $pathSource | Should -Be "Get-PSContentPath" + $pathUsed | Should -Be $script:sessionContentPath + } else { + # When Get-PSContentPath cmdlet doesn't exist, PSResourceGet should use legacy path + $pathSource | Should -Be "Legacy" + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $pathUsed | Should -BeLike "*$documentsPath*PowerShell" + } + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + } + } + + Context "When PSContentPath feature is enabled in session (PS >= 7.7)" { + It "Should install to custom LocalAppData path (not Documents)" { + if (-not $script:getPSContentPathAvailable -or -not $script:isPSContentPathEnabled) { + Set-ItResult -Skipped -Because "PSContentPath feature not enabled in this session" + return + } + + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + # PSResourceGet should call Get-PSContentPath + $pathSource | Should -Be "Get-PSContentPath" + + # Path should NOT be Documents (feature enabled means custom path) + $documentsPath = [Environment]::GetFolderPath('MyDocuments') + $pathUsed | Should -Not -BeLike "*$documentsPath*" + + # Module should be installed in custom path + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -Not -BeLike "*$documentsPath*" + } + } + + Context "PSResourceGet correctly delegates path resolution (PS >= 7.7)" { + It "Should always defer to Get-PSContentPath when cmdlet is available" { + if (-not $script:getPSContentPathAvailable) { + Set-ItResult -Skipped -Because "Get-PSContentPath cmdlet not available" + return + } + + $beforePath = Get-PSContentPath + + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository + + $pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource") + $pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath") + + # PSResourceGet should call Get-PSContentPath + $pathSource | Should -Be "Get-PSContentPath" + + # Path should match what Get-PSContentPath returns + $pathUsed | Should -Be $beforePath + + # Module should be installed + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + } + } + + Context "AllUsers scope should not be affected by PSContentPath/PSUserContentPath" { + BeforeAll { + if (!$IsWindows -or !(Test-IsAdmin)) { return } + } + It "Should install to Program Files (AllUsers not affected by PSContentPath)" { + if (!$IsWindows -or !(Test-IsAdmin)) { + Set-ItResult -Skipped -Because "Test requires Windows and Administrator privileges" + return + } + Install-PSResource -Name $testModuleName -Repository $localRepo -Scope AllUsers -TrustRepository + $programFilesPath = [Environment]::GetFolderPath('ProgramFiles') + $expectedPath = Join-Path $programFilesPath "PowerShell\Modules\$testModuleName" + Test-Path $expectedPath | Should -BeTrue + $res = Get-InstalledPSResource -Name $testModuleName + $res.Name | Should -Be $testModuleName + $res.InstalledLocation | Should -BeLike "*Program Files*PowerShell*Modules*" + } + } +} + +function Test-IsAdmin { + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + } + return $false +}