Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User context, don't install to %OneDriveCommercial% if OneDrive for Business Known Folder Move (KFM) is enabled #627

Open
o-l-a-v opened this issue Apr 7, 2022 · 11 comments

Comments

@o-l-a-v
Copy link
Contributor

o-l-a-v commented Apr 7, 2022

Summary of the new feature / enhancement

Behavior today

Default install location for PowerShell scripts and modules when specifying user context, is:

  • Windows PowerShell: %USERPROFILE%\Documents\WindowsPowerShell\Modules
  • PowerShell Core: %USERPROFILE%\Documents\PowerShell\Modules

But if you have OneDrive for Business set up with Known Folder Move (KFM), default install location for user context is:

  • Windows PowerShell: %OneDriveCommercial%\Documents\WindowsPowerShell\Modules
  • PowerShell Core: %OneDriveCommercial%\Documents\PowerShell\Modules

Why is it a problem

This is not ideal, as you'll end up with hundreds or thousands of small files that will be synced up and down to OneDrive, which might cause OneDrive sync issues, and other performance hits.

I currently install all modules to AllUsers scope for this reason. Currently 2.7 GB, 12 617 files, 2 315 folders.

Screenshot

image

If I did not care about this myself, I'd be using more than 1 / 10 of the capacity / max number of files recommendation for the OneDrive client, just for PowerShell modules.

Proposed technical implementation details

In my opinion, there is no reason to install PowerShell modules from PowerShell Gallery to OneDrive by default when KFM is active. A publicly available PowerShell module is nothing unique that needs to be backed up/ synced.

Option 1 - Cmdlet to set PSResourceLocation for Process/User/Machine

Add cmdlet to set PSResourceLocation for scope Process/User/Machine. For instance:

Set-PSResourceLocation -Scope 'Process' -Path ('{0}\Microsoft\PowerShell' -f $env:LOCALAPPDATA)

It could also:

  • Add path to [System.Environment]::GetEnvironmentVariable('PSModulePath','<scope>').
  • Have a Boolean parameter that specifiec whether to move modules and scripts installed by PackageManagement, PowerShellGet and Microsoft.PowerShell.PSResourceGet from old path given scope to new path.
    • Ask user if parameter wasn't specified.

Option 2 - Use first path in $env:PSModulePath if set

If I've set [System.Environment]::GetEnvironmentVariable('PSModulePath','User').Split(';')[0] to be somewhere else than the default location for <scope>, use it.

Option 3 - Don't follow KFM redirect

Users must opt in to install PowerShell modules to OneDrive, instead of current default behavior.

  • Windows PowerShell: %USERPROFILE%\Documents\WindowsPowerShell\Modules
  • PowerShell Core: %USERPROFILE%\Documents\PowerShell\Modules

Option 4 - Change default location for user scope to %LOCALAPPDATA%

Change default location for user context to:

  • Windows PowerShell: %LOCALAPPDATA%\WindowsPowerShell\Modules
  • PowerShell Core: %LOCALAPPDATA%\PowerShell\Modules
@o-l-a-v o-l-a-v changed the title User context, don't install to %OneDriveCommercial% if OneDrive for Business Known Folder Move (KFM) is enabled User context, don't install to %OneDriveCommercial% if OneDrive for Business Known Folder Move (KFM) is enabled Apr 7, 2022
@SydneyhSmith
Copy link
Collaborator

Thanks @o-l-a-v for opening this issue, we are investigating this-- to confirm have you hit this issue with our v3 previews?

@o-l-a-v
Copy link
Contributor Author

o-l-a-v commented Apr 21, 2022

Don't remember if I've tried with v3 yet.

Should be easy enough for you to reproduce? :)

@o-l-a-v
Copy link
Contributor Author

o-l-a-v commented Apr 22, 2022

Tested with beta 3.0.12, it installs to %OneDriveCommercial%\Documents from both Windows PowerShell and PowerShell 7.2.2.

\WindowsPowerShell with Windows PowerShell, \PowerShell with v7.2.2.

  • One more thing I noticed: Running the install command under first from Windows PowerShell, then with PowerShell v7.2.2, PowerShellGet says it's alrady installed. But it is really, when it get's installed to a different directory with PowerShell Core vs. Windows PowerShell? IMO, there needs to be some more thinking about both where to install, and detection logic.
# Import module downloaded from PowerShellGallery, extracted with 7-Zip
Import-Module -Name ('{0}\powershellget.3.0.12-beta\PowerShellGet.psd1' -f [System.Environment]::GetFolderPath('Desktop'))

# Check imported modules
Get-Module

# Install a module in user context
PowerShellGet\Install-PSResource -Name 'Az.Cdn' -Scope 'CurrentUser' -Repository 'PSGallery' -Quiet -Confirm:$false -TrustRepository -Reinstall

@SydneyhSmith
Copy link
Collaborator

Thanks @o-l-a-v we are planning to do a deeper dive into these (and other path) issues after our next release

@o-l-a-v o-l-a-v changed the title User context, don't install to %OneDriveCommercial% if OneDrive for Business Known Folder Move (KFM) is enabled User context, don't install to %OneDriveCommercial% if OneDrive for Business Known Folder Move (KFM) is enabled May 12, 2022
@Jaykul
Copy link
Contributor

Jaykul commented Jun 13, 2022

Honestly, whether or not the changes in PowerShell/15552 are made, having a parameter set which gives us the ability to specify the -InstallPath instead of a -Scope when calling Install-PSResource would allow users who really want or need to do this to just change their $Env:PSModulePath and set a $PSDefaultParameterValues to make it happen -- without needing to learn to use Save-PSResource instead.

On company developer laptops, OneDrive routinely causes "Access to the cloud file is denied" errors when trying to upgrade modules there, and starts needlessly mirroring the files back to the cloud, slowing down install even more. Not to mention that every time I remove an old version of Az or Microsoft.Graph it causes that scary warning about how something has deleted thousands of files from my OneDrive...

@BradCalvertLPNT
Copy link

I was more than happy to solve this in OneDrive "Choose Folders" option, but that doesn't work remotely the way I expected. I just wanted to exclude the Modules folder from syncing entirely but unchecking it in that screen does very different things than that.

@fowl2
Copy link

fowl2 commented Nov 16, 2022

Having modules roam between machines (via OneDrive) is desired functionality for me! Please don't turn it off!

I can see that some might want to turn it off, which they can at the moment by setting $env:PSModulesPath.

I can also see that some sort of alternative mechanism might be desired - eg. some sort of placeholder file, etc. although not all modules are available from the gallery, so quite a bit of design required there.

I would strongly recommend caution when breaking existing workflows by disabling/altering the currently enabled-by-default feature.

@anamnavi
Copy link
Member

@fowl2 thanks for reaching out and providing insight into your use case with this default behavior. This is being discussed and worked on from the PowerShell project side, on this issue linked here: PowerShell/PowerShell#15552

If you can share this comment there, that would be great thanks.

@o-l-a-v
Copy link
Contributor Author

o-l-a-v commented Jan 12, 2023

After some more tinkering I've found a solution that works for me.

1. Decide on desired directory

I settled on %LOCALAPPDATA%\Microsoft\PowerShell\Modules.

2. Create desired directory

The desired directory must be created prior to step 4.

Example code
$DesiredDirectory = [string] '{0}\Microsoft\PowerShell\Modules' -f $env:LOCALAPPDATA
if (-not [System.IO.Directory]::Exists($DesiredDirectory)) {
    $null = [System.IO.Directory]::CreateDirectory($DesiredDirectory)
}

3. Set user context environmental variable PSModulePath to desired directory

Important for PowerShell to automagically look for modules in desired directory.

Example code
# Assets
$PSModulePathWanted = [string] '%LOCALAPPDATA%\Microsoft\PowerShell\Modules'
$PSModulePathWantedResolved = [string] (cmd /c ('echo {0}' -f $PSModulePathWanted))
$RegistryPath = [string] 'Registry::HKEY_CURRENT_USER\Environment'

