From 618e016fce1f306bf960c192f44ff7e586917a58 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Wed, 22 Jul 2026 11:00:22 -0700 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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 003bb620d65889051d7cac2abe9d8538b30d96a6 Mon Sep 17 00:00:00 2001 From: Aditya Patwardhan Date: Tue, 4 Aug 2026 14:34:02 -0700 Subject: [PATCH 5/5] Convert .ci build/test Azure DevOps pipelines to GitHub Actions Adds GitHub Actions equivalents for the portable CI + test pipelines: - .github/workflows/psresourceget-ci.yml (from .ci/ci.yml build + test stages) - .github/workflows/psresourceget-test.yml (reusable, from .ci/test.yml) Azure-authenticated test steps use azure/login OIDC (federated credentials). Signing, compliance, release and symbol stages are intentionally not converted (Microsoft-internal ESRP / 1ES / ComplianceRepo / symbol server). Original .ci files are kept unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ceb1570e-c42e-4363-8f8e-5abc7f750458 --- .github/workflows/psresourceget-ci.yml | 183 ++++++++++++++++ .github/workflows/psresourceget-test.yml | 264 +++++++++++++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 .github/workflows/psresourceget-ci.yml create mode 100644 .github/workflows/psresourceget-test.yml diff --git a/.github/workflows/psresourceget-ci.yml b/.github/workflows/psresourceget-ci.yml new file mode 100644 index 000000000..3b7c194ed --- /dev/null +++ b/.github/workflows/psresourceget-ci.yml @@ -0,0 +1,183 @@ +# CI workflow converted from .ci/ci.yml (Azure DevOps pipeline). +# Builds the Microsoft.PowerShell.PSResourceGet module package and runs the +# cross-platform test suite. Signing / compliance / release / symbol stages from +# the original pipeline are intentionally not converted (they rely on Microsoft +# internal infrastructure: ESRP, 1ES pools, ComplianceRepo, symbol server). +name: PSResourceGet-CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + id-token: write # required so the reusable test workflow can use azure/login OIDC + +jobs: + build: + name: Build Package + runs-on: windows-latest + defaults: + run: + shell: pwsh + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AGENT_TEMPDIRECTORY + run: | + # Map the Azure DevOps $(Agent.TempDirectory) that the carried-over scripts expect. + "AGENT_TEMPDIRECTORY=$env:RUNNER_TEMP" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Capture environment for build + if: ${{ always() }} + shell: pwsh + run: | + Get-ChildItem -Path env: + + - name: Create temporary module path + shell: pwsh + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + if (Test-Path -Path $modulePath) { + Write-Verbose -Verbose "Deleting existing temp module path: $modulePath" + Remove-Item -Path $modulePath -Recurse -Force -ErrorAction Ignore + } + if (! (Test-Path -Path $modulePath)) { + Write-Verbose -Verbose "Creating new temp module path: $modulePath" + $null = New-Item -Path $modulePath -ItemType Directory + } + + - name: Install Microsoft.PowerShell.PSResourceGet v3 + shell: pwsh + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + Write-Verbose -Verbose "Install PSResourceGet to temp module path" + Save-Module -Name Microsoft.PowerShell.PSResourceGet -MinimumVersion 0.9.0-rc1 -Path $modulePath -AllowPrerelease -Force + + - name: Capture source code for build + if: ${{ always() }} + shell: pwsh + run: | + Get-ChildItem -Path ${{ github.workspace }}/src/code -Recurse + + - name: Build Module + shell: pwsh + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name ${{ github.workspace }}/buildtools.psd1 -Force + ${{ github.workspace }}/build.ps1 -Build -Clean -BuildConfiguration Release -BuildFramework 'net472' + + - name: Publish module nuget package + shell: pwsh + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name ${{ github.workspace }}/buildtools.psd1 -Force + ${{ github.workspace }}/build.ps1 -Publish + + - name: Stage nuget package for upload + shell: pwsh + run: | + # build.ps1 -Publish drops the .nupkg into a temp local repo folder. Copy it + # to a well-known folder so it can be uploaded as the "nupkg" artifact, matching + # the layout expected by Install-ModulePackageForTest (a "nupkg" subfolder). + $localRepoLocation = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath 'packagebuild-local-repo' + $nupkg = Get-ChildItem -Path $localRepoLocation -Filter '*.nupkg' -Recurse + if (-not $nupkg) { + throw "Could not find built .nupkg under $localRepoLocation" + } + $stagingPath = Join-Path -Path ${{ github.workspace }} -ChildPath 'nupkg' + $null = New-Item -Path $stagingPath -ItemType Directory -Force + $nupkg | Copy-Item -Destination $stagingPath -Force -Verbose + Get-ChildItem -Path $stagingPath + + - name: Upload nuget package artifact + uses: actions/upload-artifact@v4 + with: + name: nupkg + path: ${{ github.workspace }}/nupkg/*.nupkg + if-no-files-found: error + + - name: Upload module artifact + shell: pwsh + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Import-Module -Name ${{ github.workspace }}/buildtools.psd1 -Force + $config = Get-BuildConfiguration + $srcModulePath = Resolve-Path -Path "$($config.BuildOutputPath)/$($config.ModuleName)" + Get-ChildItem $srcModulePath + "MODULE_ARTIFACT_PATH=$srcModulePath" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload built module artifact + uses: actions/upload-artifact@v4 + with: + name: Microsoft.PowerShell.PSResourceGet + path: ${{ env.MODULE_ARTIFACT_PATH }} + if-no-files-found: error + + test-windows-pwsh: + name: PowerShell Core on Windows + needs: build + uses: ./.github/workflows/psresourceget-test.yml + secrets: inherit + with: + imageName: windows-latest + displayName: PowerShell Core on Windows + powershellExecutable: pwsh + useAzAuth: false + + test-windows-winps: + name: Windows PowerShell on Windows + needs: build + uses: ./.github/workflows/psresourceget-test.yml + secrets: inherit + with: + imageName: windows-latest + displayName: Windows PowerShell on Windows + powershellExecutable: powershell + useAzAuth: false + + test-ubuntu: + name: PowerShell Core on Ubuntu + needs: build + uses: ./.github/workflows/psresourceget-test.yml + secrets: inherit + with: + imageName: ubuntu-latest + displayName: PowerShell Core on Ubuntu + powershellExecutable: pwsh + useAzAuth: false + + test-macos: + name: PowerShell Core on macOS + needs: build + uses: ./.github/workflows/psresourceget-test.yml + secrets: inherit + with: + imageName: macos-latest + displayName: PowerShell Core on macOS + powershellExecutable: pwsh + useAzAuth: false + + test-windows-azauth: + name: AzAuth PowerShell Core on Windows + needs: build + uses: ./.github/workflows/psresourceget-test.yml + secrets: inherit + with: + imageName: windows-latest + displayName: AzAuth PowerShell Core on Windows + powershellExecutable: pwsh + useAzAuth: true diff --git a/.github/workflows/psresourceget-test.yml b/.github/workflows/psresourceget-test.yml new file mode 100644 index 000000000..b62dca3ad --- /dev/null +++ b/.github/workflows/psresourceget-test.yml @@ -0,0 +1,264 @@ +# Reusable test workflow converted from .ci/test.yml (Azure DevOps template). +# Called by psresourceget-ci.yml once per target platform / configuration. +name: PSResourceGet-Test + +on: + workflow_call: + inputs: + imageName: + description: 'Runner image to execute the tests on (e.g. windows-latest, ubuntu-latest, macos-latest).' + required: true + type: string + displayName: + description: 'Human readable job name.' + required: false + default: 'Test package' + type: string + powershellExecutable: + description: 'Shell used to run the test steps: pwsh (PowerShell Core) or powershell (Windows PowerShell).' + required: false + default: 'pwsh' + type: string + useAzAuth: + description: 'Run the ACR functional tests using Azure authentication instead of the local SecretStore.' + required: false + default: false + type: boolean + +permissions: + contents: read + id-token: write # required for azure/login OIDC (federated credentials) + +jobs: + test: + name: ${{ inputs.displayName }} + runs-on: ${{ inputs.imageName }} + defaults: + run: + # Selects pwsh (PowerShell Core) or powershell (Windows PowerShell) per caller input. + shell: ${{ inputs.powershellExecutable }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure AGENT_TEMPDIRECTORY + run: | + # Many pwsh snippets below were carried over verbatim from the Azure DevOps template, + # which relied on $(Agent.TempDirectory). Map it to the runner temp directory so the + # scripts keep working unchanged across all subsequent steps. + "AGENT_TEMPDIRECTORY=$env:RUNNER_TEMP" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Install Secret store + if: ${{ inputs.useAzAuth == false }} + run: | + Install-Module -Name 'Microsoft.PowerShell.SecretManagement' -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name 'Microsoft.PowerShell.SecretStore' -Force -SkipPublisherCheck -AllowClobber + $vaultPassword = ConvertTo-SecureString $("a!!" + (Get-Random -Maximum ([int]::MaxValue))) -AsPlainText -Force + Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false + Register-SecretVault -Name SecretStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault + + - name: Download build artifact (nupkg) + uses: actions/download-artifact@v4 + with: + name: nupkg + path: ${{ github.workspace }}/artifacts/nupkg + + - name: Capture artifacts directory + run: | + Get-ChildItem -Path "${{ github.workspace }}/artifacts" -Recurse + + - name: Create temporary module path + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + if (Test-Path -Path $modulePath) { + Write-Verbose -Verbose "Deleting existing temp module path: $modulePath" + Remove-Item -Path $modulePath -Recurse -Force -ErrorAction Ignore + } + if (! (Test-Path -Path $modulePath)) { + Write-Verbose -Verbose "Creating new temp module path: $modulePath" + $null = New-Item -Path $modulePath -ItemType Directory + } + + - name: Install Microsoft.PowerShell.PSResourceGet and Pester + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + Write-Verbose -Verbose "Install Microsoft.PowerShell.PSResourceGet to temp module path" + Save-Module -Name Microsoft.PowerShell.PSResourceGet -Path $modulePath -Force -Verbose + Write-Verbose -Verbose "Install Pester 4.X to temp module path" + Save-Module -Name "Pester" -MaximumVersion 4.99 -Path $modulePath -Force + + - name: Install module for test from downloaded artifact + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name (Join-Path -Path '.' -ChildPath 'buildtools.psd1') -Force + Install-ModulePackageForTest -PackagePath "${{ github.workspace }}/artifacts" -ErrorAction Stop -Verbose + + # Azure authentication using OIDC / federated credentials (no long lived secret stored). + # Enables the Az PowerShell module for the subsequent ACR / Azure Artifacts steps. + - name: Azure login (OIDC) + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + enable-AzPSSession: true + + - name: Setup Azure Artifacts Credential Provider secret + if: ${{ inputs.useAzAuth == false }} + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Write-Verbose -Verbose "Setting up secret for Azure Artifacts Credential Provider" + $azt = (Get-AzAccessToken).Token | ConvertFrom-SecureString -AsPlainText + + $ADORepoName = "psrg-credprovidertest" + $ADORepoUri = "https://pkgs.dev.azure.com/powershell-rel/PSResourceGet/_packaging/psrg-credprovidertest/nuget/v2" + + $endpointCredsObj = @{ endpointCredentials = @( @{ endpoint = $ADORepoUri; password = $azt }) } + $VSS_NUGET_EXTERNAL_FEED_ENDPOINTS = $endpointCredsObj | ConvertTo-Json -Compress + + Write-Verbose -Verbose "Setting VSS_NUGET_EXTERNAL_FEED_ENDPOINTS environment variable" + "VSS_NUGET_EXTERNAL_FEED_ENDPOINTS=$VSS_NUGET_EXTERNAL_FEED_ENDPOINTS" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Install latest DSC v3 + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $repo = "PowerShell/DSC" + $api = "https://api.github.com/repos/$repo/releases" + + $headers = @{ + "User-Agent" = "pwsh" + "Accept" = "application/vnd.github+json" + "Authorization" = "Bearer $env:GITHUB_TOKEN" + } + + $releases = Invoke-RestMethod -Uri $api -Headers $headers -RetryIntervalSec 30 -MaximumRetryCount 10 + + $latest = $releases | Sort-Object published_at -Descending | Select-Object -First 1 + Write-Host "Latest version: $($latest.tag_name) (prerelease=$($latest.prerelease))" + + if ($IsWindows) { + $assetFilter = "x86_64-pc-windows-msvc" + } + elseif ($IsMacOs) { + $assetFilter = "x86_64-apple-darwin" + } + else { + $assetFilter = "x86_64-linux" + } + + $assets = $latest.assets | Where-Object { $_.name -match $assetFilter } + $selectedAsset = $assets | Select-Object -First 1 + Write-Host "Selected asset: $($selectedAsset.name)" + + $outFile = Join-Path $PWD $selectedAsset.name + Write-Host "Downloading $($selectedAsset.name)" + Invoke-WebRequest -Uri $selectedAsset.browser_download_url -Headers @{ "User-Agent" = "pwsh" } -OutFile $outFile + + if ($IsWindows) { + Expand-Archive -Path $outFile -DestinationPath $env:AGENT_TEMPDIRECTORY -Force -Verbose + } + else { + tar -xzf $outFile -C $env:AGENT_TEMPDIRECTORY + } + + $executableName = 'dsc' + $executableExt = if ($IsWindows) { '.exe' } else { '' } + $executableFileName = $executableName + $executableExt + + $executable = Get-ChildItem -Path $env:AGENT_TEMPDIRECTORY -File -Recurse -Verbose | + Where-Object { $_.Name -eq $executableFileName } | Select-Object -First 1 + + if (-not $executable) { + throw "Could not find dsc executable in the downloaded package" + } + + $dscRoot = Split-Path -Path $executable.FullName -Parent + "DSC_ROOT=$dscRoot" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Capture environment + run: | + Get-ChildItem -Path env: | Out-String -Width 9999 -Stream | Write-Verbose -Verbose + + - name: Setup Azure Container Registry secret + if: ${{ inputs.useAzAuth == false }} + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + Write-Verbose -Verbose "Getting Azure Container Registry" + Get-AzContainerRegistry -ResourceGroupName 'PSResourceGet' -Name 'psresourcegettest' | Select-Object -Property * + Write-Verbose -Verbose "Setting up secret for Azure Container Registry" + $azt = Get-AzAccessToken + $tenantId = $azt.TenantID + Set-Secret -Name $tenantId -Secret $azt.Token -Verbose + "TenantId=$tenantId" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Set UsingAzAuth environment variable + if: ${{ inputs.useAzAuth == true }} + run: | + "UsingAzAuth=true" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload empty file for ACR functional tests to write repository names to + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + Write-Verbose -Verbose "Creating new folder for acr repository names file: $acrRepositoryNamesFolder" + $null = New-Item -Path $acrRepositoryNamesFolder -ItemType Directory -Force + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + New-Item -Path $acrRepositoryNamesFilePath -Force + + - name: Execute functional tests with AzAuth + if: ${{ inputs.useAzAuth == true }} + uses: azure/powershell@v2 + env: + MAPPED_GITHUB_PAT: ${{ secrets.GITHUB_PAT }} + MAPPED_ADO_PUBLIC_PAT: ${{ secrets.ADO_PUBLIC_PAT }} + MAPPED_ADO_PRIVATE_PAT: ${{ secrets.ADO_PRIVATE_PAT }} + MAPPED_ADO_PRIVATE_REPO_URL: ${{ secrets.ADO_PRIVATE_REPO_URL }} + with: + azPSVersion: 'latest' + inlineScript: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name (Join-Path -Path '.' -ChildPath 'buildtools.psd1') -Force + Invoke-ModuleTestsACR -Type Functional + + - name: Execute functional tests + if: ${{ inputs.useAzAuth == false }} + continue-on-error: true + env: + MAPPED_GITHUB_PAT: ${{ secrets.GITHUB_PAT }} + MAPPED_ADO_PUBLIC_PAT: ${{ secrets.ADO_PUBLIC_PAT }} + MAPPED_ADO_PRIVATE_PAT: ${{ secrets.ADO_PRIVATE_PAT }} + MAPPED_ADO_PRIVATE_REPO_URL: ${{ secrets.ADO_PRIVATE_REPO_URL }} + run: | + $modulePath = Join-Path -Path $env:AGENT_TEMPDIRECTORY -ChildPath 'TempModules' + $env:PSModulePath = $modulePath + [System.IO.Path]::PathSeparator + $env:PSModulePath + Write-Verbose -Verbose "Importing build utilities (buildtools.psd1)" + Import-Module -Name (Join-Path -Path '.' -ChildPath 'buildtools.psd1') -Force + Invoke-ModuleTests -Type Functional + + - name: Delete test repositories from ACR + if: ${{ always() && inputs.useAzAuth == false }} + uses: azure/powershell@v2 + with: + azPSVersion: 'latest' + inlineScript: | + $registryName = 'psresourcegettest' + $acrRepositoryNamesFolder = Join-Path -Path ([Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData)) -ChildPath 'TempModules' + $acrRepositoryNamesFilePath = Join-Path -Path $acrRepositoryNamesFolder -ChildPath 'ACRTestRepositoryNames.txt' + if (Test-Path -Path $acrRepositoryNamesFilePath) { + $repositoryNames = Get-Content -Path $acrRepositoryNamesFilePath + foreach ($name in $repositoryNames) { + Remove-AzContainerRegistryRepository -Name $name -RegistryName $registryName + } + }