From 618e016fce1f306bf960c192f44ff7e586917a58 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 22 Jul 2026 11:00:22 -0700 Subject: [PATCH 1/9] Add PSGet to PSResourceGet migration tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PowerShell-based migration tool that scans .ps1/.psm1/.psd1 files for PowerShellGet v2 cmdlet usage and converts them to PSResourceGet equivalents using AST-based parsing. Features: - 25 cmdlet mappings (Find-Module → Find-PSResource, etc.) - Parameter transformations (version ranges, renames, removals) - Dry-run report mode and in-place apply with backups - 30 Pester tests covering all conversion scenarios Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/ConvertTo-PSResourceGet.ps1 | 101 ++++ tool/migration/PSGetMigration.psd1 | 30 + tool/migration/PSGetMigration.psm1 | 544 ++++++++++++++++++ tool/migration/README.md | 113 ++++ tool/migration/Tests/PSGetMigration.Tests.ps1 | 285 +++++++++ 5 files changed, 1073 insertions(+) create mode 100644 tool/migration/ConvertTo-PSResourceGet.ps1 create mode 100644 tool/migration/PSGetMigration.psd1 create mode 100644 tool/migration/PSGetMigration.psm1 create mode 100644 tool/migration/README.md create mode 100644 tool/migration/Tests/PSGetMigration.Tests.ps1 diff --git a/tool/migration/ConvertTo-PSResourceGet.ps1 b/tool/migration/ConvertTo-PSResourceGet.ps1 new file mode 100644 index 000000000..36914f6bb --- /dev/null +++ b/tool/migration/ConvertTo-PSResourceGet.ps1 @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Converts PowerShellGet v2 cmdlet usage to PSResourceGet equivalents. + +.DESCRIPTION + Scans PowerShell files (.ps1, .psm1, .psd1) for PSGet v2 cmdlet usage + (e.g., Install-Module, Find-Module) and converts them to their + PSResourceGet equivalents (e.g., Install-PSResource, Find-PSResource). + + By default, runs in WhatIf/report mode. Use -Apply to modify files. + +.PARAMETER Path + Path to a file or directory to scan. Supports wildcards. + Defaults to the current directory. + +.PARAMETER Recurse + When Path is a directory, scan subdirectories recursively. + +.PARAMETER Apply + Apply the changes to files in-place. Without this switch, only a report is generated. + +.PARAMETER BackupPath + Directory for backup copies. Defaults to .bak files alongside originals. + +.PARAMETER PassThru + Emit structured result objects to the pipeline instead of formatted output. + +.EXAMPLE + # Dry-run: scan current directory recursively and show migration report + .\ConvertTo-PSResourceGet.ps1 -Path . -Recurse + +.EXAMPLE + # Apply changes with backups + .\ConvertTo-PSResourceGet.ps1 -Path .\scripts -Recurse -Apply -BackupPath .\backups + +.EXAMPLE + # Scan a single file and get structured output + .\ConvertTo-PSResourceGet.ps1 -Path .\deploy.ps1 -PassThru +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Position = 0)] + [string] $Path = '.', + + [switch] $Recurse, + + [switch] $Apply, + + [string] $BackupPath, + + [switch] $PassThru +) + +# Import the migration module +$modulePath = Join-Path $PSScriptRoot 'PSGetMigration.psm1' +Import-Module $modulePath -Force + +# Resolve files to scan +$resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + +$filesToScan = if (Test-Path $resolvedPath.Path -PathType Container) { + $gciParams = @{ + Path = $resolvedPath.Path + Include = @('*.ps1', '*.psm1', '*.psd1') + File = $true + } + if ($Recurse) { + $gciParams['Recurse'] = $true + } + Get-ChildItem @gciParams +} +else { + Get-Item $resolvedPath.Path +} + +if (-not $filesToScan) { + Write-Host "No PowerShell files found at '$Path'." -ForegroundColor Yellow + return +} + +Write-Host "Scanning $($filesToScan.Count) file(s)..." -ForegroundColor Cyan + +# Process each file +$convertParams = @{} +if ($Apply) { $convertParams['Apply'] = $true } +if ($BackupPath) { $convertParams['BackupPath'] = $BackupPath } + +$results = $filesToScan | ForEach-Object { + ConvertTo-PSResourceGetScript -Path $_.FullName @convertParams +} + +if ($PassThru) { + $results +} +else { + $results | Format-MigrationReport +} diff --git a/tool/migration/PSGetMigration.psd1 b/tool/migration/PSGetMigration.psd1 new file mode 100644 index 000000000..32eb22dc7 --- /dev/null +++ b/tool/migration/PSGetMigration.psd1 @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +@{ + RootModule = 'PSGetMigration.psm1' + ModuleVersion = '0.1.0' + GUID = 'a3f7b2c1-4d5e-6f78-9a0b-c1d2e3f4a5b6' + Author = 'Microsoft Corporation' + CompanyName = 'Microsoft Corporation' + Copyright = '(c) Microsoft Corporation. All rights reserved.' + Description = 'Migration tool to convert PowerShellGet v2 (PSGet) cmdlet usage to PSResourceGet equivalents.' + PowerShellVersion = '5.1' + FunctionsToExport = @( + 'Get-PSGetCommandMapping', + 'Get-PSGetParameterMapping', + 'Find-PSGetCommand', + 'Convert-PSGetCommand', + 'ConvertTo-PSResourceGetScript', + 'Format-MigrationReport' + ) + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + PrivateData = @{ + PSData = @{ + Tags = @('Migration', 'PSResourceGet', 'PowerShellGet', 'PSGet') + ProjectUri = 'https://github.com/PowerShell/PSResourceGet' + } + } +} diff --git a/tool/migration/PSGetMigration.psm1 b/tool/migration/PSGetMigration.psm1 new file mode 100644 index 000000000..d030e3d53 --- /dev/null +++ b/tool/migration/PSGetMigration.psm1 @@ -0,0 +1,544 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Migration tool to convert PowerShellGet v2 (PSGet) cmdlet usage to PSResourceGet equivalents. + +.DESCRIPTION + This module provides functions to scan PowerShell script files for PSGet v2 cmdlet usage + and convert them to their PSResourceGet equivalents, handling cmdlet name changes, + parameter renames, and version range syntax transformations. +#> + +#region Cmdlet Mapping + +function Get-PSGetCommandMapping { + <# + .SYNOPSIS + Returns a hashtable mapping PSGet v2 cmdlet names to PSResourceGet equivalents. + #> + [OutputType([hashtable])] + param() + + return @{ + # Find cmdlets + 'Find-Module' = @{ Command = 'Find-PSResource' } + 'Find-Script' = @{ Command = 'Find-PSResource' } + 'Find-DscResource' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'DscResource' } } + 'Find-Command' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'Command' } } + 'Find-RoleCapability' = @{ Command = $null; Warning = 'Find-RoleCapability has no PSResourceGet equivalent. Manual migration required.' } + + # Install cmdlets + 'Install-Module' = @{ Command = 'Install-PSResource' } + 'Install-Script' = @{ Command = 'Install-PSResource' } + + # Update cmdlets + 'Update-Module' = @{ Command = 'Update-PSResource' } + 'Update-Script' = @{ Command = 'Update-PSResource' } + + # Uninstall cmdlets + 'Uninstall-Module' = @{ Command = 'Uninstall-PSResource' } + 'Uninstall-Script' = @{ Command = 'Uninstall-PSResource' } + + # Save cmdlets + 'Save-Module' = @{ Command = 'Save-PSResource' } + 'Save-Script' = @{ Command = 'Save-PSResource' } + + # Publish cmdlets + 'Publish-Module' = @{ Command = 'Publish-PSResource' } + 'Publish-Script' = @{ Command = 'Publish-PSResource' } + + # Get installed + 'Get-InstalledModule' = @{ Command = 'Get-InstalledPSResource' } + 'Get-InstalledScript' = @{ Command = 'Get-InstalledPSResource' } + + # Repository cmdlets + 'Register-PSRepository' = @{ Command = 'Register-PSResourceRepository' } + 'Unregister-PSRepository' = @{ Command = 'Unregister-PSResourceRepository' } + 'Set-PSRepository' = @{ Command = 'Set-PSResourceRepository' } + 'Get-PSRepository' = @{ Command = 'Get-PSResourceRepository' } + + # Script file info cmdlets + 'New-ScriptFileInfo' = @{ Command = 'New-PSScriptFileInfo' } + 'Test-ScriptFileInfo' = @{ Command = 'Test-PSScriptFileInfo' } + 'Update-ScriptFileInfo' = @{ Command = 'Update-PSScriptFileInfo' } + + # Module manifest + 'Update-ModuleManifest' = @{ Command = 'Update-PSModuleManifest' } + } +} + +function Get-PSGetParameterMapping { + <# + .SYNOPSIS + Returns parameter transformation rules for PSGet v2 to PSResourceGet migration. + .DESCRIPTION + Each entry defines how a PSGet v2 parameter maps to its PSResourceGet equivalent. + Mapping types: + - Rename: parameter name changes but value stays the same. + - Remove: parameter is dropped (with optional warning). + - Transform: parameter requires value/logic transformation (handled by Convert-PSGetCommand). + #> + [OutputType([hashtable])] + param() + + return @{ + # Simple renames + 'AllowPrerelease' = @{ Type = 'Rename'; NewName = 'Prerelease' } + 'NuGetApiKey' = @{ Type = 'Rename'; NewName = 'ApiKey' } + + # Version parameters are handled specially (Transform type) + 'RequiredVersion' = @{ Type = 'Transform'; Handler = 'VersionExact' } + 'MinimumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } + 'MaximumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } + 'AllVersions' = @{ Type = 'Transform'; Handler = 'VersionAll' } + + # Includes → Type for Find cmdlets + 'Includes' = @{ Type = 'Rename'; NewName = 'Type' } + + # Removed parameters + 'AllowClobber' = @{ Type = 'Remove'; Warning = '-AllowClobber is not needed in PSResourceGet (clobber is allowed by default).' } + 'SkipPublisherCheck' = @{ Type = 'Remove'; Warning = '-SkipPublisherCheck is not supported in PSResourceGet.' } + 'Force' = @{ Type = 'Remove'; Warning = '-Force semantics differ in PSResourceGet. Review the migrated command. Use -Reinstall for Install-PSResource if forced reinstall is needed.' } + 'Proxy' = @{ Type = 'Remove'; Warning = '-Proxy is not supported in PSResourceGet. Configure proxy at the system level.' } + 'ProxyCredential' = @{ Type = 'Remove'; Warning = '-ProxyCredential is not supported in PSResourceGet. Configure proxy at the system level.' } + } +} + +#endregion + +#region AST Scanner + +function Find-PSGetCommand { + <# + .SYNOPSIS + Finds all PSGet v2 cmdlet invocations in a PowerShell file using AST parsing. + .PARAMETER Path + Path to the PowerShell file to scan. + .OUTPUTS + Objects with properties: CommandName, Line, Column, Extent, CommandAst + #> + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + $commandMapping = Get-PSGetCommandMapping + $psGetCommands = $commandMapping.Keys + + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $resolvedPath.Path, [ref]$tokens, [ref]$parseErrors + ) + + if ($parseErrors.Count -gt 0) { + Write-Warning "Parse errors in '$Path': $($parseErrors | ForEach-Object { $_.Message } | Join-String -Separator '; ')" + } + + # Find all CommandAst nodes + $commandAsts = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true) + + foreach ($cmdAst in $commandAsts) { + $commandName = $cmdAst.GetCommandName() + if ($commandName -and $psGetCommands -contains $commandName) { + [PSCustomObject]@{ + CommandName = $commandName + Line = $cmdAst.Extent.StartLineNumber + Column = $cmdAst.Extent.StartColumnNumber + StartOffset = $cmdAst.Extent.StartOffset + EndOffset = $cmdAst.Extent.EndOffset + ExtentText = $cmdAst.Extent.Text + CommandAst = $cmdAst + } + } + } +} + +#endregion + +#region Command Converter + +function Convert-PSGetCommand { + <# + .SYNOPSIS + Converts a single PSGet v2 command invocation to its PSResourceGet equivalent. + .PARAMETER CommandInfo + A PSCustomObject from Find-PSGetCommand containing the AST node and metadata. + .OUTPUTS + PSCustomObject with: OriginalText, ConvertedText, Warnings, Line + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $CommandInfo + ) + + $commandMapping = Get-PSGetCommandMapping + $parameterMapping = Get-PSGetParameterMapping + + $mapping = $commandMapping[$CommandInfo.CommandName] + $warnings = [System.Collections.Generic.List[string]]::new() + + # If no equivalent exists, return a warning + if ($null -eq $mapping.Command) { + return [PSCustomObject]@{ + OriginalText = $CommandInfo.ExtentText + ConvertedText = $null + Warnings = @($mapping.Warning) + Line = $CommandInfo.Line + Column = $CommandInfo.Column + Status = 'NoEquivalent' + } + } + + $cmdAst = $CommandInfo.CommandAst + $elements = $cmdAst.CommandElements + + # Build the new command parts + $newCmdName = $mapping.Command + $newParams = [System.Collections.Generic.List[string]]::new() + + # Track version-related parameters for merging + $minimumVersion = $null + $maximumVersion = $null + $hasRequiredVersion = $false + $hasAllVersions = $false + $hasForce = $false + + # Extra parameters from the mapping (e.g., -Type DscResource for Find-DscResource) + $extraParams = if ($mapping.ContainsKey('ExtraParams')) { $mapping.ExtraParams } else { @{} } + + # Parse existing parameters from AST + $i = 1 # skip first element (command name) + while ($i -lt $elements.Count) { + $element = $elements[$i] + + if ($element -is [System.Management.Automation.Language.CommandParameterAst]) { + $paramName = $element.ParameterName + $paramValue = $null + + # Check if the parameter has an argument (inline via : or next element) + if ($null -ne $element.Argument) { + $paramValue = $element.Argument.Extent.Text + } + elseif (($i + 1) -lt $elements.Count -and + $elements[$i + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) { + $i++ + $paramValue = $elements[$i].Extent.Text + } + + # Apply parameter mapping + $paramRule = $null + foreach ($key in $parameterMapping.Keys) { + if ($key -eq $paramName) { + $paramRule = $parameterMapping[$key] + break + } + } + + if ($null -ne $paramRule) { + switch ($paramRule.Type) { + 'Rename' { + if ($null -ne $paramValue) { + # If the extra params would set the same parameter, check for conflict + if ($extraParams.ContainsKey($paramRule.NewName)) { + $warnings.Add("Parameter '-$paramName' conflicts with auto-added '-$($paramRule.NewName)' from cmdlet mapping. Using the explicit value.") + $extraParams.Remove($paramRule.NewName) + } + $newParams.Add("-$($paramRule.NewName) $paramValue") + } + else { + $newParams.Add("-$($paramRule.NewName)") + } + } + 'Remove' { + $warnings.Add($paramRule.Warning) + if ($paramName -eq 'Force') { + $hasForce = $true + } + } + 'Transform' { + switch ($paramRule.Handler) { + 'VersionExact' { + $hasRequiredVersion = $true + if ($null -ne $paramValue) { + $newParams.Add("-Version $paramValue") + } + } + 'VersionRange' { + if ($paramName -eq 'MinimumVersion') { + $minimumVersion = $paramValue + } + elseif ($paramName -eq 'MaximumVersion') { + $maximumVersion = $paramValue + } + } + 'VersionAll' { + $hasAllVersions = $true + } + } + } + } + } + else { + # Unknown/unmapped parameter — pass through as-is + if ($null -ne $paramValue) { + $newParams.Add("-$paramName $paramValue") + } + else { + $newParams.Add("-$paramName") + } + } + } + else { + # Positional argument — pass through + $newParams.Add($element.Extent.Text) + } + + $i++ + } + + # Handle version range merging + if (-not $hasRequiredVersion -and -not $hasAllVersions) { + if ($null -ne $minimumVersion -and $null -ne $maximumVersion) { + $minVal = $minimumVersion.Trim("'`"") + $maxVal = $maximumVersion.Trim("'`"") + $newParams.Add("-Version '[$minVal,$maxVal]'") + } + elseif ($null -ne $minimumVersion) { + $minVal = $minimumVersion.Trim("'`"") + $newParams.Add("-Version '[$minVal,)'") + } + elseif ($null -ne $maximumVersion) { + $maxVal = $maximumVersion.Trim("'`"") + $newParams.Add("-Version '(,$maxVal]'") + } + } + + # Handle -AllVersions → -Version '*' + if ($hasAllVersions) { + $newParams.Add("-Version '*'") + } + + # Add -Reinstall if -Force was used with Install-PSResource + if ($hasForce -and $newCmdName -eq 'Install-PSResource') { + $newParams.Add('-Reinstall') + $warnings.Add("Replaced '-Force' with '-Reinstall' for Install-PSResource.") + } + + # Add extra params from cmdlet mapping (e.g., -Type DscResource) + foreach ($extraKey in $extraParams.Keys) { + $newParams.Add("-$extraKey $($extraParams[$extraKey])") + } + + # Compose final command + $convertedParts = @($newCmdName) + $newParams + $convertedText = $convertedParts -join ' ' + + return [PSCustomObject]@{ + OriginalText = $CommandInfo.ExtentText + ConvertedText = $convertedText + Warnings = $warnings.ToArray() + Line = $CommandInfo.Line + Column = $CommandInfo.Column + Status = 'Converted' + } +} + +#endregion + +#region File-Level Orchestrator + +function ConvertTo-PSResourceGetScript { + <# + .SYNOPSIS + Scans a PowerShell file for PSGet v2 usage and returns migration results. + .PARAMETER Path + Path to the PowerShell file to process. + .PARAMETER Apply + If specified, modifies the file in-place with the converted commands. + .PARAMETER BackupPath + Directory to store backup copies before modification. Defaults to creating .bak files alongside originals. + .OUTPUTS + PSCustomObject per conversion with: File, Line, OriginalText, ConvertedText, Warnings, Status + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('FullName')] + [string] $Path, + + [switch] $Apply, + + [string] $BackupPath + ) + + process { + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + $filePath = $resolvedPath.Path + + Write-Verbose "Scanning: $filePath" + + $commands = @(Find-PSGetCommand -Path $filePath) + + if ($commands.Count -eq 0) { + Write-Verbose "No PSGet v2 commands found in '$filePath'." + return + } + + # Convert each command + $conversions = foreach ($cmd in $commands) { + $result = Convert-PSGetCommand -CommandInfo $cmd + $result | Add-Member -NotePropertyName 'File' -NotePropertyValue $filePath + $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset + $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset + $result + } + + # Output the conversion results + $conversions | ForEach-Object { + [PSCustomObject]@{ + File = $_.File + Line = $_.Line + Column = $_.Column + OriginalText = $_.OriginalText + ConvertedText = $_.ConvertedText + Warnings = $_.Warnings + Status = $_.Status + } + } + + # Apply changes if requested + if ($Apply -and $PSCmdlet.ShouldProcess($filePath, 'Convert PSGet commands to PSResourceGet')) { + $content = [System.IO.File]::ReadAllText($filePath) + + # Create backup + if ($BackupPath) { + $backupDir = $BackupPath + if (-not (Test-Path $backupDir)) { + New-Item -ItemType Directory -Path $backupDir -Force | Out-Null + } + $backupFile = Join-Path $backupDir ((Split-Path $filePath -Leaf) + '.bak') + } + else { + $backupFile = "$filePath.bak" + } + Copy-Item -Path $filePath -Destination $backupFile -Force + Write-Verbose "Backup created: $backupFile" + + # Apply replacements in reverse offset order so positions stay valid + $sortedConversions = $conversions | + Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | + Sort-Object -Property StartOffset -Descending + + foreach ($conv in $sortedConversions) { + $content = $content.Substring(0, $conv.StartOffset) + + $conv.ConvertedText + + $content.Substring($conv.EndOffset) + } + + [System.IO.File]::WriteAllText($filePath, $content) + Write-Verbose "File updated: $filePath" + } + } +} + +#endregion + +#region Formatting + +function Format-MigrationReport { + <# + .SYNOPSIS + Formats migration results into a human-readable report. + .PARAMETER Results + Array of conversion result objects from ConvertTo-PSResourceGetScript. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject[]] $Results + ) + + begin { + $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() + } + + process { + foreach ($r in $Results) { + $allResults.Add($r) + } + } + + end { + if ($allResults.Count -eq 0) { + Write-Host "`n No PSGet v2 commands found. Nothing to migrate.`n" -ForegroundColor Green + return + } + + $grouped = $allResults | Group-Object -Property File + + Write-Host "`n========================================" -ForegroundColor Cyan + Write-Host " PSGet → PSResourceGet Migration Report" -ForegroundColor Cyan + Write-Host "========================================`n" -ForegroundColor Cyan + + foreach ($group in $grouped) { + Write-Host " File: $($group.Name)" -ForegroundColor Yellow + Write-Host " $('-' * 60)" -ForegroundColor DarkGray + + foreach ($item in $group.Group) { + $statusColor = switch ($item.Status) { + 'Converted' { 'Green' } + 'NoEquivalent' { 'Red' } + default { 'White' } + } + + Write-Host " Line $($item.Line):" -ForegroundColor White + Write-Host " - $($item.OriginalText)" -ForegroundColor Red + if ($item.ConvertedText) { + Write-Host " + $($item.ConvertedText)" -ForegroundColor Green + } + + foreach ($w in $item.Warnings) { + Write-Host " ⚠ $w" -ForegroundColor DarkYellow + } + Write-Host "" + } + } + + # Summary + $total = $allResults.Count + $converted = ($allResults | Where-Object Status -eq 'Converted').Count + $noEquiv = ($allResults | Where-Object Status -eq 'NoEquivalent').Count + $withWarnings = ($allResults | Where-Object { $_.Warnings.Count -gt 0 }).Count + + Write-Host " Summary" -ForegroundColor Cyan + Write-Host " $('-' * 60)" -ForegroundColor DarkGray + Write-Host " Total commands found : $total" + Write-Host " Converted : $converted" -ForegroundColor Green + Write-Host " No equivalent : $noEquiv" -ForegroundColor Red + Write-Host " With warnings : $withWarnings" -ForegroundColor DarkYellow + Write-Host "" + } +} + +#endregion + +# Export module members +Export-ModuleMember -Function @( + 'Get-PSGetCommandMapping', + 'Get-PSGetParameterMapping', + 'Find-PSGetCommand', + 'Convert-PSGetCommand', + 'ConvertTo-PSResourceGetScript', + 'Format-MigrationReport' +) diff --git a/tool/migration/README.md b/tool/migration/README.md new file mode 100644 index 000000000..3cd9416af --- /dev/null +++ b/tool/migration/README.md @@ -0,0 +1,113 @@ +# PSGet → PSResourceGet Migration Tool + +A PowerShell-based tool to automatically migrate scripts from PowerShellGet v2 (PSGet) +cmdlets to their [PSResourceGet](https://github.com/PowerShell/PSResourceGet) equivalents. + +## Quick Start + +```powershell +# Dry-run: scan a directory and show the migration report +.\tool\migration\ConvertTo-PSResourceGet.ps1 -Path .\scripts -Recurse + +# Apply changes (creates .bak backups automatically) +.\tool\migration\ConvertTo-PSResourceGet.ps1 -Path .\scripts -Recurse -Apply + +# Apply changes with a custom backup directory +.\tool\migration\ConvertTo-PSResourceGet.ps1 -Path .\scripts -Recurse -Apply -BackupPath .\backups + +# Get structured output for pipeline processing +.\tool\migration\ConvertTo-PSResourceGet.ps1 -Path .\deploy.ps1 -PassThru +``` + +## What It Does + +### Cmdlet Conversions + +| PSGet v2 | PSResourceGet | +|---|---| +| `Find-Module` | `Find-PSResource` | +| `Find-Script` | `Find-PSResource` | +| `Find-DscResource` | `Find-PSResource -Type DscResource` | +| `Find-Command` | `Find-PSResource -Type Command` | +| `Install-Module` | `Install-PSResource` | +| `Install-Script` | `Install-PSResource` | +| `Update-Module` | `Update-PSResource` | +| `Update-Script` | `Update-PSResource` | +| `Uninstall-Module` | `Uninstall-PSResource` | +| `Uninstall-Script` | `Uninstall-PSResource` | +| `Save-Module` | `Save-PSResource` | +| `Save-Script` | `Save-PSResource` | +| `Publish-Module` | `Publish-PSResource` | +| `Publish-Script` | `Publish-PSResource` | +| `Get-InstalledModule` | `Get-InstalledPSResource` | +| `Get-InstalledScript` | `Get-InstalledPSResource` | +| `Register-PSRepository` | `Register-PSResourceRepository` | +| `Unregister-PSRepository` | `Unregister-PSResourceRepository` | +| `Set-PSRepository` | `Set-PSResourceRepository` | +| `Get-PSRepository` | `Get-PSResourceRepository` | +| `New-ScriptFileInfo` | `New-PSScriptFileInfo` | +| `Test-ScriptFileInfo` | `Test-PSScriptFileInfo` | +| `Update-ScriptFileInfo` | `Update-PSScriptFileInfo` | +| `Update-ModuleManifest` | `Update-PSModuleManifest` | + +### Parameter Conversions + +| PSGet v2 | PSResourceGet | Notes | +|---|---|---| +| `-RequiredVersion '1.0'` | `-Version '1.0'` | Exact version | +| `-MinimumVersion '1.0'` | `-Version '[1.0,)'` | NuGet version range | +| `-MaximumVersion '2.0'` | `-Version '(,2.0]'` | NuGet version range | +| `-MinimumVersion '1.0' -MaximumVersion '2.0'` | `-Version '[1.0,2.0]'` | Combined range | +| `-AllVersions` | `-Version '*'` | Wildcard | +| `-AllowPrerelease` | `-Prerelease` | Switch renamed | +| `-NuGetApiKey` | `-ApiKey` | Parameter renamed | +| `-Force` (with `Install-Module`) | `-Reinstall` | Semantic equivalent | + +### Removed Parameters (with warnings) + +- `-AllowClobber` — Not needed; PSResourceGet allows clobber by default. +- `-SkipPublisherCheck` — Not supported in PSResourceGet. +- `-Proxy` / `-ProxyCredential` — Configure at system level instead. +- `-Force` — Semantics differ; review migrated commands. + +### Unsupported Cmdlets + +- `Find-RoleCapability` — No PSResourceGet equivalent. Generates a warning. + +## Using as a Module + +```powershell +Import-Module .\tool\migration\PSGetMigration.psd1 + +# Scan a single file +$results = ConvertTo-PSResourceGetScript -Path .\deploy.ps1 + +# Display the report +$results | Format-MigrationReport + +# Apply changes +ConvertTo-PSResourceGetScript -Path .\deploy.ps1 -Apply + +# Get the mapping tables +Get-PSGetCommandMapping +Get-PSGetParameterMapping +``` + +## How It Works + +The tool uses PowerShell's **Abstract Syntax Tree (AST)** parser to accurately identify +PSGet v2 cmdlet invocations. This approach is more reliable than regex-based text +replacement because it: + +- Correctly handles splatting, multiline commands, and nested expressions. +- Identifies commands by their AST node type, avoiding false positives in comments or strings. +- Preserves file structure outside of the converted commands. + +## Output + +The migration report shows each file with: +- **Line number** of each PSGet v2 command found +- **Original** command (in red) +- **Converted** command (in green) +- **Warnings** for removed parameters or unsupported cmdlets (in yellow) +- **Summary** counts of total, converted, and problematic commands diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 new file mode 100644 index 000000000..60b5eeea0 --- /dev/null +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -0,0 +1,285 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe 'PSGetMigration Module' { + + BeforeAll { + $modulePath = Join-Path $PSScriptRoot '..' 'PSGetMigration.psd1' + Import-Module $modulePath -Force + + # Helper to create temp script files + function New-TempScript { + param([string]$Content) + $tempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.ps1' + Set-Content -Path $tempFile -Value $Content -Encoding UTF8 + return $tempFile + } + } + + Context 'Get-PSGetCommandMapping' { + It 'Returns a hashtable with expected cmdlet mappings' { + $mapping = Get-PSGetCommandMapping + $mapping | Should -BeOfType [hashtable] + $mapping.Keys.Count | Should -BeGreaterThan 20 + } + + It 'Maps Install-Module to Install-PSResource' { + $mapping = Get-PSGetCommandMapping + $mapping['Install-Module'].Command | Should -Be 'Install-PSResource' + } + + It 'Maps Find-DscResource to Find-PSResource with Type parameter' { + $mapping = Get-PSGetCommandMapping + $mapping['Find-DscResource'].Command | Should -Be 'Find-PSResource' + $mapping['Find-DscResource'].ExtraParams.Type | Should -Be 'DscResource' + } + + It 'Marks Find-RoleCapability as having no equivalent' { + $mapping = Get-PSGetCommandMapping + $mapping['Find-RoleCapability'].Command | Should -BeNullOrEmpty + $mapping['Find-RoleCapability'].Warning | Should -Not -BeNullOrEmpty + } + } + + Context 'Get-PSGetParameterMapping' { + It 'Returns parameter rules' { + $params = Get-PSGetParameterMapping + $params | Should -BeOfType [hashtable] + } + + It 'Maps AllowPrerelease to Prerelease (Rename)' { + $params = Get-PSGetParameterMapping + $params['AllowPrerelease'].Type | Should -Be 'Rename' + $params['AllowPrerelease'].NewName | Should -Be 'Prerelease' + } + + It 'Maps AllowClobber as Remove with warning' { + $params = Get-PSGetParameterMapping + $params['AllowClobber'].Type | Should -Be 'Remove' + $params['AllowClobber'].Warning | Should -Not -BeNullOrEmpty + } + } + + Context 'Find-PSGetCommand' { + It 'Finds Install-Module in a script' { + $file = New-TempScript -Content 'Install-Module -Name Pester -Force' + try { + $results = @(Find-PSGetCommand -Path $file) + $results.Count | Should -Be 1 + $results[0].CommandName | Should -Be 'Install-Module' + $results[0].Line | Should -Be 1 + } + finally { + Remove-Item $file -Force + } + } + + It 'Finds multiple PSGet commands' { + $content = @' +Install-Module -Name Pester +Find-Module -Name Az +Get-InstalledModule +'@ + $file = New-TempScript -Content $content + try { + $results = @(Find-PSGetCommand -Path $file) + $results.Count | Should -Be 3 + $results[0].CommandName | Should -Be 'Install-Module' + $results[1].CommandName | Should -Be 'Find-Module' + $results[2].CommandName | Should -Be 'Get-InstalledModule' + } + finally { + Remove-Item $file -Force + } + } + + It 'Does not match non-PSGet commands' { + $file = New-TempScript -Content 'Get-Module -Name Pester' + try { + $results = @(Find-PSGetCommand -Path $file) + $results.Count | Should -Be 0 + } + finally { + Remove-Item $file -Force + } + } + + It 'Does not match commands in comments' { + $file = New-TempScript -Content '# Install-Module -Name Pester' + try { + $results = @(Find-PSGetCommand -Path $file) + $results.Count | Should -Be 0 + } + finally { + Remove-Item $file -Force + } + } + } + + Context 'Convert-PSGetCommand' { + BeforeAll { + function Invoke-ConvertFromScript { + param([string]$Script) + $file = New-TempScript -Content $Script + try { + $cmd = @(Find-PSGetCommand -Path $file)[0] + return Convert-PSGetCommand -CommandInfo $cmd + } + finally { + Remove-Item $file -Force + } + } + } + + It 'Converts Install-Module to Install-PSResource' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester' + $result.ConvertedText | Should -Be 'Install-PSResource -Name Pester' + $result.Status | Should -Be 'Converted' + } + + It 'Converts Find-Module to Find-PSResource' { + $result = Invoke-ConvertFromScript -Script 'Find-Module -Name Az' + $result.ConvertedText | Should -Be 'Find-PSResource -Name Az' + } + + It 'Converts Find-DscResource and adds -Type DscResource' { + $result = Invoke-ConvertFromScript -Script 'Find-DscResource -Name MyDsc' + $result.ConvertedText | Should -Match 'Find-PSResource' + $result.ConvertedText | Should -Match '-Type DscResource' + $result.ConvertedText | Should -Match '-Name MyDsc' + } + + It 'Converts -RequiredVersion to -Version' { + $result = Invoke-ConvertFromScript -Script "Install-Module -Name Pester -RequiredVersion '5.0.0'" + $result.ConvertedText | Should -Match "-Version '5.0.0'" + $result.ConvertedText | Should -Not -Match '-RequiredVersion' + } + + It 'Converts -MinimumVersion to -Version range' { + $result = Invoke-ConvertFromScript -Script "Install-Module -Name Pester -MinimumVersion '4.0'" + $result.ConvertedText | Should -Match "-Version '\[4\.0,\)'" + } + + It 'Converts -MaximumVersion to -Version range' { + $result = Invoke-ConvertFromScript -Script "Install-Module -Name Pester -MaximumVersion '5.0'" + $result.ConvertedText | Should -Match "-Version '\(,5\.0\]'" + } + + It 'Merges -MinimumVersion and -MaximumVersion into a single range' { + $result = Invoke-ConvertFromScript -Script "Install-Module -Name Pester -MinimumVersion '4.0' -MaximumVersion '5.0'" + $result.ConvertedText | Should -Match "-Version '\[4\.0,5\.0\]'" + } + + It 'Converts -AllVersions to -Version wildcard' { + $result = Invoke-ConvertFromScript -Script 'Find-Module -Name Az -AllVersions' + $result.ConvertedText | Should -Match "-Version '\*'" + } + + It 'Renames -AllowPrerelease to -Prerelease' { + $result = Invoke-ConvertFromScript -Script 'Find-Module -Name Az -AllowPrerelease' + $result.ConvertedText | Should -Match '-Prerelease' + $result.ConvertedText | Should -Not -Match '-AllowPrerelease' + } + + It 'Renames -NuGetApiKey to -ApiKey' { + $result = Invoke-ConvertFromScript -Script "Publish-Module -Path ./MyModule -NuGetApiKey 'abc123'" + $result.ConvertedText | Should -Match "-ApiKey 'abc123'" + $result.ConvertedText | Should -Not -Match '-NuGetApiKey' + } + + It 'Removes -AllowClobber with warning' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester -AllowClobber' + $result.ConvertedText | Should -Not -Match '-AllowClobber' + $result.Warnings | Should -Not -BeNullOrEmpty + $result.Warnings | Where-Object { $_ -match 'AllowClobber' } | Should -Not -BeNullOrEmpty + } + + It 'Replaces -Force with -Reinstall for Install-PSResource' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester -Force' + $result.ConvertedText | Should -Match '-Reinstall' + $result.ConvertedText | Should -Not -Match '-Force' + } + + It 'Returns NoEquivalent for Find-RoleCapability' { + $result = Invoke-ConvertFromScript -Script 'Find-RoleCapability -Name MyRole' + $result.Status | Should -Be 'NoEquivalent' + $result.ConvertedText | Should -BeNullOrEmpty + $result.Warnings | Should -Not -BeNullOrEmpty + } + + It 'Preserves unmapped parameters' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester -Scope CurrentUser -Repository PSGallery' + $result.ConvertedText | Should -Match '-Scope CurrentUser' + $result.ConvertedText | Should -Match '-Repository PSGallery' + } + + It 'Converts repository cmdlets' { + $result = Invoke-ConvertFromScript -Script "Register-PSRepository -Name MyRepo -SourceLocation 'https://example.com'" + $result.ConvertedText | Should -Match 'Register-PSResourceRepository' + } + } + + Context 'ConvertTo-PSResourceGetScript' { + It 'Returns conversion results for a file' { + $content = @' +Install-Module -Name Pester -Force +Find-Module -Name Az -AllowPrerelease +'@ + $file = New-TempScript -Content $content + try { + $results = @(ConvertTo-PSResourceGetScript -Path $file) + $results.Count | Should -Be 2 + $results[0].Status | Should -Be 'Converted' + $results[1].Status | Should -Be 'Converted' + } + finally { + Remove-Item $file -Force + } + } + + It 'Returns nothing for files without PSGet commands' { + $file = New-TempScript -Content 'Get-Module -Name Pester' + try { + $results = @(ConvertTo-PSResourceGetScript -Path $file) + $results.Count | Should -Be 0 + } + finally { + Remove-Item $file -Force + } + } + + It 'Applies changes in-place when -Apply is specified' { + $content = 'Install-Module -Name Pester' + $file = New-TempScript -Content $content + try { + ConvertTo-PSResourceGetScript -Path $file -Apply + $newContent = Get-Content -Path $file -Raw + $newContent | Should -Match 'Install-PSResource' + $newContent | Should -Not -Match 'Install-Module' + + # Verify backup was created + "$file.bak" | Should -Exist + } + finally { + Remove-Item $file -Force -ErrorAction SilentlyContinue + Remove-Item "$file.bak" -Force -ErrorAction SilentlyContinue + } + } + + It 'Applies changes to a custom backup path' { + $content = 'Install-Module -Name Pester' + $file = New-TempScript -Content $content + $backupDir = Join-Path ([System.IO.Path]::GetTempPath()) "psget-migration-test-$(Get-Random)" + try { + ConvertTo-PSResourceGetScript -Path $file -Apply -BackupPath $backupDir + $backupDir | Should -Exist + $newContent = Get-Content -Path $file -Raw + $newContent | Should -Match 'Install-PSResource' + } + finally { + Remove-Item $file -Force -ErrorAction SilentlyContinue + Remove-Item $backupDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} From 481a01945e6ac38c42e1d63626df1ec9004550b1 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 22 Jul 2026 11:04:47 -0700 Subject: [PATCH 2/9] Add string input/output support to migration tool Adds ConvertTo-PSResourceGetString function and -InputScript parameter to the entry-point script, enabling direct string-to-string conversion without needing files on disk. Usage: .\ConvertTo-PSResourceGet.ps1 -InputScript 'Install-Module -Name Pester' 'Find-Module -Name Az' | .\ConvertTo-PSResourceGet.ps1 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/ConvertTo-PSResourceGet.ps1 | 49 +++++++++-- tool/migration/PSGetMigration.psd1 | 1 + tool/migration/PSGetMigration.psm1 | 82 +++++++++++++++++++ tool/migration/Tests/PSGetMigration.Tests.ps1 | 56 ++++++++++++- 4 files changed, 180 insertions(+), 8 deletions(-) diff --git a/tool/migration/ConvertTo-PSResourceGet.ps1 b/tool/migration/ConvertTo-PSResourceGet.ps1 index 36914f6bb..8f9457526 100644 --- a/tool/migration/ConvertTo-PSResourceGet.ps1 +++ b/tool/migration/ConvertTo-PSResourceGet.ps1 @@ -6,15 +6,19 @@ Converts PowerShellGet v2 cmdlet usage to PSResourceGet equivalents. .DESCRIPTION - Scans PowerShell files (.ps1, .psm1, .psd1) for PSGet v2 cmdlet usage - (e.g., Install-Module, Find-Module) and converts them to their - PSResourceGet equivalents (e.g., Install-PSResource, Find-PSResource). + Scans PowerShell files (.ps1, .psm1, .psd1) or inline script strings for + PSGet v2 cmdlet usage (e.g., Install-Module, Find-Module) and converts them + to their PSResourceGet equivalents (e.g., Install-PSResource, Find-PSResource). By default, runs in WhatIf/report mode. Use -Apply to modify files. .PARAMETER Path Path to a file or directory to scan. Supports wildcards. - Defaults to the current directory. + Defaults to the current directory. Cannot be used with -InputScript. + +.PARAMETER InputScript + A string containing PowerShell script text to convert. The converted string + is returned as output. Cannot be used with -Path. .PARAMETER Recurse When Path is a directory, scan subdirectories recursively. @@ -39,17 +43,31 @@ .EXAMPLE # Scan a single file and get structured output .\ConvertTo-PSResourceGet.ps1 -Path .\deploy.ps1 -PassThru + +.EXAMPLE + # Convert a script string and get the converted text back + .\ConvertTo-PSResourceGet.ps1 -InputScript 'Install-Module -Name Pester -Force' + +.EXAMPLE + # Pipe a string for conversion + 'Find-Module -Name Az -AllowPrerelease' | .\ConvertTo-PSResourceGet.ps1 #> -[CmdletBinding(SupportsShouldProcess)] +[CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Path')] param( - [Parameter(Position = 0)] - [string] $Path = '.', + [Parameter(Position = 0, ParameterSetName = 'Path')] + [string] $Path, + + [Parameter(Mandatory, ParameterSetName = 'String', ValueFromPipeline)] + [string] $InputScript, + [Parameter(ParameterSetName = 'Path')] [switch] $Recurse, + [Parameter(ParameterSetName = 'Path')] [switch] $Apply, + [Parameter(ParameterSetName = 'Path')] [string] $BackupPath, [switch] $PassThru @@ -59,6 +77,23 @@ param( $modulePath = Join-Path $PSScriptRoot 'PSGetMigration.psm1' Import-Module $modulePath -Force +# String input mode +if ($PSCmdlet.ParameterSetName -eq 'String') { + $result = ConvertTo-PSResourceGetString -InputScript $InputScript + if ($PassThru) { + $result + } + else { + $result.ConvertedScript + } + return +} + +# File/directory mode +if (-not $Path) { + $Path = '.' +} + # Resolve files to scan $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop diff --git a/tool/migration/PSGetMigration.psd1 b/tool/migration/PSGetMigration.psd1 index 32eb22dc7..3cb475d97 100644 --- a/tool/migration/PSGetMigration.psd1 +++ b/tool/migration/PSGetMigration.psd1 @@ -16,6 +16,7 @@ 'Find-PSGetCommand', 'Convert-PSGetCommand', 'ConvertTo-PSResourceGetScript', + 'ConvertTo-PSResourceGetString', 'Format-MigrationReport' ) CmdletsToExport = @() diff --git a/tool/migration/PSGetMigration.psm1 b/tool/migration/PSGetMigration.psm1 index d030e3d53..9250985a0 100644 --- a/tool/migration/PSGetMigration.psm1 +++ b/tool/migration/PSGetMigration.psm1 @@ -454,6 +454,87 @@ function ConvertTo-PSResourceGetScript { #endregion +#region String Converter + +function ConvertTo-PSResourceGetString { + <# + .SYNOPSIS + Converts PSGet v2 cmdlet usage in a script string to PSResourceGet equivalents. + .PARAMETER InputScript + A string containing PowerShell script text with PSGet v2 commands. + .OUTPUTS + PSCustomObject with: ConvertedScript, Conversions (detail array), Warnings + .EXAMPLE + $result = ConvertTo-PSResourceGetString -InputScript 'Install-Module -Name Pester -Force' + $result.ConvertedScript # → 'Install-PSResource -Name Pester -Reinstall' + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [string] $InputScript + ) + + process { + # Write to a temp file so the AST parser can process it + $tempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.ps1' + try { + [System.IO.File]::WriteAllText($tempFile, $InputScript) + + $commands = @(Find-PSGetCommand -Path $tempFile) + $allWarnings = [System.Collections.Generic.List[string]]::new() + + if ($commands.Count -eq 0) { + return [PSCustomObject]@{ + ConvertedScript = $InputScript + Conversions = @() + Warnings = @() + } + } + + # Convert each command and collect results + $conversions = foreach ($cmd in $commands) { + $result = Convert-PSGetCommand -CommandInfo $cmd + $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset + $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset + foreach ($w in $result.Warnings) { $allWarnings.Add($w) } + $result + } + + # Apply replacements in reverse offset order + $output = $InputScript + $sortedConversions = $conversions | + Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | + Sort-Object -Property StartOffset -Descending + + foreach ($conv in $sortedConversions) { + $output = $output.Substring(0, $conv.StartOffset) + + $conv.ConvertedText + + $output.Substring($conv.EndOffset) + } + + return [PSCustomObject]@{ + ConvertedScript = $output + Conversions = @($conversions | ForEach-Object { + [PSCustomObject]@{ + Line = $_.Line + OriginalText = $_.OriginalText + ConvertedText = $_.ConvertedText + Warnings = $_.Warnings + Status = $_.Status + } + }) + Warnings = $allWarnings.ToArray() + } + } + finally { + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + } + } +} + +#endregion + #region Formatting function Format-MigrationReport { @@ -540,5 +621,6 @@ Export-ModuleMember -Function @( 'Find-PSGetCommand', 'Convert-PSGetCommand', 'ConvertTo-PSResourceGetScript', + 'ConvertTo-PSResourceGetString', 'Format-MigrationReport' ) diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 index 60b5eeea0..91dbb25b5 100644 --- a/tool/migration/Tests/PSGetMigration.Tests.ps1 +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -282,4 +282,58 @@ Find-Module -Name Az -AllowPrerelease } } } -} + + Context 'ConvertTo-PSResourceGetString' { + It 'Converts a simple command string' { + $result = ConvertTo-PSResourceGetString -InputScript 'Install-Module -Name Pester' + $result.ConvertedScript | Should -Be 'Install-PSResource -Name Pester' + $result.Conversions.Count | Should -Be 1 + } + + It 'Converts multiple commands in a script string' { + $script = @' +Install-Module -Name Pester -Force +Find-Module -Name Az -AllowPrerelease +'@ + $result = ConvertTo-PSResourceGetString -InputScript $script + $result.ConvertedScript | Should -Match 'Install-PSResource' + $result.ConvertedScript | Should -Match 'Find-PSResource' + $result.ConvertedScript | Should -Match '-Prerelease' + $result.ConvertedScript | Should -Not -Match 'Install-Module' + $result.ConvertedScript | Should -Not -Match 'Find-Module' + $result.Conversions.Count | Should -Be 2 + } + + It 'Returns the original string when no PSGet commands are found' { + $script = 'Get-Module -Name Pester' + $result = ConvertTo-PSResourceGetString -InputScript $script + $result.ConvertedScript | Should -Be $script + $result.Conversions.Count | Should -Be 0 + } + + It 'Collects warnings from removed parameters' { + $result = ConvertTo-PSResourceGetString -InputScript 'Install-Module -Name Pester -AllowClobber -Force' + $result.Warnings.Count | Should -BeGreaterThan 0 + $result.ConvertedScript | Should -Match '-Reinstall' + } + + It 'Preserves surrounding script content' { + $script = @' +# Setup +$moduleName = 'Pester' +Install-Module -Name $moduleName -Scope CurrentUser +Write-Host 'Done' +'@ + $result = ConvertTo-PSResourceGetString -InputScript $script + $result.ConvertedScript | Should -Match '# Setup' + $result.ConvertedScript | Should -Match '\$moduleName = ''Pester''' + $result.ConvertedScript | Should -Match 'Install-PSResource' + $result.ConvertedScript | Should -Match "Write-Host 'Done'" + } + + It 'Accepts pipeline input' { + $result = 'Find-Module -Name Az' | ConvertTo-PSResourceGetString + $result.ConvertedScript | Should -Be 'Find-PSResource -Name Az' + } + } +} \ No newline at end of file From 6b905992a97b8de204fe3da7987c517620d8b9ee Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 22 Jul 2026 13:14:20 -0700 Subject: [PATCH 3/9] Consolidate migration tool into single ps1 file Merge PSGetMigration.psm1/psd1 module into ConvertTo-PSResourceGet.ps1 as a self-contained script. All functions are defined inline and the main entry point is guarded so dot-sourcing for tests works correctly. Removes: PSGetMigration.psm1, PSGetMigration.psd1 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/ConvertTo-PSResourceGet.ps1 | 558 +++++++++++++++- tool/migration/PSGetMigration.psd1 | 31 - tool/migration/PSGetMigration.psm1 | 626 ------------------ tool/migration/Tests/PSGetMigration.Tests.ps1 | 6 +- 4 files changed, 556 insertions(+), 665 deletions(-) delete mode 100644 tool/migration/PSGetMigration.psd1 delete mode 100644 tool/migration/PSGetMigration.psm1 diff --git a/tool/migration/ConvertTo-PSResourceGet.ps1 b/tool/migration/ConvertTo-PSResourceGet.ps1 index 8f9457526..427e2c2da 100644 --- a/tool/migration/ConvertTo-PSResourceGet.ps1 +++ b/tool/migration/ConvertTo-PSResourceGet.ps1 @@ -10,6 +10,9 @@ PSGet v2 cmdlet usage (e.g., Install-Module, Find-Module) and converts them to their PSResourceGet equivalents (e.g., Install-PSResource, Find-PSResource). + Handles cmdlet name changes, parameter renames, version range syntax + transformations, and removed parameters with appropriate warnings. + By default, runs in WhatIf/report mode. Use -Apply to modify files. .PARAMETER Path @@ -73,9 +76,552 @@ param( [switch] $PassThru ) -# Import the migration module -$modulePath = Join-Path $PSScriptRoot 'PSGetMigration.psm1' -Import-Module $modulePath -Force +# ============================================================================ +#region Cmdlet & Parameter Mapping +# ============================================================================ + +function Get-PSGetCommandMapping { + [OutputType([hashtable])] + param() + + return @{ + # Find cmdlets + 'Find-Module' = @{ Command = 'Find-PSResource' } + 'Find-Script' = @{ Command = 'Find-PSResource' } + 'Find-DscResource' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'DscResource' } } + 'Find-Command' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'Command' } } + 'Find-RoleCapability' = @{ Command = $null; Warning = 'Find-RoleCapability has no PSResourceGet equivalent. Manual migration required.' } + + # Install cmdlets + 'Install-Module' = @{ Command = 'Install-PSResource' } + 'Install-Script' = @{ Command = 'Install-PSResource' } + + # Update cmdlets + 'Update-Module' = @{ Command = 'Update-PSResource' } + 'Update-Script' = @{ Command = 'Update-PSResource' } + + # Uninstall cmdlets + 'Uninstall-Module' = @{ Command = 'Uninstall-PSResource' } + 'Uninstall-Script' = @{ Command = 'Uninstall-PSResource' } + + # Save cmdlets + 'Save-Module' = @{ Command = 'Save-PSResource' } + 'Save-Script' = @{ Command = 'Save-PSResource' } + + # Publish cmdlets + 'Publish-Module' = @{ Command = 'Publish-PSResource' } + 'Publish-Script' = @{ Command = 'Publish-PSResource' } + + # Get installed + 'Get-InstalledModule' = @{ Command = 'Get-InstalledPSResource' } + 'Get-InstalledScript' = @{ Command = 'Get-InstalledPSResource' } + + # Repository cmdlets + 'Register-PSRepository' = @{ Command = 'Register-PSResourceRepository' } + 'Unregister-PSRepository' = @{ Command = 'Unregister-PSResourceRepository' } + 'Set-PSRepository' = @{ Command = 'Set-PSResourceRepository' } + 'Get-PSRepository' = @{ Command = 'Get-PSResourceRepository' } + + # Script file info cmdlets + 'New-ScriptFileInfo' = @{ Command = 'New-PSScriptFileInfo' } + 'Test-ScriptFileInfo' = @{ Command = 'Test-PSScriptFileInfo' } + 'Update-ScriptFileInfo' = @{ Command = 'Update-PSScriptFileInfo' } + + # Module manifest + 'Update-ModuleManifest' = @{ Command = 'Update-PSModuleManifest' } + } +} + +function Get-PSGetParameterMapping { + [OutputType([hashtable])] + param() + + return @{ + # Simple renames + 'AllowPrerelease' = @{ Type = 'Rename'; NewName = 'Prerelease' } + 'NuGetApiKey' = @{ Type = 'Rename'; NewName = 'ApiKey' } + + # Version parameters are handled specially (Transform type) + 'RequiredVersion' = @{ Type = 'Transform'; Handler = 'VersionExact' } + 'MinimumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } + 'MaximumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } + 'AllVersions' = @{ Type = 'Transform'; Handler = 'VersionAll' } + + # Includes → Type for Find cmdlets + 'Includes' = @{ Type = 'Rename'; NewName = 'Type' } + + # Removed parameters + 'AllowClobber' = @{ Type = 'Remove'; Warning = '-AllowClobber is not needed in PSResourceGet (clobber is allowed by default).' } + 'SkipPublisherCheck' = @{ Type = 'Remove'; Warning = '-SkipPublisherCheck is not supported in PSResourceGet.' } + 'Force' = @{ Type = 'Remove'; Warning = '-Force semantics differ in PSResourceGet. Review the migrated command. Use -Reinstall for Install-PSResource if forced reinstall is needed.' } + 'Proxy' = @{ Type = 'Remove'; Warning = '-Proxy is not supported in PSResourceGet. Configure proxy at the system level.' } + 'ProxyCredential' = @{ Type = 'Remove'; Warning = '-ProxyCredential is not supported in PSResourceGet. Configure proxy at the system level.' } + } +} + +#endregion + +# ============================================================================ +#region AST Scanner +# ============================================================================ + +function Find-PSGetCommand { + [CmdletBinding()] + [OutputType([PSCustomObject[]])] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + $commandMapping = Get-PSGetCommandMapping + $psGetCommands = $commandMapping.Keys + + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $resolvedPath.Path, [ref]$tokens, [ref]$parseErrors + ) + + if ($parseErrors.Count -gt 0) { + Write-Warning "Parse errors in '$Path': $($parseErrors | ForEach-Object { $_.Message } | Join-String -Separator '; ')" + } + + $commandAsts = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] + }, $true) + + foreach ($cmdAst in $commandAsts) { + $commandName = $cmdAst.GetCommandName() + if ($commandName -and $psGetCommands -contains $commandName) { + [PSCustomObject]@{ + CommandName = $commandName + Line = $cmdAst.Extent.StartLineNumber + Column = $cmdAst.Extent.StartColumnNumber + StartOffset = $cmdAst.Extent.StartOffset + EndOffset = $cmdAst.Extent.EndOffset + ExtentText = $cmdAst.Extent.Text + CommandAst = $cmdAst + } + } + } +} + +#endregion + +# ============================================================================ +#region Command Converter +# ============================================================================ + +function Convert-PSGetCommand { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $CommandInfo + ) + + $commandMapping = Get-PSGetCommandMapping + $parameterMapping = Get-PSGetParameterMapping + + $mapping = $commandMapping[$CommandInfo.CommandName] + $warnings = [System.Collections.Generic.List[string]]::new() + + if ($null -eq $mapping.Command) { + return [PSCustomObject]@{ + OriginalText = $CommandInfo.ExtentText + ConvertedText = $null + Warnings = @($mapping.Warning) + Line = $CommandInfo.Line + Column = $CommandInfo.Column + Status = 'NoEquivalent' + } + } + + $cmdAst = $CommandInfo.CommandAst + $elements = $cmdAst.CommandElements + + $newCmdName = $mapping.Command + $newParams = [System.Collections.Generic.List[string]]::new() + + # Track version-related parameters for merging + $minimumVersion = $null + $maximumVersion = $null + $hasRequiredVersion = $false + $hasAllVersions = $false + $hasForce = $false + + $extraParams = if ($mapping.ContainsKey('ExtraParams')) { $mapping.ExtraParams } else { @{} } + + # Parse existing parameters from AST + $i = 1 # skip first element (command name) + while ($i -lt $elements.Count) { + $element = $elements[$i] + + if ($element -is [System.Management.Automation.Language.CommandParameterAst]) { + $paramName = $element.ParameterName + $paramValue = $null + + if ($null -ne $element.Argument) { + $paramValue = $element.Argument.Extent.Text + } + elseif (($i + 1) -lt $elements.Count -and + $elements[$i + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) { + $i++ + $paramValue = $elements[$i].Extent.Text + } + + # Apply parameter mapping + $paramRule = $null + foreach ($key in $parameterMapping.Keys) { + if ($key -eq $paramName) { + $paramRule = $parameterMapping[$key] + break + } + } + + if ($null -ne $paramRule) { + switch ($paramRule.Type) { + 'Rename' { + if ($null -ne $paramValue) { + if ($extraParams.ContainsKey($paramRule.NewName)) { + $warnings.Add("Parameter '-$paramName' conflicts with auto-added '-$($paramRule.NewName)' from cmdlet mapping. Using the explicit value.") + $extraParams.Remove($paramRule.NewName) + } + $newParams.Add("-$($paramRule.NewName) $paramValue") + } + else { + $newParams.Add("-$($paramRule.NewName)") + } + } + 'Remove' { + $warnings.Add($paramRule.Warning) + if ($paramName -eq 'Force') { + $hasForce = $true + } + } + 'Transform' { + switch ($paramRule.Handler) { + 'VersionExact' { + $hasRequiredVersion = $true + if ($null -ne $paramValue) { + $newParams.Add("-Version $paramValue") + } + } + 'VersionRange' { + if ($paramName -eq 'MinimumVersion') { + $minimumVersion = $paramValue + } + elseif ($paramName -eq 'MaximumVersion') { + $maximumVersion = $paramValue + } + } + 'VersionAll' { + $hasAllVersions = $true + } + } + } + } + } + else { + # Unknown/unmapped parameter — pass through as-is + if ($null -ne $paramValue) { + $newParams.Add("-$paramName $paramValue") + } + else { + $newParams.Add("-$paramName") + } + } + } + else { + # Positional argument — pass through + $newParams.Add($element.Extent.Text) + } + + $i++ + } + + # Handle version range merging + if (-not $hasRequiredVersion -and -not $hasAllVersions) { + if ($null -ne $minimumVersion -and $null -ne $maximumVersion) { + $minVal = $minimumVersion.Trim("'`"") + $maxVal = $maximumVersion.Trim("'`"") + $newParams.Add("-Version '[$minVal,$maxVal]'") + } + elseif ($null -ne $minimumVersion) { + $minVal = $minimumVersion.Trim("'`"") + $newParams.Add("-Version '[$minVal,)'") + } + elseif ($null -ne $maximumVersion) { + $maxVal = $maximumVersion.Trim("'`"") + $newParams.Add("-Version '(,$maxVal]'") + } + } + + if ($hasAllVersions) { + $newParams.Add("-Version '*'") + } + + # Add -Reinstall if -Force was used with Install-PSResource + if ($hasForce -and $newCmdName -eq 'Install-PSResource') { + $newParams.Add('-Reinstall') + $warnings.Add("Replaced '-Force' with '-Reinstall' for Install-PSResource.") + } + + # Add extra params from cmdlet mapping (e.g., -Type DscResource) + foreach ($extraKey in $extraParams.Keys) { + $newParams.Add("-$extraKey $($extraParams[$extraKey])") + } + + $convertedParts = @($newCmdName) + $newParams + $convertedText = $convertedParts -join ' ' + + return [PSCustomObject]@{ + OriginalText = $CommandInfo.ExtentText + ConvertedText = $convertedText + Warnings = $warnings.ToArray() + Line = $CommandInfo.Line + Column = $CommandInfo.Column + Status = 'Converted' + } +} + +#endregion + +# ============================================================================ +#region File-Level Orchestrator +# ============================================================================ + +function ConvertTo-PSResourceGetScript { + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('FullName')] + [string] $Path, + + [switch] $Apply, + + [string] $BackupPath + ) + + process { + $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop + $filePath = $resolvedPath.Path + + Write-Verbose "Scanning: $filePath" + + $commands = @(Find-PSGetCommand -Path $filePath) + + if ($commands.Count -eq 0) { + Write-Verbose "No PSGet v2 commands found in '$filePath'." + return + } + + $conversions = foreach ($cmd in $commands) { + $result = Convert-PSGetCommand -CommandInfo $cmd + $result | Add-Member -NotePropertyName 'File' -NotePropertyValue $filePath + $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset + $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset + $result + } + + # Output the conversion results + $conversions | ForEach-Object { + [PSCustomObject]@{ + File = $_.File + Line = $_.Line + Column = $_.Column + OriginalText = $_.OriginalText + ConvertedText = $_.ConvertedText + Warnings = $_.Warnings + Status = $_.Status + } + } + + # Apply changes if requested + if ($Apply -and $PSCmdlet.ShouldProcess($filePath, 'Convert PSGet commands to PSResourceGet')) { + $content = [System.IO.File]::ReadAllText($filePath) + + if ($BackupPath) { + $backupDir = $BackupPath + if (-not (Test-Path $backupDir)) { + New-Item -ItemType Directory -Path $backupDir -Force | Out-Null + } + $backupFile = Join-Path $backupDir ((Split-Path $filePath -Leaf) + '.bak') + } + else { + $backupFile = "$filePath.bak" + } + Copy-Item -Path $filePath -Destination $backupFile -Force + Write-Verbose "Backup created: $backupFile" + + $sortedConversions = $conversions | + Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | + Sort-Object -Property StartOffset -Descending + + foreach ($conv in $sortedConversions) { + $content = $content.Substring(0, $conv.StartOffset) + + $conv.ConvertedText + + $content.Substring($conv.EndOffset) + } + + [System.IO.File]::WriteAllText($filePath, $content) + Write-Verbose "File updated: $filePath" + } + } +} + +#endregion + +# ============================================================================ +#region String Converter +# ============================================================================ + +function ConvertTo-PSResourceGetString { + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [string] $InputScript + ) + + process { + $tempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.ps1' + try { + [System.IO.File]::WriteAllText($tempFile, $InputScript) + + $commands = @(Find-PSGetCommand -Path $tempFile) + $allWarnings = [System.Collections.Generic.List[string]]::new() + + if ($commands.Count -eq 0) { + return [PSCustomObject]@{ + ConvertedScript = $InputScript + Conversions = @() + Warnings = @() + } + } + + $conversions = foreach ($cmd in $commands) { + $result = Convert-PSGetCommand -CommandInfo $cmd + $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset + $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset + foreach ($w in $result.Warnings) { $allWarnings.Add($w) } + $result + } + + $output = $InputScript + $sortedConversions = $conversions | + Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | + Sort-Object -Property StartOffset -Descending + + foreach ($conv in $sortedConversions) { + $output = $output.Substring(0, $conv.StartOffset) + + $conv.ConvertedText + + $output.Substring($conv.EndOffset) + } + + return [PSCustomObject]@{ + ConvertedScript = $output + Conversions = @($conversions | ForEach-Object { + [PSCustomObject]@{ + Line = $_.Line + OriginalText = $_.OriginalText + ConvertedText = $_.ConvertedText + Warnings = $_.Warnings + Status = $_.Status + } + }) + Warnings = $allWarnings.ToArray() + } + } + finally { + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + } + } +} + +#endregion + +# ============================================================================ +#region Report Formatter +# ============================================================================ + +function Format-MigrationReport { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject[]] $Results + ) + + begin { + $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() + } + + process { + foreach ($r in $Results) { + $allResults.Add($r) + } + } + + end { + if ($allResults.Count -eq 0) { + Write-Host "`n No PSGet v2 commands found. Nothing to migrate.`n" -ForegroundColor Green + return + } + + $grouped = $allResults | Group-Object -Property File + + Write-Host "`n========================================" -ForegroundColor Cyan + Write-Host " PSGet → PSResourceGet Migration Report" -ForegroundColor Cyan + Write-Host "========================================`n" -ForegroundColor Cyan + + foreach ($group in $grouped) { + Write-Host " File: $($group.Name)" -ForegroundColor Yellow + Write-Host " $('-' * 60)" -ForegroundColor DarkGray + + foreach ($item in $group.Group) { + $statusColor = switch ($item.Status) { + 'Converted' { 'Green' } + 'NoEquivalent' { 'Red' } + default { 'White' } + } + + Write-Host " Line $($item.Line):" -ForegroundColor White + Write-Host " - $($item.OriginalText)" -ForegroundColor Red + if ($item.ConvertedText) { + Write-Host " + $($item.ConvertedText)" -ForegroundColor Green + } + + foreach ($w in $item.Warnings) { + Write-Host " ⚠ $w" -ForegroundColor DarkYellow + } + Write-Host "" + } + } + + $total = $allResults.Count + $converted = ($allResults | Where-Object Status -eq 'Converted').Count + $noEquiv = ($allResults | Where-Object Status -eq 'NoEquivalent').Count + $withWarnings = ($allResults | Where-Object { $_.Warnings.Count -gt 0 }).Count + + Write-Host " Summary" -ForegroundColor Cyan + Write-Host " $('-' * 60)" -ForegroundColor DarkGray + Write-Host " Total commands found : $total" + Write-Host " Converted : $converted" -ForegroundColor Green + Write-Host " No equivalent : $noEquiv" -ForegroundColor Red + Write-Host " With warnings : $withWarnings" -ForegroundColor DarkYellow + Write-Host "" + } +} + +#endregion + +# ============================================================================ +#region Main Entry Point — only runs when script is invoked directly, not dot-sourced +# ============================================================================ + +if ($MyInvocation.InvocationName -ne '.') { # String input mode if ($PSCmdlet.ParameterSetName -eq 'String') { @@ -94,7 +640,6 @@ if (-not $Path) { $Path = '.' } -# Resolve files to scan $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop $filesToScan = if (Test-Path $resolvedPath.Path -PathType Container) { @@ -119,7 +664,6 @@ if (-not $filesToScan) { Write-Host "Scanning $($filesToScan.Count) file(s)..." -ForegroundColor Cyan -# Process each file $convertParams = @{} if ($Apply) { $convertParams['Apply'] = $true } if ($BackupPath) { $convertParams['BackupPath'] = $BackupPath } @@ -134,3 +678,7 @@ if ($PassThru) { else { $results | Format-MigrationReport } + +} # end if not dot-sourced + +#endregion diff --git a/tool/migration/PSGetMigration.psd1 b/tool/migration/PSGetMigration.psd1 deleted file mode 100644 index 3cb475d97..000000000 --- a/tool/migration/PSGetMigration.psd1 +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -@{ - RootModule = 'PSGetMigration.psm1' - ModuleVersion = '0.1.0' - GUID = 'a3f7b2c1-4d5e-6f78-9a0b-c1d2e3f4a5b6' - Author = 'Microsoft Corporation' - CompanyName = 'Microsoft Corporation' - Copyright = '(c) Microsoft Corporation. All rights reserved.' - Description = 'Migration tool to convert PowerShellGet v2 (PSGet) cmdlet usage to PSResourceGet equivalents.' - PowerShellVersion = '5.1' - FunctionsToExport = @( - 'Get-PSGetCommandMapping', - 'Get-PSGetParameterMapping', - 'Find-PSGetCommand', - 'Convert-PSGetCommand', - 'ConvertTo-PSResourceGetScript', - 'ConvertTo-PSResourceGetString', - 'Format-MigrationReport' - ) - CmdletsToExport = @() - VariablesToExport = @() - AliasesToExport = @() - PrivateData = @{ - PSData = @{ - Tags = @('Migration', 'PSResourceGet', 'PowerShellGet', 'PSGet') - ProjectUri = 'https://github.com/PowerShell/PSResourceGet' - } - } -} diff --git a/tool/migration/PSGetMigration.psm1 b/tool/migration/PSGetMigration.psm1 deleted file mode 100644 index 9250985a0..000000000 --- a/tool/migration/PSGetMigration.psm1 +++ /dev/null @@ -1,626 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. - -<# -.SYNOPSIS - Migration tool to convert PowerShellGet v2 (PSGet) cmdlet usage to PSResourceGet equivalents. - -.DESCRIPTION - This module provides functions to scan PowerShell script files for PSGet v2 cmdlet usage - and convert them to their PSResourceGet equivalents, handling cmdlet name changes, - parameter renames, and version range syntax transformations. -#> - -#region Cmdlet Mapping - -function Get-PSGetCommandMapping { - <# - .SYNOPSIS - Returns a hashtable mapping PSGet v2 cmdlet names to PSResourceGet equivalents. - #> - [OutputType([hashtable])] - param() - - return @{ - # Find cmdlets - 'Find-Module' = @{ Command = 'Find-PSResource' } - 'Find-Script' = @{ Command = 'Find-PSResource' } - 'Find-DscResource' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'DscResource' } } - 'Find-Command' = @{ Command = 'Find-PSResource'; ExtraParams = @{ Type = 'Command' } } - 'Find-RoleCapability' = @{ Command = $null; Warning = 'Find-RoleCapability has no PSResourceGet equivalent. Manual migration required.' } - - # Install cmdlets - 'Install-Module' = @{ Command = 'Install-PSResource' } - 'Install-Script' = @{ Command = 'Install-PSResource' } - - # Update cmdlets - 'Update-Module' = @{ Command = 'Update-PSResource' } - 'Update-Script' = @{ Command = 'Update-PSResource' } - - # Uninstall cmdlets - 'Uninstall-Module' = @{ Command = 'Uninstall-PSResource' } - 'Uninstall-Script' = @{ Command = 'Uninstall-PSResource' } - - # Save cmdlets - 'Save-Module' = @{ Command = 'Save-PSResource' } - 'Save-Script' = @{ Command = 'Save-PSResource' } - - # Publish cmdlets - 'Publish-Module' = @{ Command = 'Publish-PSResource' } - 'Publish-Script' = @{ Command = 'Publish-PSResource' } - - # Get installed - 'Get-InstalledModule' = @{ Command = 'Get-InstalledPSResource' } - 'Get-InstalledScript' = @{ Command = 'Get-InstalledPSResource' } - - # Repository cmdlets - 'Register-PSRepository' = @{ Command = 'Register-PSResourceRepository' } - 'Unregister-PSRepository' = @{ Command = 'Unregister-PSResourceRepository' } - 'Set-PSRepository' = @{ Command = 'Set-PSResourceRepository' } - 'Get-PSRepository' = @{ Command = 'Get-PSResourceRepository' } - - # Script file info cmdlets - 'New-ScriptFileInfo' = @{ Command = 'New-PSScriptFileInfo' } - 'Test-ScriptFileInfo' = @{ Command = 'Test-PSScriptFileInfo' } - 'Update-ScriptFileInfo' = @{ Command = 'Update-PSScriptFileInfo' } - - # Module manifest - 'Update-ModuleManifest' = @{ Command = 'Update-PSModuleManifest' } - } -} - -function Get-PSGetParameterMapping { - <# - .SYNOPSIS - Returns parameter transformation rules for PSGet v2 to PSResourceGet migration. - .DESCRIPTION - Each entry defines how a PSGet v2 parameter maps to its PSResourceGet equivalent. - Mapping types: - - Rename: parameter name changes but value stays the same. - - Remove: parameter is dropped (with optional warning). - - Transform: parameter requires value/logic transformation (handled by Convert-PSGetCommand). - #> - [OutputType([hashtable])] - param() - - return @{ - # Simple renames - 'AllowPrerelease' = @{ Type = 'Rename'; NewName = 'Prerelease' } - 'NuGetApiKey' = @{ Type = 'Rename'; NewName = 'ApiKey' } - - # Version parameters are handled specially (Transform type) - 'RequiredVersion' = @{ Type = 'Transform'; Handler = 'VersionExact' } - 'MinimumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } - 'MaximumVersion' = @{ Type = 'Transform'; Handler = 'VersionRange' } - 'AllVersions' = @{ Type = 'Transform'; Handler = 'VersionAll' } - - # Includes → Type for Find cmdlets - 'Includes' = @{ Type = 'Rename'; NewName = 'Type' } - - # Removed parameters - 'AllowClobber' = @{ Type = 'Remove'; Warning = '-AllowClobber is not needed in PSResourceGet (clobber is allowed by default).' } - 'SkipPublisherCheck' = @{ Type = 'Remove'; Warning = '-SkipPublisherCheck is not supported in PSResourceGet.' } - 'Force' = @{ Type = 'Remove'; Warning = '-Force semantics differ in PSResourceGet. Review the migrated command. Use -Reinstall for Install-PSResource if forced reinstall is needed.' } - 'Proxy' = @{ Type = 'Remove'; Warning = '-Proxy is not supported in PSResourceGet. Configure proxy at the system level.' } - 'ProxyCredential' = @{ Type = 'Remove'; Warning = '-ProxyCredential is not supported in PSResourceGet. Configure proxy at the system level.' } - } -} - -#endregion - -#region AST Scanner - -function Find-PSGetCommand { - <# - .SYNOPSIS - Finds all PSGet v2 cmdlet invocations in a PowerShell file using AST parsing. - .PARAMETER Path - Path to the PowerShell file to scan. - .OUTPUTS - Objects with properties: CommandName, Line, Column, Extent, CommandAst - #> - [CmdletBinding()] - [OutputType([PSCustomObject[]])] - param( - [Parameter(Mandatory)] - [string] $Path - ) - - $commandMapping = Get-PSGetCommandMapping - $psGetCommands = $commandMapping.Keys - - $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop - $tokens = $null - $parseErrors = $null - $ast = [System.Management.Automation.Language.Parser]::ParseFile( - $resolvedPath.Path, [ref]$tokens, [ref]$parseErrors - ) - - if ($parseErrors.Count -gt 0) { - Write-Warning "Parse errors in '$Path': $($parseErrors | ForEach-Object { $_.Message } | Join-String -Separator '; ')" - } - - # Find all CommandAst nodes - $commandAsts = $ast.FindAll({ - param($node) - $node -is [System.Management.Automation.Language.CommandAst] - }, $true) - - foreach ($cmdAst in $commandAsts) { - $commandName = $cmdAst.GetCommandName() - if ($commandName -and $psGetCommands -contains $commandName) { - [PSCustomObject]@{ - CommandName = $commandName - Line = $cmdAst.Extent.StartLineNumber - Column = $cmdAst.Extent.StartColumnNumber - StartOffset = $cmdAst.Extent.StartOffset - EndOffset = $cmdAst.Extent.EndOffset - ExtentText = $cmdAst.Extent.Text - CommandAst = $cmdAst - } - } - } -} - -#endregion - -#region Command Converter - -function Convert-PSGetCommand { - <# - .SYNOPSIS - Converts a single PSGet v2 command invocation to its PSResourceGet equivalent. - .PARAMETER CommandInfo - A PSCustomObject from Find-PSGetCommand containing the AST node and metadata. - .OUTPUTS - PSCustomObject with: OriginalText, ConvertedText, Warnings, Line - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param( - [Parameter(Mandatory)] - [PSCustomObject] $CommandInfo - ) - - $commandMapping = Get-PSGetCommandMapping - $parameterMapping = Get-PSGetParameterMapping - - $mapping = $commandMapping[$CommandInfo.CommandName] - $warnings = [System.Collections.Generic.List[string]]::new() - - # If no equivalent exists, return a warning - if ($null -eq $mapping.Command) { - return [PSCustomObject]@{ - OriginalText = $CommandInfo.ExtentText - ConvertedText = $null - Warnings = @($mapping.Warning) - Line = $CommandInfo.Line - Column = $CommandInfo.Column - Status = 'NoEquivalent' - } - } - - $cmdAst = $CommandInfo.CommandAst - $elements = $cmdAst.CommandElements - - # Build the new command parts - $newCmdName = $mapping.Command - $newParams = [System.Collections.Generic.List[string]]::new() - - # Track version-related parameters for merging - $minimumVersion = $null - $maximumVersion = $null - $hasRequiredVersion = $false - $hasAllVersions = $false - $hasForce = $false - - # Extra parameters from the mapping (e.g., -Type DscResource for Find-DscResource) - $extraParams = if ($mapping.ContainsKey('ExtraParams')) { $mapping.ExtraParams } else { @{} } - - # Parse existing parameters from AST - $i = 1 # skip first element (command name) - while ($i -lt $elements.Count) { - $element = $elements[$i] - - if ($element -is [System.Management.Automation.Language.CommandParameterAst]) { - $paramName = $element.ParameterName - $paramValue = $null - - # Check if the parameter has an argument (inline via : or next element) - if ($null -ne $element.Argument) { - $paramValue = $element.Argument.Extent.Text - } - elseif (($i + 1) -lt $elements.Count -and - $elements[$i + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) { - $i++ - $paramValue = $elements[$i].Extent.Text - } - - # Apply parameter mapping - $paramRule = $null - foreach ($key in $parameterMapping.Keys) { - if ($key -eq $paramName) { - $paramRule = $parameterMapping[$key] - break - } - } - - if ($null -ne $paramRule) { - switch ($paramRule.Type) { - 'Rename' { - if ($null -ne $paramValue) { - # If the extra params would set the same parameter, check for conflict - if ($extraParams.ContainsKey($paramRule.NewName)) { - $warnings.Add("Parameter '-$paramName' conflicts with auto-added '-$($paramRule.NewName)' from cmdlet mapping. Using the explicit value.") - $extraParams.Remove($paramRule.NewName) - } - $newParams.Add("-$($paramRule.NewName) $paramValue") - } - else { - $newParams.Add("-$($paramRule.NewName)") - } - } - 'Remove' { - $warnings.Add($paramRule.Warning) - if ($paramName -eq 'Force') { - $hasForce = $true - } - } - 'Transform' { - switch ($paramRule.Handler) { - 'VersionExact' { - $hasRequiredVersion = $true - if ($null -ne $paramValue) { - $newParams.Add("-Version $paramValue") - } - } - 'VersionRange' { - if ($paramName -eq 'MinimumVersion') { - $minimumVersion = $paramValue - } - elseif ($paramName -eq 'MaximumVersion') { - $maximumVersion = $paramValue - } - } - 'VersionAll' { - $hasAllVersions = $true - } - } - } - } - } - else { - # Unknown/unmapped parameter — pass through as-is - if ($null -ne $paramValue) { - $newParams.Add("-$paramName $paramValue") - } - else { - $newParams.Add("-$paramName") - } - } - } - else { - # Positional argument — pass through - $newParams.Add($element.Extent.Text) - } - - $i++ - } - - # Handle version range merging - if (-not $hasRequiredVersion -and -not $hasAllVersions) { - if ($null -ne $minimumVersion -and $null -ne $maximumVersion) { - $minVal = $minimumVersion.Trim("'`"") - $maxVal = $maximumVersion.Trim("'`"") - $newParams.Add("-Version '[$minVal,$maxVal]'") - } - elseif ($null -ne $minimumVersion) { - $minVal = $minimumVersion.Trim("'`"") - $newParams.Add("-Version '[$minVal,)'") - } - elseif ($null -ne $maximumVersion) { - $maxVal = $maximumVersion.Trim("'`"") - $newParams.Add("-Version '(,$maxVal]'") - } - } - - # Handle -AllVersions → -Version '*' - if ($hasAllVersions) { - $newParams.Add("-Version '*'") - } - - # Add -Reinstall if -Force was used with Install-PSResource - if ($hasForce -and $newCmdName -eq 'Install-PSResource') { - $newParams.Add('-Reinstall') - $warnings.Add("Replaced '-Force' with '-Reinstall' for Install-PSResource.") - } - - # Add extra params from cmdlet mapping (e.g., -Type DscResource) - foreach ($extraKey in $extraParams.Keys) { - $newParams.Add("-$extraKey $($extraParams[$extraKey])") - } - - # Compose final command - $convertedParts = @($newCmdName) + $newParams - $convertedText = $convertedParts -join ' ' - - return [PSCustomObject]@{ - OriginalText = $CommandInfo.ExtentText - ConvertedText = $convertedText - Warnings = $warnings.ToArray() - Line = $CommandInfo.Line - Column = $CommandInfo.Column - Status = 'Converted' - } -} - -#endregion - -#region File-Level Orchestrator - -function ConvertTo-PSResourceGetScript { - <# - .SYNOPSIS - Scans a PowerShell file for PSGet v2 usage and returns migration results. - .PARAMETER Path - Path to the PowerShell file to process. - .PARAMETER Apply - If specified, modifies the file in-place with the converted commands. - .PARAMETER BackupPath - Directory to store backup copies before modification. Defaults to creating .bak files alongside originals. - .OUTPUTS - PSCustomObject per conversion with: File, Line, OriginalText, ConvertedText, Warnings, Status - #> - [CmdletBinding(SupportsShouldProcess)] - param( - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [Alias('FullName')] - [string] $Path, - - [switch] $Apply, - - [string] $BackupPath - ) - - process { - $resolvedPath = Resolve-Path -Path $Path -ErrorAction Stop - $filePath = $resolvedPath.Path - - Write-Verbose "Scanning: $filePath" - - $commands = @(Find-PSGetCommand -Path $filePath) - - if ($commands.Count -eq 0) { - Write-Verbose "No PSGet v2 commands found in '$filePath'." - return - } - - # Convert each command - $conversions = foreach ($cmd in $commands) { - $result = Convert-PSGetCommand -CommandInfo $cmd - $result | Add-Member -NotePropertyName 'File' -NotePropertyValue $filePath - $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset - $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset - $result - } - - # Output the conversion results - $conversions | ForEach-Object { - [PSCustomObject]@{ - File = $_.File - Line = $_.Line - Column = $_.Column - OriginalText = $_.OriginalText - ConvertedText = $_.ConvertedText - Warnings = $_.Warnings - Status = $_.Status - } - } - - # Apply changes if requested - if ($Apply -and $PSCmdlet.ShouldProcess($filePath, 'Convert PSGet commands to PSResourceGet')) { - $content = [System.IO.File]::ReadAllText($filePath) - - # Create backup - if ($BackupPath) { - $backupDir = $BackupPath - if (-not (Test-Path $backupDir)) { - New-Item -ItemType Directory -Path $backupDir -Force | Out-Null - } - $backupFile = Join-Path $backupDir ((Split-Path $filePath -Leaf) + '.bak') - } - else { - $backupFile = "$filePath.bak" - } - Copy-Item -Path $filePath -Destination $backupFile -Force - Write-Verbose "Backup created: $backupFile" - - # Apply replacements in reverse offset order so positions stay valid - $sortedConversions = $conversions | - Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | - Sort-Object -Property StartOffset -Descending - - foreach ($conv in $sortedConversions) { - $content = $content.Substring(0, $conv.StartOffset) + - $conv.ConvertedText + - $content.Substring($conv.EndOffset) - } - - [System.IO.File]::WriteAllText($filePath, $content) - Write-Verbose "File updated: $filePath" - } - } -} - -#endregion - -#region String Converter - -function ConvertTo-PSResourceGetString { - <# - .SYNOPSIS - Converts PSGet v2 cmdlet usage in a script string to PSResourceGet equivalents. - .PARAMETER InputScript - A string containing PowerShell script text with PSGet v2 commands. - .OUTPUTS - PSCustomObject with: ConvertedScript, Conversions (detail array), Warnings - .EXAMPLE - $result = ConvertTo-PSResourceGetString -InputScript 'Install-Module -Name Pester -Force' - $result.ConvertedScript # → 'Install-PSResource -Name Pester -Reinstall' - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param( - [Parameter(Mandatory, ValueFromPipeline)] - [string] $InputScript - ) - - process { - # Write to a temp file so the AST parser can process it - $tempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.ps1' - try { - [System.IO.File]::WriteAllText($tempFile, $InputScript) - - $commands = @(Find-PSGetCommand -Path $tempFile) - $allWarnings = [System.Collections.Generic.List[string]]::new() - - if ($commands.Count -eq 0) { - return [PSCustomObject]@{ - ConvertedScript = $InputScript - Conversions = @() - Warnings = @() - } - } - - # Convert each command and collect results - $conversions = foreach ($cmd in $commands) { - $result = Convert-PSGetCommand -CommandInfo $cmd - $result | Add-Member -NotePropertyName 'StartOffset' -NotePropertyValue $cmd.StartOffset - $result | Add-Member -NotePropertyName 'EndOffset' -NotePropertyValue $cmd.EndOffset - foreach ($w in $result.Warnings) { $allWarnings.Add($w) } - $result - } - - # Apply replacements in reverse offset order - $output = $InputScript - $sortedConversions = $conversions | - Where-Object { $_.Status -eq 'Converted' -and $null -ne $_.ConvertedText } | - Sort-Object -Property StartOffset -Descending - - foreach ($conv in $sortedConversions) { - $output = $output.Substring(0, $conv.StartOffset) + - $conv.ConvertedText + - $output.Substring($conv.EndOffset) - } - - return [PSCustomObject]@{ - ConvertedScript = $output - Conversions = @($conversions | ForEach-Object { - [PSCustomObject]@{ - Line = $_.Line - OriginalText = $_.OriginalText - ConvertedText = $_.ConvertedText - Warnings = $_.Warnings - Status = $_.Status - } - }) - Warnings = $allWarnings.ToArray() - } - } - finally { - Remove-Item $tempFile -Force -ErrorAction SilentlyContinue - } - } -} - -#endregion - -#region Formatting - -function Format-MigrationReport { - <# - .SYNOPSIS - Formats migration results into a human-readable report. - .PARAMETER Results - Array of conversion result objects from ConvertTo-PSResourceGetScript. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory, ValueFromPipeline)] - [PSCustomObject[]] $Results - ) - - begin { - $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() - } - - process { - foreach ($r in $Results) { - $allResults.Add($r) - } - } - - end { - if ($allResults.Count -eq 0) { - Write-Host "`n No PSGet v2 commands found. Nothing to migrate.`n" -ForegroundColor Green - return - } - - $grouped = $allResults | Group-Object -Property File - - Write-Host "`n========================================" -ForegroundColor Cyan - Write-Host " PSGet → PSResourceGet Migration Report" -ForegroundColor Cyan - Write-Host "========================================`n" -ForegroundColor Cyan - - foreach ($group in $grouped) { - Write-Host " File: $($group.Name)" -ForegroundColor Yellow - Write-Host " $('-' * 60)" -ForegroundColor DarkGray - - foreach ($item in $group.Group) { - $statusColor = switch ($item.Status) { - 'Converted' { 'Green' } - 'NoEquivalent' { 'Red' } - default { 'White' } - } - - Write-Host " Line $($item.Line):" -ForegroundColor White - Write-Host " - $($item.OriginalText)" -ForegroundColor Red - if ($item.ConvertedText) { - Write-Host " + $($item.ConvertedText)" -ForegroundColor Green - } - - foreach ($w in $item.Warnings) { - Write-Host " ⚠ $w" -ForegroundColor DarkYellow - } - Write-Host "" - } - } - - # Summary - $total = $allResults.Count - $converted = ($allResults | Where-Object Status -eq 'Converted').Count - $noEquiv = ($allResults | Where-Object Status -eq 'NoEquivalent').Count - $withWarnings = ($allResults | Where-Object { $_.Warnings.Count -gt 0 }).Count - - Write-Host " Summary" -ForegroundColor Cyan - Write-Host " $('-' * 60)" -ForegroundColor DarkGray - Write-Host " Total commands found : $total" - Write-Host " Converted : $converted" -ForegroundColor Green - Write-Host " No equivalent : $noEquiv" -ForegroundColor Red - Write-Host " With warnings : $withWarnings" -ForegroundColor DarkYellow - Write-Host "" - } -} - -#endregion - -# Export module members -Export-ModuleMember -Function @( - 'Get-PSGetCommandMapping', - 'Get-PSGetParameterMapping', - 'Find-PSGetCommand', - 'Convert-PSGetCommand', - 'ConvertTo-PSResourceGetScript', - 'ConvertTo-PSResourceGetString', - 'Format-MigrationReport' -) diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 index 91dbb25b5..4e95b050c 100644 --- a/tool/migration/Tests/PSGetMigration.Tests.ps1 +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -Describe 'PSGetMigration Module' { +Describe 'PSGet Migration Tool' { BeforeAll { - $modulePath = Join-Path $PSScriptRoot '..' 'PSGetMigration.psd1' - Import-Module $modulePath -Force + # Dot-source the script to load all functions into the current scope + . (Join-Path $PSScriptRoot '..' 'ConvertTo-PSResourceGet.ps1') # Helper to create temp script files function New-TempScript { From 0c951b29f45b9f675aa784fed25a44d8f4b06758 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 22 Jul 2026 15:29:20 -0700 Subject: [PATCH 4/9] Fix version range quoting when value contains a variable When -MinimumVersion or -MaximumVersion contains a variable reference (e.g. \), use double quotes for the version range string so the variable expands at runtime. Literal version strings continue to use single quotes. Before: -Version '(,\]' (broken - no expansion) After: -Version "(,\]" (correct - variable expands) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/ConvertTo-PSResourceGet.ps1 | 10 +++++++--- tool/migration/Tests/PSGetMigration.Tests.ps1 | 11 +++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/tool/migration/ConvertTo-PSResourceGet.ps1 b/tool/migration/ConvertTo-PSResourceGet.ps1 index 427e2c2da..65c4e248a 100644 --- a/tool/migration/ConvertTo-PSResourceGet.ps1 +++ b/tool/migration/ConvertTo-PSResourceGet.ps1 @@ -347,15 +347,19 @@ function Convert-PSGetCommand { if ($null -ne $minimumVersion -and $null -ne $maximumVersion) { $minVal = $minimumVersion.Trim("'`"") $maxVal = $maximumVersion.Trim("'`"") - $newParams.Add("-Version '[$minVal,$maxVal]'") + # Use double quotes if the value contains a variable reference + $quote = if ($minVal -match '\$' -or $maxVal -match '\$') { '"' } else { "'" } + $newParams.Add("-Version $quote[$minVal,$maxVal]$quote") } elseif ($null -ne $minimumVersion) { $minVal = $minimumVersion.Trim("'`"") - $newParams.Add("-Version '[$minVal,)'") + $quote = if ($minVal -match '\$') { '"' } else { "'" } + $newParams.Add("-Version $quote[$minVal,)$quote") } elseif ($null -ne $maximumVersion) { $maxVal = $maximumVersion.Trim("'`"") - $newParams.Add("-Version '(,$maxVal]'") + $quote = if ($maxVal -match '\$') { '"' } else { "'" } + $newParams.Add("-Version $quote(,$maxVal]$quote") } } diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 index 4e95b050c..ed07fa614 100644 --- a/tool/migration/Tests/PSGetMigration.Tests.ps1 +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -170,6 +170,17 @@ Get-InstalledModule $result.ConvertedText | Should -Match "-Version '\[4\.0,5\.0\]'" } + It 'Uses double quotes for version range when value contains a variable' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester -MaximumVersion $MaximumVersion' + $result.ConvertedText | Should -Match '-Version "\(,\$MaximumVersion\]"' + $result.ConvertedText | Should -Not -Match "'" + } + + It 'Uses double quotes for merged version range with variables' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Name Pester -MinimumVersion $MinVer -MaximumVersion $MaxVer' + $result.ConvertedText | Should -Match '-Version "\[\$MinVer,\$MaxVer\]"' + } + It 'Converts -AllVersions to -Version wildcard' { $result = Invoke-ConvertFromScript -Script 'Find-Module -Name Az -AllVersions' $result.ConvertedText | Should -Match "-Version '\*'" From aeb90695a071cced0c2134ca9a7a8a176a5c7026 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 23 Jul 2026 10:20:39 -0700 Subject: [PATCH 5/9] Address PR review feedback from Copilot - Replace Join-String (PS 7+ only) with -join operator for PS 5.1 compat - Add known switch parameter list to prevent consuming positional args after switches like -Force (e.g. 'Install-Module -Force Pester') - Fix README to document dot-sourcing instead of removed module manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/ConvertTo-PSResourceGet.ps1 | 13 +++++++++++-- tool/migration/README.md | 5 +++-- tool/migration/Tests/PSGetMigration.Tests.ps1 | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/tool/migration/ConvertTo-PSResourceGet.ps1 b/tool/migration/ConvertTo-PSResourceGet.ps1 index 65c4e248a..31ce2f520 100644 --- a/tool/migration/ConvertTo-PSResourceGet.ps1 +++ b/tool/migration/ConvertTo-PSResourceGet.ps1 @@ -184,7 +184,7 @@ function Find-PSGetCommand { ) if ($parseErrors.Count -gt 0) { - Write-Warning "Parse errors in '$Path': $($parseErrors | ForEach-Object { $_.Message } | Join-String -Separator '; ')" + Write-Warning "Parse errors in '$Path': $(($parseErrors | ForEach-Object { $_.Message }) -join '; ')" } $commandAsts = $ast.FindAll({ @@ -263,10 +263,19 @@ function Convert-PSGetCommand { $paramName = $element.ParameterName $paramValue = $null + # Known switch parameters that never take a value argument + $switchParams = @( + 'Force', 'AllowClobber', 'AllowPrerelease', 'AllVersions', + 'SkipPublisherCheck', 'Reinstall', 'Prerelease', + 'AcceptLicense', 'NoClobber', 'NoPathUpdate', + 'PassThru', 'WhatIf', 'Confirm', 'TrustRepository' + ) + if ($null -ne $element.Argument) { $paramValue = $element.Argument.Extent.Text } - elseif (($i + 1) -lt $elements.Count -and + elseif ($switchParams -notcontains $paramName -and + ($i + 1) -lt $elements.Count -and $elements[$i + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) { $i++ $paramValue = $elements[$i].Extent.Text diff --git a/tool/migration/README.md b/tool/migration/README.md index 3cd9416af..1d4189ea6 100644 --- a/tool/migration/README.md +++ b/tool/migration/README.md @@ -74,10 +74,11 @@ cmdlets to their [PSResourceGet](https://github.com/PowerShell/PSResourceGet) eq - `Find-RoleCapability` — No PSResourceGet equivalent. Generates a warning. -## Using as a Module +## Using as a Script Library ```powershell -Import-Module .\tool\migration\PSGetMigration.psd1 +# Dot-source the script to load all functions +. .\tool\migration\ConvertTo-PSResourceGet.ps1 # Scan a single file $results = ConvertTo-PSResourceGetScript -Path .\deploy.ps1 diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 index ed07fa614..c47237050 100644 --- a/tool/migration/Tests/PSGetMigration.Tests.ps1 +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -211,6 +211,13 @@ Get-InstalledModule $result.ConvertedText | Should -Not -Match '-Force' } + It 'Does not consume positional argument after a switch parameter' { + $result = Invoke-ConvertFromScript -Script 'Install-Module -Force Pester' + $result.ConvertedText | Should -Match 'Install-PSResource' + $result.ConvertedText | Should -Match 'Pester' + $result.ConvertedText | Should -Match '-Reinstall' + } + It 'Returns NoEquivalent for Find-RoleCapability' { $result = Invoke-ConvertFromScript -Script 'Find-RoleCapability -Name MyRole' $result.Status | Should -Be 'NoEquivalent' From 235e11ba2430a3879c2df2b6dc745ec0be2c664d Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 23 Jul 2026 10:22:55 -0700 Subject: [PATCH 6/9] Include migration tool in module build output Copy ConvertTo-PSResourceGet.ps1 to the 'migration' subdirectory of the module output during build. The existing signing step already signs all **\*.ps1 files under the output path, so the migration tool will be signed as part of the release pipeline automatically. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doBuild.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doBuild.ps1 b/doBuild.ps1 index 87985f06c..2cc55769e 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -53,6 +53,13 @@ function DoBuild Write-Verbose -Verbose -Message "Copying resource manifests to '$BuildOutPath'" Copy-Item -Path "${SrcPath}/dsc/*.resource.json" -Dest "$BuildOutPath" -Force + # Copy migration tool + $migrationToolOutPath = Join-Path -Path $BuildOutPath -ChildPath "migration" + Write-Verbose -Verbose -Message "Creating migration tool output path: '$migrationToolOutPath'" + $null = New-Item -ItemType Directory -Path $migrationToolOutPath -Force + Write-Verbose -Verbose -Message "Copying ConvertTo-PSResourceGet.ps1 to '$migrationToolOutPath'" + Copy-Item -Path "tool/migration/ConvertTo-PSResourceGet.ps1" -Dest "$migrationToolOutPath" -Force + # Build and place binaries if ( Test-Path "${SrcPath}/code" ) { Write-Verbose -Verbose -Message "Building assembly and copying to '$BuildOutPath'" From 099fc6699bc1ab8415c11261244e680d3c6b25f4 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 23 Jul 2026 10:25:01 -0700 Subject: [PATCH 7/9] Add migration tool tests to CI pipeline Adds a 'TestMigrationTool' job in the Test stage that runs the migration tool Pester tests on both PowerShell Core (pwsh) and Windows PowerShell. The job is independent of the module build since the migration tool is a standalone script. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .ci/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.ci/ci.yml b/.ci/ci.yml index 9c75987a0..a4283f632 100644 --- a/.ci/ci.yml +++ b/.ci/ci.yml @@ -149,3 +149,36 @@ stages: displayName: AzAuth PowerShell Core on Windows imageName: windows-latest useAzAuth: true + + - job: TestMigrationTool + displayName: Migration Tool Tests + pool: + vmImage: windows-latest + steps: + - checkout: self + + - pwsh: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + if (-not (Test-Path -Path $modulePath)) { + $null = New-Item -Path $modulePath -ItemType Directory + } + Save-Module -Name "Pester" -MaximumVersion 4.99 -Path $modulePath -Force + displayName: Install Pester + + - pwsh: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + $results = Invoke-Pester -Path '$(Build.SourcesDirectory)/tool/migration/Tests' -PassThru + if ($results.FailedCount -gt 0) { + throw "$($results.FailedCount) migration tool test(s) failed." + } + displayName: Run migration tool Pester tests + + - powershell: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + $results = Invoke-Pester -Path '$(Build.SourcesDirectory)/tool/migration/Tests' -PassThru + if ($results.FailedCount -gt 0) { + throw "$($results.FailedCount) migration tool test(s) failed." + } + displayName: Run migration tool Pester tests (Windows PowerShell) From 0f84b45d9fb451edf59b6d791e811a7cd44b4428 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 23 Jul 2026 10:39:43 -0700 Subject: [PATCH 8/9] Fix Join-Path call for PowerShell 5.1 compatibility Join-Path only accepts two path arguments in PS 5.1. Chain two Join-Path calls instead of passing three positional arguments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tool/migration/Tests/PSGetMigration.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/migration/Tests/PSGetMigration.Tests.ps1 b/tool/migration/Tests/PSGetMigration.Tests.ps1 index c47237050..463dd444d 100644 --- a/tool/migration/Tests/PSGetMigration.Tests.ps1 +++ b/tool/migration/Tests/PSGetMigration.Tests.ps1 @@ -5,7 +5,7 @@ Describe 'PSGet Migration Tool' { BeforeAll { # Dot-source the script to load all functions into the current scope - . (Join-Path $PSScriptRoot '..' 'ConvertTo-PSResourceGet.ps1') + . (Join-Path (Join-Path $PSScriptRoot '..') 'ConvertTo-PSResourceGet.ps1') # Helper to create temp script files function New-TempScript { From 844c896d2b9ccc8a2f0de465f13a75b3f2a29b9a Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Thu, 23 Jul 2026 15:36:27 -0700 Subject: [PATCH 9/9] Remove migration tool from module package, keep CI tests The migration script lives in the repo as a standalone tool but is not included in the packaged module. The CI job uses dependsOn: [] so it runs directly from the checked-out source without waiting for the module build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .ci/ci.yml | 1 + doBuild.ps1 | 7 ------- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.ci/ci.yml b/.ci/ci.yml index a4283f632..3f54e3927 100644 --- a/.ci/ci.yml +++ b/.ci/ci.yml @@ -152,6 +152,7 @@ stages: - job: TestMigrationTool displayName: Migration Tool Tests + dependsOn: [] pool: vmImage: windows-latest steps: diff --git a/doBuild.ps1 b/doBuild.ps1 index 2cc55769e..87985f06c 100644 --- a/doBuild.ps1 +++ b/doBuild.ps1 @@ -53,13 +53,6 @@ function DoBuild Write-Verbose -Verbose -Message "Copying resource manifests to '$BuildOutPath'" Copy-Item -Path "${SrcPath}/dsc/*.resource.json" -Dest "$BuildOutPath" -Force - # Copy migration tool - $migrationToolOutPath = Join-Path -Path $BuildOutPath -ChildPath "migration" - Write-Verbose -Verbose -Message "Creating migration tool output path: '$migrationToolOutPath'" - $null = New-Item -ItemType Directory -Path $migrationToolOutPath -Force - Write-Verbose -Verbose -Message "Copying ConvertTo-PSResourceGet.ps1 to '$migrationToolOutPath'" - Copy-Item -Path "tool/migration/ConvertTo-PSResourceGet.ps1" -Dest "$migrationToolOutPath" -Force - # Build and place binaries if ( Test-Path "${SrcPath}/code" ) { Write-Verbose -Verbose -Message "Building assembly and copying to '$BuildOutPath'"