Skip to content

✨ Support for PSResourceGet, RequiredModules, and PSDepend manifest files #60

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 192 additions & 16 deletions ModuleFast.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ function Install-ModuleFast {

#Provide a required module specification path to install from. This can be a local psd1/json file, or a remote URL with a psd1/json file in supported manifest formats, or a .ps1/.psm1 file with a #Requires statement.
[Parameter(Mandatory, ParameterSetName = 'Path')][string]$Path,
#Explicitly specify the type of SpecFile to use. ModuleFast has some limited autodetection capability for ModuleBuilder and PSDepend formats, you should use this parameter if they explicitly fail. This is ignored if the file is not a .psd1 file.
[Parameter(ParameterSetName = 'Path')][SpecFileType]$SpecFileType = [SpecFileType]::AutoDetect,
#Where to install the modules. This defaults to the builtin module path on non-windows and a custom LOCALAPPDATA location on Windows. You can also specify 'CurrentUser' to install to the Documents folder on Windows Only (this is not recommended)
[string]$Destination,
#The repository to scan for modules. TODO: Multi-repo support
Expand Down Expand Up @@ -312,7 +314,7 @@ function Install-ModuleFast {
break
}
'Path' {
$ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $Path
$ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $Path -SpecFileType $SpecFileType
}
}
}
Expand All @@ -325,7 +327,7 @@ function Install-ModuleFast {
Write-Verbose '🔎 No modules specified to install. Beginning SpecFile detection...'
$modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) {
Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others."
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath -SpecFileType $SpecFileType
} else {
$Destination = $PWD
$specFiles = Find-RequiredSpecFile $Destination -CILockFileHint $CILockFilePath
Expand All @@ -334,7 +336,7 @@ function Install-ModuleFast {
}
foreach ($specfile in $specFiles) {
Write-Verbose "Found Specfile $specFile. Evaluating..."
ConvertFrom-RequiredSpec -RequiredSpecPath $specFile
ConvertFrom-RequiredSpec -RequiredSpecPath $specFile -SpecFileType $SpecFileType
}
}
}
Expand Down Expand Up @@ -1052,10 +1054,144 @@ function Import-ModuleManifest {
}
}

function ConvertFrom-PSDepend {
[OutputType([ModuleFastSpec[]])]
param(
[hashtable]$PSDependManifest
)

$initialSpec = [ordered]@{}
foreach ($key in $PSDependManifest.Keys) {
$value = $PSDependManifest[$key]

if ($key -isnot [string]) {
throw [InvalidDataException]"PSDepend Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)"
}

if ($key -like '*/*') {
Write-Debug "PSDepend Parse: Skipping Unsupported GitHub module $key"
continue
}

if ($key -match '^(.+)::(.+)$') {
if ($matches[0] -ne 'PSGalleryModule') {
Write-Debug "PSDepend Parse: Skipping $key because its extended type is not PSGalleryModule"
continue
} else {
Write-Debug "PSDepend Parse: Adding $key $value)"
$initialSpec[$matches[1]] = $value
continue
}
} elseif ($value -is [string]) {
#If the key doesn't have any special formats and the value is a string, we can assume it is a direct "shorthand" specification
$initialSpec[$key] = $value
continue
}

#At this point there should only be PSDepend "Extended" Syntax objects
if ($value -isnot [hashtable]) {
throw [NotSupportedException]'PSDepend Parse: Value target must be a string or hashtable'
}

if ($value.DependencyType -ne 'PSGalleryModule') {
Write-Debug "PSDepend Parse: Skipping $key because its extended DependencyType is not PSGalleryModule"
continue
}

if ($value.Parameters.Repository) {
Write-Warning "PSDepend Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now."
}

$version = $value.Version ?? 'latest'

#TODO: Repository support
if (-not $value.Name) {
Write-Debug 'PSDepend Parse: Skipping $key because no Name property was specified'
}

if ($value.Parameters.AllowPrerelease) {
Write-Debug "PSDepend Parse: Prerelease detected for $key"
$value.Name = "!$($value.Name)"
}

Write-Debug "PSDepend Parse: Adding $key extended module name $($value.Name) $version"
$initialSpec[$value.Name] = $version
}

foreach ($entry in $initialspec.GetEnumerator()) {
if ($entry.Value -eq 'latest') {
[ModuleFastSpec]::new($entry.Key)
} else {
[ModuleFastSpec]::new($entry.Key, $entry.Value)
}
}
}

function ConvertFrom-PSResourceGet {
[OutputType([ModuleFastSpec[]])]
param(
[hashtable]$PSDependManifest
)

$initialSpec = [ordered]@{}
foreach ($key in $PSDependManifest.Keys) {
$value = $PSDependManifest[$key]

if ($key -isnot [string]) {
throw [InvalidDataException]"PSResourceGet Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)"
}

if ($value -is [string]) {
$initialSpec[$key] = $value
continue
}

#At this point there should only be PSDepend "Extended" Syntax objects
if ($value -isnot [hashtable]) {throw [NotSupportedException]'PSResourceGet Parse: Value target must be a string or hashtable'}

$version = $value.Version ?? 'latest'

if ($value.prerelease) {
Write-Debug "PSResourceGet Parse: Prerelease detected for $key"
$key = "!$key"
}

if ($value.Repository) {
Write-Warning "PSResourceGet Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now."
}

Write-Debug "PSResourceGet Parse: Adding $key extended module name $key $version"
$initialSpec[$key] = $version
}

foreach ($entry in $initialspec.GetEnumerator()) {
if ($entry.Value -eq 'latest') {
[ModuleFastSpec]::new($entry.Key)
} else {
$version = $entry.Value

#HACK: This handles a PSResourceGet/RequiresModule quirk where '1.0.5' is meant to be a specific version, not a minimum version which is what the NuGet version spec defines it as.
# https://learn.microsoft.com/en-us/nuget/concepts/package-versioning?tabs=semver20sort
if ($version.StartsWith('[') -or $version.StartsWith('(') -or $version.Contains('*')) {
$version = [VersionRange]::Parse($entry.Value)
}

[ModuleFastSpec]::new($entry.Key, $version)
}
}
}

#endregion Private

#region Classes

enum SpecFileType {
AutoDetect
ModuleFast
PSResourceGet #Note: RequiredModules seems to be semantically close enough to PSResourceGet to use the same parser
PSDepend
}

#This is a module construction helper to create "getters" in classes. The getters must be defined as a static hidden class prefixed with Get_ (case sensitive) and take a single parameter of the PSObject type that will be an instance of the class object for you to act on. Place this in your class constructor to automatically add the getters to the class.
function Add-Getters ([Parameter(Mandatory, ValueFromPipeline)][Type]$Type) {
$Type.GetMethods([BindingFlags]::Static -bor [BindingFlags]::Public)
Expand Down Expand Up @@ -1425,15 +1561,6 @@ class ModuleFastSpec {
}
[ModuleFastSpec] | Add-Getters


#The supported hashtable types
enum HashtableType {
ModuleSpecification
PSDepend
RequiredModule
NugetRange
}

#endRegion Classes

#region Helpers
Expand Down Expand Up @@ -1744,7 +1871,8 @@ filter ConvertFrom-RequiredSpec {
[OutputType([ModuleFastSpec[]])]
param(
[Parameter(Mandatory, ParameterSetName = 'File')][string]$RequiredSpecPath,
[Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec
[Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec,
[SpecFileType]$SpecFileType
)
$ErrorActionPreference = 'Stop'

Expand Down Expand Up @@ -1775,9 +1903,26 @@ filter ConvertFrom-RequiredSpec {
}

if ($RequiredSpec -is [IDictionary]) {

if ($SpecFileType -eq 'AutoDetect') {
$SpecFileType = Select-RequiredSpecFileType $RequiredSpec
}
if ($SpecFileType -eq 'AutoDetect') {throw 'There was an unexpected error processing the spec file type. This is a bug that should be reported.'}

switch ($SpecFileType) {
([SpecFileType]::PSDepend) {
Write-Debug 'Requires Parse: PSDepend Spec specified, evaluating...'
return ConvertFrom-PSDepend $requiredSpec
}
([SpecFileType]::PSResourceGet) {
Write-Debug 'Requires Parse: PSResourceGet Spec specified, evaluating...'
return ConvertFrom-PSResourceGet $requiredSpec
}
}

foreach ($kv in $RequiredSpec.GetEnumerator()) {
if ($kv.Value -is [IDictionary]) {
throw [NotImplementedException]'TODO: PSResourceGet/PSDepend full syntax'
throw [NotSupportedException]'ModuleFast SpecFile detected but the value is a hashtable. This is not supported. Try using the -SpecFileType parameter if you expected another format'
}
if ($kv.Value -isnot [string]) {
throw [NotSupportedException]'Only strings and hashtables are supported on the right hand side of the = operator.'
Expand All @@ -1787,12 +1932,20 @@ filter ConvertFrom-RequiredSpec {
continue
}
if ($kv.Value -as [NuGetVersion]) {
[ModuleFastSpec]"$($kv.Name)=$($kv.Value)"
[ModuleFastSpec]::new($kv.Name, $kv.Value)
continue
}
if ($kv.Value -as [VersionRange]) {
[ModuleFastSpec]::new($kv.Name, ($kv.Value -as [VersionRange]))
continue
}

#All other potential options (<=, @, :, etc.) are a direct merge
[ModuleFastSpec]"$($kv.Name)$($kv.Value)"
try {
[ModuleFastSpec]"$($kv.Name)$($kv.Value)"
} catch {
throw [NotSupportedException]"Could not parse $($kv.Value) as a valid ModuleFastSpec. Check out the simplified syntax instructions for your options."
}
}
return
}
Expand Down Expand Up @@ -1822,6 +1975,29 @@ function Find-RequiredSpecFile ([string]$Path) {
return $requireFiles
}

function Select-RequiredSpecFileType ([IDictionary]$requiredSpec) {
Write-Debug 'SpecFile Parse: Attempting to auto-detect SpecFile type'
foreach ($key in $requiredSpec.Keys) {
if ($key -match '::|/') {
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of :: or / in keys'
return [SpecFileType]::PSDepend
}

if ($requiredSpec[$key] -is [IDictionary]) {
if ($requiredSpec[$key].ContainsKey('DependencyType')) {
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of DependencyType key'
return [SpecFileType]::PSDepend
}
if ($requiredSpec[$key].ContainsKey('Repository') -or $requiredSpec[$key].ContainsKey('Version')) {
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSResourceGet/RequiredModules due to presence of Repository or Version key'
return [SpecFileType]::PSResourceGet
}
}
}
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as ModuleFast due to lack of other indicators'
return [SpecFileType]::ModuleFast
}

function Read-RequiredSpecFile ($RequiredSpecPath) {
if ($uri.scheme -in 'http', 'https') {
[string]$content = (Invoke-WebRequest -Uri $uri).Content
Expand Down
16 changes: 16 additions & 0 deletions ModuleFast.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,14 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
$modulesToInstall = Install-ModuleFast @imfParams -Path $specFilePath -Plan
#TODO: Verify individual modules and versions
$modulesToInstall | Should -Not -BeNullOrEmpty
if ($modules) {
foreach ($module in $modules) {
$module | Should -BeIn $modulesToInstall
$modulesToInstall.Remove($module)
}
#All modules should be removed at this point
$modulesToInstall | Should -BeNullOrEmpty
}
} -TestCases @(
@{
Name = 'PowerShell Data File'
Expand All @@ -605,6 +613,14 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
@{
Name = 'DynamicManifest'
File = 'Dynamic.psd1'
},
@{
Name = 'ModuleBuilder'
File = 'ModuleBuilder-RequiredModules.psd1'
},
@{
Name = 'PSDepend-Implicit'
File = 'PSDepend.psd1'
}
)

Expand Down
12 changes: 12 additions & 0 deletions Test/Mocks/ModuleBuilder-RequiredModules.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# NOTE: follow nuget syntax for versions: https://docs.microsoft.com/en-us/nuget/reference/package-versioning#version-ranges-and-wildcards
@{
'Configuration' = '[1.3.1,2.0)'
'ModuleBuilder' = '1.*'
'Pester' = '[4.10.1,5.0)'
'PowerShellGet' = '2.0.4'
'PSScriptAnalyzer' = '1.*'
'ImportExcel' = @{
Version = '7.*'
Repository = 'https://www.powershellgallery.com/api/v2'
}
}
33 changes: 33 additions & 0 deletions Test/Mocks/PSDepend.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@{
psdeploy = 'latest'
psake = 'latest'
Pester = 'latest'
BuildHelpers = '0.0.20' # I don't trust this Warren guy...
'PSGalleryModule::InvokeBuild' = 'latest'
'GitHub::RamblingCookieMonster/PSNeo4j' = 'master'
'RamblingCookieMonster/PowerShell' = 'master'
buildhelpers_0_0_20 = @{
Name = 'buildhelpers'
DependencyType = 'PSGalleryModule'
Parameters = @{
Repository = 'PSGallery'
SkipPublisherCheck = $true
}
Version = '0.0.20'
Tags = 'prod', 'test'
PreScripts = 'C:\RunThisFirst.ps1'
DependsOn = 'some_task'
}

some_task = @{
DependencyType = 'task'
Target = 'C:\RunThisFirst.ps1'
DependsOn = 'nuget'
}

nuget = @{
DependencyType = 'FileDownload'
Source = 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe'
Target = 'C:\nuget.exe'
}
}