Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 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
c04a5ef
Add Move-PSContentPath cmdlet
jshigetomi Jan 19, 2026
c4fa79b
Skip Move-PSContentPath in HelpSystem tests for now
jshigetomi Jan 19, 2026
2c58858
Separate out fallback resolution for UNIX/Windows
jshigetomi Jan 19, 2026
cd55051
Add Move-PSContent to cmdlets list and clean up code
jshigetomi Jan 19, 2026
c83004f
Merge branch 'master' into PSModulePathFix
jshigetomi Jan 19, 2026
3342865
Add back switch-process
jshigetomi Jan 19, 2026
4986975
Merge branch 'PSModulePathFix' of https://github.com/jshigetomi/Power…
jshigetomi Jan 19, 2026
960951e
Changes from Copilot review
Jan 21, 2026
7cfca95
Fix Set-PSContentPath logic, add summary for Get-PSContentPath
Jan 22, 2026
26d3b69
Add PSUserContentPath as a pwsh variable, Remove Move-PSContentPath
jshigetomi Feb 2, 2026
fcc449f
Make LocalAppData default config file location
jshigetomi Feb 3, 2026
859b3ba
Add PSUSerContentVariable class to emit custom error message, Fix tes…
jshigetomi Feb 3, 2026
b2d5d3f
Point test fixutre at new locaiton for config LocalAppData
jshigetomi Feb 3, 2026
ff76bf4
Merge branch 'master' into PSModulePathFix
jshigetomi Feb 3, 2026
4c351a8
Add -ConfigFile parameter to Get-PSContentPath, -WhatIf
jshigetomi Feb 3, 2026
3990e6c
Add both paths to PSModulePath
jshigetomi Feb 4, 2026
fd9d8ec
Rename to PSContentCommands
jshigetomi Feb 13, 2026
d69bb03
Add config file as a note property and rename -reset parameter to def…
jshigetomi Feb 25, 2026
95ec9f8
Change impact for Set-PSContentPath to High
jshigetomi Feb 26, 2026
b212b12
Copilot review fixes
Mar 23, 2026
cdd6313
Add in necessary directives
Mar 23, 2026
2eace0b
- Add null check for GetPersonalModulePath.
Apr 1, 2026
740ef74
Remove readonly for PSUserContentPath and rely on setter to throw a h…
Apr 1, 2026
f8459dc
Remove tests for helpful error message
Apr 2, 2026
5902fb4
Remove deduplicatation test because tests inject module path
Apr 2, 2026
ab4feea
Merge branch 'master' into PSModulePathFix
jshigetomi Jun 2, 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
12 changes: 12 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,20 @@ 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 LegacyPSContentDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA);
internal static readonly string LocalAppDataPSContentDirectory = Platform.SelectProductNameForDirectory(Platform.XDG_Type.DATA);
#else
// Gets the location for cache and config folders.
// Legacy Documents\PowerShell path kept for backward compatibility (modules fallback).
// The preferred location is LocalAppData\PowerShell via LocalAppDataPSContentDirectory.
internal static readonly string LegacyPSContentDirectory = SafeDeriveFromSpecialFolder(
Environment.SpecialFolder.Personal,
@"PowerShell");
Comment thread
jshigetomi marked this conversation as resolved.

internal static readonly string LocalAppDataPSContentDirectory = SafeDeriveFromSpecialFolder(
Environment.SpecialFolder.LocalApplicationData,
@"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,261 @@
// 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")]
[OutputType(typeof(DirectoryInfo))]
public class GetPSContentPathCommand : PSCmdlet
{
/// <summary>
/// EndProcessing method of this cmdlet.
/// Outputs the PSContentPath as a DirectoryInfo object with ConfigFile NoteProperty.
/// </summary>
protected override void EndProcessing()
{
try
{
var psContentPath = Utils.GetPSContentPath();
var configFilePath = PowerShellConfig.Instance.GetConfigFilePath(ConfigScope.CurrentUser);

// Create DirectoryInfo object
var directoryInfo = new DirectoryInfo(psContentPath);

// Wrap in PSObject to add the ConfigFile NoteProperty
var result = PSObject.AsPSObject(directoryInfo);
result.Properties.Add(new PSNoteProperty("ConfigFile", configFilePath));

WriteObject(result);
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"GetPSContentPathFailed",
ErrorCategory.ReadError,
null));
}
}
}

/// <summary>
/// Implements Set-PSContentPath cmdlet.
/// </summary>
[Cmdlet(VerbsCommon.Set, "PSContentPath", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, HelpUri = "https://go.microsoft.com/fwlink/?linkid=2344807")]
public class SetPSContentPathCommand : PSCmdlet
{
private const string RestartWarning = "Restart PowerShell for the content path change to take full effect on module paths, profiles, help files, and scripts.";
/// <summary>
/// Gets or sets the PSContentPath to configure.
/// </summary>
[Parameter(Mandatory = true, Position = 0, ParameterSetName = "Path")]
[ValidateNotNullOrEmpty]
public string Path { get; set; }

/// <summary>
/// Resets the PSContentPath to the platform default.
/// </summary>
[Parameter(Mandatory = true, ParameterSetName = "Default")]
public SwitchParameter Default { get; set; }

/// <summary>
/// EndProcessing method of this cmdlet.
/// Validates the path and sets the PSContentPath in the configuration.
/// </summary>
protected override void EndProcessing()
{
if (Default)
{
ResetToDefault();
return;
}

// Validate the path
if (!ValidatePath(Path))
{
return;
}

string expandedPath = Environment.ExpandEnvironmentVariables(Path);
string currentPath = Utils.GetPSContentPath();
string configFile = PowerShellConfig.Instance.GetConfigFilePath(ConfigScope.CurrentUser);

string target = $"Config file: '{configFile}'";
string action = $"Set PSUserContentPath from '{currentPath}' to '{expandedPath}'";

if (ShouldProcess(target, action))
{
try
{
PowerShellConfig.Instance.SetPSContentPath(Path);

// Update the $PSUserContentPath readonly variable in the current session
UpdatePSUserContentPathVariable(expandedPath);

Comment thread
jshigetomi marked this conversation as resolved.
WriteWarning(RestartWarning);
WriteVerbose($"Successfully set PSContentPath to '{Path}'");
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"SetPSContentPathFailed",
ErrorCategory.WriteError,
Path));
}
}
}

