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 + } + } +}