microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/plugins/Generate-Plugins.ps1
521lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Generates Copilot CLI plugin directories from collection manifests. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Reads collection YAML manifests from the collections/ directory and generates |
| 12 | plugin directories under plugins/ with symlinks to source artifacts, plugin.json |
| 13 | manifests, and auto-generated README files. |
| 14 | |
| 15 | Supports generating all plugins or specific collections. Use -Refresh to |
| 16 | regenerate existing plugins (deletes and recreates). |
| 17 | |
| 18 | .PARAMETER CollectionIds |
| 19 | Optional. Array of collection IDs to generate. Generates all when omitted. |
| 20 | |
| 21 | .PARAMETER Refresh |
| 22 | Optional. Deletes and recreates existing plugin directories. |
| 23 | |
| 24 | .PARAMETER DryRun |
| 25 | Optional. Shows what would be done without making changes. |
| 26 | |
| 27 | .PARAMETER Channel |
| 28 | Optional. Release channel controlling eligible item maturities. |
| 29 | Stable includes only stable items. PreRelease includes stable, preview, |
| 30 | and experimental. Deprecated and removed are excluded from both channels. |
| 31 | |
| 32 | .EXAMPLE |
| 33 | ./Generate-Plugins.ps1 |
| 34 | # Generates all plugins (default: all + refresh) |
| 35 | |
| 36 | .EXAMPLE |
| 37 | ./Generate-Plugins.ps1 -CollectionIds rpi,github |
| 38 | # Generates only the rpi and github plugins |
| 39 | |
| 40 | .EXAMPLE |
| 41 | ./Generate-Plugins.ps1 -DryRun |
| 42 | # Shows what would be generated without making changes |
| 43 | |
| 44 | .EXAMPLE |
| 45 | ./Generate-Plugins.ps1 -Channel Stable |
| 46 | # Generates plugins with stable-only items |
| 47 | |
| 48 | .NOTES |
| 49 | Dependencies: PowerShell-Yaml module, scripts/plugins/Modules/PluginHelpers.psm1 |
| 50 | #> |
| 51 | |
| 52 | [CmdletBinding()] |
| 53 | param( |
| 54 | [Parameter(Mandatory = $false)] |
| 55 | [string[]]$CollectionIds, |
| 56 | |
| 57 | [Parameter(Mandatory = $false)] |
| 58 | [switch]$Refresh, |
| 59 | |
| 60 | [Parameter(Mandatory = $false)] |
| 61 | [switch]$DryRun, |
| 62 | |
| 63 | [Parameter(Mandatory = $false)] |
| 64 | [ValidateSet('Stable', 'PreRelease')] |
| 65 | [string]$Channel = 'PreRelease' |
| 66 | ) |
| 67 | |
| 68 | $ErrorActionPreference = 'Stop' |
| 69 | |
| 70 | Import-Module (Join-Path $PSScriptRoot 'Modules/PluginHelpers.psm1') -Force |
| 71 | Import-Module (Join-Path $PSScriptRoot '../collections/Modules/CollectionHelpers.psm1') -Force |
| 72 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 73 | |
| 74 | #region Orchestration |
| 75 | |
| 76 | function Get-AllowedCollectionMaturities { |
| 77 | <# |
| 78 | .SYNOPSIS |
| 79 | Returns allowed collection item maturities for a channel. |
| 80 | |
| 81 | .PARAMETER Channel |
| 82 | Release channel ('Stable' or 'PreRelease'). |
| 83 | |
| 84 | .OUTPUTS |
| 85 | [string[]] Allowed maturity values for collection items. |
| 86 | #> |
| 87 | [CmdletBinding()] |
| 88 | [OutputType([string[]])] |
| 89 | param( |
| 90 | [Parameter(Mandatory = $true)] |
| 91 | [ValidateSet('Stable', 'PreRelease')] |
| 92 | [string]$Channel |
| 93 | ) |
| 94 | |
| 95 | if ($Channel -eq 'Stable') { |
| 96 | return @('stable') |
| 97 | } |
| 98 | |
| 99 | return @('stable', 'preview', 'experimental') |
| 100 | } |
| 101 | |
| 102 | function Select-CollectionItemsByChannel { |
| 103 | <# |
| 104 | .SYNOPSIS |
| 105 | Filters collection items by channel using item maturity metadata. |
| 106 | |
| 107 | .PARAMETER Collection |
| 108 | Collection manifest hashtable. |
| 109 | |
| 110 | .PARAMETER Channel |
| 111 | Release channel ('Stable' or 'PreRelease'). |
| 112 | |
| 113 | .OUTPUTS |
| 114 | [hashtable] Collection clone with filtered items. |
| 115 | #> |
| 116 | [CmdletBinding()] |
| 117 | [OutputType([hashtable])] |
| 118 | param( |
| 119 | [Parameter(Mandatory = $true)] |
| 120 | [hashtable]$Collection, |
| 121 | |
| 122 | [Parameter(Mandatory = $true)] |
| 123 | [ValidateSet('Stable', 'PreRelease')] |
| 124 | [string]$Channel |
| 125 | ) |
| 126 | |
| 127 | $allowedMaturities = Get-AllowedCollectionMaturities -Channel $Channel |
| 128 | $filteredItems = @() |
| 129 | |
| 130 | foreach ($item in $Collection.items) { |
| 131 | $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $item.maturity |
| 132 | if ($effectiveMaturity -eq 'removed') { |
| 133 | Write-Verbose "Skipping removed item: $($item.path)" |
| 134 | continue |
| 135 | } |
| 136 | if ($allowedMaturities -contains $effectiveMaturity) { |
| 137 | $filteredItems += $item |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | $filteredCollection = @{} |
| 142 | foreach ($key in $Collection.Keys) { |
| 143 | $filteredCollection[$key] = $Collection[$key] |
| 144 | } |
| 145 | $filteredCollection['items'] = $filteredItems |
| 146 | |
| 147 | return $filteredCollection |
| 148 | } |
| 149 | |
| 150 | function Invoke-PluginGeneration { |
| 151 | <# |
| 152 | .SYNOPSIS |
| 153 | Orchestrates plugin directory generation from collection manifests. |
| 154 | |
| 155 | .DESCRIPTION |
| 156 | Loads collection manifests from the collections/ directory, optionally |
| 157 | filters to specified IDs, and generates plugin directory structures |
| 158 | under plugins/. Each plugin receives symlinks to source artifacts, |
| 159 | a plugin.json manifest, and an auto-generated README. |
| 160 | |
| 161 | .PARAMETER RepoRoot |
| 162 | Absolute path to the repository root directory. |
| 163 | |
| 164 | .PARAMETER CollectionIds |
| 165 | Optional. Array of collection IDs to generate. Generates all when omitted. |
| 166 | |
| 167 | .PARAMETER Refresh |
| 168 | When specified, removes existing plugin directories before regenerating. |
| 169 | |
| 170 | .PARAMETER DryRun |
| 171 | When specified, logs actions without creating files or directories. |
| 172 | |
| 173 | .PARAMETER Channel |
| 174 | Release channel controlling item maturity eligibility. |
| 175 | |
| 176 | .OUTPUTS |
| 177 | Hashtable with Success, PluginCount, and ErrorMessage keys |
| 178 | via New-GenerateResult. |
| 179 | #> |
| 180 | [CmdletBinding()] |
| 181 | [OutputType([hashtable])] |
| 182 | param( |
| 183 | [Parameter(Mandatory = $true)] |
| 184 | [ValidateNotNullOrEmpty()] |
| 185 | [string]$RepoRoot, |
| 186 | |
| 187 | [Parameter(Mandatory = $false)] |
| 188 | [string[]]$CollectionIds, |
| 189 | |
| 190 | [Parameter(Mandatory = $false)] |
| 191 | [switch]$Refresh, |
| 192 | |
| 193 | [Parameter(Mandatory = $false)] |
| 194 | [switch]$DryRun, |
| 195 | |
| 196 | [Parameter(Mandatory = $false)] |
| 197 | [ValidateSet('Stable', 'PreRelease')] |
| 198 | [string]$Channel = 'PreRelease' |
| 199 | ) |
| 200 | |
| 201 | $collectionsDir = Join-Path -Path $RepoRoot -ChildPath 'collections' |
| 202 | $pluginsDir = Join-Path -Path $RepoRoot -ChildPath 'plugins' |
| 203 | |
| 204 | # Read repo version from package.json for plugin manifests |
| 205 | $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json' |
| 206 | $repoVersion = (Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json).version |
| 207 | |
| 208 | # Auto-update hve-core-all collection with discovered artifacts |
| 209 | $updateResult = Update-HveCoreAllCollection -RepoRoot $RepoRoot -DryRun:$DryRun |
| 210 | Write-Verbose "hve-core-all updated: $($updateResult.ItemCount) items ($($updateResult.AddedCount) added, $($updateResult.RemovedCount) removed)" |
| 211 | |
| 212 | # Probe symlink capability once for the entire generation run |
| 213 | $symlinkCapable = Test-SymlinkCapability |
| 214 | Write-Verbose "Symlink capability: $symlinkCapable ($(if ($symlinkCapable) { 'using symlinks' } else { 'using file copies' }))" |
| 215 | |
| 216 | # Load all collection manifests |
| 217 | $allCollections = Get-AllCollections -CollectionsDir $collectionsDir |
| 218 | |
| 219 | if ($allCollections.Count -eq 0) { |
| 220 | Write-Warning 'No collection manifests found in collections/' |
| 221 | return New-GenerateResult -Success $true -PluginCount 0 |
| 222 | } |
| 223 | |
| 224 | # Filter to requested IDs when provided |
| 225 | if ($CollectionIds -and $CollectionIds.Count -gt 0) { |
| 226 | $filtered = @($allCollections | Where-Object { $CollectionIds -contains $_.id }) |
| 227 | $missing = @($CollectionIds | Where-Object { $_ -notin ($allCollections | ForEach-Object { $_.id }) }) |
| 228 | if ($missing.Count -gt 0) { |
| 229 | Write-Warning "Collections not found: $($missing -join ', ')" |
| 230 | } |
| 231 | $allCollections = $filtered |
| 232 | } |
| 233 | |
| 234 | Write-Host "`n=== Plugin Generation ===" -ForegroundColor Cyan |
| 235 | Write-Host "Collections: $($allCollections.Count)" |
| 236 | Write-Host "Channel: $Channel" |
| 237 | Write-Host "Plugins dir: $pluginsDir" |
| 238 | if ($DryRun) { |
| 239 | Write-Host '[DRY RUN] No changes will be made' -ForegroundColor Yellow |
| 240 | } |
| 241 | |
| 242 | $generated = 0 |
| 243 | $totalAgents = 0 |
| 244 | $totalCommands = 0 |
| 245 | $totalInstructions = 0 |
| 246 | $totalSkills = 0 |
| 247 | |
| 248 | foreach ($collection in $allCollections) { |
| 249 | $id = $collection.id |
| 250 | $pluginDir = Join-Path -Path $pluginsDir -ChildPath $id |
| 251 | |
| 252 | # Skip deprecated collections |
| 253 | $collectionMaturity = if ($collection.ContainsKey('maturity') -and $collection.maturity) { |
| 254 | [string]$collection.maturity |
| 255 | } else { 'stable' } |
| 256 | |
| 257 | if ($collectionMaturity -eq 'deprecated') { |
| 258 | Write-Verbose "Skipping deprecated collection: $id" |
| 259 | continue |
| 260 | } |
| 261 | |
| 262 | if ($collectionMaturity -eq 'removed') { |
| 263 | Write-Verbose "Skipping removed collection: $id" |
| 264 | continue |
| 265 | } |
| 266 | |
| 267 | # Generate plugin directory structure (overwrites in place) |
| 268 | $filteredCollection = Select-CollectionItemsByChannel -Collection $collection -Channel $Channel |
| 269 | |
| 270 | $result = Write-PluginDirectory -Collection $filteredCollection ` |
| 271 | -PluginsDir $pluginsDir ` |
| 272 | -RepoRoot $RepoRoot ` |
| 273 | -Version $repoVersion ` |
| 274 | -Maturity $collectionMaturity ` |
| 275 | -DryRun:$DryRun ` |
| 276 | -SymlinkCapable:$symlinkCapable |
| 277 | |
| 278 | # Orphan cleanup in Refresh mode |
| 279 | if ($Refresh -and (Test-Path -LiteralPath $pluginDir)) { |
| 280 | $generatedFiles = $result.GeneratedFiles |
| 281 | $existingFiles = [System.Collections.Generic.List[string]]::new() |
| 282 | $scanQueue = [System.Collections.Generic.Queue[string]]::new() |
| 283 | $scanQueue.Enqueue($pluginDir) |
| 284 | while ($scanQueue.Count -gt 0) { |
| 285 | $currentDir = $scanQueue.Dequeue() |
| 286 | foreach ($entry in Get-ChildItem -LiteralPath $currentDir -Force) { |
| 287 | if ($entry.PSIsContainer -and -not $entry.LinkType) { |
| 288 | $scanQueue.Enqueue($entry.FullName) |
| 289 | } |
| 290 | else { |
| 291 | $existingFiles.Add($entry.FullName) |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | foreach ($existingFile in $existingFiles) { |
| 296 | if (-not $generatedFiles.Contains($existingFile)) { |
| 297 | if ($DryRun) { |
| 298 | Write-Host " [DRY RUN] Would remove orphan: $existingFile" -ForegroundColor Yellow |
| 299 | } |
| 300 | else { |
| 301 | Remove-Item -LiteralPath $existingFile -Force -ErrorAction Stop |
| 302 | Write-Verbose "Removed orphan file: $existingFile" |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | # Remove empty directories bottom-up |
| 307 | if (-not $DryRun) { |
| 308 | Get-ChildItem -LiteralPath $pluginDir -Recurse -Directory | |
| 309 | Where-Object { -not $_.LinkType } | |
| 310 | Sort-Object { $_.FullName.Length } -Descending | |
| 311 | Where-Object { @(Get-ChildItem -LiteralPath $_.FullName).Count -eq 0 } | |
| 312 | ForEach-Object { |
| 313 | Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop |
| 314 | Write-Verbose "Removed empty directory: $($_.FullName)" |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | #region Update collection.md artifact tables |
| 320 | if (-not $DryRun) { |
| 321 | $collectionMdPath = Join-Path $collectionsDir "$id.collection.md" |
| 322 | if (Test-Path $collectionMdPath) { |
| 323 | $bodyContent = Get-Content -Path $collectionMdPath -Raw |
| 324 | $parsed = Split-CollectionMdByMarkers -Content $bodyContent |
| 325 | |
| 326 | if ($parsed.HasMarkers) { |
| 327 | $agents = @() |
| 328 | $prompts = @() |
| 329 | $instructions = @() |
| 330 | $skills = @() |
| 331 | |
| 332 | foreach ($item in $filteredCollection.items) { |
| 333 | if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) { |
| 334 | continue |
| 335 | } |
| 336 | $kind = [string]$item.kind |
| 337 | $path = [string]$item.path |
| 338 | $artifactName = Get-CollectionArtifactKey -Kind $kind -Path $path |
| 339 | |
| 340 | $resolvedPath = Join-Path $RepoRoot ($path -replace '^\./', '') |
| 341 | if ($kind -eq 'skill') { |
| 342 | $resolvedPath = Join-Path $resolvedPath 'SKILL.md' |
| 343 | } |
| 344 | $artifactDesc = Get-ArtifactDescription -FilePath $resolvedPath |
| 345 | |
| 346 | $entry = @{ Name = $artifactName; Description = $artifactDesc } |
| 347 | switch ($kind) { |
| 348 | 'agent' { $agents += $entry } |
| 349 | 'prompt' { $prompts += $entry } |
| 350 | 'instruction' { $instructions += $entry } |
| 351 | 'skill' { $skills += $entry } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | $artifactSections = [System.Text.StringBuilder]::new() |
| 356 | |
| 357 | foreach ($section in @( |
| 358 | @{ Title = 'Chat Agents'; Items = $agents }, |
| 359 | @{ Title = 'Prompts'; Items = $prompts }, |
| 360 | @{ Title = 'Instructions'; Items = $instructions }, |
| 361 | @{ Title = 'Skills'; Items = $skills } |
| 362 | )) { |
| 363 | if ($section.Items.Count -eq 0) { continue } |
| 364 | |
| 365 | $null = $artifactSections.AppendLine("### $($section.Title)") |
| 366 | $null = $artifactSections.AppendLine() |
| 367 | $null = $artifactSections.AppendLine('| Name | Description |') |
| 368 | $null = $artifactSections.AppendLine('|------|-------------|') |
| 369 | foreach ($entry in ($section.Items | Sort-Object { $_.Name })) { |
| 370 | $null = $artifactSections.AppendLine("| **$($entry.Name)** | $($entry.Description) |") |
| 371 | } |
| 372 | $null = $artifactSections.AppendLine() |
| 373 | } |
| 374 | |
| 375 | $generatedBlock = $artifactSections.ToString().TrimEnd() |
| 376 | $updatedCollectionMd = "$($parsed.Intro)`n`n$($CollectionMdBeginMarker)`n`n$generatedBlock`n`n$($CollectionMdEndMarker)" |
| 377 | if (-not [string]::IsNullOrWhiteSpace($parsed.Footer)) { |
| 378 | $updatedCollectionMd += "`n`n$($parsed.Footer.TrimEnd())" |
| 379 | } |
| 380 | $updatedCollectionMd += "`n" |
| 381 | Set-ContentIfChanged -Path $collectionMdPath -Value $updatedCollectionMd |
| 382 | } |
| 383 | } |
| 384 | } |
| 385 | #endregion |
| 386 | |
| 387 | $itemCount = $filteredCollection.items.Count |
| 388 | $totalAgents += $result.AgentCount |
| 389 | $totalCommands += $result.CommandCount |
| 390 | $totalInstructions += $result.InstructionCount |
| 391 | $totalSkills += $result.SkillCount |
| 392 | $generated++ |
| 393 | |
| 394 | Write-Host " $id ($itemCount items)" -ForegroundColor Green |
| 395 | } |
| 396 | |
| 397 | # Generate marketplace.json from all collections |
| 398 | Write-MarketplaceManifest ` |
| 399 | -RepoRoot $RepoRoot ` |
| 400 | -Collections $allCollections ` |
| 401 | -DryRun:$DryRun |
| 402 | |
| 403 | # Fix git index modes for text stubs on non-symlink systems so Linux |
| 404 | # checkouts materialize real symbolic links instead of plain files. |
| 405 | if (-not $symlinkCapable) { |
| 406 | $fixedCount = Repair-PluginSymlinkIndex -PluginsDir $pluginsDir -RepoRoot $RepoRoot -DryRun:$DryRun |
| 407 | if ($fixedCount -gt 0) { |
| 408 | Write-Host " Symlink index: $fixedCount entries fixed (100644 -> 120000)" -ForegroundColor Green |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | Write-Host "`n--- Summary ---" -ForegroundColor Cyan |
| 413 | Write-Host " Plugins generated: $generated" |
| 414 | Write-Host " Agents: $totalAgents" |
| 415 | Write-Host " Commands: $totalCommands" |
| 416 | Write-Host " Instructions: $totalInstructions" |
| 417 | Write-Host " Skills: $totalSkills" |
| 418 | |
| 419 | return New-GenerateResult -Success $true -PluginCount $generated |
| 420 | } |
| 421 | |
| 422 | #endregion Orchestration |
| 423 | |
| 424 | #region Main Execution |
| 425 | |
| 426 | function Start-PluginGeneration { |
| 427 | <# |
| 428 | .SYNOPSIS |
| 429 | Entry point for CLI invocation. Returns 0 on success, 1 on failure. |
| 430 | |
| 431 | .PARAMETER ScriptPath |
| 432 | Absolute path to this script file, used to resolve the repo root. |
| 433 | |
| 434 | .PARAMETER CollectionIds |
| 435 | Optional collection IDs forwarded to Invoke-PluginGeneration. |
| 436 | |
| 437 | .PARAMETER Refresh |
| 438 | Forwarded refresh switch. |
| 439 | |
| 440 | .PARAMETER DryRun |
| 441 | Forwarded dry-run switch. |
| 442 | |
| 443 | .PARAMETER Channel |
| 444 | Forwarded channel parameter. |
| 445 | |
| 446 | .OUTPUTS |
| 447 | [int] Exit code: 0 for success, 1 for failure. |
| 448 | #> |
| 449 | [CmdletBinding()] |
| 450 | [OutputType([int])] |
| 451 | param( |
| 452 | [Parameter(Mandatory = $true)] |
| 453 | [string]$ScriptPath, |
| 454 | |
| 455 | [Parameter(Mandatory = $false)] |
| 456 | [string[]]$CollectionIds, |
| 457 | |
| 458 | [Parameter(Mandatory = $false)] |
| 459 | [switch]$Refresh, |
| 460 | |
| 461 | [Parameter(Mandatory = $false)] |
| 462 | [switch]$DryRun, |
| 463 | |
| 464 | [Parameter(Mandatory = $false)] |
| 465 | [ValidateSet('Stable', 'PreRelease')] |
| 466 | [string]$Channel = 'PreRelease' |
| 467 | ) |
| 468 | |
| 469 | try { |
| 470 | # Verify PowerShell-Yaml module |
| 471 | if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) { |
| 472 | throw "Required module 'PowerShell-Yaml' is not installed." |
| 473 | } |
| 474 | Import-Module PowerShell-Yaml -ErrorAction Stop |
| 475 | |
| 476 | # Resolve paths |
| 477 | $ScriptDir = Split-Path -Parent $ScriptPath |
| 478 | $RepoRoot = (Get-Item "$ScriptDir/../..").FullName |
| 479 | |
| 480 | Write-Host 'HVE Core Plugin Generator' -ForegroundColor Cyan |
| 481 | Write-Host '==========================' -ForegroundColor Cyan |
| 482 | |
| 483 | # Default to all + refresh when no args |
| 484 | $effectiveRefresh = $Refresh |
| 485 | if (-not $CollectionIds -and -not $Refresh.IsPresent -and -not $DryRun.IsPresent) { |
| 486 | $effectiveRefresh = [switch]::new($true) |
| 487 | } |
| 488 | |
| 489 | $result = Invoke-PluginGeneration ` |
| 490 | -RepoRoot $RepoRoot ` |
| 491 | -CollectionIds $CollectionIds ` |
| 492 | -Refresh:$effectiveRefresh ` |
| 493 | -DryRun:$DryRun ` |
| 494 | -Channel $Channel |
| 495 | |
| 496 | if (-not $result.Success) { |
| 497 | throw $result.ErrorMessage |
| 498 | } |
| 499 | |
| 500 | Write-Host '' |
| 501 | Write-Host 'Done!' -ForegroundColor Green |
| 502 | Write-Host " $($result.PluginCount) plugin(s) generated." |
| 503 | |
| 504 | return 0 |
| 505 | } |
| 506 | catch { |
| 507 | Write-Error "Plugin generation failed: $($_.Exception.Message)" |
| 508 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 509 | return 1 |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | if ($MyInvocation.InvocationName -ne '.') { |
| 514 | exit (Start-PluginGeneration ` |
| 515 | -ScriptPath $MyInvocation.MyCommand.Path ` |
| 516 | -CollectionIds $CollectionIds ` |
| 517 | -Refresh:$Refresh ` |
| 518 | -DryRun:$DryRun ` |
| 519 | -Channel $Channel) |
| 520 | } |
| 521 | #endregion |
| 522 | |