diff --git a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs
index 8be65502543..41df2886708 100644
--- a/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs
+++ b/src/System.Management.Automation/CoreCLR/CorePsPlatform.cs
@@ -165,8 +165,14 @@ 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 = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA);
#else
// Gets the location for cache and config folders.
+ // 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,
@"Microsoft\PowerShell");
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..3901146df0e
--- /dev/null
+++ b/src/System.Management.Automation/engine/Configuration/GetSetPSContentPathCommand.cs
@@ -0,0 +1,183 @@
+// 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=2344910")]
+ public class GetPSContentPathCommand : PSCmdlet
+ {
+ ///
+ /// EndProcessing method of this cmdlet.
+ /// Main logic is in EndProcessing to ensure all pipeline input is processed first.
+ ///
+ protected override void EndProcessing()
+ {
+ 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=2344807")]
+ public class SetPSContentPathCommand : PSCmdlet
+ {
+ ///
+ /// Gets or sets the PSContentPath to configure.
+ ///
+ [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
+ [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 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;
+ }
+
+ if (ShouldProcess($"PSContentPath = {validatedPath}", "Set PSContentPath"))
+ {
+ try
+ {
+ PowerShellConfig.Instance.SetPSContentPath(validatedPath);
+ WriteVerbose($"Successfully set PSContentPath to '{validatedPath}'");
+ WriteWarning("PSContentPath changes will take effect after restarting PowerShell.");
+ }
+ catch (Exception ex)
+ {
+ WriteError(new ErrorRecord(
+ ex,
+ "SetPSContentPathFailed",
+ ErrorCategory.WriteError,
+ validatedPath));
+ }
+ }
+ }
+
+ ///
+ /// 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 (!System.IO.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 = System.IO.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/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
index 7e17ec43137..9f0d3d6e578 100644
--- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
+++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs
@@ -109,12 +109,10 @@ static ExperimentalFeature()
description: "Expose an API to allow assembly loading from native code"),
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: PSProfileDSCResource,
- description: "DSC v3 resources for managing PowerShell profile."
- )
+ description: "DSC v3 resources for managing PowerShell profile.")
};
EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures);
diff --git a/src/System.Management.Automation/engine/InitialSessionState.cs b/src/System.Management.Automation/engine/InitialSessionState.cs
index 62308282d17..72fa4df1ceb 100644
--- a/src/System.Management.Automation/engine/InitialSessionState.cs
+++ b/src/System.Management.Automation/engine/InitialSessionState.cs
@@ -5250,7 +5250,7 @@ internal static void AnalyzePSSnapInAssembly(
}
Diagnostics.Assert(cmdletsCheck.Count == cmdlets.Count, "new Cmdlet added to System.Management.Automation.dll - update InitializeCoreCmdletsAndProviders");
-
+
foreach (var pair in cmdletsCheck)
{
SessionStateCmdletEntry other;
@@ -5476,6 +5476,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) },
@@ -5498,6 +5499,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 b687e502763..b7dc5c9cc3a 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";
}
///
@@ -490,7 +491,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,12 +965,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
+ return Path.Combine(Utils.GetPSContentPath(), "Modules");
}
///
@@ -1315,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();
@@ -1366,7 +1365,7 @@ private static string SetModulePath()
}
#endif
string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers);
- string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser);
+ string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser) ?? GetPersonalModulePath();
string newModulePathString = GetModulePath(currentModulePath, allUsersModulePath, personalModulePath);
if (!string.IsNullOrEmpty(newModulePathString))
diff --git a/src/System.Management.Automation/engine/PSConfiguration.cs b/src/System.Management.Automation/engine/PSConfiguration.cs
index 419a4cae95f..cca8858b4d8 100644
--- a/src/System.Management.Automation/engine/PSConfiguration.cs
+++ b/src/System.Management.Automation/engine/PSConfiguration.cs
@@ -144,6 +144,43 @@ internal string GetModulePath(ConfigScope scope)
return modulePath;
}
+ ///
+ /// Gets the PSContentPath from the configuration 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 OneDrive location (never null).
+ internal string GetPSContentPath()
+ {
+ string contentPath = ReadValueFromFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar);
+ if (!string.IsNullOrEmpty(contentPath))
+ {
+ contentPath = Environment.ExpandEnvironmentVariables(contentPath);
+ return contentPath;
+ }
+
+ // Return default location using Platform.DefaultPSContentDirectory
+ // - Windows: Documents\PowerShell (OneDrive location)
+ // - Unix: XDG_DATA_HOME/powershell (~/.local/share/powershell)
+ return 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, Constants.PSUserContentPathEnvVar);
+ }
+ else
+ {
+ WriteValueToFile(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar, path);
+ }
+ }
+
///
/// Existing Key = HKCU and HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell
/// Proposed value = Existing default execution policy if not already specified
@@ -218,6 +255,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
}
}
diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs
index 0de9fe0d5cc..061a999e997 100644
--- a/src/System.Management.Automation/engine/Utils.cs
+++ b/src/System.Management.Automation/engine/Utils.cs
@@ -705,6 +705,16 @@ 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.
+ /// 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()
+ {
+ return PowerShellConfig.Instance.GetPSContentPath();
+ }
+
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 8e5d30be71f..99378aaac83 100644
--- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs
+++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs
@@ -143,7 +143,17 @@ internal static string GetFullProfileFileName(string shellId, bool forCurrentUse
if (forCurrentUser)
{
- basePath = Platform.ConfigDirectory;
+ 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
{
diff --git a/src/System.Management.Automation/help/HelpUtils.cs b/src/System.Management.Automation/help/HelpUtils.cs
index ab4a39f362a..805fb61bac1 100644
--- a/src/System.Management.Automation/help/HelpUtils.cs
+++ b/src/System.Management.Automation/help/HelpUtils.cs
@@ -16,17 +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)
{
-#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
- userHomeHelpPath = Path.Combine(userScopeRootPath, "Help");
+ userHomeHelpPath = expectedPath;
}
return userHomeHelpPath;
diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1
index f8762a63929..95b6e33269f 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,27 @@ 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
+ Write-Warning "PSContentPath is not available: $_"
}
+
+ # 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/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..a678311c0b7 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 {
+ Write-Warning "PSContentPath is not available: $_"
+ }
+
+ # 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
}
diff --git a/test/powershell/engine/PSContentPath.Tests.ps1 b/test/powershell/engine/PSContentPath.Tests.ps1
new file mode 100644
index 00000000000..6b2490c5d4d
--- /dev/null
+++ b/test/powershell/engine/PSContentPath.Tests.ps1
@@ -0,0 +1,120 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+Describe "Get-PSContentPath and Set-PSContentPath cmdlet tests" -tags "CI" {
+ BeforeAll {
+ # 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'
+ }
+
+ $configBackup = $null
+ if (Test-Path $documentsConfigPath) {
+ $configBackup = Get-Content $documentsConfigPath -Raw
+ }
+ }
+
+ AfterAll {
+ # 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
+ }
+ }
+
+ 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
+ }
+ }
+ }
+
+ 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 "Get-PSContentPath returns path without creating config file" {
+ # Ensure no config exists
+ if (Test-Path $documentsConfigPath) {
+ Remove-Item $documentsConfigPath -Force
+ }
+
+ $result = Get-PSContentPath
+ $result | Should -Not -BeNullOrEmpty
+
+ # Config file should NOT be created just by calling Get-PSContentPath
+ Test-Path $documentsConfigPath | Should -Be $false
+ }
+ }
+
+ 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
+ }
+
+ $customPath = if ($IsWindows) { "$env:TEMP\CustomPowerShell" } else { "/tmp/CustomPowerShell" }
+
+ Set-PSContentPath -Path $customPath -WarningAction SilentlyContinue
+
+ # Config file should now exist
+ Test-Path $documentsConfigPath | Should -Be $true
+
+ # Verify custom path is stored
+ $config = Get-Content $documentsConfigPath -Raw | ConvertFrom-Json
+ $config.PSUserContentPath | Should -Be $customPath
+ }
+
+ It "Set-PSContentPath expands environment variables on Windows" -Skip:(!$IsWindows) {
+ Set-PSContentPath -Path '%TEMP%\PowerShell' -WarningAction SilentlyContinue
+
+ $result = Get-PSContentPath
+ $result | Should -Be "$env:TEMP\PowerShell"
+ $result | Should -Not -BeLike '*%TEMP%*'
+ }
+
+ It "Set-PSContentPath validates path input" {
+ { Set-PSContentPath -Path '' -WarningAction SilentlyContinue -ErrorAction Stop } | Should -Throw
+ }
+
+ It "Set-PSContentPath supports WhatIf" {
+ if (Test-Path $documentsConfigPath) {
+ Remove-Item $documentsConfigPath -Force
+ }
+
+ $customPath = if ($IsWindows) { "$env:TEMP\TestPath" } else { "/tmp/TestPath" }
+
+ Set-PSContentPath -Path $customPath -WhatIf
+
+ # Config file should NOT be created with -WhatIf
+ Test-Path $documentsConfigPath | Should -Be $false
+ }
+ }
+}