Skip to content
16 changes: 16 additions & 0 deletions src/code/InternalHooks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,28 @@ public class InternalHooks

internal static string MARPrefix;

// PSContentPath testing hooks
internal static string LastUserContentPathSource;
internal static string LastUserContentPath;

public static void SetTestHook(string property, object value)
{
var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic);
fieldInfo?.SetValue(null, value);
}

public static object GetTestHook(string property)
{
var fieldInfo = typeof(InternalHooks).GetField(property, BindingFlags.Static | BindingFlags.NonPublic);
return fieldInfo?.GetValue(null);
}

public static void ClearPSContentPathHooks()
{
LastUserContentPathSource = null;
LastUserContentPath = null;
}

public static string GetUserString()
{
return Microsoft.PowerShell.PSResourceGet.Cmdlets.UserAgentInfo.UserAgentString();
Expand Down
86 changes: 78 additions & 8 deletions src/code/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using Azure.Identity;
using System.Text.RegularExpressions;
using System.Threading;
using System.Text.Json;
using System.Threading.Tasks;
using System.Xml;

Expand Down Expand Up @@ -1049,14 +1050,14 @@ public static List<string> GetPathsFromEnvVarAndScope(
{
GetStandardPlatformPaths(
psCmdlet,
out string myDocumentsPath,
out string psUserContentPath,
out string programFilesPath);

List<string> resourcePaths = new List<string>();
if (scope is null || scope.Value is ScopeType.CurrentUser)
{
resourcePaths.Add(Path.Combine(myDocumentsPath, "Modules"));
resourcePaths.Add(Path.Combine(myDocumentsPath, "Scripts"));
resourcePaths.Add(Path.Combine(psUserContentPath, "Modules"));
resourcePaths.Add(Path.Combine(psUserContentPath, "Scripts"));
}

if (scope.Value is ScopeType.AllUsers)
Expand Down Expand Up @@ -1156,28 +1157,97 @@ private static string GetHomeOrCreateTempHome()
}

private readonly static Version PSVersion6 = new Version(6, 0);
private readonly static Version PSVersion7_7 = new Version(7, 7);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the PR in the PowerShell repo I & the community really do not want this dragging out until 7.7.0

We can get this in much sooner than that as I mentioned in that PR @jshigetomi

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kilasuit This feature is coming out in 7.7.0-preivew.1 which shouldn't be too far away from release.


/// <summary>
/// Gets the user content directory path using PowerShell's $PSUserContentPath variable.
/// Falls back to legacy path if the variable is not available or PowerShell version is below 7.7.0.
/// </summary>
private static string GetUserContentPath(PSCmdlet psCmdlet, Version psVersion, string legacyPath)
{

// Only use PSContentPath features if PowerShell version is 7.7.0 or greater (when PSContentPath feature is available)
if (psVersion >= PSVersion7_7)
{
// Try to get the readonly $PSUserContentPath variable (PowerShell 7.7+ with PSContentPath enabled)
try
{
var contentPathVar = psCmdlet.SessionState.PSVariable.GetValue("PSUserContentPath");
if (contentPathVar != null)
{
string userContentPath = contentPathVar.ToString();
if (!string.IsNullOrEmpty(userContentPath))
{
psCmdlet.WriteVerbose($"User content path from $PSUserContentPath variable: {userContentPath}");
InternalHooks.LastUserContentPathSource = "$PSUserContentPath";
InternalHooks.LastUserContentPath = userContentPath;
return userContentPath;
}
}
}
catch (Exception ex)
{
psCmdlet.WriteVerbose($"$PSUserContentPath variable not available: {ex.Message}");
}
}
else
{
psCmdlet.WriteVerbose($"PowerShell version {psVersion} is below 7.7.0, using legacy location");
}

// Fallback to legacy location
psCmdlet.WriteVerbose($"Using legacy location: {legacyPath}");
InternalHooks.LastUserContentPathSource = "Legacy";
InternalHooks.LastUserContentPath = legacyPath;
return legacyPath;
}

private static void GetStandardPlatformPaths(
PSCmdlet psCmdlet,
out string localUserDir,
out string allUsersDir)
{
// Get PowerShell engine version from $PSVersionTable.PSVersion (automatic variable, always available)
dynamic psVersionObj = (psCmdlet.SessionState.PSVariable.GetValue("PSVersionTable") as Hashtable)["PSVersion"];
Version psVersion = new Version((int)psVersionObj.Major, (int)psVersionObj.Minor);

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string powerShellType = (psCmdlet.Host.Version >= PSVersion6) ? "PowerShell" : "WindowsPowerShell";
localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType);
string powerShellType = (psVersion >= PSVersion6) ? "PowerShell" : "WindowsPowerShell";

// Windows PowerShell doesn't support experimental features or PSContentPath
if (powerShellType == "WindowsPowerShell")
{
// Use legacy Documents folder for Windows PowerShell
localUserDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), powerShellType);
psCmdlet.WriteVerbose($"Using Windows PowerShell Documents folder: {localUserDir}");
}
else
{
string legacyPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
powerShellType
);

