Automated PowerShell module maintenance for Windows. Updates all PSResourceGet-managed modules and prunes old versions on a weekly schedule with comprehensive logging.
- 🔄 Automatic Updates — Updates all installed PowerShell modules via PSResourceGet
- 🧹 Version Pruning — Removes old module versions, keeping only the latest
- ☁️ OneDrive Migration — Standalone script to migrate modules out of OneDrive-synced folders to AllUsers scope
- 📋 Comprehensive Logging — Structured logs with transcripts and JSON summaries
- ⚙️ Configurable Exclusions — Skip specific modules via config file
- ⏰ Scheduled Execution — Runs weekly via Windows Task Scheduler
- 🔔 Toast Notifications — Optional Windows toast notifications after each run
- 🛡️ Per-Module Timeout — Each module update runs in an isolated runspace with a configurable timeout, preventing one slow module from blocking the entire run
- Windows 10/11 or Windows Server 2019+
- PowerShell 7.0 or later
- Microsoft.PowerShell.PSResourceGet module
git clone https://github.com/haakonwibe/PSModuleMaintenance.git
cd PSModuleMaintenanceEdit config.json to exclude specific modules:
{
"ExcludedModules": [
"Az.Accounts",
"SomeModuleIPinToSpecificVersion"
],
"LogRetentionDays": 180,
"TrustPSGallery": true,
"NotificationMode": "Always",
"ModuleUpdateTimeoutSeconds": 600
}If your device uses OneDrive Known Folder Move (common on enterprise/Intune-managed devices), your PowerShell modules are synced to OneDrive, which causes file locks and sync conflicts. Run the migration script first to move them out:
# Check if you're affected (dry run)
.\Invoke-OneDriveMigration.ps1 -WhatIf
# If it finds modules, run the migration (requires Administrator)
.\Invoke-OneDriveMigration.ps1If OneDrive is not detected, the script exits immediately. See OneDrive Migration for details.
Run as Administrator:
.\Install-ModuleMaintenance.ps1This creates a weekly task running Sundays at 3:00 AM.
.\Install-ModuleMaintenance.ps1 -DayOfWeek Saturday -Time "04:30"Run the maintenance script directly:
# Full maintenance (update + prune)
.\Invoke-PSModuleMaintenance.ps1
# Update only
.\Invoke-PSModuleMaintenance.ps1 -UpdateOnly
# Prune only
.\Invoke-PSModuleMaintenance.ps1 -PruneOnly
# Dry run - see what would happen
.\Invoke-PSModuleMaintenance.ps1 -WhatIf
# Verbose output
.\Invoke-PSModuleMaintenance.ps1 -Verbose
# Migrate modules out of OneDrive (one-time, see OneDrive Migration section below)
.\Invoke-OneDriveMigration.ps1| Setting | Type | Default | Description |
|---|---|---|---|
ExcludedModules |
string[] | [] |
Module names to skip during updates and pruning |
LogRetentionDays |
int | 180 |
Days to keep log files before auto-cleanup |
TrustPSGallery |
bool | true |
Trust PSGallery during updates (avoids prompts) |
NotificationMode |
string | "Always" |
Toast notifications: "Always", "OnFailure", or "Never" |
ModuleUpdateTimeoutSeconds |
int | 600 |
Max seconds per module update before timing out and moving to the next |
PSModuleMaintenance can show a Windows toast notification after each run. Set NotificationMode in config.json:
"Always"— Notification after every run with a summary of updates and pruning (default)"OnFailure"— Notification only when modules fail to update or versions fail to prune"Never"— No notifications
The toast uses the built-in Windows "Security and Maintenance" notification channel — no additional setup required.
Logs are written to %ProgramData%\PSModuleMaintenance\Logs\:
C:\ProgramData\PSModuleMaintenance\Logs\
├── maintenance_2024-01-15_030000.log # Structured log
├── transcript_2024-01-15_030000.log # Full verbose transcript
└── summary_2024-01-15_030512.json # Machine-readable summary
{
"StartTime": "2024-01-15T03:00:00.0000000+01:00",
"EndTime": "2024-01-15T03:05:12.0000000+01:00",
"ModulesChecked": 79,
"ModulesUpdated": 12,
"ModulesFailed": [],
"VersionsPruned": 45,
"PrunesFailed": [],
"ExcludedModules": ["Az.Accounts"]
}Remove the scheduled task:
.\Install-ModuleMaintenance.ps1 -UninstallOptionally remove logs:
Remove-Item "$env:ProgramData\PSModuleMaintenance" -Recurse -ForcePowerShell 7 installs CurrentUser-scope modules to $HOME\Documents\PowerShell\Modules. On enterprise devices with Known Folder Move enabled, the Documents folder is redirected to OneDrive. This causes OneDrive to sync module files, leading to:
- File locks during sync that block
Update-PSResourceandUninstall-PSResourcewith "Cannot remove package path" and "Access denied" errors - Cloud placeholders (reparse points) — OneDrive replaces local files with cloud-only stubs that standard file APIs cannot delete
- Deletion confirmation popups when pruning old module versions
- Inability to exclude the folder from sync on managed devices (organizational policy)
How do I know if this affects me? Run .\Invoke-OneDriveMigration.ps1 -WhatIf — if it says "CurrentUser module path is not in OneDrive", you're not affected and can ignore this section entirely. If it lists modules to copy, you're affected. This is common on enterprise devices managed by Intune/SCCM with Known Folder Move policies.
Solving this required fighting four systems at once, each with undocumented edge cases that only revealed themselves when the previous layer was fixed:
-
PSResourceGet's scope model —
Get-PSResourcewithout-Scopedefaults to CurrentUser only (finds nothing after migration).Uninstall-PSResourcewithout-Scopetargets any scope (deletes the wrong copies).InstalledLocationreturns inconsistent paths. Each API call needed different scope handling. -
OneDrive Known Folder Move — Silently redirects a system path that PowerShell depends on. No API to detect it directly — you have to infer it by comparing
[Environment]::GetFolderPath('MyDocuments')against OneDrive environment variables. -
OneDrive cloud placeholders — Files that look normal to
Get-ChildItembut are actually NTFS reparse points with no local data. They return "Access denied" on delete, buthandle.exeshows no locks and ACLs show FullControl. The fix: strip cloud attributes withattrib -P -U -O, then usecmd.exe rd /s /qwhich handles reparse points where PowerShell'sRemove-Itemcannot. -
The cascading reveal — Each fix exposed the next bug. Fix the migration → scope mismatch deletes AllUsers modules. Fix the scope →
Get-PSResourcereturns 1 module instead of 160. Fix that →InstalledLocationpoints to wrong path. Fix that → "Access denied" on cloud placeholders. No single system was "wrong" — the bugs only existed at the intersections.
Invoke-OneDriveMigration.ps1 is a standalone one-time migration script that:
- Detects if your CurrentUser module path is inside a OneDrive-synced folder
- Copies all modules to AllUsers scope (
$env:ProgramFiles\PowerShell\Modules) — safely, without deleting the originals - Cleans up the old OneDrive copies with four-stage force-removal for cloud placeholders and locked files (see Troubleshooting)
After migration, the weekly maintenance script (Invoke-PSModuleMaintenance.ps1) automatically detects OneDrive on the module path and targets AllUsers scope for all future updates and pruning — no configuration needed.
The migration is idempotent and gradual — modules that already exist at the destination are skipped, and OneDrive copies that can't be removed are either force-deleted or scheduled for reboot deletion.
If you skip migration, the weekly maintenance script will still work (it detects OneDrive and targets AllUsers scope automatically), but any modules left in the OneDrive path will trigger a warning in the logs: Found N module(s) in OneDrive path — run Invoke-OneDriveMigration.ps1 to migrate them.
# Dry-run first to see what would happen
.\Invoke-OneDriveMigration.ps1 -WhatIf
# Run the migration (requires Administrator)
.\Invoke-OneDriveMigration.ps1
# Copy modules to AllUsers but keep OneDrive copies in place
.\Invoke-OneDriveMigration.ps1 -SkipCleanup- Load Configuration — Reads
config.jsonfor exclusions and settings - Initialize Logging — Creates timestamped log files and starts transcript
- Clean Old Logs — Removes logs older than retention period
- Update Modules — Bulk checks PSGallery for available updates, then updates each module in an isolated runspace with a per-module timeout (targets AllUsers scope when OneDrive is detected)
- Prune Versions — Groups modules by name, keeps newest, removes the rest (skips built-in modules like PackageManagement). When OneDrive is detected and modules are found in the CurrentUser path, logs a warning to run
Invoke-OneDriveMigration.ps1 - Save Summary — Writes JSON summary after each phase (incremental saves protect against process termination)
- Toast Notification — Shows a Windows toast with the run summary (if enabled via
NotificationMode)
Check Task Scheduler history. Common issues:
- Script path changed after installation
- User password changed (re-run installer)
- Network not available at scheduled time
Check the log files for specific errors. Common causes:
- Module removed from PSGallery
- Dependency conflicts
- Network/proxy issues
Large meta-modules like Microsoft.Graph (40+ sub-modules) can exceed the default 10-minute timeout. Increase it in config.json:
{
"ModuleUpdateTimeoutSeconds": 1200
}The log will show which module timed out and how far through the update list it got (e.g. "Updating module 1/40: Microsoft.Graph").
The maintenance script requires Administrator privileges when modules are in AllUsers scope (after OneDrive migration). The scheduled task is configured to run with highest privileges. For manual runs, use an elevated PowerShell prompt. Invoke-OneDriveMigration.ps1 requires Administrator (enforced via #Requires -RunAsAdministrator).
OneDrive can block file deletion in two ways: sync locks during active syncing, and cloud placeholders (reparse points) where OneDrive replaces local files with cloud-only stubs. Both cause "Access denied" errors. The script handles this with a four-stage escalation:
- Normal deletion —
Remove-Item -Recurse -Force - Cloud attribute strip +
rd /s /q— Strips OneDrive cloud-file attributes (attrib -P -U -O) to convert placeholders back to normal files, then usescmd.exe rd /s /qwhich handles reparse points differently than PowerShell'sRemove-Item - File-by-file deletion — Deletes individual files, skipping those still locked
- Reboot-scheduled deletion — Uses
kernel32.dll MoveFileExwithMOVEFILE_DELAY_UNTIL_REBOOTto schedule remaining locked files for deletion by the Windows kernel on next reboot (before any user-mode process starts)
Most OneDrive cleanup completes at stage 2. Stage 4 is the nuclear option for truly stubborn files.
OneDrive may warn about mass deletions when cleaning up migrated module copies. This is expected — the modules have already been copied to AllUsers scope. Click "Delete" to allow OneDrive to sync the removal.
Issues and PRs welcome! Please include log output when reporting bugs.
MIT License - See LICENSE for details.
Haakon Wibe
- Blog: alttabtowork.com
- Twitter: @HaakonWibe