# Create path if it does not exist
if (-not [System.IO.Directory]::Exists($PSModulePathWantedResolved)) {
    $null = [System.IO.Directory]::CreateDirectory($PSModulePathWantedResolved)
}

# Get current value without resolving the path / expanding the environmental variable
$PSModulePathCurrent = [string](
    (Get-Item -Path $RegistryPath).GetValue(
        'PSModulePath',
        '',
        'DoNotExpandEnvironmentNames'
    )
)

# Make current PSModulePath to a string array for easier operations
$PSModulePathNewAsArray = [string[]](
    $PSModulePathCurrent.Split(
        [System.IO.Path]::PathSeparator
    ).Where{
        -not [string]::IsNullOrEmpty($_)
    }
)

# Remove "MyDocuments" if present, as it will resolve to OneDrive if Known Folder Move is enabled
$PSModulePathNewAsArray = [string[]](
    $PSModulePathNewAsArray.Where{
        $_ -notlike ('{0}\*' -f [System.Environment]::GetFolderPath('MyDocuments'))
    }
)

# Add $PSModulePathWanted if not already present
if ($PSModulePathNewAsArray -notcontains $PSModulePathWanted) {
    $PSModulePathNewAsArray = [string[]](
        [string[]]($PSModulePathWanted) + [string[]]($PSModulePathNewAsArray) | Where-Object -FilterScript {
            -not [string]::IsNullOrEmpty($_)
        }
    )
}

# Convert $PSModulePathNewAsArray to string for easier comparison to existing value
$PSModulePathNew = [string]($PSModulePathNewAsArray -join [System.IO.Path]::PathSeparator)

# Set new value if it changed
if ($PSModulePathNew -ne $PSModulePathCurrent) {
    $null = Set-ItemProperty -Path $RegistryPath -Name 'PSModulePath' -Value $PSModulePathNew -Force -Type ([Microsoft.Win32.RegistryValueKind]::ExpandString)
}

4. Use Save-Package for installing modules

Use PackageManagement cmdlet Save-Package, which let's you specify path.

Example code using PackageManagement
# Install a module
## Assets
$DesiredDirectory = [string] '{0}\Microsoft\PowerShell\Modules' -f $env:LOCALAPPDATA
$ModuleToInstall  = [string] 'Az.Accounts'

## Create directory if it does not already exist
if (-not [System.IO.Directory]::Exists($DesiredDirectory)) {
    $null = [System.IO.Directory]::CreateDirectory($DesiredDirectory)
}

## Install module
$null = PackageManagement\Save-Package -Type 'Module' -Source 'PSGallery' -Name $ModuleToInstall -Path $DesiredDirectory

Edit 1

I later found out that PackageManagement and PowerShellGet does not actually use $env:PSModulePath when searching for installed modules, while Microsoft.PowerShell.Core\Get-Module and Import-Module does. Opened an issue on this here:

The problem with Microsoft.PowerShell.Core\Get-Module though, for me at least, is that it does not return enough attributes on the module, like "Author".

  • "Author" is usefull when looking for child modules, say for Az, and you want to filter out modules made by others than Microsoft Corporation.
Workaround for finding installed modules and versions to custom folder location.
### System context / AllUsers
$ModulesDirectory = [string] '{0}\WindowsPowerShell\Modules' -f $env:ProgramW6432

### User context / CurrentUser
$ModulesDirectory = [string] '{0}\Microsoft\PowerShell\Modules' -f $env:LOCALAPPDATA