/// <summary>
/// Resets the PSContentPath to the platform default by clearing the custom config.
/// </summary>
/// <remarks>
/// Currently resets to Documents\PowerShell (LegacyPSContentDirectory) for backward compatibility.
/// In a future release, this will change to LocalAppData\PowerShell as the default content directory.
/// </remarks>
private void ResetToDefault()
{
string defaultPath = Platform.LegacyPSContentDirectory;
string currentPath = Utils.GetPSContentPath();
string configFile = PowerShellConfig.Instance.GetConfigFilePath(ConfigScope.CurrentUser);

string target = $"Config file: '{configFile}'";
string action = $"Reset PSUserContentPath from '{currentPath}' to platform default '{defaultPath}'";

if (ShouldProcess(target, action))
{
try
{
// Clear the custom path from config (passing null/empty removes the key)
PowerShellConfig.Instance.SetPSContentPath(null);

// Update the variable to the platform default
UpdatePSUserContentPathVariable(defaultPath);

WriteWarning(RestartWarning);
WriteVerbose($"Successfully reset PSContentPath to default: '{defaultPath}'");
}
catch (Exception ex)
{
WriteError(new ErrorRecord(
ex,
"ResetPSContentPathFailed",
ErrorCategory.WriteError,
null));
}
}
}

/// <summary>
/// Updates the $PSUserContentPath readonly variable in the current session.
/// </summary>
/// <param name="newPath">The new path value to set.</param>
private void UpdatePSUserContentPathVariable(string newPath)
{
// Get the existing PSUserContentPathVariable and update its internal value
var existingVariable = SessionState.PSVariable.Get(SpecialVariables.PSUserContentPath);
if (existingVariable is PSUserContentPathVariable contentPathVariable)
{
contentPathVariable.UpdateValue(newPath);
}
else
{
// Fallback: create a new PSUserContentPathVariable (shouldn't normally happen)
var variable = new PSUserContentPathVariable(newPath);
SessionState.Internal.SetVariableAtScope(variable, "global", force: true, CommandOrigin.Internal);
}
}

/// <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 @@ -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 @@ -19,6 +19,7 @@ namespace System.Management.Automation
internal static class Constants
{
public const string PSModulePathEnvVar = "PSModulePath";
public const string PSUserContentPathConfigKey = "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 @@ -961,17 +962,30 @@ internal static string GetModuleName(string path)
/// <summary>
/// Gets the personal module path.
/// </summary>
/// <returns>Personal module path.</returns>
/// <returns>Personal module path, or null if the content path cannot be determined.</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, Environment.SpecialFolderOption.DoNotVerify);
return string.IsNullOrEmpty(myDocumentsPath) ? null : Path.Combine(myDocumentsPath, Utils.ModuleDirectory);
#endif
string contentPath = Utils.GetPSContentPath();
return string.IsNullOrEmpty(contentPath) ? null : Path.Combine(contentPath, "Modules");
}

/// <summary>
/// Gets the legacy personal module path (Documents\PowerShell\Modules).
/// This is used for backwards compatibility with PowerShellGet.
/// </summary>
/// <returns>Legacy personal module path, or null if same as current personal path.</returns>
internal static string GetLegacyPersonalModulePath()
{
string legacyPath = Path.Combine(Platform.LegacyPSContentDirectory, "Modules");
string currentPath = GetPersonalModulePath();

// Only return the legacy path if it's different from the current personal module path
if (!string.Equals(legacyPath, currentPath, StringComparison.OrdinalIgnoreCase))
{
return legacyPath;
}

return null;
}

/// <summary>
Expand Down Expand Up @@ -1348,6 +1362,22 @@ private static string SetModulePath()
#endif
string allUsersModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.AllUsers);
Comment thread
jshigetomi marked this conversation as resolved.
string personalModulePath = PowerShellConfig.Instance.GetModulePath(ConfigScope.CurrentUser);

// If no user-configured module path, use the PSContentPath-based personal module path
// and include the legacy Documents module path for backwards compatibility with PowerShellGet.
// This ensures both old (Documents) and new (PSContentPath) module locations are searched.
if (string.IsNullOrEmpty(personalModulePath))
{
personalModulePath = GetPersonalModulePath();
string legacyModulePath = GetLegacyPersonalModulePath();

if (!string.IsNullOrEmpty(legacyModulePath) && !string.IsNullOrEmpty(personalModulePath))
{
// Combine personal path with legacy path (personal takes precedence)
personalModulePath = string.Concat(personalModulePath, Path.PathSeparator, legacyModulePath);
}
}

string newModulePathString = GetModulePath(currentModulePath, allUsersModulePath, personalModulePath);

if (!string.IsNullOrEmpty(newModulePathString))
Expand Down
Loading
Loading