localUserDir = GetUserContentPath(psCmdlet, psVersion, legacyPath);
}

allUsersDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), powerShellType);
}
else
{
// paths are the same for both Linux and macOS
localUserDir = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell");
// Create the default data directory if it doesn't exist.
string legacyPath = Path.Combine(GetHomeOrCreateTempHome(), ".local", "share", "powershell");

localUserDir = GetUserContentPath(psCmdlet, psVersion, legacyPath);

// Create the default data directory if it doesn't exist
if (!Directory.Exists(localUserDir))
{
Directory.CreateDirectory(localUserDir);
}

allUsersDir = System.IO.Path.Combine("/usr", "local", "share", "powershell");
allUsersDir = Path.Combine("/", "usr", "local", "share", "powershell");
}
}

Expand Down
171 changes: 171 additions & 0 deletions test/PSContentPath.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')]
Param()

$ProgressPreference = "SilentlyContinue"
$modPath = "$psscriptroot/PSGetTestUtils.psm1"
Import-Module $modPath -Force

Describe 'PSUserContentPath/PSContentPath - End-to-End Install Location' -Tags 'CI' {
BeforeAll {
$script:originalPSModulePath = $env:PSModulePath
$script:actualConfigPath = Join-Path $env:LOCALAPPDATA "PowerShell\powershell.config.json"
$script:configBackup = $null

# Detect if Get-PSContentPath cmdlet is available (requires PSContentPath experimental feature)
$script:getPSContentPathAvailable = $false
$script:isPSContentPathEnabled = $false
$script:sessionContentPath = $null
try {
# Check if Get-PSContentPath cmdlet exists
$null = Get-Command Get-PSContentPath -ErrorAction Stop
$script:getPSContentPathAvailable = $true

# Get the actual session path
$script:sessionContentPath = Get-PSContentPath
$documentsPath = [Environment]::GetFolderPath('MyDocuments')
$documentsPS = Join-Path $documentsPath "PowerShell"

# If Get-PSContentPath returns something other than Documents, the feature is enabled
$script:isPSContentPathEnabled = $script:sessionContentPath -ne $documentsPS
} catch {
# Get-PSContentPath not available (feature disabled)
}

# Backup existing config if it exists
if (Test-Path $script:actualConfigPath) {
$script:configBackup = Get-Content $script:actualConfigPath -Raw
}

$localRepo = "psgettestlocal"
$testModuleName = "PSContentPathTestModule"
Get-NewPSResourceRepositoryFile
Register-LocalRepos

# Create a test module
New-TestModule -moduleName $testModuleName -repoName $localRepo -packageVersion "1.0.0" -prereleaseLabel "" -tags @()
}

AfterEach {
# Restore PSModulePath
$env:PSModulePath = $script:originalPSModulePath
# Clean up installed test modules
Uninstall-PSResource $testModuleName -Version "*" -SkipDependencyCheck -ErrorAction SilentlyContinue
# Clear testing hooks
[Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::ClearPSContentPathHooks()
}

AfterAll {
# Restore original config
if ($null -ne $script:configBackup) {
Set-Content -Path $script:actualConfigPath -Value $script:configBackup -Force
}
Get-RevertPSResourceRepositoryFile
}

Context "PSResourceGet behavior on PS 7.7+" {
It "Should use Get-PSContentPath when available, Legacy when not" {
Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository

$pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource")
$pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath")

if ($script:getPSContentPathAvailable) {
# When Get-PSContentPath cmdlet exists, PSResourceGet should use it
$pathSource | Should -Be "Get-PSContentPath"
$pathUsed | Should -Be $script:sessionContentPath
} else {
# When Get-PSContentPath cmdlet doesn't exist, PSResourceGet should use legacy path
$pathSource | Should -Be "Legacy"
$documentsPath = [Environment]::GetFolderPath('MyDocuments')
$pathUsed | Should -BeLike "*$documentsPath*PowerShell"
}

# Module should be installed
$res = Get-InstalledPSResource -Name $testModuleName
$res.Name | Should -Be $testModuleName
}
}

Context "When PSContentPath feature is enabled in session (PS >= 7.7)" {
It "Should install to custom LocalAppData path (not Documents)" {
if (-not $script:getPSContentPathAvailable -or -not $script:isPSContentPathEnabled) {
Set-ItResult -Skipped -Because "PSContentPath feature not enabled in this session"
return
}

Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository

$pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource")
$pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath")

# PSResourceGet should call Get-PSContentPath
$pathSource | Should -Be "Get-PSContentPath"

# Path should NOT be Documents (feature enabled means custom path)
$documentsPath = [Environment]::GetFolderPath('MyDocuments')
$pathUsed | Should -Not -BeLike "*$documentsPath*"

# Module should be installed in custom path
$res = Get-InstalledPSResource -Name $testModuleName
$res.Name | Should -Be $testModuleName
$res.InstalledLocation | Should -Not -BeLike "*$documentsPath*"
}
}

Context "PSResourceGet correctly delegates path resolution (PS >= 7.7)" {
It "Should always defer to Get-PSContentPath when cmdlet is available" {
if (-not $script:getPSContentPathAvailable) {
Set-ItResult -Skipped -Because "Get-PSContentPath cmdlet not available"
return
}

$beforePath = Get-PSContentPath

Install-PSResource -Name $testModuleName -Repository $localRepo -Scope CurrentUser -TrustRepository

$pathSource = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPathSource")
$pathUsed = [Microsoft.PowerShell.PSResourceGet.UtilClasses.InternalHooks]::GetTestHook("LastUserContentPath")

# PSResourceGet should call Get-PSContentPath
$pathSource | Should -Be "Get-PSContentPath"

# Path should match what Get-PSContentPath returns
$pathUsed | Should -Be $beforePath

# Module should be installed
$res = Get-InstalledPSResource -Name $testModuleName
$res.Name | Should -Be $testModuleName
}
}

Context "AllUsers scope should not be affected by PSContentPath/PSUserContentPath" {
BeforeAll {
if (!$IsWindows -or !(Test-IsAdmin)) { return }
}
It "Should install to Program Files (AllUsers not affected by PSContentPath)" {
if (!$IsWindows -or !(Test-IsAdmin)) {
Set-ItResult -Skipped -Because "Test requires Windows and Administrator privileges"
return
}
Install-PSResource -Name $testModuleName -Repository $localRepo -Scope AllUsers -TrustRepository
$programFilesPath = [Environment]::GetFolderPath('ProgramFiles')
$expectedPath = Join-Path $programFilesPath "PowerShell\Modules\$testModuleName"
Test-Path $expectedPath | Should -BeTrue
$res = Get-InstalledPSResource -Name $testModuleName
$res.Name | Should -Be $testModuleName
$res.InstalledLocation | Should -BeLike "*Program Files*PowerShell*Modules*"
}
}
}

function Test-IsAdmin {
if ($IsWindows) {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
return $false
}