Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b73eea5
Testing
Mar 13, 2025
805fd0e
Update PSModulePath
Mar 17, 2025
6dc662d
Add Experimental feature for PSContent
jshigetomi Aug 6, 2025
67a18aa
Merge branch 'master' into PSModulePathFix
jshigetomi Aug 6, 2025
4a7bb3d
Removed unused variables
jshigetomi Aug 6, 2025
00b6307
Switch to default PSContentPath LOCALAPPData, cmdlets added
jshigetomi Aug 20, 2025
7e5802b
Add lazy migration
jshigetomi Oct 2, 2025
830016e
Add null checks incase PSUserContentPath fails to get any value
jshigetomi Oct 9, 2025
56acf2d
Able to use expanded environmental variables
jshigetomi Oct 14, 2025
3f5709d
Remove help URI
jshigetomi Oct 14, 2025
9e516fa
Reassign perUserConfigDirectory if experimental feature is enabled wh…
jshigetomi Oct 14, 2025
d371979
Move migration to GetPSContentPath API, added safety fallbacks to def…
jshigetomi Nov 21, 2025
c62f82a
Merge branch 'master' into PSModulePathFix
jshigetomi Nov 21, 2025
b207b5c
Add tests
jshigetomi Dec 1, 2025
8fc45c7
Merge branch 'PSModulePathFix' of https://github.com/jshigetomi/Power…
jshigetomi Dec 1, 2025
fb2036c
Merge branch 'master' into PSModulePathFix
jshigetomi Dec 1, 2025
eaaaedd
Add initial test cases for PSContentPath
jshigetomi Dec 1, 2025
eed1cf3
Fix expanding env variable test
jshigetomi Dec 12, 2025
ec18785
Separate commands for env var test
jshigetomi Dec 12, 2025
c37e776
Initial redo to remove experimental feature aspect and point to OneDr…
jshigetomi Jan 12, 2026
f01f00f
Switch default PSContentPath variable to OneDrive
jshigetomi Jan 12, 2026
b567218
Remove old PSUserContentPath code
jshigetomi Jan 12, 2026
c70f885
Fix help test and add Get/Set to cmdlet test
jshigetomi Jan 12, 2026
1bc5dbc
Update tab completion tests with PSContentPath resolution
jshigetomi Jan 12, 2026
2cefe89
Add warning message in tabcompletion tests
jshigetomi Jan 13, 2026
c639721
Remove changes to build script
jshigetomi Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/System.Management.Automation/CoreCLR/CorePsPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Implements Get-PSContentPath cmdlet.
/// </summary>
[Cmdlet(VerbsCommon.Get, "PSContentPath", HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344910")]
public class GetPSContentPathCommand : PSCmdlet
{
/// <summary>
/// EndProcessing method of this cmdlet.
/// Main logic is in EndProcessing to ensure all pipeline input is processed first.
/// </summary>
protected override void EndProcessing()
{
try
{
var psContentPath = Utils.GetPSContentPath();
WriteObject(psContentPath);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"GetPSContentPathFailed",
ErrorCategory.ReadError,
null));
}
}
}

/// <summary>
/// Implements Set-PSContentPath cmdlet.
/// </summary>
[Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344807")]
public class SetPSContentPathCommand : PSCmdlet
{
/// <summary>
/// Gets or sets the PSContentPath to configure.
/// </summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
[ValidateNotNullOrEmpty]
public string Path { get; set; }

private string validatedPath = null;

/// <summary>
/// ProcessRecord method of this cmdlet.
/// Validates each path from the pipeline and stores the last valid one.
/// </summary>
protected override void ProcessRecord()
{
// Validate the path from pipeline input
if (ValidatePath(Path))
{
// Store the last valid path from pipeline
validatedPath = Path;
}
}

/// <summary>
/// EndProcessing method of this cmdlet.
/// Main logic is in EndProcessing to use the last valid path from the pipeline.
/// </summary>
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));
}
}
}

/// <summary>
/// Validates that the provided path is a valid directory path.
/// </summary>
/// <param name="path">The path to validate.</param>
/// <returns>True if the path is valid, false otherwise.</returns>
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;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExperimentalFeature>(engineFeatures);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) },
Expand All @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ namespace System.Management.Automation
internal static class Constants
{
public const string PSModulePathEnvVar = "PSModulePath";
public const string PSUserContentPathEnvVar = "PSUserContentPath";
}

/// <summary>
Expand Down Expand Up @@ -490,7 +491,7 @@ internal static bool IsModuleMatchingConstraints(
/// <param name="requiredVersion">The required version of the expected module.</param>
/// <param name="minimumVersion">The minimum required version of the expected module.</param>
/// <param name="maximumVersion">The maximum required version of the expected module.</param>
/// <returns>True if the module info object matches all given constraints, false otherwise.</returns>
/// <returns>True if the module info object matches all the constraints on the module specification, false otherwise.</returns>
internal static bool IsModuleMatchingConstraints(
out ModuleMatchFailure matchFailureReason,
PSModuleInfo moduleInfo,
Expand Down Expand Up @@ -964,12 +965,7 @@ internal static string GetModuleName(string path)
/// <returns>Personal module path.</returns>
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");
}

/// <summary>
Expand Down Expand Up @@ -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<string>(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<string>();
Expand Down Expand Up @@ -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))
Expand Down
38 changes: 38 additions & 0 deletions src/System.Management.Automation/engine/PSConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,43 @@ internal string GetModulePath(ConfigScope scope)
return modulePath;
}

/// <summary>
/// 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.
/// </summary>
/// <returns>The configured PSContentPath if found, otherwise the default OneDrive location (never null).</returns>
internal string GetPSContentPath()
{
string contentPath = ReadValueFromFile<string>(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;
}

/// <summary>
/// Sets the PSContentPath in the configuration file.
/// </summary>
/// <param name="path">The path to set as PSContentPath.</param>
internal void SetPSContentPath(string path)
{
if (string.IsNullOrEmpty(path))
{
RemoveValueFromFile<string>(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar);
}
else
{
WriteValueToFile<string>(ConfigScope.CurrentUser, Constants.PSUserContentPathEnvVar, path);
}
}

/// <summary>
/// Existing Key = HKCU and HKLM\SOFTWARE\Microsoft\PowerShell\1\ShellIds\Microsoft.PowerShell
/// Proposed value = Existing default execution policy if not already specified
Expand Down Expand Up @@ -218,6 +255,7 @@ internal void SetExperimentalFeatures(ConfigScope scope, string featureName, boo
{
features.Remove(featureName);
WriteValueToFile<string[]>(scope, "ExperimentalFeatures", features.ToArray());
// Note: WriteValueToFile already handles syncing to legacy config if it exists
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/System.Management.Automation/engine/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,16 @@ internal static bool IsValidPSEditionValue(string editionValue)
/// </summary>
internal static readonly string ModuleDirectory = Path.Combine(ProductNameForDirectory, "Modules");

/// <summary>
/// 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).
/// </summary>
/// <returns>The PSContent directory path (never null).</returns>
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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading