Skip to content

Commit 3feccb7

Browse files
authored
✨ Support for PSResourceGet, RequiredModules, and PSDepend manifest files
The `-Path` parameter can now be directed to a manifest .psd1 or .json file of these alternate specification formats and ModuleFast will resolve and install the packages. ModuleFast has some limited autodetection for the format used, but in case it guesses wrong, there is also a new `-SpecFileType` parameter to explicitly define which format the file is in. Check the debug messages to see which format it detected and why.
1 parent c157a3f commit 3feccb7

4 files changed

Lines changed: 253 additions & 16 deletions

File tree

ModuleFast.psm1

Lines changed: 192 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ function Install-ModuleFast {
203203

204204
#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.
205205
[Parameter(Mandatory, ParameterSetName = 'Path')][string]$Path,
206+
#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.
207+
[Parameter(ParameterSetName = 'Path')][SpecFileType]$SpecFileType = [SpecFileType]::AutoDetect,
206208
#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)
207209
[string]$Destination,
208210
#The repository to scan for modules. TODO: Multi-repo support
@@ -312,7 +314,7 @@ function Install-ModuleFast {
312314
break
313315
}
314316
'Path' {
315-
$ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $Path
317+
$ModulesToInstall = ConvertFrom-RequiredSpec -RequiredSpecPath $Path -SpecFileType $SpecFileType
316318
}
317319
}
318320
}
@@ -325,7 +327,7 @@ function Install-ModuleFast {
325327
Write-Verbose '🔎 No modules specified to install. Beginning SpecFile detection...'
326328
$modulesToInstall = if ($CI -and (Test-Path $CILockFilePath)) {
327329
Write-Debug "Found lockfile at $CILockFilePath. Using for specification evaluation and ignoring all others."
328-
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath
330+
ConvertFrom-RequiredSpec -RequiredSpecPath $CILockFilePath -SpecFileType $SpecFileType
329331
} else {
330332
$Destination = $PWD
331333
$specFiles = Find-RequiredSpecFile $Destination -CILockFileHint $CILockFilePath
@@ -334,7 +336,7 @@ function Install-ModuleFast {
334336
}
335337
foreach ($specfile in $specFiles) {
336338
Write-Verbose "Found Specfile $specFile. Evaluating..."
337-
ConvertFrom-RequiredSpec -RequiredSpecPath $specFile
339+
ConvertFrom-RequiredSpec -RequiredSpecPath $specFile -SpecFileType $SpecFileType
338340
}
339341
}
340342
}
@@ -1052,10 +1054,144 @@ function Import-ModuleManifest {
10521054
}
10531055
}
10541056

1057+
function ConvertFrom-PSDepend {
1058+
[OutputType([ModuleFastSpec[]])]
1059+
param(
1060+
[hashtable]$PSDependManifest
1061+
)
1062+
1063+
$initialSpec = [ordered]@{}
1064+
foreach ($key in $PSDependManifest.Keys) {
1065+
$value = $PSDependManifest[$key]
1066+
1067+
if ($key -isnot [string]) {
1068+
throw [InvalidDataException]"PSDepend Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)"
1069+
}
1070+
1071+
if ($key -like '*/*') {
1072+
Write-Debug "PSDepend Parse: Skipping Unsupported GitHub module $key"
1073+
continue
1074+
}
1075+
1076+
if ($key -match '^(.+)::(.+)$') {
1077+
if ($matches[0] -ne 'PSGalleryModule') {
1078+
Write-Debug "PSDepend Parse: Skipping $key because its extended type is not PSGalleryModule"
1079+
continue
1080+
} else {
1081+
Write-Debug "PSDepend Parse: Adding $key $value)"
1082+
$initialSpec[$matches[1]] = $value
1083+
continue
1084+
}
1085+
} elseif ($value -is [string]) {
1086+
#If the key doesn't have any special formats and the value is a string, we can assume it is a direct "shorthand" specification
1087+
$initialSpec[$key] = $value
1088+
continue
1089+
}
1090+
1091+
#At this point there should only be PSDepend "Extended" Syntax objects
1092+
if ($value -isnot [hashtable]) {
1093+
throw [NotSupportedException]'PSDepend Parse: Value target must be a string or hashtable'
1094+
}
1095+
1096+
if ($value.DependencyType -ne 'PSGalleryModule') {
1097+
Write-Debug "PSDepend Parse: Skipping $key because its extended DependencyType is not PSGalleryModule"
1098+
continue
1099+
}
1100+
1101+
if ($value.Parameters.Repository) {
1102+
Write-Warning "PSDepend Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now."
1103+
}
1104+
1105+
$version = $value.Version ?? 'latest'
1106+
1107+
#TODO: Repository support
1108+
if (-not $value.Name) {
1109+
Write-Debug 'PSDepend Parse: Skipping $key because no Name property was specified'
1110+
}
1111+
1112+
if ($value.Parameters.AllowPrerelease) {
1113+
Write-Debug "PSDepend Parse: Prerelease detected for $key"
1114+
$value.Name = "!$($value.Name)"
1115+
}
1116+
1117+
Write-Debug "PSDepend Parse: Adding $key extended module name $($value.Name) $version"
1118+
$initialSpec[$value.Name] = $version
1119+
}
1120+
1121+
foreach ($entry in $initialspec.GetEnumerator()) {
1122+
if ($entry.Value -eq 'latest') {
1123+
[ModuleFastSpec]::new($entry.Key)
1124+
} else {
1125+
[ModuleFastSpec]::new($entry.Key, $entry.Value)
1126+
}
1127+
}
1128+
}
1129+
1130+
function ConvertFrom-PSResourceGet {
1131+
[OutputType([ModuleFastSpec[]])]
1132+
param(
1133+
[hashtable]$PSDependManifest
1134+
)
1135+
1136+
$initialSpec = [ordered]@{}
1137+
foreach ($key in $PSDependManifest.Keys) {
1138+
$value = $PSDependManifest[$key]
1139+
1140+
if ($key -isnot [string]) {
1141+
throw [InvalidDataException]"PSResourceGet Parse: Manifest is invalid. Keys must be strings. Found $key $($key.GetType().FullName)"
1142+
}
1143+
1144+
if ($value -is [string]) {
1145+
$initialSpec[$key] = $value
1146+
continue
1147+
}
1148+
1149+
#At this point there should only be PSDepend "Extended" Syntax objects
1150+
if ($value -isnot [hashtable]) {throw [NotSupportedException]'PSResourceGet Parse: Value target must be a string or hashtable'}
1151+
1152+
$version = $value.Version ?? 'latest'
1153+
1154+
if ($value.prerelease) {
1155+
Write-Debug "PSResourceGet Parse: Prerelease detected for $key"
1156+
$key = "!$key"
1157+
}
1158+
1159+
if ($value.Repository) {
1160+
Write-Warning "PSResourceGet Parse: Repository specification detected for $key. This is not currently supported and will use the default Source for now."
1161+
}
1162+
1163+
Write-Debug "PSResourceGet Parse: Adding $key extended module name $key $version"
1164+
$initialSpec[$key] = $version
1165+
}
1166+
1167+
foreach ($entry in $initialspec.GetEnumerator()) {
1168+
if ($entry.Value -eq 'latest') {
1169+
[ModuleFastSpec]::new($entry.Key)
1170+
} else {
1171+
$version = $entry.Value
1172+
1173+
#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.
1174+
# https://learn.microsoft.com/en-us/nuget/concepts/package-versioning?tabs=semver20sort
1175+
if ($version.StartsWith('[') -or $version.StartsWith('(') -or $version.Contains('*')) {
1176+
$version = [VersionRange]::Parse($entry.Value)
1177+
}
1178+
1179+
[ModuleFastSpec]::new($entry.Key, $version)
1180+
}
1181+
}
1182+
}
1183+
10551184
#endregion Private
10561185

10571186
#region Classes
10581187

1188+
enum SpecFileType {
1189+
AutoDetect
1190+
ModuleFast
1191+
PSResourceGet #Note: RequiredModules seems to be semantically close enough to PSResourceGet to use the same parser
1192+
PSDepend
1193+
}
1194+
10591195
#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.
10601196
function Add-Getters ([Parameter(Mandatory, ValueFromPipeline)][Type]$Type) {
10611197
$Type.GetMethods([BindingFlags]::Static -bor [BindingFlags]::Public)
@@ -1425,15 +1561,6 @@ class ModuleFastSpec {
14251561
}
14261562
[ModuleFastSpec] | Add-Getters
14271563

1428-
1429-
#The supported hashtable types
1430-
enum HashtableType {
1431-
ModuleSpecification
1432-
PSDepend
1433-
RequiredModule
1434-
NugetRange
1435-
}
1436-
14371564
#endRegion Classes
14381565

14391566
#region Helpers
@@ -1744,7 +1871,8 @@ filter ConvertFrom-RequiredSpec {
17441871
[OutputType([ModuleFastSpec[]])]
17451872
param(
17461873
[Parameter(Mandatory, ParameterSetName = 'File')][string]$RequiredSpecPath,
1747-
[Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec
1874+
[Parameter(Mandatory, ParameterSetName = 'Object')]$RequiredSpec,
1875+
[SpecFileType]$SpecFileType
17481876
)
17491877
$ErrorActionPreference = 'Stop'
17501878

@@ -1775,9 +1903,26 @@ filter ConvertFrom-RequiredSpec {
17751903
}
17761904

17771905
if ($RequiredSpec -is [IDictionary]) {
1906+
1907+
if ($SpecFileType -eq 'AutoDetect') {
1908+
$SpecFileType = Select-RequiredSpecFileType $RequiredSpec
1909+
}
1910+
if ($SpecFileType -eq 'AutoDetect') {throw 'There was an unexpected error processing the spec file type. This is a bug that should be reported.'}
1911+
1912+
switch ($SpecFileType) {
1913+
([SpecFileType]::PSDepend) {
1914+
Write-Debug 'Requires Parse: PSDepend Spec specified, evaluating...'
1915+
return ConvertFrom-PSDepend $requiredSpec
1916+
}
1917+
([SpecFileType]::PSResourceGet) {
1918+
Write-Debug 'Requires Parse: PSResourceGet Spec specified, evaluating...'
1919+
return ConvertFrom-PSResourceGet $requiredSpec
1920+
}
1921+
}
1922+
17781923
foreach ($kv in $RequiredSpec.GetEnumerator()) {
17791924
if ($kv.Value -is [IDictionary]) {
1780-
throw [NotImplementedException]'TODO: PSResourceGet/PSDepend full syntax'
1925+
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'
17811926
}
17821927
if ($kv.Value -isnot [string]) {
17831928
throw [NotSupportedException]'Only strings and hashtables are supported on the right hand side of the = operator.'
@@ -1787,12 +1932,20 @@ filter ConvertFrom-RequiredSpec {
17871932
continue
17881933
}
17891934
if ($kv.Value -as [NuGetVersion]) {
1790-
[ModuleFastSpec]"$($kv.Name)=$($kv.Value)"
1935+
[ModuleFastSpec]::new($kv.Name, $kv.Value)
1936+
continue
1937+
}
1938+
if ($kv.Value -as [VersionRange]) {
1939+
[ModuleFastSpec]::new($kv.Name, ($kv.Value -as [VersionRange]))
17911940
continue
17921941
}
17931942

17941943
#All other potential options (<=, @, :, etc.) are a direct merge
1795-
[ModuleFastSpec]"$($kv.Name)$($kv.Value)"
1944+
try {
1945+
[ModuleFastSpec]"$($kv.Name)$($kv.Value)"
1946+
} catch {
1947+
throw [NotSupportedException]"Could not parse $($kv.Value) as a valid ModuleFastSpec. Check out the simplified syntax instructions for your options."
1948+
}
17961949
}
17971950
return
17981951
}
@@ -1822,6 +1975,29 @@ function Find-RequiredSpecFile ([string]$Path) {
18221975
return $requireFiles
18231976
}
18241977

1978+
function Select-RequiredSpecFileType ([IDictionary]$requiredSpec) {
1979+
Write-Debug 'SpecFile Parse: Attempting to auto-detect SpecFile type'
1980+
foreach ($key in $requiredSpec.Keys) {
1981+
if ($key -match '::|/') {
1982+
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of :: or / in keys'
1983+
return [SpecFileType]::PSDepend
1984+
}
1985+
1986+
if ($requiredSpec[$key] -is [IDictionary]) {
1987+
if ($requiredSpec[$key].ContainsKey('DependencyType')) {
1988+
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSDepend due to presence of DependencyType key'
1989+
return [SpecFileType]::PSDepend
1990+
}
1991+
if ($requiredSpec[$key].ContainsKey('Repository') -or $requiredSpec[$key].ContainsKey('Version')) {
1992+
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as PSResourceGet/RequiredModules due to presence of Repository or Version key'
1993+
return [SpecFileType]::PSResourceGet
1994+
}
1995+
}
1996+
}
1997+
Write-Debug 'SpecFile Parse: Auto-detected SpecFile type as ModuleFast due to lack of other indicators'
1998+
return [SpecFileType]::ModuleFast
1999+
}
2000+
18252001
function Read-RequiredSpecFile ($RequiredSpecPath) {
18262002
if ($uri.scheme -in 'http', 'https') {
18272003
[string]$content = (Invoke-WebRequest -Uri $uri).Content

ModuleFast.tests.ps1

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,14 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
581581
$modulesToInstall = Install-ModuleFast @imfParams -Path $specFilePath -Plan
582582
#TODO: Verify individual modules and versions
583583
$modulesToInstall | Should -Not -BeNullOrEmpty
584+
if ($modules) {
585+
foreach ($module in $modules) {
586+
$module | Should -BeIn $modulesToInstall
587+
$modulesToInstall.Remove($module)
588+
}
589+
#All modules should be removed at this point
590+
$modulesToInstall | Should -BeNullOrEmpty
591+
}
584592
} -TestCases @(
585593
@{
586594
Name = 'PowerShell Data File'
@@ -605,6 +613,14 @@ Describe 'Install-ModuleFast' -Tag 'E2E' {
605613
@{
606614
Name = 'DynamicManifest'
607615
File = 'Dynamic.psd1'
616+
},
617+
@{
618+
Name = 'ModuleBuilder'
619+
File = 'ModuleBuilder-RequiredModules.psd1'
620+
},
621+
@{
622+
Name = 'PSDepend-Implicit'
623+
File = 'PSDepend.psd1'
608624
}
609625
)
610626

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# NOTE: follow nuget syntax for versions: https://docs.microsoft.com/en-us/nuget/reference/package-versioning#version-ranges-and-wildcards
2+
@{
3+
'Configuration' = '[1.3.1,2.0)'
4+
'ModuleBuilder' = '1.*'
5+
'Pester' = '[4.10.1,5.0)'
6+
'PowerShellGet' = '2.0.4'
7+
'PSScriptAnalyzer' = '1.*'
8+
'ImportExcel' = @{
9+
Version = '7.*'
10+
Repository = 'https://www.powershellgallery.com/api/v2'
11+
}
12+
}

Test/Mocks/PSDepend.psd1

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
@{
2+
psdeploy = 'latest'
3+
psake = 'latest'
4+
Pester = 'latest'
5+
BuildHelpers = '0.0.20' # I don't trust this Warren guy...
6+
'PSGalleryModule::InvokeBuild' = 'latest'
7+
'GitHub::RamblingCookieMonster/PSNeo4j' = 'master'
8+
'RamblingCookieMonster/PowerShell' = 'master'
9+
buildhelpers_0_0_20 = @{
10+
Name = 'buildhelpers'
11+
DependencyType = 'PSGalleryModule'
12+
Parameters = @{
13+
Repository = 'PSGallery'
14+
SkipPublisherCheck = $true
15+
}
16+
Version = '0.0.20'
17+
Tags = 'prod', 'test'
18+
PreScripts = 'C:\RunThisFirst.ps1'
19+
DependsOn = 'some_task'
20+
}
21+
22+
some_task = @{
23+
DependencyType = 'task'
24+
Target = 'C:\RunThisFirst.ps1'
25+
DependsOn = 'nuget'
26+
}
27+
28+
nuget = @{
29+
DependencyType = 'FileDownload'
30+
Source = 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe'
31+
Target = 'C:\nuget.exe'
32+
}
33+
}

0 commit comments

Comments
 (0)