Prism.psm1
# Copyright WebMD Health Services # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License #Requires -Version 5.1 Set-StrictMode -Version 'Latest' # Functions should use $moduleRoot as the relative root from which to find # things. A published module has its function appended to this file, while a # module in development has its functions in the Functions directory. $moduleRoot = $PSScriptRoot # Store each of your module's functions in its own file in the Functions # directory. On the build server, your module's functions will be appended to # this file, so only dot-source files that exist on the file system. This allows # developers to work on a module without having to build it first. Grab all the # functions that are in their own files. $functionsPath = Join-Path -Path $moduleRoot -ChildPath 'Functions\*.ps1' if( (Test-Path -Path $functionsPath) ) { foreach( $functionPath in (Get-Item $functionsPath) ) { . $functionPath.FullName } } function Get-PackageManagementPreference { [CmdletBinding()] param( ) Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $deepPrefs = @{} if( (Test-Path -Path 'env:PRISM_DISABLE_DEEP_DEBUG') -and ` 'Continue' -in @($Global:DebugPreference, $DebugPreference) ) { $deepPrefs['Debug'] = $false } if( (Test-Path -Path 'env:PRISM_DISABLE_DEEP_VERBOSE') -and ` 'Continue' -in @($Global:VerbosePreference, $VerbosePreference)) { $deepPrefs['Verbose'] = $false } return $deepPrefs } # Ugh. I hate this name, but it interferes with Install-Module in one of the package management modules. function Install-PrivateModule { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [Object] $Configuration, # A subset of the required modules to install or update. [String[]] $Name ) begin { Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $pkgMgmtPrefs = Get-PackageManagementPreference $repoByLocation = @{} foreach ($repo in (Get-PSRepository)) { $repoUrl = $repo.SourceLocation $repoByLocation[$repoUrl] = $repo.Name # Ignore slashes at the end of URLs. $trimmedRepoUrl = $repoUrl.TrimEnd('/') $repoByLocation[$trimmedRepoUrl] = $repo.Name } } process { if (-not (Test-Path -Path $Configuration.LockPath)) { $Configuration | Update-ModuleLock | Out-Null } $installDirPath = $Configuration.InstallDirectoryPath $locks = Get-Content -Path $Configuration.LockPath | ConvertFrom-Json $locks | Add-Member -Name 'PSModules' -MemberType NoteProperty -Value @() -ErrorAction Ignore if ($Name) { $locks.PSModules = $locks.PSModules | Where-Object {$_.Name -in $Name} } $installedModules = & { $origPSModulePath = $env:PSModulePath $env:PSModulePath = $Configuration.InstallDirectoryPath try { Write-Debug $env:PSModulePath Get-Module -ListAvailable -ErrorAction Ignore } finally { $env:PSModulePath = $origPSModulePath } } | Add-Member -Name 'SemVer' -MemberType ScriptProperty -PassThru -Value { $prerelease = $this.PrivateData['PSData']['PreRelease'] if ($prerelease) { $prerelease = "-$($prerelease)" } return "$($this.Version)$($prerelease)" } $installedModules | Format-Table -Auto | Out-String | Write-Debug foreach ($module in $locks.PSModules) { $module | Format-List | Out-String | Write-Debug $installedModule = $installedModules | Where-Object 'Name' -EQ $module.Name | Where-Object 'SemVer' -EQ $module.version if ($installedModule) { Write-Debug 'Module already installed.' continue } $sourceUrl = $module.repositorySourceLocation $repoName = $repoByLocation[$sourceUrl] if (-not $repoName) { # Ignore slashes at the end of URLs. $sourceUrl = $sourceUrl.TrimEnd('/') } $repoName = $repoByLocation[$sourceUrl] if (-not $repoName) { $msg = "PowerShell repository at ""$($module.repositorySourceLocation)"" does not exist. Use " + '"Get-PSRepository" to see the current list of repositories, "Register-PSRepository" ' + 'to add a new repository, or "Set-PSRepository" to update an existing repository.' Write-Debug "Unknown repo." Write-Error $msg continue } if (-not (Test-Path -Path $installDirPath)) { New-Item -Path $installDirPath -ItemType 'Directory' -Force | Out-Null } # How many versions of this module will we be installing? $moduleVersionCount = ($locks.PSModules | Where-Object 'Name' -EQ $module.name | Measure-Object).Count $singleVersion = $moduleVersionCount -eq 1 Write-Debug "Nested $($Configuration.Nested)" Write-Debug "moduleVersionCount ${moduleVersionCount}" Write-Debug "singleVersion ${singleVersion}" $moduleDirPath = Join-Path -Path $installDirPath -ChildPath $module.Name Write-Debug "moduleDirPath ${moduleDirPath}" if ($singleVersion -and (Test-Path -Path $moduleDirPath)) { Write-Debug "Removing ${moduleDirPath}" Remove-Item -Path $moduleDirPath -Recurse -Force if (Test-Path -Path $moduleDirPath) { $msg = "Failed to save PowerShell module ""$($module.name)"" $($module.version) to " + "destination ""${moduleDirPath}"" because that destination already exists and deletion " + 'failed.' Write-Debug "Failed to delete module." Write-Error -Message $msg -ErrorAction $ErrorActionPreference continue } } Save-Module -Name $module.name ` -Path $installDirPath ` -RequiredVersion $module.version ` -AllowPrerelease ` -Repository $repoName ` @pkgMgmtPrefs # Windows has a 260 character limit for path length. Reduce paths by removing extraneous version # directories. if ($singleVersion) { $modulePath = Join-Path -Path $installDirPath -ChildPath $module.name $versionDirName = $module.version if ($versionDirName -match '^(\d+\.\d+\.\d+)') { $versionDirName = $Matches[1] } $moduleVersionPath = Join-Path -Path $modulePath -ChildPath $versionDirName Get-ChildItem -Path $moduleVersionPath -Force | Move-Item -Destination $modulePath Get-Item -Path $moduleVersionPath | Remove-Item } $modulePath = Join-Path -Path $installDirPath -ChildPath $module.name | Resolve-Path -Relative $installedModule = [pscustomobject]@{ Name = $module.name; Version = $module.version; Path = $modulePath; RepositorySourceLocation = $module.repositorySourceLocation; } $installedModule.pstypenames.Add('Prism.InstalledModule') $installedModule | Write-Output } } } function Invoke-Prism { <# .SYNOPSIS Invokes Prism. .DESCRIPTION A tool similar to nuget but for PowerShell modules. A config file in the root of a repository that specifies what modules should be installed into the PSModules directory of the repository. If a path is provided for the module it will be installed at the specified path instead of the PSModules directory. .EXAMPLE Invoke-Prism 'install' Demonstrates how to call this function to install required PSModules. .EXAMPLE Invoke-Prism 'install' -Name 'Module1', 'Module2' Demonstrates how to install a subset of the required PSModules. .EXAMPLE Invoke-Prism 'update' -Name 'Module1', 'Module2' Demonstrates how to update a subset of the required PSModules. #> [CmdletBinding()] param( [Parameter(Mandatory, Position=0)] [ValidateSet('install', 'update')] [String] $Command, # A subset of the required modules to install or update. [Parameter(Position=1)] [String[]] $Name, # The path to a prism.json file or a directory where Prism can find a prism.json file. If path is to a file, # the "FileName" parameter is ignored, if given. # # If the path is a directory, Prism will look for a "prism.json' file in that directory. If the -Recurse switch # is given and the path is to a directory, Prism will recursively search in and under that directory and run for # each prism.json file it finds. If Path is to a directory and FileName is given, Prism will look for a file # with that name instead. [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [String] $Path, # The name of the Prism configuration file to use. Defaults to `prism.json`. Ignored if "Path" is given and is # the path to a file. [String] $FileName = 'prism.json', # If given, searches the current directory and all sub-directories for prism.json files and runs the command # for each file. If the Path parameter is given and is to a directory, Prism will start searching in that # directory instead of the current directory. [switch] $Recurse ) begin { Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $origModulePath = $env:PSModulePath $pkgMgmtPrefs = Get-PackageManagementPreference Import-Module -Name 'PackageManagement' ` -MinimumVersion '1.3.2' ` -MaximumVersion '1.4.8.1' ` -Global ` -ErrorAction Stop ` @pkgMgmtPrefs Import-Module -Name 'PowerShellGet' ` -MinimumVersion '2.0.0' ` -MaximumVersion '2.2.5' ` -Global ` -ErrorAction Stop ` @pkgMgmtPrefs } process { try { $startIn = '.' if ($Path) { if ((Test-Path -Path $Path -PathType Leaf)) { $FileName = $Path | Split-Path -Leaf $startIn = $Path | Split-Path -Parent } elseif ((Test-Path -Path $Path -PathType Container)) { $startIn = $Path } else { Write-Error -Message "Path ""$($Path)"" does not exist." return } } $Force = $FileName.StartsWith('.') $prismJsonFiles = Get-ChildItem -Path $startIn -Filter $FileName -Recurse:$Recurse -Force:$Force -ErrorAction Ignore if (-not $prismJsonFiles) { $msg = '' $suffix = '' if ($Recurse) { $suffix = 's' $msg = ' or any of its sub-directories' } $locationMsg = 'the current directory' if ($startIn -ne '.' -and $startIn -ne (Get-Location).Path) { $locationMsg = """$($startIn | Resolve-Path -Relative)""" } $msg = "No $($FileName) file$($suffix) found in $($locationMsg)$($msg)." Write-Error -Message $msg -ErrorAction Stop return } foreach ($prismJsonFile in $prismJsonFiles) { $prismJsonPath = $prismJsonFile.FullName $config = Get-Content -Path $prismJsonPath | ConvertFrom-Json if (-not $config) { Write-Warning "File ""$($prismJsonPath | Resolve-Path -Relative) is empty." continue } $lockBaseName = [IO.Path]::GetFileNameWithoutExtension($prismJsonPath) $lockExtension = [IO.Path]::GetExtension($prismJsonPath) # Hidden file with no extension, e.g. `.prism` if (-not $lockBaseName -and $lockExtension) { $lockBaseName = $lockExtension $lockExtension = '' } $isNested = (Test-Path -Path (Join-Path -Path $prismJsonFile.DirectoryName -ChildPath '*.psd1')) -or ` (Test-Path -Path (Join-Path -Path $prismJsonFile.DirectoryName -ChildPath '*.psm1')) $defaultInstallDirName = 'PSModules' if ($isNested) { $defaultInstallDirName = 'Modules' } $ignore = @{ 'ErrorAction' = 'Ignore' } # public configuration that users can customize. # Add-Member doesn't return an object if the member already exists, so these can't be part of one pipeline. $config | Add-Member -Name 'PSModules' -MemberType NoteProperty -Value @() @ignore $config | Add-Member -Name 'PSModulesDirectoryName' ` -MemberType NoteProperty ` -Value $defaultInstallDirName ` @ignore if ($config.PSModulesDirectoryName.Contains('\') -or ` $config.PSModulesDirectoryName.Contains('/') -or ` $config.PSModulesDirectoryName -eq '..') { $msg = "Failed to run ``prism ${Command}`` because the ""PSModulesDirectoryName"" configuration " + "value, ""$($config.PSModulesDirectoryName)"", in ""${primsJsonPath}"" is invalid. It can " + 'not contain the "\" or "/" characters or be "..".' Write-Error -Message $msg -ErrorAction Stop return } $installDirPath = Join-Path -Path $prismJsonFile.DirectoryName -ChildPath $config.PSModulesDirectoryName $installDirPath = [IO.Path]::GetFullPath($installDirPath) $lockPath = Join-Path -Path ($prismJsonPath | Split-Path -Parent) -ChildPath "$($lockBaseName).lock$($lockExtension)" $addMemberArgs = @{ MemberType = 'NoteProperty'; PassThru = $true; # Force so users can't customize these properties. Force = $true; } # Members that users aren't allowed to customize/override. $config | Add-Member -Name 'Path' -Value $prismJsonPath @addMemberArgs | Add-Member -Name 'File' -Value $prismJsonFile @addMemberArgs | Add-Member -Name 'LockPath' -Value $lockPath @addMemberArgs | Add-Member -Name 'Nested' -Value $isNested @addMemberArgs | Add-Member -Name 'InstallDirectoryPath' -Value $installDirPath @addMemberArgs | Out-Null # This makes it so we can use PowerShell's module cmdlets as much as possible. $privateModulePath = & { # Prism's private module path, PSModules, or a module directory, if installing nested modules. $config.InstallDirectoryPath | Write-Output # PackageManagement needs to be able to find and load PowerShellGet so it can get repositoriees, # package sources, etc, so it and PowerShellGet have to be in PSModulePath, unfortunately. Get-Module -Name 'PackageManagement','PowerShellGet' | Select-Object -ExpandProperty 'Path' | # **\PSModules\MODULE\VERSION\MODULE.psd1 Split-Path -Parent | # **\PSModules\MODULE\VERSION Split-Path -Parent | # **\PSModules\MODULE Split-Path -Parent | # **\PSModules Select-Object -Unique | Write-Output } $env:PSModulePath = $privateModulePath -join [IO.Path]::PathSeparator Write-Debug -Message "env:PSModulePath $($env:PSModulePath)" switch ($Command) { 'install' { $config | Install-PrivateModule -Name $Name } 'update' { $config | Update-ModuleLock -Name $Name } } } } finally { $env:PSModulePath = $origModulePath } } } Set-Alias -Name 'prism' -Value 'Invoke-Prism' function Select-Module { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [Object] $Module, [Parameter(Mandatory)] [String] $Name, [String] $Version, [switch] $AllowPrerelease ) process { if( $Module.Name -ne $Name ) { return } if( $Version -and $Module.Version -notlike $Version ) { return } if( $AllowPrerelease ) { return $Module } [Version]$moduleVersion = $null if( [Version]::TryParse($Module.Version, [ref]$moduleVersion) ) { return $Module } } } function Update-ModuleLock { [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [Object] $Configuration, # A subset of the required modules to install or update. [String[]] $Name ) begin { Set-StrictMode -Version 'Latest' Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState $pkgMgmtPrefs = Get-PackageManagementPreference } process { $modulesNotFound = [Collections.ArrayList]::New() $moduleNames = $Configuration.PSModules | Select-Object -ExpandProperty 'Name' if (-not $moduleNames) { Write-Warning "There are no modules listed in ""$($Configuration.Path | Resolve-Path -Relative)""." return } $numFinds = $moduleNames | Measure-Object | Select-Object -ExpandProperty 'Count' $numFinds = $numFinds + 2 Write-Debug " numSteps $($numFinds)" $curStep = 0 $uniqueModuleNames = $moduleNames | Select-Object -Unique | Where-Object { if (-not $Name) { return $true } $moduleName = $_ return $Name | Where-Object { $moduleName -like $_ } } if (-not $uniqueModuleNames) { return } $status = "Find-Module -Name '$($uniqueModuleNames -join "', '")'" $percentComplete = ($curStep++/$numFinds * 100) $activity = @{ Activity = 'Resolving Module Versions' } Write-Progress @activity -Status $status -PercentComplete $percentComplete $currentLocks = [pscustomobject]@{ 'PSModules' = @() } $lockDisplayPath = $Configuration.LockPath if ($Configuration.LockPath -and (Test-Path -Path $Configuration.LockPath)) { $numErrors = $Global:Error.Count try { $currentLocks = Get-Content -Path $Configuration.LockPath | ConvertFrom-Json -ErrorAction Ignore } catch { $numErrorsToDelete = $Global:Error.Count - $numErrors for ($idx = 0 ; $idx -lt $numErrorsToDelete; ++$idx) { $Global:Error.RemoveAt(0) } } $lockDisplayPath = $lockDisplayPath | Resolve-Path -Relative } try { $modules = Find-Module -Name $uniqueModuleNames -ErrorAction Ignore @pkgMgmtPrefs # Find-Module is expensive. Limit calls as much as possible. $findModuleCache = @{} $env:PSModulePath = Join-Path -Path $Configuration.File.DirectoryName -ChildPath $Configuration.PSModulesDirectoryName $locksUpdated = $false foreach ($module in $Configuration.PSModules) { if ($Name -and -not ($Name | Where-Object { $module.Name -like $_ })) { continue } $optionalParams = @{} # Make sure these members are present and have default values. $module | Add-Member -Name 'Version' -MemberType NoteProperty -Value '' -ErrorAction Ignore $module | Add-Member -Name 'AllowPrerelease' -MemberType NoteProperty -Value $false -ErrorAction Ignore $versionDesc = 'latest' if ($module.Version) { $versionDesc = $optionalParams['Version'] = $module.Version } $allowPrerelease = $false if ($module.AllowPrerelease -or $module.Version -match '-|\+') { $allowPrerelease = $optionalParams['AllowPrerelease'] = $true } $curStep += 1 Write-Debug " curStep $($curStep)" $moduleToInstall = $modules | Select-Module -Name $module.Name @optionalParams | Select-Object -First 1 # Find-Module doesn't return prerelease modules by default, so if allow prerlease is set, we need to do # an explicit search. Otherwise, if the most recent version found by the first call to Find-Module # matches the user's wildcard, the prerelease version won't be found and used. if (-not $moduleToInstall -or $allowPrerelease) { $status = "Find-Module -Name '$($module.Name)' -AllVersions" if ($allowPrerelease) { $status = "$($status) -AllowPrerelease" } if (-not $findModuleCache.ContainsKey($status)) { Write-Progress @activity -Status $status -PercentComplete ($curStep/$numFinds * 100) $findModuleCache[$status] = Find-Module -Name $module.Name ` -AllVersions ` -AllowPrerelease:$allowPrerelease ` -ErrorAction Ignore ` @pkgMgmtPrefs } $moduleToInstall = $findModuleCache[$status] | Select-Module -Name $module.Name @optionalParams | Select-Object -First 1 } if (-not $moduleToInstall) { [void]$modulesNotFound.Add($module.Name) continue } $lockUpdated = $false $oldVersion = '' $lock = $currentLocks.PSModules | Where-Object 'name' -eq $moduleToInstall.name if ($lock) { $oldVersion = $lock.version } else { $lock = [pscustomobject]@{ name = $moduleToInstall.Name; version = $moduleToInstall.Version; repositorySourceLocation = $moduleToInstall.RepositorySourceLocation; } $currentLocks.PSModules += $lock $lockUpdated = $true } if ($moduleToInstall.Version -ne $lock.version) { $lock.version = $moduleToInstall.Version $lockUpdated = $true } if (-not $lockUpdated) { continue } $locksUpdated = $lockUpdated $moduleLock = [pscustomobject]@{ ModuleName = $lock.Name; Version = $versionDesc; PreviousLockedVersion = $oldVersion; LockedVersion = $lock.version; RepositorySourceLocation = $lock.repositorySourceLocation; Path = $lockDisplayPath; } $moduleLock.pstypenames.Add('Prism.ModuleLock') $moduleLock | Write-Output } if ($locksUpdated) { Write-Progress @activity -Status "Saving lock file ""$($Configuration.LockPath)""." -PercentComplete 100 [Object[]] $sortedPSModules = $currentLocks.PSModules | Sort-Object -Property 'Name','Version' $currentLocks.PSModules = $sortedPSModules $currentLocks | ConvertTo-Json -Depth 2 | Set-Content -Path $Configuration.LockPath -NoNewline } if ($modulesNotFound.Count) { $suffix = '' if ($modulesNotFound.Count -gt 1) { $suffix = 's' } $msg = "$($Path | Resolve-Path -Relative): Module$($suffix) ""$($modulesNotFound -join '", "')"" not " + 'found.' Write-Error $msg } } finally { Write-Progress @activity -Completed } } } function Use-CallerPreference { <# .SYNOPSIS Sets the PowerShell preference variables in a module's function based on the callers preferences. .DESCRIPTION Script module functions do not automatically inherit their caller's variables, including preferences set by common parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't get passed into any function that belongs to a module. When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the function's caller: * ErrorAction * Debug * Confirm * InformationAction * Verbose * WarningAction * WhatIf This function should be used in a module's function to grab the caller's preference variables so the caller doesn't have to explicitly pass common parameters to the module function. This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d). There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get fixed. .LINK about_Preference_Variables .LINK about_CommonParameters .LINK https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d .LINK http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/ .EXAMPLE Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState Demonstrates how to set the caller's common parameter preference variables in a module function. #> [CmdletBinding()] param ( [Parameter(Mandatory)] #[Management.Automation.PSScriptCmdlet] # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]` # attribute. $Cmdlet, [Parameter(Mandatory)] # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the # `[CmdletBinding()]` attribute. # # Used to set variables in its callers' scope, even if that caller is in a different script module. [Management.Automation.SessionState]$SessionState ) Set-StrictMode -Version 'Latest' # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken # from about_CommonParameters). $commonPreferences = @{ 'ErrorActionPreference' = 'ErrorAction'; 'DebugPreference' = 'Debug'; 'ConfirmPreference' = 'Confirm'; 'InformationPreference' = 'InformationAction'; 'VerbosePreference' = 'Verbose'; 'WarningPreference' = 'WarningAction'; 'WhatIfPreference' = 'WhatIf'; } foreach( $prefName in $commonPreferences.Keys ) { $parameterName = $commonPreferences[$prefName] # Don't do anything if the parameter was passed in. if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) ) { continue } $variable = $Cmdlet.SessionState.PSVariable.Get($prefName) # Don't do anything if caller didn't use a common parameter. if( -not $variable ) { continue } if( $SessionState -eq $ExecutionContext.SessionState ) { Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false } else { $SessionState.PSVariable.Set($variable.Name, $variable.Value) } } } |