### Get installed modules
$ModulesInstalledFromPSGallery = [PSCustomObject[]](
    $(
        [array](
            Get-ChildItem -Path $ModulesDirectory -Depth 0 -Directory
        )
    ).ForEach{
        Get-ChildItem -Path $_.'FullName' -Depth 0 -Directory | Where-Object -FilterScript {
            [System.IO.File]::Exists(
                ('{0}\PSGetModuleInfo.xml' -f $_.'FullName')
            )
        } | Group-Object -Property 'Parent'
    }.ForEach{
        [PSCustomObject]@{
            'Module'   = [string] $_.'Name'
            'Versions' = [System.Version[]] $_.'Group'.'Name'
            'Author'   = [string](
                (Get-Content -Path ('{0}\PSGetModuleInfo.xml' -f $_.'Group'[0].'FullName') -Raw).Split(
                    [System.Environment]::NewLine
                ).Where{               
                    $_ -like '*<S N="Author">*'
                }.Trim().Split('>')[-2].Split('<')[0]
            )
            'Path'     = [string] [System.IO.Directory]::GetParent($_.'Group'[0])
        }
    }
)

Edit 2

Got it faster by using .NET classes

Click to expand
### System context / AllUsers
$ModulesDirectory = [string] '{0}\WindowsPowerShell\Modules' -f $env:ProgramW6432

### User context / CurrentUser
$ModulesDirectory = [string] '{0}\Microsoft\PowerShell\Modules' -f $env:LOCALAPPDATA

$ModulesInstalledFromPSGallery = [PSCustomObject[]](
    $(
        [string[]]([System.IO.Directory]::GetDirectories($ModulesDirectory))
    ).ForEach{
        $(
            [string[]]([System.IO.Directory]::GetDirectories($_))
        ).Where{ 
            [System.IO.File]::Exists(('{0}\PSGetModuleInfo.xml' -f $_))
        } | Group-Object -Property @{'Expression'={[string]$_.Split([System.IO.Path]::DirectorySeparatorChar)[-2]}}
    }.ForEach{
        [PSCustomObject]@{
            'Module'   = [string] $_.'Name'
            'Versions' = [System.Version[]]($_.'Group'.ForEach{$_.Split([System.IO.Path]::DirectorySeparatorChar)[-1]})
            'Author'   = [string](
                [System.IO.File]::ReadAllLines(('{0}\PSGetModuleInfo.xml' -f $_.'Group'[0])).Where{
                    $_ -like '*<S N="Author">*'
                }.Trim().Split('>')[-2].Split('<')[0]
            )
            'Path'     = [string] [System.IO.Directory]::GetParent($_.'Group'[0])
        }
    }
)

Edit 3

Got it even faster by using <string>.Contains() vs -like, and .Where({<filter>},'First').

Click to expand
$ModulesInstalledFromPSGallery = [PSCustomObject[]](
    $(
        [string[]]([System.IO.Directory]::GetDirectories($ModulesDirectory))
    ).ForEach{
        $(
            [string[]]([System.IO.Directory]::GetDirectories($_))
        ).Where{
            [System.IO.File]::Exists(('{0}\PSGetModuleInfo.xml' -f $_))
        } | Group-Object -Property @{'Expression'={[string]$_.Split([System.IO.Path]::DirectorySeparatorChar)[-2]}}
    }.ForEach{
        [PSCustomObject]@{
            'Module'   = [string] $_.'Name'
            'Versions' = [System.Version[]]($_.'Group'.ForEach{$_.Split([System.IO.Path]::DirectorySeparatorChar)[-1]})
            'Author'   = [string](
                [System.IO.File]::ReadAllLines(('{0}\PSGetModuleInfo.xml' -f $_.'Group'[0])).Where(
                    {$_.Contains('<S N="Author">')},
                    'First'
                ).Trim().Split('>')[-2].Split('<')[0]
            )
            'Path'     = [string] [System.IO.Directory]::GetParent($_.'Group'[0])
        }
    }
)

@alerickson alerickson added this to the vNext milestone Jan 18, 2023
@aetos382
Copy link

I have not researched the behavior of PowerShellGet v3, but in v2, when the Update-Module command calls the Install-Module command internally, it uses the value of the InstalledLocation from the PSGetModuleInfo.xml file located within the directory where the module is installed to determine the value of the Scope parameter. This file contains the full path of the module directory. However, if the document directory is synchronized with OneDrive and the same Microsoft account is used on multiple machines, or if the PC is re-setup and the username changes, it is possible that the value of InstalledLocation and the actual path where the module exists may differ.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants