configmap.ps1
#requires -version 7.0 $reservedKeys = @("options", "exec", "list") function Import-ConfigMap { [OutputType([System.Collections.IDictionary])] param( [Parameter(Mandatory = $false)] [AllowNull()] # somehow validateScript is throwing an error when $map is null # [ValidateScript({ $null -eq $_ -or $_ -is [string] -or $_ -is [System.Collections.IDictionary] })] $map, [Parameter(Mandatory = $false)] $fallback ) # Set default map file if null if (!$map) { if (!$fallback) { throw "map is null and defaultMapFile is not provided" return $null } $map = $fallback } # Load map from file if it's a string path if ($map -is [string]) { if (!(Test-Path $map)) { throw "map file '$map' not found" return $null } $map = . $map } # Validate that we have a loaded map if (!$map) { throw "failed to load map from $fallback" return $null } if ($map -isnot [System.Collections.IDictionary]) { throw "map is not a dictionary" } return $map } function Get-CompletionList { [OutputType([System.Collections.Specialized.OrderedDictionary])] param( [ValidateScript({ # the function do not suppurt strings, but ValidateScript iterates over the array, so for string[] we'll get string items here. # see: https://github.com/PowerShell/PowerShell/issues/6185 $_ -is [System.Collections.IDictionary] -or $_ -is [array] -or $_ -is [scriptblock] -or $_ -is [string] })] $map, [switch][bool]$flatten = $true, $separator = ".", $groupMarker = "*", $listKey = "list" ) $list = $map.$listKey ? $map.$listKey : $map $list = $list -is [scriptblock] ? (Invoke-Command -ScriptBlock $list) : $list $r = switch ($true) { { $list -is [System.Collections.IDictionary] } { $result = [ordered]@{} foreach ($kvp in $list.GetEnumerator()) { if ($kvp.key -in $reservedKeys -or $kvp.key -eq $listKey) { continue } $entry = $kvp.value if ($entry.$listKey) { if ($flatten) { $result["$($kvp.key)$groupMarker"] = $entry } $subEntries = Get-CompletionList $entry -listKey $listKey -flatten:$flatten foreach ($sub in $subEntries.GetEnumerator()) { $subKey = $sub.Key if (!$flatten) { $subKey = "$($kvp.key)$separator$($sub.Key)" } $result[$subKey] = $sub.value } } else { $result[$kvp.key] = $entry } } return $result } { $list -is [array] } { $result = [ordered]@{} $subEntries = $list | ForEach-Object { $r = [ordered]@{} } { $r[$_] = $_ } { $r } if ($subEntries) { foreach ($sub in $subEntries.GetEnumerator()) { if ($sub.key -in $reservedKeys -or $sub.key -eq $listKey) { continue } $result[$sub.key] = $sub.value } } return $result } { $list -is [string] } { throw "string type not supported" } default { throw "$($list.GetType().FullName) type not supported" } } return $r } function Write-MapHelp { param([System.Collections.IDictionary]$map, $invocation) $commandName = $invocation.Statement $scripts = Get-CompletionList $map # Calculate max command name length for alignment $maxNameLength = ($scripts.Keys | Measure-Object -Property Length -Maximum).Maximum $maxNameLength = [Math]::Max($maxNameLength, 12) # Minimum width Write-Host "" Write-Host "$($commandName.ToUpper())" -ForegroundColor Cyan Write-Host "A command line tool to manage build scripts" -ForegroundColor Gray Write-Host "" Write-Host "USAGE:" -ForegroundColor Yellow Write-Host " $commandName <COMMAND> [OPTIONS]" -ForegroundColor White Write-Host "" Write-Host "COMMANDS:" -ForegroundColor Yellow # Sort scripts alphabetically $sortedScripts = $scripts.GetEnumerator() | Sort-Object Name foreach ($item in $sortedScripts) { $name = $item.Name $script = $item.Value $entry = Get-EntryCommand $script $args = Get-ScriptArgs $entry # Format command name with proper padding $paddedName = $name.PadRight($maxNameLength) # Get description $description = "No description available" if ($script -is [System.Collections.IDictionary] -and $script.description) { $description = $script.description } # Show parameters if any $paramInfo = "" if ($args.Count -gt 0) { $argList = $args.Keys | % { "-$($_)" } $paramInfo = " " + ($argList -join " ") } Write-Host " " -NoNewline Write-Host "$paddedName" -ForegroundColor Green -NoNewline Write-Host "$paramInfo" -ForegroundColor DarkGray -NoNewline Write-Host " $description" -ForegroundColor White } Write-Host "" Write-Host "EXAMPLES:" -ForegroundColor Yellow Write-Host " $commandName build" -ForegroundColor White Write-Host " $commandName test" -ForegroundColor White Write-Host " $commandName list" -ForegroundColor White Write-Host "" Write-Host "Use '$commandName help' for more information about this tool." -ForegroundColor Gray } function Write-Help { param($invocation, [string]$mapPath) $commandName = $invocation.Statement Write-Host "No build map file found at '$mapPath'" Write-Host "" Write-Host "To create a new build map file, run:" Write-Host " $commandName init" Write-Host "" Write-Host "This will create a sample $mapPath file with basic build scripts." } function Get-EntryCompletion( [ValidateScript({ $_ -is [System.Collections.IDictionary] })] $map, $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) { $list = Get-CompletionList $map return $list.Keys | ? { $_.startswith($wordToComplete) } } function Get-ValuesList( [ValidateScript({ $_ -is [System.Collections.IDictionary] -and $_.options })] $map ) { if (!$map.options) { throw "map doesn't have 'options' entry" } return Get-CompletionList $map.options } function Get-EntryDynamicParam( [System.Collections.IDictionary] $map, $key, $command, $bound ) { if (!$key) { return @() } $selectedEntry = Get-MapEntry $map $key if (!$selectedEntry) { return @() } # Use the command parameter to determine which command to extract, defaulting to "exec" $commandKey = $command ? $command : "exec" $entryCommand = Get-EntryCommand $selectedEntry $commandKey if (!$entryCommand) { return @() } $p = Get-ScriptArgs $entryCommand return $p } function Get-ScriptArgs { [OutputType([System.Management.Automation.RuntimeDefinedParameterDictionary])] param([scriptblock]$func) function Get-SingleArg { [OutputType([System.Management.Automation.RuntimeDefinedParameter])] param([System.Management.Automation.Language.ParameterAst] $ast) $paramAttributesCollect = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute] $paramAttribute = New-Object -Type System.Management.Automation.ParameterAttribute $paramAttributesCollect.Add($paramAttribute) $paramType = $ast.StaticType foreach ($attr in $ast.Attributes) { if ($attr -is [System.Management.Automation.Language.TypeConstraintAst]) { if ($attr.TypeName.ToString() -eq "switch") { $paramType = [switch] } else { # $newAttr = New-Object -type System.Management.Automation.PSTypeNameAttribute($attr.TypeName.Name) # $paramAttributesCollect.Add($newAttr) } } } # Create parameter with name, type, and attributes $name = $ast.Name.ToString().Trim("`$") $dynParam = New-Object -Type System.Management.Automation.RuntimeDefinedParameter($name, $paramType, $paramAttributesCollect) return $dynParam } # Add parameter to parameter dictionary and return the object $paramDictionary = New-Object ` -Type System.Management.Automation.RuntimeDefinedParameterDictionary # Check if ParamBlock exists before accessing Parameters if ($func.AST.ParamBlock -and $func.AST.ParamBlock.Parameters) { $parameters = $func.AST.ParamBlock.Parameters foreach ($param in $parameters) { $dynParam = Get-SingleArg $param $paramDictionary.Add($dynParam.Name, $dynParam) } } return $paramDictionary } function Get-MapEntries( [ValidateScript({ $_ -is [System.Collections.IDictionary] -or $_ -is [array] })] $map, $keys, [switch][bool]$flatten = $true ) { $list = Get-CompletionList $map -flatten:$flatten $found = $list.GetEnumerator() | ? { $_.key -in @($keys) } if (!$found) { Write-Verbose "entry '$keys' not found in ($($list.Keys))" } return $found } function Get-MapEntry( [ValidateScript({ $_ -is [System.Collections.IDictionary] -or $_ -is [array] })] $map, $key ) { return (Get-MapEntries $map $key).Value } # TODO: key should be a hidden property of $entry function Get-EntryCommand($entry, $commandKey = "exec") { if (!$entry) { throw "entry is NULL" } if ($entry -is [scriptblock]) { return $entry } if ($entry -is [System.Collections.IDictionary] -or $entry -is [System.Collections.Hashtable]) { if (!$entry.$commandKey) { throw "Command '$commandKey' not found" } return $entry.$commandKey } throw "Entry of type $($entry.GetType().Name) is not supported" } function Invoke-EntryCommand($entry, $key, $ordered = @(), $bound = @{}) { $command = Get-EntryCommand $entry $key if (!$command) { throw "Command '$key' not found" } if ($command -isnot [scriptblock]) { throw "Entry '$key' of type $($command.GetType().Name) is not supported" } if (!$bound) { $bound = @{} } if (!$bound.context) { $bound.context = @{} } if (!$bound.context.self) { $bound.context.self = $entry } return & $command @ordered @bound } function Invoke-Entry( [ValidateScript({ $_ -is [string] -or $_ -is [System.Collections.IDictionary] })] $map, $entry, $bound ) { $map = Import-ConfigMap $map $targets = Get-MapEntries $map $entry Write-Verbose "running targets: $($targets.Key)" @($targets) | % { Write-Verbose "running entry '$($_.key)'" Invoke-EntryCommand -entry $_.value -key "exec" -bound $bound } } function Invoke-Set($entry, $bound = @{}) { # use ordered parameters, just in case the handler has different parameter names Invoke-EntryCommand $entry "set" -ordered @("", $bound.key, $bound.value) -bound $bound } function Invoke-Get($entry, $bound = @{}) { # use ordered parameters, just in case the handler has different parameter names Invoke-EntryCommand $entry "get" -ordered @("", $bound.options) -bound $bound } function Invoke-QBuild { [CmdletBinding()] param( [ArgumentCompleter({ param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) try { # ipmo configmap $map = $fakeBoundParameters.map $map = $map ? $map : "./.build.map.ps1" if (!(Test-Path $map)) { return @("init", "help", "list") | ? { $_.startswith($wordToComplete) } } $map = Import-ConfigMap $map return Get-EntryCompletion $map @PSBoundParameters } catch { return "ERROR [-entry]: $($_.Exception.Message) $($_.ScriptStackTrace)" } })] $entry = $null, $command = "exec", $map = "./.build.map.ps1" ) dynamicparam { $map = Import-ConfigMap $map -fallback "./.build.map.ps1" $result = Get-EntryDynamicParam $map $entry $command $PSBoundParameters Write-Debug "Dynamic parameters for entry '$entry': $($result.Keys -join ', ')" return $result } process { if ($entry -eq "help") { Write-Host "QBUILD" Write-Host "A command line tool to manage build scripts" Write-Host "" Write-Host "Usage:" Write-Host "qbuild <your-script-name>" return } if ($entry -eq "list") { $map = Import-ConfigMap $map -fallback "./.build.map.ps1" -ErrorAction Ignore if (!$map) { $invocation = $MyInvocation Write-Help -invocation $invocation -mapPath "./.build.map.ps1" return } Write-MapHelp -map $map -invocation $MyInvocation return } if ($entry -eq "init") { $loadedMap = Import-ConfigMap $map -ErrorAction Ignore if (!$loadedMap) { if ($map -isnot [string]) { throw "Map appears to be an object, not a file" } if ((Test-Path $map)) { throw "map file '$map' already exists" } Initialize-BuildMap -file $map return } else { $completionList = Get-CompletionList $loadedMap if ($completionList.Keys -notcontains "init") { throw "map file '$map' already exists" } else { # continue with executing "init" command } } } $map = Import-ConfigMap $map -fallback "./.build.map.ps1" -ErrorAction Ignore if (!$map) { $invocation = $MyInvocation $commandName = $invocation.Statement Write-Help -invocation $invocation -mapPath "./.build.map.ps1" return } # If no entry is provided, list all available scripts if (-not $entry) { Write-MapHelp -map $map -invocation $MyInvocation return } $targets = Get-MapEntries $map $entry Write-Verbose "running targets: $($targets.Key)" @($targets) | % { Write-Verbose "running entry '$($_.key)'" # FIXME: we already have the entry in $_.value, we know ITs own key, but we don't want to search for this key inside this object # we should pass null instead? #Invoke-EntryCommand -entry $_.value -key $_.Key $bound $bound = $PSBoundParameters Invoke-EntryCommand -entry $_.value -key $command -bound $bound } } } function Invoke-QConf { [CmdletBinding()] param( [ValidateSet("set", "get", "list", "help", "init")] $command, [ArgumentCompleter({ param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) try { if ($fakeBoundParameters.command -in @("init", "help")) { return @() } $map = $fakeBoundParameters.map $map = Import-ConfigMap $map -fallback "./.configuration.map.ps1" return Get-EntryCompletion $map @PSBoundParameters } catch { return "ERROR [-entry]: $($_.Exception.Message) $($_.ScriptStackTrace)" } })] $entry = $null, [ArgumentCompleter({ param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) try { if ($fakeBoundParameters.command -in @("init", "help")) { return @() } $map = $fakeBoundParameters.map $map = Import-ConfigMap $map -fallback "./.configuration.map.ps1" $entry = $fakeBoundParameters.entry $entry = Get-MapEntry $map $entry if (!$entry) { throw "entry '$entry' not found" } $options = Get-CompletionList $entry -listKey "options" return $options.Keys | ? { $_.startswith($wordToComplete) } } catch { return "ERROR [-value]: $($_.Exception.Message) $($_.ScriptStackTrace)" } })] $value = $null, $map = "./.configuration.map.ps1" ) ## we need dynamic parameters for commands that have custom parameter list ## this assumes that -entry and -command are already provided dynamicparam { # ipmo configmap try { if ( !$entry) { return @() } $map = Import-ConfigMap $map -fallback"./.configuration.map.ps1" return Get-EntryDynamicParam $map "$entry.$command" $PSBoundParameters } catch { return "ERROR [dynamic]: $($_.Exception.Message) $($_.ScriptStackTrace)" } } process { if ($command -eq "help") { Write-Host "QCONF" Write-Host "A command line tool to manage configuration maps" Write-Host "" Write-Host "Usage:" Write-Host "qconf -entry <entry> -command <command> -value <value>" return } if ($command -eq "init") { if (!$map) { $map = "./.configuration.map.ps1" } if ($map -is [string]) { if ((Test-Path $map)) { throw "map file '$map' already exists" } } else { throw "Map appears to be an object, not a file" } Initialize-ConfigMap -file $map return } $map = Import-ConfigMap $map -fallback "./.configuration.map.ps1" Write-Verbose "entry=$entry command=$command" $subEntry = $map.$entry if (!$subEntry) { throw "entry '$entry' not found" } switch ($command) { "set" { $submodule = $map.$module if (!$submodule) { throw "module '$module' not found" } $optionKey = $value $options = Get-CompletionList $subEntry -listKey "options" $optionValue = $options.$optionKey $bound = $PSBoundParameters $bound.key = $optionKey $bound.value = $optionValue Invoke-Set $subEntry -ordered "", $optionValue, $optionKey -bound $bound } "get" { $options = Get-CompletionList $subEntry -listKey "options" $bound = $PSBoundParameters $bound.options = $options $value = Invoke-Get $subEntry -bound $bound $result = ConvertTo-MapResult $value $subEntry $options $result | Write-Output } default { throw "command '$command' not supported" } } } } function ConvertTo-MapResult($value, $entry, $options, $validate = $true) { $result = $null if ($value -is [Hashtable]) { $hash = @{ Path = "$entryName/$subPath" } $hash += $value $result = $hash } else { $result = @{ Path = "$entryName/$subPath" Value = $value } } if (!$result.Active) { $result.Active = $options.keys | where { $options.$_ -eq $value } } $result.Options = $options.keys $isvalid = "?" if ($validate -and $entry.validate) { if (!$result.Active) { Write-Host "no active option found for $entryName/$subPath" $isvalid = $null } else { $optionvalue = $options.$($result.Active) $isvalid = Invoke-EntryCommand $entry validate -ordered @($path, $optionvalue, $result.Active) } } $result = [PSCustomObject]@{ Path = $result.Path Value = $result.Value Active = $result.Active Options = $result.Options IsValid = $isvalid } return $result } function Initialize-ConfigMap([Parameter(Mandatory = $true)] $file) { if (Test-Path $file) { throw "map file '$file' already exists" } $defaultConfig = Get-Content $PSScriptRoot/samples/_default/.configuration.map.ps1 Write-Host "Initializing configmap file '$file'" $defaultConfig | Out-File $file $fullPath = (Get-Item $file).FullName $dir = Split-Path $fullPath -Parent $defaultUtils = Get-Content $PSScriptRoot/samples/_default/.config-utils.ps1 $defaultUtils | Out-File (Join-Path $dir ".config-utils.ps1") } function Initialize-BuildMap([Parameter(Mandatory = $true)] $file) { if (Test-Path $file) { throw "map file '$file' already exists" } $defaultConfig = Get-Content $PSScriptRoot/samples/_default/.build.map.ps1 Write-Host "Initializing buildmap file '$file'" $defaultConfig | Out-File $file } Set-Alias -Name "qbuild" -Value "Invoke-QBuild" -Force Set-Alias -Name "qconf" -Value "Invoke-QConf" -Force |