├── @Delay.ps1 ├── @FileChange.ps1 ├── @Repeat.ps1 ├── @Time.ps1 ├── Clear-EventSource.ps1 ├── EventSources ├── @HttpResponse.ps1 ├── @Job.ps1 ├── @ModuleChanged.ps1 ├── @PowerShellAsync.ps1 ├── @Process.ps1 ├── @UDP.ps1 └── @VariableSet.ps1 ├── Formatting └── OnQ.EventSource.format.ps1 ├── Get-EventSource.ps1 ├── LICENSE ├── OnQ.azure-pipeline.yml ├── OnQ.ezout.ps1 ├── OnQ.format.ps1xml ├── OnQ.psd1 ├── OnQ.psm1 ├── OnQ.tests.ps1 ├── OnQ.types.ps1xml ├── README.md ├── Receive-Event.ps1 ├── Send-Event.ps1 ├── Types └── OnQ.EventSource │ ├── get_Description.ps1 │ ├── get_EventSourceID.ps1 │ ├── get_Help.ps1 │ └── get_Synopsis.ps1 ├── Understanding_Event_Sources.md ├── Watch-Event.ps1 └── en-us ├── Understanding_Event_Sources.help.txt └── about_OnQ.help.txt /@Delay.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Send an event after a delay. 4 | .Description 5 | Send an event after waiting an arbitrary [Timespan] 6 | .Example 7 | On Delay "00:00:01" -Then { "In a second!" | Out-Host } 8 | #> 9 | 10 | param( 11 | # The amount of time to wait 12 | [Parameter(Mandatory,Position=0,ParameterSetName='Delay',ValueFromPipelineByPropertyName=$true)] 13 | [Alias('Delay', 'In')] 14 | [Timespan] 15 | $Wait 16 | ) 17 | 18 | process { 19 | $timer = New-Object Timers.Timer -Property @{Interval=$Wait.TotalMilliseconds;AutoReset=$false} 20 | $timer.Start() 21 | $timer | Add-Member NoteProperty EventName Elapsed -PassThru 22 | } -------------------------------------------------------------------------------- /@FileChange.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Watches for File Changes. 4 | .Description 5 | Uses the [IO.FileSystemWatcher] to watch for changes to files. 6 | 7 | Because some applications and frameworks write to files differently, 8 | you may see more than one event for a given change. 9 | #> 10 | param( 11 | # The path to the file or directory 12 | [Parameter(ValueFromPipelineByPropertyName)] 13 | [Alias('Fullname')] 14 | [string] 15 | $FilePath = "$pwd", 16 | 17 | # A wildcard filter describing the names of files to watch 18 | [Parameter(ValueFromPipelineByPropertyName)] 19 | [string] 20 | $FileFilter, 21 | 22 | # A notify filter describing the file changes that should raise events. 23 | [Parameter(ValueFromPipelineByPropertyName)] 24 | [IO.NotifyFilters[]] 25 | $NotifyFilter = @("FileName", "DirectoryName", "LastWrite"), 26 | 27 | # If set, will include subdirectories in the watcher. 28 | [Alias('InludeSubsdirectory','InludeSubsdirectories')] 29 | [switch] 30 | $Recurse, 31 | 32 | # The names of the file change events to watch. 33 | # By default, watches for Changed, Created, Deleted, or Renamed 34 | [ValidateSet('Changed','Created','Deleted','Renamed')] 35 | [string[]] 36 | $EventName = @('Changed','Created','Deleted','Renamed') 37 | ) 38 | 39 | process { 40 | $resolvedFilePath = try { 41 | $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($FilePath) 42 | } catch { 43 | Write-Error "Could not resolve path '$FilePath'" 44 | return 45 | } 46 | 47 | if ([IO.File]::Exists("$resolvedFilePath")) { # If we're passed a path to a specific file 48 | $fileInfo = ([IO.FileInfo]"$resolvedFilePath") 49 | $filePath = $fileInfo.Directory.FullName # we need to watch the directory 50 | $FileFilter = $fileInfo.Name # and then filter based off of the file name. 51 | } elseif ([IO.Directory]::Exists("$resolvedFilePath")) { 52 | $filePath = "$resolvedFilePath" 53 | } 54 | 55 | $fileSystemWatcher = [IO.FileSystemWatcher]::new($FilePath) # Create the watcher 56 | $fileSystemWatcher.IncludeSubdirectories = $Recurse # include subdirectories if -Recurse was passed 57 | $fileSystemWatcher.EnableRaisingEvents = $true # Enable raising events 58 | if ($FileFilter) { 59 | $fileSystemWatcher.Filter = $FileFilter 60 | } else { 61 | $fileSystemWatcher.Filter = "*" 62 | } 63 | $combinedNotifyFilter = 0 64 | foreach ($n in $NotifyFilter) { 65 | $combinedNotifyFilter = $combinedNotifyFilter -bor $n 66 | } 67 | $fileSystemWatcher.NotifyFilter = $combinedNotifyFilter 68 | $fileSystemWatcher | 69 | Add-Member NoteProperty EventName $EventName -Force -PassThru 70 | } 71 | -------------------------------------------------------------------------------- /@Repeat.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Send events on repeat. 4 | .Description 5 | Sends events on repeat, at a given [Timespan] -Interval. 6 | .Example 7 | On Interval "00:01:00" { "Every minute" | Out-Host } 8 | #> 9 | [Diagnostics.Tracing.EventSource(Name='Elapsed')] 10 | param( 11 | # The amount of time to wait between sending events. 12 | [Parameter(Mandatory,Position=0,ValueFromPipelineByPropertyName=$true)] 13 | [Alias('Every')] 14 | [Timespan] 15 | $Interval 16 | ) 17 | 18 | process { 19 | $timer = New-Object Timers.Timer -Property @{Interval=$Interval.TotalMilliseconds;AutoReset=$true} 20 | $timer.Start() 21 | $timer 22 | } -------------------------------------------------------------------------------- /@Time.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Sends an event at a specific time. 4 | .Description 5 | Sends an event at a specific date and time. 6 | .Example 7 | On Time "5:00 PM" { "EOD!" | Out-Host } 8 | #> 9 | [Diagnostics.Tracing.EventSource(Name='Elapsed')] 10 | param( 11 | [Parameter(Mandatory,Position=0,ParameterSetName='SpecificTime')] 12 | [DateTime] 13 | $DateTime 14 | ) 15 | 16 | process { 17 | if ($DateTime -lt [DateTime]::Now) { 18 | Write-Error "-DateTime '$DateTime' must be in the future" 19 | return 20 | } 21 | 22 | $timer = 23 | New-Object Timers.Timer -Property @{Interval=($DateTime - [DateTime]::Now).TotalMilliseconds;AutoReset=$false} 24 | 25 | if (-not $timer) { return } 26 | $timer.Start() 27 | return $timer 28 | } 29 | -------------------------------------------------------------------------------- /Clear-EventSource.ps1: -------------------------------------------------------------------------------- 1 | function Clear-EventSource 2 | { 3 | <# 4 | .Synopsis 5 | Clears event source subscriptions 6 | .Description 7 | Clears any active subscriptions for any event source. 8 | .Example 9 | Clear-EventSource 10 | .Link 11 | Get-EventSource 12 | #> 13 | [CmdletBinding(SupportsShouldProcess=$true)] 14 | [OutputType([nullable])] 15 | param( 16 | # The name of the event source. 17 | [Parameter(ValueFromPipelineByPropertyName)] 18 | [string[]] 19 | $Name) 20 | 21 | process { 22 | #region Determine Event Sources 23 | $parameterCopy = @{} + $PSBoundParameters 24 | $null = $parameterCopy.Remove('WhatIf') 25 | $eventSources = Get-EventSource @parameterCopy -Subscription 26 | if ($WhatIfPreference) { 27 | $eventSources 28 | return 29 | } 30 | #endregion Determine Event Sources 31 | #region Unregister 32 | if ($PSCmdlet.ShouldProcess("Clear event sources $($Name -join ',')")) { 33 | $eventSources | Unregister-Event 34 | } 35 | #endregion Unregister 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /EventSources/@HttpResponse.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Sends events on HTTP Responses. 4 | .Description 5 | Sends HTTP requests and signals on Responses 6 | 7 | 8 | Event MessageData will contain the response, with two additional properties: 9 | * .ResponseBytes 10 | * .ResponseContent 11 | #> 12 | [ComponentModel.InitializationEvent({ 13 | # Because of the potential for a "narrow window" timing issue, 14 | # this event source needs to send the request the moment after the event has been subscribed to. 15 | $httpAsyncInfo = [PSCustomObject]@{ 16 | InputObject = $this 17 | IAsyncResult = $this.BeginGetResponse($null, $null) 18 | InvokeID = $this.RequestID 19 | } 20 | if ($null -eq $Global:HttpResponsesAsync) { 21 | $Global:HttpResponsesAsync = [Collections.ArrayList]::new() 22 | } 23 | $null = $Global:HttpResponsesAsync.Add($httpAsyncInfo) 24 | })] 25 | param( 26 | # The Uniform Resource Identifier. 27 | [Parameter(Mandatory,Position=0,ValueFromPipelineByPropertyName)] 28 | [Alias('Url')] 29 | [Uri] 30 | $Uri, 31 | 32 | # The HTTP Method 33 | [Parameter(Position=1,ValueFromPipelineByPropertyName)] 34 | [ValidateSet('Get','Head','Post','Put','Delete','Trace','Options','Merge','Patch')] 35 | [string] 36 | $Method = 'GET', 37 | 38 | # A collection of headers to send with the request. 39 | [Parameter(Position=2,ValueFromPipelineByPropertyName)] 40 | [Alias('Headers')] 41 | [Collections.IDictionary] 42 | $Header, 43 | 44 | # The request body. 45 | [Parameter(Position=3,ValueFromPipelineByPropertyName)] 46 | [PSObject] 47 | $Body, 48 | 49 | # The polling interval. 50 | # This is the minimum amount of time until you will be notified of the success or failure of a http request 51 | [Parameter(Position=3,ValueFromPipelineByPropertyName)] 52 | [Timespan] 53 | $PollingInterval = "00:00:00.331", 54 | 55 | [Text.Encoding] 56 | $TransferEncoding = $([Text.Encoding]::UTF8) 57 | ) 58 | 59 | process { 60 | # Clear the event subscriber if one exists. 61 | $eventSubscriber = Get-EventSubscriber -SourceIdentifier "@HttpResponse_Check" -ErrorAction SilentlyContinue 62 | if ($eventSubscriber) {$eventSubscriber | Unregister-Event} 63 | 64 | # Create a new subscriber for the request. 65 | $httpResponseCheckTimer = New-Object Timers.Timer -Property @{ 66 | Interval = $PollingInterval.TotalMilliseconds # Every pollinginterval, 67 | AutoReset = $true 68 | } 69 | $HttpResponseChecker = 70 | Register-ObjectEvent -InputObject $httpResponseCheckTimer -EventName Elapsed -Action { 71 | $toCallEnd = # check to see if any requests have completed. 72 | @(foreach ($httpAsyncInfo in $Global:HttpResponsesAsync) { 73 | if ($httpAsyncInfo.IAsyncResult.IsCompleted) { 74 | $httpAsyncInfo 75 | } 76 | }) 77 | 78 | $null = # Foreach completed request 79 | foreach ($httpAsyncInfo in $toCallEnd) { 80 | $webResponse = 81 | try { # try to get the response 82 | $httpAsyncInfo.InputObject.EndGetResponse($httpAsyncInfo.IAsyncResult) 83 | } catch { 84 | $_ # and catch any error. 85 | } 86 | 87 | 88 | if ($webResponse -is [Management.Automation.ErrorRecord] -or 89 | $webResponse -is [Exception]) # If we got an error 90 | { 91 | # Signal the error 92 | New-Event -SourceIdentifier "HttpRequest.Failed.$($httpAsyncInfo.InvokeID)" -MessageData $webResponse 93 | $Global:HttpResponsesAsync.Remove($httpAsyncInfo) 94 | continue 95 | } 96 | 97 | 98 | # Otherwise, get the response stream 99 | $ms = [IO.MemoryStream]::new() 100 | $null = $webResponse.GetResponseStream().CopyTo($ms) 101 | $responseBytes = $ms.ToArray() # as a [byte[]]. 102 | $ms.Close() 103 | $ms.Dispose() 104 | 105 | 106 | $encoding = # See if the response had an encoding 107 | if ($webResponse.ContentEncoding) { $webResponse.ContentEncoding } 108 | elseif ($webResponse.CharacterSet) { 109 | # or a character set. 110 | [Text.Encoding]::GetEncodings() | Where-Object Name -EQ $webResponse.CharacterSet 111 | } 112 | 113 | $webResponseContent = 114 | if ($encoding) { # If it did, decode the response content. 115 | [IO.StreamReader]::new([IO.MemoryStream]::new($responseBytes), $encoding).ReadToEnd() 116 | } else { 117 | $null 118 | } 119 | 120 | # Add the properties to the web response. 121 | $webResponse | 122 | Add-Member NoteProperty ResponseBytes $webResponseContent -Force -PassThru | 123 | Add-Member NoteProperty ResponseContent $webResponseContent -Force 124 | $webResponse.Close() 125 | 126 | # And send the response with the additional information. 127 | New-Event -SourceIdentifier "HttpRequest.Completed.$($httpAsyncInfo.InvokeID)" -MessageData $webResponse 128 | $Global:HttpResponsesAsync.Remove($httpAsyncInfo) 129 | } 130 | 131 | if ($Global:HttpResponsesAsync.Count -eq 0) { 132 | Get-EventSubscriber -SourceIdentifier "@HttpResponse_Check" | Unregister-Event 133 | } 134 | } 135 | 136 | $httpResponseCheckTimer.Start() # Start it's timer. 137 | 138 | 139 | $httpRequest = [Net.HttpWebRequest]::CreateHttp($Uri) 140 | $httpRequest.Method = $Method 141 | if ($Header -and $Header.Count) { 142 | foreach ($kv in $Header.GetEnumerator()) { 143 | $httpRequest.Headers[$kv.Key] = $kv.Value 144 | } 145 | } 146 | if ($Body) { 147 | $requestStream = $httpRequest.GetRequestStream() 148 | 149 | if (-not $requestStream) { return } 150 | if ($body -is [byte[]] -or $Body -as [byte[]]) { 151 | [IO.MemoryStream]::new([byte[]]$Body).CopyTo($requestStream) 152 | } 153 | elseif ($Body -is [string]) { 154 | [IO.StreamWriter]::new($requestStream, $TransferEncoding).Write($Body) 155 | } 156 | else { 157 | [IO.StreamWriter]::new($requestStream, $TransferEncoding).Write((ConvertTo-Json -InputObject $body -Depth 100)) 158 | } 159 | } 160 | $requestId = [Guid]::NewGuid().ToString() 161 | $httpRequest | 162 | Add-Member NoteProperty SourceIdentifier "HttpRequest.Completed.$requestId","HttpRequest.Failed.$requestId" -Force -PassThru | 163 | Add-Member NoteProperty RequestID $requestId -Force -PassThru 164 | } -------------------------------------------------------------------------------- /EventSources/@Job.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Watches a PowerShell Job's State. 4 | .Description 5 | Watches a PowerShell Job's StateChange event. 6 | 7 | This will send an event when a job finishes. 8 | #> 9 | param( 10 | [Parameter(Mandatory,ValueFromPipelineByPropertyName)] 11 | [Alias('ID')] 12 | [int] 13 | $JobID) 14 | 15 | if ($_ -is [Management.Automation.Job]) { 16 | $_ 17 | } else { 18 | Get-Job -Id $JobID -ErrorAction SilentlyContinue 19 | } 20 | 21 | -------------------------------------------------------------------------------- /EventSources/@ModuleChanged.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Watches for Module loads and unloads. 4 | .Description 5 | Polls the current set of globally imported PowerShell modules. 6 | 7 | When this changes, one of two events will be generated: 8 | 9 | PowerShell.Module.Loaded will be sent when one or more modules is loaded 10 | PowerShell.Module.Unloaded will be sent when one or more modules is unloaded 11 | 12 | Only one event if each will be sent per polling interval. 13 | #> 14 | param( 15 | # The frequency to check for a module load. 16 | [Timespan] 17 | $PollingInterval = [Timespan]::FromMilliseconds(7037) 18 | ) 19 | 20 | process { 21 | $eventSubscriber = Get-EventSubscriber -SourceIdentifier "ModuleLoaded_Check" -ErrorAction SilentlyContinue 22 | if ($eventSubscriber) { 23 | $eventSubscriber | Unregister-Event 24 | } 25 | 26 | $timer = [Timers.Timer]::new() 27 | $timer.AutoReset = $true 28 | $timer.Interval = $PollingInterval.TotalMilliseconds 29 | $timer.Start() 30 | Register-ObjectEvent -SourceIdentifier "ModuleLoaded_Check" -InputObject $timer -EventName Elapsed -Action { 31 | $loadedModules = Get-Module 32 | if ($script:ModuleLoadedList) { 33 | $Compared = Compare-Object -ReferenceObject $script:ModuleLoadedList -DifferenceObject $loadedModules 34 | 35 | $newModules = $compared | Where-Object SideIndicator -eq '=>' | Select-Object -ExpandProperty InputObject 36 | $removedModules = $compared | Where-Object SideIndicator -eq '<=' | Select-Object -ExpandProperty InputObject 37 | if ($newModules) { 38 | New-Event -SourceIdentifier "PowerShell.Module.Loaded" -EventArguments $newModules -MessageData $newModules 39 | } 40 | if ($removedModules) { 41 | New-Event -SourceIdentifier "PowerShell.Module.Unloaded" -EventArguments $removedModules -MessageData $removedModules 42 | } 43 | } 44 | $script:ModuleLoadedList = $loadedModules 45 | } | Out-Null 46 | [PSCustomObject]@{ 47 | SourceIdentifier = "PowerShell.Module.Loaded","PowerShell.Module.Unloaded" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /EventSources/@PowerShellAsync.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Runs PowerShell asynchronously 4 | .Description 5 | Runs PowerShell in the background. 6 | Events are fired on the completion or failure of the PowerShell command. 7 | #> 8 | [ComponentModel.InitializationEvent({ 9 | # Because of the potential for a "narrow window" timing issue, 10 | # this event source needs to return a script to run in order to start it. 11 | # $this is whatever object is returned. 12 | # If multiple objects are returned, this will be run multiple times. 13 | $Global:PowerAsyncResults[$this.InstanceID] = $this.BeginInvoke() 14 | })] 15 | [CmdletBinding(PositionalBinding=$false)] 16 | param( 17 | # The scripts you would like to run. Each script block will be counted as a distinct statement. 18 | [Parameter(Mandatory,Position=0)] 19 | [ScriptBlock[]] 20 | $ScriptBlock, 21 | 22 | # The named parameters passed to each script. 23 | [Collections.IDictionary[]] 24 | [Alias('Parameters')] 25 | $Parameter, 26 | 27 | # If provided, will run in a specified runspace. The Runspace must already be open. 28 | [Management.Automation.Runspaces.Runspace] 29 | $Runspace, 30 | 31 | # If provided, will run in a runspace pool. The RunspacePool must already be open. 32 | [Management.Automation.Runspaces.RunspacePool] 33 | $RunspacePool 34 | ) 35 | 36 | process { 37 | $psAsync = [PowerShell]::Create() 38 | $null = for ($n =0; $n -lt $ScriptBlock.Length;$n++) { 39 | $psAsync.AddStatement() 40 | $psAsync.AddScript($ScriptBlock[$n]) 41 | if ($parameter -and $Parameter[$n]) { 42 | $psAsync.AddParameters($Parameter[$n]) 43 | } 44 | } 45 | foreach ($cmd in $psAsync.Commands) { 46 | foreach ($ce in $cmd.Commands) { 47 | $ce.MergeMyResults("All", "Output") 48 | } 49 | } 50 | if (-not $Global:PowerAsyncResults) { 51 | $Global:PowerAsyncResults = @{} 52 | } 53 | 54 | if ($Runspace) { 55 | $psAsync.Runspace = $Runspace 56 | } 57 | elseif ($RunspacePool) { 58 | $psAsync.RunspacePool = $RunspacePool 59 | } 60 | 61 | $onFinishedSubscriber = 62 | Register-ObjectEvent -InputObject $psAsync -EventName InvocationStateChanged -Action { 63 | $psAsyncCmd = $event.Sender 64 | if ($psAsyncCmd.InvocationStateInfo.State -notin 'Completed', 'Failed', 'Stopped') { 65 | return 66 | } 67 | if ($Global:PowerAsyncResults[$psAsyncCmd.InstanceID]) { 68 | $asyncResults = $event.Sender.EndInvoke($Global:PowerAsyncResults[$psAsyncCmd.InstanceID]) 69 | $Global:PowerAsyncResults.Remove($psAsyncCmd.InstanceID) 70 | New-Event -SourceIdentifier "PowerShell.Async.$($psAsyncCmd.InstanceID)" -Sender $event.Sender -MessageData $asyncResults -EventArguments $event.SourceEventArgs 71 | } 72 | 73 | if ($psAsyncCmd.InvocationStateInfo.Reason) { 74 | New-Event -SourceIdentifier "PowerShell.Async.Failed.$($psAsyncCmd.InstanceID)" -Sender $psAsyncCmd -MessageData $psAsyncCmd.InvocationStateInfo.Reason 75 | } 76 | Get-EventSubscriber | 77 | Where-Object SourceObject -EQ $psAsyncCmd | 78 | Unregister-Event 79 | 80 | $psAsyncCmd.Dispose() 81 | } 82 | 83 | $psAsync | 84 | Add-Member NoteProperty SourceIdentifier @( 85 | "PowerShell.Async.$($psAsync.InstanceID)","PowerShell.Async.Failed.$($psAsync.InstanceID)" 86 | ) -Force -PassThru 87 | 88 | return 89 | } -------------------------------------------------------------------------------- /EventSources/@Process.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Watches a process. 4 | .Description 5 | Watches a process. 6 | 7 | If -Exit is passed, watches for process exit. 8 | 9 | If -Output is passed, watches for process output 10 | 11 | If -Error is passed, watched for process error 12 | #> 13 | param( 14 | # The process identifier 15 | [Parameter(Mandatory,ValueFromPipelineByPropertyName)] 16 | [Alias('ID')] 17 | [int] 18 | $ProcessID, 19 | 20 | # If set, will watch for process exit. This is the default unless -StandardError or -StandardOutput are passed. 21 | [switch] 22 | $Exit, 23 | 24 | # If set, will watch for new standard output. 25 | [switch] 26 | $StandardOutput, 27 | 28 | # If set, will watch for new standard erorr. 29 | [switch] 30 | $StandardError 31 | ) 32 | 33 | process { 34 | $eventNames = @( 35 | if ($Exit) { 36 | "Exited" 37 | } 38 | if ($StandardOutput) { 39 | "OutputDataReceived" 40 | } 41 | if ($StandardError) { 42 | "ErrorDataReceived" 43 | } 44 | ) 45 | 46 | if ($eventNames) { 47 | Get-Process -Id $ProcessID | 48 | Add-Member EventName $eventNames -Force -PassThru 49 | } else { 50 | Get-Process -Id $ProcessID | 51 | Add-Member EventName "Exited" -Force -PassThru 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /EventSources/@UDP.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Signals on UDP 4 | .Description 5 | Runs PowerShell in the background. 6 | Events are fired on the completion or failure of the PowerShell command. 7 | #> 8 | param( 9 | # The IP Address where UDP packets can originate. By default, [IPAddress]::Any. 10 | [Parameter(ValueFromPipelineByPropertyName)] 11 | [Net.IPAddress] 12 | $IPAddress = [Net.IPAddress]::Any, 13 | 14 | # The Port used to listen for packets. 15 | [Parameter(Mandatory,ValueFromPipelineByPropertyName)] 16 | [int] 17 | $Port, 18 | 19 | # The encoding. If provided, packet content will be decoded. 20 | [Parameter(ValueFromPipelineByPropertyName)] 21 | [Text.Encoding] 22 | $Encoding 23 | ) 24 | 25 | $startedJob = Start-Job -ScriptBlock { 26 | param($IPAddress, $port, $encoding) 27 | 28 | $udpSvr= [Net.Sockets.UdpClient]::new() 29 | $endpoint = [Net.IpEndpoint]::new($IPAddress, $Port) 30 | 31 | try { 32 | $udpSvr.Client.Bind($endpoint) 33 | } catch { 34 | Write-Error -Message $_.Message -Exception $_ 35 | return 36 | } 37 | $eventSourceId = "UDP.${IPAddress}:$port" 38 | Register-EngineEvent -SourceIdentifier $eventSourceId -Forward 39 | if ($encoding) { 40 | $RealEncoding = [Text.Encoding]::GetEncoding($encoding.BodyName) 41 | if (-not $RealEncoding) { 42 | throw "Could not find $($encoding | Out-String)" 43 | } 44 | } 45 | while ($true) { 46 | $packet = $udpSvr.Receive([ref]$endpoint) 47 | 48 | if ($RealEncoding) { 49 | New-Event -Sender $IPAddress -MessageData $RealEncoding.GetString($packet) -SourceIdentifier $eventSourceId | 50 | Out-Null 51 | } else { 52 | New-Event -Sender $IPAddress -MessageData $packet -SourceIdentifier $eventSourceId | Out-Null 53 | } 54 | 55 | } 56 | } -ArgumentList $IPAddress, $port, $Encoding 57 | 58 | @{ 59 | SourceIdentifier = "UDP.${IPAddress}:$port" 60 | } 61 | 62 | 63 | -------------------------------------------------------------------------------- /EventSources/@VariableSet.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .Synopsis 3 | Watches for variable sets. 4 | .Description 5 | Watches for assignments to a variable. 6 | 7 | Events are sent directly after the variable is set. 8 | 9 | The -Sender is the callstack, The -MessageData is the value of the variable. 10 | #> 11 | param( 12 | # The name of the variable 13 | [Parameter(Mandatory,ValueFromPipelineByPropertyName)] 14 | [string] 15 | $VariableName 16 | ) 17 | 18 | process { 19 | Get-PSBreakpoint | Where-Object Variable -eq $VariableName | Remove-PSBreakpoint 20 | 21 | $raiseEvent = [ScriptBlock]::Create(@" 22 | New-Event -SourceIdentifier 'VariableSet.$variableName' -MessageData `$$variableName -Sender (Get-PSCallstack) 23 | continue 24 | "@) 25 | Set-PSBreakpoint -Variable $VariableName -Action $raiseEvent | Out-Null 26 | 27 | [PSCustomObject]@{SourceIdentifier="VariableSet.$variableName"} 28 | } 29 | -------------------------------------------------------------------------------- /Formatting/OnQ.EventSource.format.ps1: -------------------------------------------------------------------------------- 1 | Write-FormatView -TypeName OnQ.EventSource -Property Name, Synopsis, Parameters -VirtualProperty @{ 2 | Parameters = { 3 | @(foreach ($kv in ([Management.Automation.CommandMetaData]$_).Parameters.GetEnumerator()) { 4 | @( 5 | . $setOutputStyle -ForegroundColor Verbose 6 | "[$($kv.Value.ParameterType)]" 7 | . $clearOutputStyle 8 | . $setOutputStyle -ForegroundColor Warning 9 | "`$$($kv.Key)" 10 | . $clearOutputStyle 11 | ) -join '' 12 | }) -join [Environment]::NewLine 13 | } 14 | } -Wrap -ColorProperty @{ 15 | "Name" = {"Success"} 16 | } 17 | -------------------------------------------------------------------------------- /Get-EventSource.ps1: -------------------------------------------------------------------------------- 1 | function Get-EventSource 2 | { 3 | <# 4 | .Synopsis 5 | Gets Event Sources 6 | .Description 7 | Gets Event Sources. 8 | 9 | Event sources are commands or script blocks that can generate events. 10 | 11 | Event sources can be implemented in: 12 | * A .PS1 file starting with @ 13 | * An in-memory scriptblock variable starting with @ 14 | * A module command referenced within a PrivateData.OnQ section of the module manifest. 15 | .Example 16 | Get-EventSource 17 | .Example 18 | Get-EventSource -Subscription 19 | .Link 20 | Watch-Event 21 | #> 22 | [OutputType('OnQ.EventSource', 23 | [Management.Automation.CommandInfo], 24 | [Management.Automation.PSEventSubscriber], 25 | [PSObject])] 26 | param( 27 | # The name of the event source. 28 | [Parameter(ValueFromPipelineByPropertyName)] 29 | [string[]] 30 | $Name, 31 | 32 | # If set, will get subscriptions related to event sources. 33 | [Parameter(ValueFromPipelineByPropertyName)] 34 | [switch] 35 | $Subscription, 36 | 37 | # If set, will get source objects from the subscriptions related to event sources. 38 | [Parameter(ValueFromPipelineByPropertyName)] 39 | [switch] 40 | $SourceObject, 41 | 42 | # If set, will get full help for each event source. 43 | [Parameter(ValueFromPipelineByPropertyName)] 44 | [switch] 45 | $Help 46 | ) 47 | begin { 48 | #region Discover Event Sources 49 | $atFunctions = $ExecutionContext.SessionState.InvokeCommand.GetCommands('@*', 'Function',$true)| 50 | Where-Object { $_.Value -is [ScriptBlock] } 51 | 52 | # Save a pointer to the method for terseness and speed. 53 | $getCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand 54 | 55 | $myInv = $MyInvocation 56 | 57 | $lookInDirectory = @( 58 | "$pwd" 59 | $myRoot = $myInv.MyCommand.ScriptBlock.File | Split-Path -ErrorAction SilentlyContinue 60 | "$myRoot" 61 | if ($myInv.MyCommand.Module) { # Assuming, of course, we have a module. 62 | $MyModuleRoot = $myInv.MyCommand.Module | Split-Path -ErrorAction SilentlyContinue 63 | if ($MyModuleRoot -ne $myRoot) { "$MyModuleRoot" } 64 | } 65 | ) | Select-Object -Unique 66 | 67 | $atScripts = $lookInDirectory | 68 | Get-Item | 69 | Get-ChildItem -Filter '@*.ps1' | 70 | & { process { 71 | $getCmd.Invoke($_.Fullname,'ExternalScript') 72 | } } 73 | 74 | 75 | 76 | # If we had a module, and we still don't have a match, we'll look for extensions. 77 | 78 | $loadedModules = @(Get-Module) 79 | 80 | if ($loadedModules -notcontains $myInv.MyCommand.Module) { 81 | $loadedModules = @($myInv.MyCommand.Module) + $loadedModules 82 | } 83 | $extendedCommands = 84 | 85 | foreach ($loadedModule in $loadedModules) { # Walk over all modules. 86 | if ( # If the module has PrivateData keyed to this module 87 | $loadedModule.PrivateData.($myInv.MyCommand.Module.Name) 88 | ) { 89 | $thisModuleRoot = [IO.Path]::GetDirectoryName($loadedModule.Path) 90 | $extensionData = $loadedModule.PrivateData.($myInv.MyCommand.Module.Name) 91 | if ($extensionData -is [Hashtable]) { 92 | foreach ($ed in $extensionData.GetEnumerator()) { 93 | 94 | $extensionCmd = 95 | if ($ed.Value -like '*.ps1') { 96 | $getCmd.Invoke( 97 | [IO.Path]::Combine($thisModuleRoot, $ed.Value), 98 | 'ExternalScript' 99 | ) 100 | } else { 101 | $loadedModule.ExportedCommands[$ed.Value] 102 | } 103 | if ($extensionCmd) { 104 | $extensionCmd 105 | } 106 | } 107 | } 108 | } 109 | } 110 | $allSources = @() + $atFunctions + $atScripts + $extendedCommands 111 | 112 | $allSources = $allSources | Select-Object -Unique 113 | #endregion Discover Event Sources 114 | } 115 | 116 | process { 117 | foreach ($src in $allSources) { 118 | if ($Name) { 119 | 120 | $ok = 121 | foreach ($n in $Name) { 122 | $src.Name -like "$n" -or 123 | $src.Name -replace '^@' -replace '\.ps1$' -like "$n" 124 | } 125 | 126 | if (-not $ok) { 127 | continue 128 | } 129 | } 130 | 131 | 132 | 133 | if ($Subscription -or $SourceObject) { 134 | if (-not $script:SubscriptionsByEventSource) { continue } 135 | $eventSourceKey = # Then, if the event source was a script, 136 | if ($src -is [Management.Automation.ExternalScriptInfo]) { 137 | $src.Path # the key is the path. 138 | } elseif ($src.Module) { # If it was from a module 139 | $src.Module + '\' + $eventSource.Name # it's the module qualified name. 140 | } else { 141 | $src.Name # Otherwise, it's just the function name. 142 | } 143 | if (-not $script:SubscriptionsByEventSource[$eventSourceKey]) { continue } 144 | if ($Subscription) { 145 | $script:SubscriptionsByEventSource[$eventSourceKey] | 146 | Where-Object { 147 | [Runspace]::DefaultRunspace.Events.Subscribers -contains $_ 148 | } 149 | } else { 150 | if ($script:SubscriptionsByEventSource[$eventSourceKey].SourceObject) { 151 | $script:SubscriptionsByEventSource[$eventSourceKey].SourceObject 152 | } else { 153 | $jobName = $script:SubscriptionsByEventSource[$eventSourceKey].Name 154 | Get-EventSubscriber -SourceIdentifier $jobName -ErrorAction SilentlyContinue | 155 | Select-Object -ExpandProperty SourceObject -ErrorAction SilentlyContinue 156 | } 157 | 158 | } 159 | continue 160 | } 161 | 162 | 163 | 164 | $src.pstypenames.clear() 165 | $src.pstypenames.add('OnQ.EventSource') 166 | if ($Help -and -not $Parameter) { 167 | Get-Help $src.EventSourceID -Full 168 | continue 169 | } 170 | $src 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Brundage 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /OnQ.azure-pipeline.yml: -------------------------------------------------------------------------------- 1 |  2 | parameters: 3 | - name: ModulePath 4 | type: string 5 | default: 6 | - name: Recurse 7 | type: boolean 8 | default: false 9 | - name: PesterMaxVersion 10 | type: string 11 | default: '4.99.99' 12 | stages: 13 | - stage: PowerShellStaticAnalysis 14 | displayName: Static Analysis 15 | condition: succeeded() 16 | jobs: 17 | - job: PSScriptAnalyzer 18 | displayName: PSScriptAnalyzer 19 | pool: 20 | vmImage: windows-latest 21 | steps: 22 | - powershell: | 23 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 24 | Install-Module -Name PSDevOps -Repository PSGallery -Force -Scope CurrentUser 25 | Import-Module PSDevOps -Force -PassThru 26 | name: InstallPSDevOps 27 | displayName: InstallPSDevOps 28 | - powershell: | 29 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 30 | Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Force -Scope CurrentUser 31 | Import-Module PSScriptAnalyzer -Force -PassThru 32 | name: InstallPSScriptAnalyzer 33 | displayName: InstallPSScriptAnalyzer 34 | - powershell: | 35 | $Parameters = @{} 36 | $Parameters.ModulePath = @' 37 | ${{parameters.ModulePath}} 38 | '@ 39 | $Parameters.Recurse = @' 40 | ${{parameters.Recurse}} 41 | '@ 42 | $Parameters.Recurse = $parameters.Recurse -match 'true'; 43 | foreach ($k in @($parameters.Keys)) { 44 | if ([String]::IsNullOrEmpty($parameters[$k])) { 45 | $parameters.Remove($k) 46 | } 47 | } 48 | Write-Host "##[command] RunPSScriptAnalyzer $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 49 | & {param( 50 | [string]$ModulePath, 51 | [switch]$Recurse 52 | ) 53 | Import-Module PSScriptAnalyzer, PSDevOps 54 | if (-not $ModulePath) { $ModulePath = '.\'} 55 | $invokeScriptAnalyzerSplat = @{Path=$ModulePath} 56 | if ($ENV:PSScriptAnalyzer_Recurse -or $Recurse) { 57 | $invokeScriptAnalyzerSplat.Recurse = $true 58 | } 59 | $result = @(Invoke-ScriptAnalyzer @invokeScriptAnalyzerSplat) 60 | $violatedRules = $result | Select-Object -ExpandProperty RuleName 61 | 62 | Write-ADOVariable -Name PSScriptAnalyzerIssueCount -Value $result.Length -IsOutput 63 | Write-ADOVariable -Name PSScriptAnalyzerRulesViolated -Value ($violatedRules -join ',') -IsOutput 64 | foreach ($r in $result) { 65 | if ('information', 'warning' -contains $r.Severity) { 66 | Write-ADOWarning -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column 67 | } 68 | elseif ($r.Severity -eq 'Error') { 69 | Write-ADOError -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column 70 | } 71 | }} @Parameters 72 | name: RunPSScriptAnalyzer 73 | displayName: RunPSScriptAnalyzer 74 | - job: ScriptCop 75 | displayName: ScriptCop 76 | pool: 77 | vmImage: windows-latest 78 | steps: 79 | - powershell: | 80 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 81 | Install-Module -Name PSDevOps -Repository PSGallery -Force -Scope CurrentUser 82 | Import-Module PSDevOps -Force -PassThru 83 | name: InstallPSDevOps 84 | displayName: InstallPSDevOps 85 | - powershell: | 86 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 87 | Install-Module -Name ScriptCop -Repository PSGallery -Force -Scope CurrentUser 88 | Import-Module ScriptCop -Force -PassThru 89 | name: InstallScriptCop 90 | displayName: InstallScriptCop 91 | - powershell: | 92 | $Parameters = @{} 93 | $Parameters.ModulePath = @' 94 | ${{parameters.ModulePath}} 95 | '@ 96 | foreach ($k in @($parameters.Keys)) { 97 | if ([String]::IsNullOrEmpty($parameters[$k])) { 98 | $parameters.Remove($k) 99 | } 100 | } 101 | Write-Host "##[command] RunScriptCop $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 102 | & {param([string]$ModulePath) 103 | Import-Module ScriptCop, PSDevOps -PassThru | Out-host 104 | 105 | if (-not $ModulePath) { 106 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 107 | $ModulePath = ".\$moduleName.psd1" 108 | } 109 | 110 | if ($ModulePath -like '*PSDevOps*') { 111 | Remove-Module PSDeVOps # If running ScriptCop on PSDeVOps, we need to remove the global module first. 112 | } 113 | "Importing from $ModulePath" | Out-Host 114 | $importedModule =Import-Module $ModulePath -Force -PassThru 115 | 116 | $importedModule | Out-Host 117 | 118 | Trace-ADOCommand -Command 'Test-Command' -Parameter @{Module=$importedModule} 119 | 120 | $importedModule | 121 | Test-Command | 122 | Tee-Object -Variable scriptCopIssues | 123 | Out-Host 124 | 125 | $scriptCopIssues = @($scriptCopIssues | Sort-Object ItemWithProblem) 126 | Write-ADOVariable -Name ScriptCopIssueCount -Value $scriptCopIssues.Length -IsOutput 127 | 128 | foreach ($issue in $scriptCopIssues) { 129 | Write-ADOWarning -Message "$($issue.ItemWithProblem): $($issue.Problem)" 130 | 131 | } 132 | } @Parameters 133 | name: RunScriptCop 134 | displayName: RunScriptCop 135 | 136 | - stage: TestPowerShellCrossPlatform 137 | displayName: Test 138 | jobs: 139 | - job: Windows 140 | displayName: Windows 141 | pool: 142 | vmImage: windows-latest 143 | steps: 144 | - powershell: | 145 | $Parameters = @{} 146 | $Parameters.PesterMaxVersion = @' 147 | ${{parameters.PesterMaxVersion}} 148 | '@ 149 | foreach ($k in @($parameters.Keys)) { 150 | if ([String]::IsNullOrEmpty($parameters[$k])) { 151 | $parameters.Remove($k) 152 | } 153 | } 154 | Write-Host "##[command] InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 155 | & {param( 156 | [string] 157 | $PesterMaxVersion = '4.99.99' 158 | ) 159 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 160 | Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber 161 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters 162 | name: InstallPester 163 | displayName: InstallPester 164 | - powershell: | 165 | $Parameters = @{} 166 | $Parameters.ModulePath = @' 167 | ${{parameters.ModulePath}} 168 | '@ 169 | $Parameters.PesterMaxVersion = @' 170 | ${{parameters.PesterMaxVersion}} 171 | '@ 172 | foreach ($k in @($parameters.Keys)) { 173 | if ([String]::IsNullOrEmpty($parameters[$k])) { 174 | $parameters.Remove($k) 175 | } 176 | } 177 | Write-Host "##[command] RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 178 | & {<# 179 | .Synopsis 180 | Runs Pester 181 | .Description 182 | Runs Pester tests after importing a PowerShell module 183 | #> 184 | param( 185 | # The module path. If not provided, will default to the second half of the repository ID. 186 | [string] 187 | $ModulePath, 188 | # The Pester max version. By default, this is pinned to 4.99.99. 189 | [string] 190 | $PesterMaxVersion = '4.99.99' 191 | ) 192 | 193 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 194 | if (-not $ModulePath) { 195 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 196 | $ModulePath = ".\$moduleName.psd1" 197 | } 198 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion | Out-Host 199 | Import-Module $ModulePath -Force -PassThru | Out-Host 200 | 201 | $Global:ErrorActionPreference = 'continue' 202 | $Global:ProgressPreference = 'silentlycontinue' 203 | 204 | $result = 205 | Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml ` 206 | -CodeCoverage "$(Build.SourcesDirectory)\*-*.ps1" -CodeCoverageOutputFile ".\$moduleName.Coverage.xml" 207 | 208 | "##vso[task.setvariable variable=FailedCount;isoutput=true]$($result.FailedCount)", 209 | "##vso[task.setvariable variable=PassedCount;isoutput=true]$($result.PassedCount)", 210 | "##vso[task.setvariable variable=TotalCount;isoutput=true]$($result.TotalCount)" | 211 | Out-Host 212 | 213 | if ($result.FailedCount -gt 0) { 214 | foreach ($r in $result.TestResult) { 215 | if (-not $r.Passed) { 216 | "##[error]$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" 217 | } 218 | } 219 | throw "$($result.FailedCount) tests failed." 220 | } 221 | 222 | } @Parameters 223 | name: RunPester 224 | displayName: RunPester 225 | - task: PublishTestResults@2 226 | inputs: 227 | testResultsFormat: NUnit 228 | testResultsFiles: '**/*.TestResults.xml' 229 | mergeTestResults: true 230 | condition: always() 231 | - task: PublishCodeCoverageResults@1 232 | inputs: 233 | codeCoverageTool: JaCoCo 234 | summaryFileLocation: '**/*.Coverage.xml' 235 | reportDirectory: $(System.DefaultWorkingDirectory) 236 | condition: always() 237 | - job: Linux 238 | displayName: Linux 239 | pool: 240 | vmImage: ubuntu-latest 241 | steps: 242 | - script: | 243 | 244 | curl https://packages.microsoft.com/keys/microsoft.asc | sudo apt-key add - 245 | curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list | sudo tee /etc/apt/sources.list.d/microsoft.list 246 | sudo apt-get update 247 | sudo apt-get install -y powershell 248 | 249 | displayName: Install PowerShell Core 250 | - pwsh: | 251 | $Parameters = @{} 252 | $Parameters.PesterMaxVersion = @' 253 | ${{parameters.PesterMaxVersion}} 254 | '@ 255 | foreach ($k in @($parameters.Keys)) { 256 | if ([String]::IsNullOrEmpty($parameters[$k])) { 257 | $parameters.Remove($k) 258 | } 259 | } 260 | Write-Host "##[command] InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 261 | & {param( 262 | [string] 263 | $PesterMaxVersion = '4.99.99' 264 | ) 265 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 266 | Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber 267 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters 268 | name: InstallPester 269 | displayName: InstallPester 270 | - pwsh: | 271 | $Parameters = @{} 272 | $Parameters.ModulePath = @' 273 | ${{parameters.ModulePath}} 274 | '@ 275 | $Parameters.PesterMaxVersion = @' 276 | ${{parameters.PesterMaxVersion}} 277 | '@ 278 | foreach ($k in @($parameters.Keys)) { 279 | if ([String]::IsNullOrEmpty($parameters[$k])) { 280 | $parameters.Remove($k) 281 | } 282 | } 283 | Write-Host "##[command] RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 284 | & {<# 285 | .Synopsis 286 | Runs Pester 287 | .Description 288 | Runs Pester tests after importing a PowerShell module 289 | #> 290 | param( 291 | # The module path. If not provided, will default to the second half of the repository ID. 292 | [string] 293 | $ModulePath, 294 | # The Pester max version. By default, this is pinned to 4.99.99. 295 | [string] 296 | $PesterMaxVersion = '4.99.99' 297 | ) 298 | 299 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 300 | if (-not $ModulePath) { 301 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 302 | $ModulePath = ".\$moduleName.psd1" 303 | } 304 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion | Out-Host 305 | Import-Module $ModulePath -Force -PassThru | Out-Host 306 | 307 | $Global:ErrorActionPreference = 'continue' 308 | $Global:ProgressPreference = 'silentlycontinue' 309 | 310 | $result = 311 | Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml ` 312 | -CodeCoverage "$(Build.SourcesDirectory)\*-*.ps1" -CodeCoverageOutputFile ".\$moduleName.Coverage.xml" 313 | 314 | "##vso[task.setvariable variable=FailedCount;isoutput=true]$($result.FailedCount)", 315 | "##vso[task.setvariable variable=PassedCount;isoutput=true]$($result.PassedCount)", 316 | "##vso[task.setvariable variable=TotalCount;isoutput=true]$($result.TotalCount)" | 317 | Out-Host 318 | 319 | if ($result.FailedCount -gt 0) { 320 | foreach ($r in $result.TestResult) { 321 | if (-not $r.Passed) { 322 | "##[error]$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" 323 | } 324 | } 325 | throw "$($result.FailedCount) tests failed." 326 | } 327 | 328 | } @Parameters 329 | name: RunPester 330 | displayName: RunPester 331 | - task: PublishTestResults@2 332 | inputs: 333 | testResultsFormat: NUnit 334 | testResultsFiles: '**/*.TestResults.xml' 335 | mergeTestResults: true 336 | condition: always() 337 | - task: PublishCodeCoverageResults@1 338 | inputs: 339 | codeCoverageTool: JaCoCo 340 | summaryFileLocation: '**/*.Coverage.xml' 341 | reportDirectory: $(System.DefaultWorkingDirectory) 342 | condition: always() 343 | - job: MacOS 344 | displayName: MacOS 345 | pool: 346 | vmImage: macos-latest 347 | steps: 348 | - script: | 349 | brew update 350 | brew tap homebrew/cask 351 | brew install --cask powershell 352 | displayName: Install PowerShell Core 353 | - pwsh: | 354 | $Parameters = @{} 355 | $Parameters.PesterMaxVersion = @' 356 | ${{parameters.PesterMaxVersion}} 357 | '@ 358 | foreach ($k in @($parameters.Keys)) { 359 | if ([String]::IsNullOrEmpty($parameters[$k])) { 360 | $parameters.Remove($k) 361 | } 362 | } 363 | Write-Host "##[command] InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 364 | & {param( 365 | [string] 366 | $PesterMaxVersion = '4.99.99' 367 | ) 368 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 369 | Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber 370 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters 371 | name: InstallPester 372 | displayName: InstallPester 373 | - pwsh: | 374 | $Parameters = @{} 375 | $Parameters.ModulePath = @' 376 | ${{parameters.ModulePath}} 377 | '@ 378 | $Parameters.PesterMaxVersion = @' 379 | ${{parameters.PesterMaxVersion}} 380 | '@ 381 | foreach ($k in @($parameters.Keys)) { 382 | if ([String]::IsNullOrEmpty($parameters[$k])) { 383 | $parameters.Remove($k) 384 | } 385 | } 386 | Write-Host "##[command] RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" 387 | & {<# 388 | .Synopsis 389 | Runs Pester 390 | .Description 391 | Runs Pester tests after importing a PowerShell module 392 | #> 393 | param( 394 | # The module path. If not provided, will default to the second half of the repository ID. 395 | [string] 396 | $ModulePath, 397 | # The Pester max version. By default, this is pinned to 4.99.99. 398 | [string] 399 | $PesterMaxVersion = '4.99.99' 400 | ) 401 | 402 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 403 | if (-not $ModulePath) { 404 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 405 | $ModulePath = ".\$moduleName.psd1" 406 | } 407 | Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion | Out-Host 408 | Import-Module $ModulePath -Force -PassThru | Out-Host 409 | 410 | $Global:ErrorActionPreference = 'continue' 411 | $Global:ProgressPreference = 'silentlycontinue' 412 | 413 | $result = 414 | Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml ` 415 | -CodeCoverage "$(Build.SourcesDirectory)\*-*.ps1" -CodeCoverageOutputFile ".\$moduleName.Coverage.xml" 416 | 417 | "##vso[task.setvariable variable=FailedCount;isoutput=true]$($result.FailedCount)", 418 | "##vso[task.setvariable variable=PassedCount;isoutput=true]$($result.PassedCount)", 419 | "##vso[task.setvariable variable=TotalCount;isoutput=true]$($result.TotalCount)" | 420 | Out-Host 421 | 422 | if ($result.FailedCount -gt 0) { 423 | foreach ($r in $result.TestResult) { 424 | if (-not $r.Passed) { 425 | "##[error]$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" 426 | } 427 | } 428 | throw "$($result.FailedCount) tests failed." 429 | } 430 | 431 | } @Parameters 432 | name: RunPester 433 | displayName: RunPester 434 | - task: PublishTestResults@2 435 | inputs: 436 | testResultsFormat: NUnit 437 | testResultsFiles: '**/*.TestResults.xml' 438 | mergeTestResults: true 439 | condition: always() 440 | - task: PublishCodeCoverageResults@1 441 | inputs: 442 | codeCoverageTool: JaCoCo 443 | summaryFileLocation: '**/*.Coverage.xml' 444 | reportDirectory: $(System.DefaultWorkingDirectory) 445 | condition: always() 446 | 447 | - stage: UpdatePowerShellGallery 448 | displayName: Update 449 | condition: and(succeeded(), in(variables['Build.SourceBranch'], 'refs/heads/master', 'refs/heads/main')) 450 | variables: 451 | - group: Gallery 452 | jobs: 453 | - job: Publish 454 | displayName: PowerShell Gallery 455 | pool: 456 | vmImage: windows-latest 457 | steps: 458 | - checkout: self 459 | clean: true 460 | persistCredentials: true 461 | - powershell: | 462 | $orgName, $moduleName = $env:BUILD_REPOSITORY_ID -split "/" 463 | $imported = Import-Module ".\$moduleName.psd1" -Force -PassThru 464 | $foundModule = Find-Module -Name $ModuleName -ErrorAction SilentlyContinue 465 | if ($foundModule.Version -ge $imported.Version) { 466 | Write-Warning "##vso[task.logissue type=warning]Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" 467 | } else { 468 | $gk = '$(GalleryKey)' 469 | $stagingDir = '$(Build.ArtifactStagingDirectory)' 470 | $moduleTempPath = Join-Path $stagingDir $moduleName 471 | 472 | Write-Host "Staging Directory: $ModuleTempPath" 473 | 474 | $imported | Split-Path | Copy-Item -Destination $moduleTempPath -Recurse 475 | $moduleGitPath = Join-Path $moduleTempPath '.git' 476 | Write-Host "Removing .git directory" 477 | Remove-Item -Recurse -Force $moduleGitPath 478 | Write-Host "Module Files:" 479 | Get-ChildItem $moduleTempPath -Recurse 480 | Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" 481 | Publish-Module -Path $moduleTempPath -NuGetApiKey $gk 482 | if ($?) { 483 | Write-Host "Published to Gallery" 484 | } else { 485 | Write-Host "Gallery Publish Failed" 486 | exit 1 487 | } 488 | } 489 | name: PublishPowerShellGallery 490 | displayName: PublishPowerShellGallery 491 | 492 | 493 | -------------------------------------------------------------------------------- /OnQ.ezout.ps1: -------------------------------------------------------------------------------- 1 | #requires -Module EZOut 2 | # Install-Module EZOut or https://github.com/StartAutomating/EZOut 3 | $myFile = $MyInvocation.MyCommand.ScriptBlock.File 4 | $myModuleName = 'OnQ' 5 | $myRoot = $myFile | Split-Path 6 | Push-Location $myRoot 7 | $formatting = @( 8 | # Add your own Write-FormatView here, 9 | # or put them in a Formatting or Views directory 10 | foreach ($potentialDirectory in 'Formatting','Views') { 11 | Join-Path $myRoot $potentialDirectory | 12 | Get-ChildItem -ea ignore | 13 | Import-FormatView -FilePath {$_.Fullname} 14 | } 15 | ) 16 | 17 | $destinationRoot = $myRoot 18 | 19 | if ($formatting) { 20 | $myFormatFile = Join-Path $destinationRoot "$myModuleName.format.ps1xml" 21 | $formatting | Out-FormatData -Module $MyModuleName | Set-Content $myFormatFile -Encoding UTF8 22 | } 23 | 24 | $types = @( 25 | # Add your own Write-TypeView statements here 26 | # or declare them in the 'Types' directory 27 | Join-Path $myRoot Types | 28 | Get-Item -ea ignore | 29 | Import-TypeView 30 | 31 | ) 32 | 33 | if ($types) { 34 | $myTypesFile = Join-Path $destinationRoot "$myModuleName.types.ps1xml" 35 | $types | Out-TypeData | Set-Content $myTypesFile -Encoding UTF8 36 | } 37 | Pop-Location 38 | -------------------------------------------------------------------------------- /OnQ.format.ps1xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | OnQ.EventSource 7 | 8 | OnQ.EventSource 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | $moduleName = 'OnQ' 28 | do { 29 | $lm = Get-Module -Name $moduleName -ErrorAction Ignore 30 | if (-not $lm) { continue } 31 | if ($lm.FormatPartsLoaded) { break } 32 | $wholeScript = @(foreach ($formatFilePath in $lm.exportedFormatFiles) { 33 | foreach ($partNodeName in Select-Xml -LiteralPath $formatFilePath -XPath "/Configuration/Controls/Control/Name[starts-with(., '$')]") { 34 | $ParentNode = $partNodeName.Node.ParentNode 35 | "$($ParentNode.Name)={ 36 | $($ParentNode.CustomControl.CustomEntries.CustomEntry.CustomItem.ExpressionBinding.ScriptBlock)}" 37 | } 38 | }) -join [Environment]::NewLine 39 | New-Module -Name "${ModuleName}.format.ps1xml" -ScriptBlock ([ScriptBlock]::Create(($wholeScript + ';Export-ModuleMember -Variable *'))) | 40 | Import-Module -Global 41 | $onRemove = [ScriptBlock]::Create("Remove-Module '${ModuleName}.format.ps1xml'") 42 | 43 | if (-not $lm.OnRemove) { 44 | $lm.OnRemove = $onRemove 45 | } else { 46 | $lm.OnRemove = [ScriptBlock]::Create($onRemove.ToString() + '' + [Environment]::NewLine + $lm.OnRemove) 47 | } 48 | $lm | Add-Member NoteProperty FormatPartsLoaded $true -Force 49 | 50 | } while ($false) 51 | 52 | 53 | $__ = $_ 54 | $ci = . {"Success"} 55 | $_ = $__ 56 | if ($ci -is [string]) { 57 | $ci = . ${OnQ_setOutputStyle} $ci 58 | } else { 59 | $ci = . ${OnQ_setOutputStyle} @ci 60 | } 61 | $output = . {$_.'Name'} 62 | @($ci; $output; . ${OnQ_ClearOutputStyle}) -join "" 63 | 64 | 65 | 66 | Synopsis 67 | 68 | 69 | 70 | @(foreach ($kv in ([Management.Automation.CommandMetaData]$_).Parameters.GetEnumerator()) { 71 | @( 72 | . ${OnQ_setOutputStyle} -ForegroundColor Verbose 73 | "[$($kv.Value.ParameterType)]" 74 | . ${OnQ_ClearOutputStyle} 75 | . ${OnQ_setOutputStyle} -ForegroundColor Warning 76 | "`$$($kv.Key)" 77 | . ${OnQ_ClearOutputStyle} 78 | ) -join '' 79 | }) -join [Environment]::NewLine 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ${OnQ_setOutputStyle} 91 | 92 | 93 | 94 | 95 | 96 | <# 97 | .Synopsis 98 | Adds style to a format output 99 | .Description 100 | Adds style information to a format output, including: 101 | 102 | * ForegroundColor 103 | * BackgroundColor 104 | * Bold 105 | * Underline 106 | .Notes 107 | Stylized Output works in two contexts at present: 108 | * Rich consoles (Windows Terminal, PowerShell.exe, Pwsh.exe) (when $host.UI.SupportsVirtualTerminal) 109 | * Web pages (Based off the presence of a $Request variable, or when $host.UI.SupportsHTML (you must add this property to $host.UI)) 110 | 111 | IsFormatPart: true 112 | #> 113 | param( 114 | [string]$ForegroundColor, 115 | [string]$BackgroundColor, 116 | [switch]$Bold, 117 | [switch]$Underline, 118 | [switch]$Invert 119 | ) 120 | 121 | $canUseANSI = $host.UI.SupportsVirtualTerminal 122 | $canUseHTML = $Request -or $host.UI.SupportsHTML 123 | if (-not ($canUseANSI -or $canUseHTML)) { return } 124 | 125 | $knownStreams = @{ 126 | Output='';Error='BrightRed';Warning='BrightYellow'; 127 | Verbose='BrightCyan';Debug='Yellow';Progress='Cyan'; 128 | Success='BrightGreen';Failure='Red';Default=''} 129 | $standardColors = 'Black', 'Red', 'Green', 'Yellow', 'Blue','Magenta', 'Cyan', 'White' 130 | $brightColors = 'BrightBlack', 'BrightRed', 'BrightGreen', 'BrightYellow', 'BrightBlue','BrightMagenta', 'BrightCyan', 'BrightWhite' 131 | $n =0 132 | $cssClasses = @() 133 | $styleAttributes = 134 | @(:nextColor foreach ($hc in $ForegroundColor,$BackgroundColor) { 135 | $n++ 136 | if (-not $hc) { continue } 137 | if ($hc[0] -eq [char]0x1b) { 138 | if ($canUseANSI) { 139 | $hc; continue 140 | } 141 | } 142 | 143 | $ansiStartPoint = if ($n -eq 1) { 30 } else { 40 } 144 | if ($knownStreams.ContainsKey($hc)) { 145 | $i = $brightColors.IndexOf($knownStreams[$hc]) 146 | if ($canUseHTML) { 147 | $cssClasses += $hc 148 | } else { 149 | if ($i -ge 0 -and $canUseANSI) { 150 | '' + [char]0x1b + "[1;$($ansiStartPoint + $i)m" 151 | } else { 152 | $i = $standardColors.IndexOf($knownStreams[$hc]) 153 | if ($i -ge 0 -and $canUseANSI) { 154 | '' + [char]0x1b + "[1;$($ansiStartPoint + $i)m" 155 | } elseif ($i -le 0 -and $canUseANSI) { 156 | '' + [char]0x1b + "[$($ansistartpoint + 8):5m" 157 | } 158 | } 159 | } 160 | continue nextColor 161 | } 162 | elseif ($standardColors -contains $hc) { 163 | for ($i = 0; $i -lt $standardColors.Count;$i++) { 164 | if ($standardColors[$i] -eq $hc) { 165 | if ($canUseANSI -and -not $canUseHTML) { 166 | '' + [char]0x1b + "[$($ansiStartPoint + $i)m" 167 | } else { 168 | $cssClasses += $standardColors[$i] 169 | } 170 | continue nextColor 171 | } 172 | } 173 | } elseif ($brightColors -contains $hc) { 174 | for ($i = 0; $i -lt $brightColors.Count;$i++) { 175 | if ($brightColors[$i] -eq $hc) { 176 | if ($canUseANSI -and -not $canUseHTML) { 177 | '' + [char]0x1b + "[1;$($ansiStartPoint + $i)m" 178 | } else { 179 | $cssClasses += $standardColors[$i] 180 | } 181 | continue nextColor 182 | } 183 | } 184 | } 185 | 186 | 187 | if ($hc -and -not $hc.StartsWith('#')) { 188 | $placesToLook= 189 | @(if ($hc.Contains('.')) { 190 | $module, $setting = $hc -split '\.', 2 191 | $theModule = Get-Module $module 192 | $theModule.PrivateData.Color, 193 | $theModule.PrivateData.Colors, 194 | $theModule.PrivateData.Colour, 195 | $theModule.PrivateData.Colours, 196 | $theModule.PrivateData.EZOut, 197 | $global:PSColors, 198 | $global:PSColours 199 | } else { 200 | $setting = $hc 201 | $moduleColorSetting = $theModule.PrivateData.PSColors.$setting 202 | }) 203 | 204 | foreach ($place in $placesToLook) { 205 | if (-not $place) { continue } 206 | foreach ($propName in $setting -split '\.') { 207 | $place = $place.$propName 208 | if (-not $place) { break } 209 | } 210 | if ($place -and "$place".StartsWith('#') -and 4,7 -contains "$place".Length) { 211 | $hc = $place 212 | continue 213 | } 214 | } 215 | if (-not $hc.StartsWith -or -not $hc.StartsWith('#')) { 216 | continue 217 | } 218 | } 219 | $r,$g,$b = if ($hc.Length -eq 7) { 220 | [int]::Parse($hc[1..2]-join'', 'HexNumber') 221 | [int]::Parse($hc[3..4]-join '', 'HexNumber') 222 | [int]::Parse($hc[5..6] -join'', 'HexNumber') 223 | }elseif ($hc.Length -eq 4) { 224 | [int]::Parse($hc[1], 'HexNumber') * 16 225 | [int]::Parse($hc[2], 'HexNumber') * 16 226 | [int]::Parse($hc[3], 'HexNumber') * 16 227 | } 228 | 229 | if ($canUseHTML) { 230 | if ($n -eq 1) { "color:$hc" } 231 | elseif ($n -eq 2) { "background-color:$hc"} 232 | } 233 | elseif ($canUseANSI) { 234 | if ($n -eq 1) { [char]0x1b+"[38;2;$r;$g;${b}m" } 235 | elseif ($n -eq 2) { [char]0x1b+"[48;2;$r;$g;${b}m" } 236 | } 237 | 238 | }) 239 | 240 | 241 | if ($Bold) { 242 | $styleAttributes += 243 | if ($canUseHTML) { 244 | "font-weight:bold" 245 | } 246 | elseif ($canUseANSI) 247 | { 248 | [char]0x1b + "[1m" 249 | } 250 | } 251 | 252 | if ($Underline) { 253 | $styleAttributes += 254 | if ($canUseHTML) { 255 | "text-decoration:underline" 256 | } elseif ($canUseANSI) { 257 | [char]0x1b + "[4m" 258 | } 259 | } 260 | 261 | if ($Invert) { 262 | $styleAttributes += 263 | if ($canUseHTML) { 264 | "filter:invert(100%)" 265 | } elseif ($canUseANSI) { 266 | [char]0x1b + "[7m" 267 | } 268 | } 269 | 270 | if ($canUseHTML) { 271 | 272 | "<span$( 273 | if ($styleAttributes) { " style='$($styleAttributes -join ';')'"} 274 | )$( 275 | if ($cssClasses) { " class='$($cssClasses -join ' ')'"} 276 | )>" 277 | } elseif ($canUseANSI) { 278 | $styleAttributes -join '' 279 | } 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | ${OnQ_ClearOutputStyle} 288 | 289 | 290 | 291 | 292 | 293 | <# 294 | .Synopsis 295 | Clears the output style 296 | .Description 297 | Clears ANSI output style or closes the most recent span element. 298 | 299 | ANSI stylization can be toggled off individually (for instance, to stop applying an -Underline but leave the color unchanged) 300 | .Notes 301 | IsFormatPart: true 302 | #> 303 | param( 304 | # If set, will explicitly clear ANSI Bold 305 | [switch] 306 | $Bold, 307 | # If set, will explicitly clear ANSI Underline 308 | [switch] 309 | $Underline, 310 | # If set, will explicitly clear ANSI Invert 311 | [switch] 312 | $Invert, 313 | # If set, will explicitly clear ANSI Foreground Color 314 | [switch] 315 | $ForegroundColor, 316 | # If set, will explicitly clear ANSI Background Color 317 | [switch] 318 | $BackgroundColor 319 | ) 320 | @(if ($request -or $host.UI.SupportsHTML) { 321 | "</span>" 322 | } elseif ($Host.UI.SupportsVirtualTerminal) { 323 | if ($Underline) { 324 | [char]0x1b + "[24m" 325 | } 326 | if ($Bold) { 327 | [char]0x1b + "[21m" 328 | } 329 | if ($Invert) { 330 | [char]0x1b + '[27m' 331 | } 332 | if ($ForegroundColor) { 333 | [char]0x1b + '[39m' 334 | } 335 | if ($BackgroundColor) { 336 | [char]0x1b + '[49m' 337 | } 338 | 339 | if (-not ($Underline -or $Bold -or $Invert -or $ForegroundColor -or $BackgroundColor)) { 340 | [char]0x1b + '[0m' 341 | } 342 | 343 | }) -join '' 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | -------------------------------------------------------------------------------- /OnQ.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | RootModule = 'OnQ.psm1' 3 | Description = 'Easy Asynchronous Event-Driven Scripting with PowerShell' 4 | ModuleVersion = '0.1.3' 5 | GUID = 'ba5ad698-89a1-4887-9511-59f9025989b1' 6 | Author = 'James Brundage' 7 | Copyright = '2021 Start-Automating' 8 | FormatsToProcess = 'OnQ.format.ps1xml' 9 | TypesToProcess = 'OnQ.types.ps1xml' 10 | AliasesToExport = '*' 11 | PrivateData = @{ 12 | PSData = @{ 13 | ProjectURI = 'https://github.com/StartAutomating/OnQ' 14 | LicenseURI = 'https://github.com/StartAutomating/OnQ/blob/master/LICENSE' 15 | 16 | Tags = 'OnQ', 'Events' 17 | 18 | ReleaseNotes = @' 19 | 0.1.3: 20 | --- 21 | New Event Source: 22 | * VariableSet 23 | 24 | Receive-Event now returns event most-recent to least-recent. 25 | Receive-Event now has -First and -Skip. 26 | 27 | Bugfix: On@Repeat now actually starts it's timer. 28 | 29 | 0.1.2: 30 | --- 31 | New Event Source: 32 | * UDP 33 | 34 | PowerShellAsync Event Source now allows for a -Parameter dictionaries. 35 | 0.1.1: 36 | --- 37 | New Event Sources: 38 | * HTTPResponse 39 | * PowerShellAsync 40 | 41 | New Event Source Capabilities: 42 | 43 | Event Sources can now return an InitializeEvent property or provide a ComponentModel.InitializationEvent attribute. 44 | This will be called directly after the subscription is created, so as to avoid signalling too soon. 45 | 46 | 0.1: 47 | --- 48 | Initial Module Release. 49 | 50 | Fun simple event syntax (e.g. on mysignal {"do this"} or on delay "00:00:01" {"do that"}) 51 | Better pipelining support for Sending events. 52 | 53 | '@ 54 | } 55 | 56 | 57 | OnQ = @{ 58 | 'Time' = '@Time.ps1' 59 | 'Delay' = '@Delay.ps1' 60 | 'Process' = 'EventSources/@Process.ps1' 61 | 'ModuleChanged' = 'EventSources/@ModuleChanged.ps1' 62 | 'Job' = 'EventSources/@Job.ps1' 63 | 'PowerShellAsync' = 'EventSources/@PowerShellAsync.ps1' 64 | 'HttpResponse' = 'EventSources/@HttpResponse.ps1' 65 | 'VariableSet' = 'EventSources/@VariableSet.ps1' 66 | UDP = 'EventSources/@UDP.ps1' 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /OnQ.psm1: -------------------------------------------------------------------------------- 1 | foreach ($file in Get-ChildItem -LiteralPath $PSScriptRoot -Filter *-*.ps1) { 2 | . $file.Fullname 3 | } 4 | 5 | 6 | Set-Alias -Name On -Value Watch-Event 7 | Set-Alias -Name Send -Value Send-Event 8 | Set-Alias -Name Receive -Value Receive-Event 9 | 10 | 11 | $eventSources = Get-EventSource 12 | 13 | foreach ($es in $eventSources) { 14 | Set-Alias "On@$($es.Name -replace '^@' -replace '\.ps1$')" -Value Watch-Event 15 | } 16 | 17 | Export-ModuleMember -Alias * -Function * -------------------------------------------------------------------------------- /OnQ.tests.ps1: -------------------------------------------------------------------------------- 1 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingCmdletAliases", "", 2 | Justification="These are smart aliases and part of syntax, and auto-fixing them is destructive.")] 3 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidGlobalVars", "", 4 | Justification="Global variables are the simplest way to test event updates.")] 5 | param() 6 | 7 | describe OnQ { 8 | it 'Helps create events' { 9 | $global:WaitAHalfSecond = $false 10 | On@Delay -Wait "00:00:00.5" -Then { $global:WaitAHalfSecond = $true; "Waited half second" } 11 | foreach ($i in 1..4) { 12 | Start-Sleep -Milliseconds 250 13 | } 14 | 15 | $global:WaitAHalfSecond | Should -Be $true 16 | } 17 | it 'Has a natural syntax' { 18 | $global:WaitedABit = $false 19 | on delay -Wait '00:00:00.5' -Then { $global:WaitedABit = $true; "Waited half second"} 20 | foreach ($i in 1..4) { 21 | Start-Sleep -Milliseconds 250 22 | } 23 | $global:WaitedABit | Should -Be $true 24 | } 25 | it 'Can handle an arbitrary signal, and send that signal' { 26 | $Global:Fired = $false 27 | on MySignal -Then { $Global:Fired = $true} 28 | send MySignal 29 | Start-Sleep -Milliseconds 250 30 | $Global:Fired | Should -Be $true 31 | } 32 | it 'Can pipe in arbitrary data to Send-Event' { 33 | $randomSourceId = "sourceId$(Get-Random)" 34 | $inputChecksum = 1..3 | 35 | Send-Event -SourceIdentifier $randomSourceId -PassThru | 36 | Select-Object -ExpandProperty MessageData | 37 | Measure-Object -Sum | 38 | Select-Object -ExpandProperty Sum 39 | 40 | $inputChecksum | Should -Be (1 + 2 + 3) 41 | } 42 | it 'Can Receive-Event sent by Send-Event, and -Clear them.' { 43 | $randomSourceId = "sourceId$(Get-Random)" 44 | 1..3 | 45 | Send-Event -SourceIdentifier $randomSourceId 46 | 47 | $outputchecksum = Receive-Event -SourceIdentifier $randomSourceId -Clear | 48 | Select-Object -ExpandProperty MessageData | 49 | Measure-Object -Sum | 50 | Select-Object -ExpandProperty Sum 51 | 52 | $outputchecksum | Should -Be (1 + 2 + 3) 53 | 54 | (Receive-Event -SourceIdentifier $randomSourceId) | Should -Be $null 55 | } 56 | it 'Can get a signal when a job finishes' { 57 | $global:JobsIsDone = $false 58 | $j = Start-Job -ScriptBlock { Start-Sleep -Milliseconds 500; "done" } 59 | 60 | $j| 61 | On@Job -Then { $global:JobsIsDone = $true } 62 | 63 | do { 64 | Start-Sleep -Milliseconds 750 65 | } while ($j.JobStateInfo.State -ne 'Completed') 66 | 67 | Start-Sleep -Milliseconds 250 68 | 69 | $global:JobsIsDone | Should -Be $true 70 | } 71 | 72 | it 'Can take any piped input with an event, and will treat -SourceIdentifier as the EventName' { 73 | $MyTimer = [Timers.Timer]::new() 74 | $MyTimer | Watch-Event -SourceIdentifier Elapsed -Then { "it's time"} 75 | } 76 | 77 | it 'Can broadly signal an event by providing an empty -Then {}' { 78 | on delay "00:00:00.1" -Then {} # Signal in a tenth of a second. 79 | Start-Sleep -Milliseconds 250 80 | $eventTimestamp = Get-Event -SourceIdentifier "System.Timers.Timer.*" | 81 | Sort-Object TimeGenerated -Descending | 82 | Select-Object -First 1 -ExpandProperty TimeGenerated 83 | ([DateTime]::Now - $eventTimestamp) | 84 | Should -BeLessOrEqual ([Timespan]"00:00:01") 85 | } 86 | 87 | it 'Can receive results from event subscriptions' { 88 | on delay "00:00:00.1" -Then {1} # Signal in a tenth of a second. 89 | Start-Sleep -Milliseconds 250 90 | $receivedResults = @(Get-EventSource -Name Delay -Subscription | Receive-Event) 91 | $receivedResults.Length | Should -BeGreaterOrEqual 1 92 | } 93 | 94 | 95 | context EventSources { 96 | it 'Has a number of built-in event source scripts.' { 97 | $es = Get-EventSource 98 | $es | 99 | Select-Object -ExpandProperty Name | 100 | Should -BeLike '@*' 101 | } 102 | it 'Can clear event sources' { 103 | $esCount = @(Get-EventSubscriber).Length 104 | $activeSubCount = @(Clear-EventSource -WhatIf).Length 105 | $activeSubCount | should -BeGreaterThan 0 106 | Clear-EventSource 107 | $esCount2 = @(Get-EventSubscriber).Length 108 | $esCount | Should -BeGreaterThan $esCount2 109 | } 110 | } 111 | 112 | context BuiltInEventSources { 113 | it 'Can run Powershell asynchronously' { 114 | On@PowerShellAsync -ScriptBlock { "hello world" } -Then {} 115 | 1..4 | Start-Sleep -Milliseconds { 250 } 116 | Get-Event -SourceIdentifier PowerShell.Async.* | 117 | Sort-Object TimeGenerated -Descending | 118 | Select-Object -First 1 -ExpandProperty MessageData | 119 | Should -Be "hello world" 120 | } 121 | it 'Can get a signal when an HTTPResponse is received' { 122 | On@HttpResponse -Uri https://github.com/ -Then {} 123 | 1..4 | Start-Sleep -Milliseconds { 250 } 124 | $responseContent = Get-Event -SourceIdentifier "HttpRequest.Completed.*" | 125 | Sort-Object TimeGenerated -Descending | 126 | Select-Object -First 1 -ExpandProperty MessageData | 127 | Select-Object -ExpandProperty ResponseContent 128 | $responseContent | Should -BeLike "* 2 | 3 | 4 | 5 | OnQ.EventSource 6 | 7 | 8 | Description 9 | 10 | # From ?<PowerShell_HelpField> in Irregular (https://github.com/StartAutomating/Irregular) 11 | [Regex]::new(@' 12 | \.(?<Field>Description) # Field Start 13 | \s{0,} # Optional Whitespace 14 | (?<Content>(.|\s)+?(?=(\.\w+|\#\>))) # Anything until the next .\field or end of the comment block 15 | '@, 'IgnoreCase,IgnorePatternWhitespace', [Timespan]::FromSeconds(1)).Match( 16 | $this.ScriptBlock 17 | ).Groups["Content"].Value 18 | 19 | 20 | 21 | 22 | EventSourceID 23 | 24 | if ($this -is [Management.Automation.ExternalScriptInfo]) { 25 | $this.Path # the key is the path. 26 | } elseif ($this.Module) { # If it was from a module 27 | $this.Module + '\' + $this.Name # it's the module qualified name. 28 | } else { 29 | $this.Name # Otherwise, it's just the function name. 30 | } 31 | 32 | 33 | 34 | 35 | Help 36 | 37 | Get-Help $this.EventSourceId 38 | 39 | 40 | 41 | 42 | Synopsis 43 | 44 | # From ?<PowerShell_HelpField> in Irregular (https://github.com/StartAutomating/Irregular) 45 | [Regex]::new(@' 46 | \.(?<Field>Synopsis) # Field Start 47 | \s{0,} # Optional Whitespace 48 | (?<Content>(.|\s)+?(?=(\.\w+|\#\>))) # Anything until the next .\field or end of the comment block 49 | '@, 'IgnoreCase,IgnorePatternWhitespace', [Timespan]::FromSeconds(1)).Match( 50 | $this.ScriptBlock 51 | ).Groups["Content"].Value 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OnQ is now [Eventful](https://github.com/StartAutomating/Eventful) 2 | -------------------------------------------------------------------------------- /Receive-Event.ps1: -------------------------------------------------------------------------------- 1 | function Receive-Event 2 | { 3 | <# 4 | .Synopsis 5 | Receives Events 6 | .Description 7 | Receives Events and output from Event Subscriptions. 8 | .Link 9 | Send-Event 10 | .Link 11 | Watch-Event 12 | .Example 13 | Get-EventSource -Subscriber | Receive-Event 14 | .Example 15 | Receive-Event -SourceIdentifier * -First 1 # Receives the most recent event with any source identifier. 16 | #> 17 | [CmdletBinding(DefaultParameterSetName='Instance')] 18 | [OutputType([PSObject], [Management.Automation.PSEventArgs])] 19 | param( 20 | # The event subscription ID. 21 | [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='SubscriptionID')] 22 | [int[]] 23 | $SubscriptionID, 24 | 25 | # The event ID. 26 | [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='EventIdentifier')] 27 | [int[]] 28 | $EventIdentifier, 29 | 30 | # The event source identifier. 31 | [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='SourceIdentifier')] 32 | [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='SubscriptionID')] 33 | [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='EventIdentifier')] 34 | [string[]] 35 | $SourceIdentifier, 36 | 37 | # If provided, will return the first N events 38 | [int] 39 | $First, 40 | 41 | # If provided, will skip the first N events. 42 | [int] 43 | $Skip, 44 | 45 | # The input object. 46 | # If the Input Object was a job, it will receive the results of the job. 47 | [Parameter(ValueFromPipeline)] 48 | [PSObject] 49 | $InputObject, 50 | 51 | # If set, will remove events from the system after they have been returned, 52 | # and will not keep results from Jobs or Event Handlers. 53 | [switch] 54 | $Clear 55 | ) 56 | 57 | 58 | 59 | begin { 60 | #region Prepare Accumulation 61 | # We will accumulate the events we output in case we need to -Clear them. 62 | $accumulated = [Collections.Arraylist]::new() 63 | filter accumulate { 64 | if (-not $skip -or ($accumulated.Count -ge $skip)) { 65 | $_ 66 | } 67 | 68 | if (-not $First -or ($accumulated.Count -lt ($First + $skip))) { 69 | $null = $accumulated.Add($_) 70 | } 71 | } 72 | #endregion Prepare Accumulation 73 | } 74 | process { 75 | #region Passthru Events 76 | if ($PSCmdlet.ParameterSetName -eq 'EventIdentifier' -and 77 | $_ -is [Management.Automation.PSEventArgs]) { 78 | $_ | accumulate # pass events thru and accumulate them for later. 79 | return 80 | } 81 | #endregion PassThru Events 82 | #region Receiving Events by SourceIdentifier 83 | if ($PSCmdlet.ParameterSetName -in 'SourceIdentifier', 'EventIdentifier') { 84 | :nextEvent for ($ec = $PSCmdlet.Events.ReceivedEvents.Count -1 ; $ec -ge 0; $ec--) { 85 | $evt = $PSCmdlet.Events.ReceivedEvents[$ec] 86 | if ($SourceIdentifier) { 87 | foreach ($sid in $sourceIdentifier) { 88 | if ($evt.SourceIdentifier -eq $sid -or $evt.SourceIdentifier -like $sid) { 89 | $evt | accumulate 90 | } 91 | if ($First -and $accumulated.Count -ge ($First + $Skip)) { 92 | break nextEvent 93 | } 94 | } 95 | } 96 | if ($EventIdentifier) { 97 | foreach ($eid in $EventIdentifier) { 98 | if ($evt.EventIdentifier -eq $eid) { 99 | $evt | accumulate 100 | } 101 | if ($First -and $accumulated.Count -ge ($First + $Skip)) { 102 | break nextEvent 103 | } 104 | } 105 | } 106 | } 107 | return 108 | } 109 | #endregion Receiving Events by SourceIdentifier 110 | #region Receiving Events by SubscriptionID 111 | if ($PSCmdlet.ParameterSetName -eq 'SubscriptionID') { 112 | $SubscriptionID | 113 | # Find all event subscriptions with that subscription ID 114 | Get-EventSubscriber -SubscriptionId { $_ } -ErrorAction SilentlyContinue | 115 | # that have an .Action. 116 | Where-Object { $_.Action } | 117 | # Then pipe that action to 118 | Select-Object -ExpandProperty Action | 119 | # Receive-Job (-Keep results by default unless -Clear is passed). 120 | Receive-Job -Keep:(-not $Clear) 121 | return 122 | } 123 | #endregion Receiving Events by SubscriptionID 124 | 125 | #region Receiving Events by InputObject 126 | if ($InputObject) { # If the input object was a job, 127 | if ($InputObject -is [Management.Automation.Job]) { 128 | # Receive-Job (-Keep results by default unless -Clear is passed). 129 | $InputObject | Receive-Job -Keep:(-not $Clear) 130 | } else { 131 | # Otherwise, find event subscribers 132 | Get-EventSubscriber | 133 | # whose source is this input object 134 | Where-Object Source -EQ $InputObject | 135 | # that have an .Action. 136 | Where-Object { $_.Action } | 137 | # Then pipe that action to 138 | Select-Object -ExpandProperty Action | 139 | # Receive-Job (-Keep results by default unless -Clear is passed). 140 | Receive-Job -Keep:(-not $Clear) 141 | } 142 | } 143 | #endregion Receiving Events by InputObject 144 | } 145 | 146 | end { 147 | #region -Clear accumulation (if requested) 148 | if ($accumulated.Count -and $Clear) { # If we accumulated events, and said to -Clear them, 149 | $accumulated | Remove-Event # remove those events. 150 | } 151 | #region -Clear accumulation (if requested) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Send-Event.ps1: -------------------------------------------------------------------------------- 1 | function Send-Event 2 | { 3 | <# 4 | .Synopsis 5 | Sends Events 6 | .Description 7 | Sends Events to PowerShell. 8 | 9 | Send-Event is a wrapper for the built-in command New-Event with a few key differences: 10 | 1. It allows MessageData to be piped in 11 | 2. You can send multiple sourceidentifiers 12 | 3. It does not output by default (you must pass -PassThru) 13 | .Example 14 | 1..4 | Send-Event "Hit It" 15 | .Link 16 | New-Event 17 | .Link 18 | Watch-Event 19 | #> 20 | [OutputType([Nullable],[Management.Automation.PSEventArgs])] 21 | param( 22 | # The SourceIdentifier 23 | [Parameter(Mandatory,Position=0)] 24 | [string[]] 25 | $SourceIdentifier, 26 | 27 | # The message data 28 | [Parameter(ValueFromPipeline,Position=1)] 29 | [PSObject] 30 | $MessageData, 31 | 32 | # The sender. 33 | [Parameter(ValueFromPipelineByPropertyName,Position=2)] 34 | [PSObject] 35 | $Sender, 36 | 37 | # The event arguments. 38 | [Parameter(ValueFromPipelineByPropertyName,Position=3)] 39 | [PSObject] 40 | $EventArgs, 41 | 42 | # If set, will output the created event. 43 | [Parameter(ValueFromPipelineByPropertyName)] 44 | [switch] 45 | $PassThru 46 | ) 47 | 48 | 49 | begin { 50 | # Be we start, get a reference to New-Event 51 | $newEvent = $ExecutionContext.SessionState.InvokeCommand.GetCommand('New-Event','Cmdlet') 52 | } 53 | process { 54 | #region Map New-Event Parameters 55 | $newEventParams = @{} + $PSBoundParameters # Copy bound parameters. 56 | foreach ($k in @($newEventParams.Keys)) { 57 | if (-not $newEvent.Parameters[$k]) { # If a parameter isn't for New-Event 58 | $newEventParams.Remove($k) # remove it from the copy. 59 | } 60 | } 61 | # If we're piping in MessageData, but the message only contains "Sender" and "Event" 62 | if ($newEventParams.Sender -and $newEventParams.EventArgs -and 63 | $newEventParams.MessageData.psobject.properties.Count -eq 2 -and 64 | $newEventParams.MessageData.Sender -and $newEventParams.MessageData.EventArgs) { 65 | $newEventParams.Remove('MessageData') # remove the MessageData. 66 | } 67 | # Always remove the sourceID parameter (New-Event allows one, Send-Event allows many) 68 | $newEventParams.Remove('SourceIdentifier') 69 | #endregion Map New-Event Parameters 70 | 71 | #region Send Each Event 72 | foreach ($sourceID in $SourceIdentifier) { # Walk over each source identifier 73 | # and call New-Event. 74 | $evt = New-Event @newEventParams -SourceIdentifier $sourceID 75 | if ($PassThru) { # If we want to -PassThru events 76 | $evt # output the created event. 77 | } 78 | } 79 | #endregion Send Each Event 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Types/OnQ.EventSource/get_Description.ps1: -------------------------------------------------------------------------------- 1 | # From ? in Irregular (https://github.com/StartAutomating/Irregular) 2 | [Regex]::new(@' 3 | \.(?Description) # Field Start 4 | \s{0,} # Optional Whitespace 5 | (?(.|\s)+?(?=(\.\w+|\#\>))) # Anything until the next .\field or end of the comment block 6 | '@, 'IgnoreCase,IgnorePatternWhitespace', [Timespan]::FromSeconds(1)).Match( 7 | $this.ScriptBlock 8 | ).Groups["Content"].Value 9 | -------------------------------------------------------------------------------- /Types/OnQ.EventSource/get_EventSourceID.ps1: -------------------------------------------------------------------------------- 1 | if ($this -is [Management.Automation.ExternalScriptInfo]) { 2 | $this.Path # the key is the path. 3 | } elseif ($this.Module) { # If it was from a module 4 | $this.Module + '\' + $this.Name # it's the module qualified name. 5 | } else { 6 | $this.Name # Otherwise, it's just the function name. 7 | } 8 | -------------------------------------------------------------------------------- /Types/OnQ.EventSource/get_Help.ps1: -------------------------------------------------------------------------------- 1 | Get-Help $this.EventSourceId 2 | -------------------------------------------------------------------------------- /Types/OnQ.EventSource/get_Synopsis.ps1: -------------------------------------------------------------------------------- 1 | # From ? in Irregular (https://github.com/StartAutomating/Irregular) 2 | [Regex]::new(@' 3 | \.(?Synopsis) # Field Start 4 | \s{0,} # Optional Whitespace 5 | (?(.|\s)+?(?=(\.\w+|\#\>))) # Anything until the next .\field or end of the comment block 6 | '@, 'IgnoreCase,IgnorePatternWhitespace', [Timespan]::FromSeconds(1)).Match( 7 | $this.ScriptBlock 8 | ).Groups["Content"].Value 9 | -------------------------------------------------------------------------------- /Understanding_Event_Sources.md: -------------------------------------------------------------------------------- 1 | Understanding Event Sources 2 | --------------------------- 3 | Event Sources are scripts that produce events. 4 | 5 | They are generally named @NameOfSource.ps1. 6 | 7 | Events in PowerShell can be produced in two ways: 8 | * .NET Objects can produce events. 9 | * An event can be sent by PowerShell. 10 | 11 | An event source script can return any object with events, 12 | and indicate which events to subscribe to either by addding a 13 | [Diagnostics.Tracing.EventSource(Name='EventName')] attribute 14 | or by adding a noteproperty called "EventName" to the return. 15 | 16 | Event sources can be found a few places: 17 | 18 | * In the current directory 19 | * In any function whose name starts with @ 20 | * In the directory where Watch-Event is defined 21 | * In the module root where Watch-Event is defined 22 | * In an .OnQ [Hashtable] within a module manifest's private data 23 | 24 | You can see the event sources currently available with: 25 | 26 | ~~~PowerShell 27 | Get-EventSource 28 | ~~~ -------------------------------------------------------------------------------- /Watch-Event.ps1: -------------------------------------------------------------------------------- 1 | function Watch-Event { 2 | <# 3 | .Synopsis 4 | Watches Events 5 | .Description 6 | Watches Events by SourceIdentifier, or using an EventSource script. 7 | .Example 8 | Watch-Event -SourceIdentifier MySignal -Then {"fire!" | Out-Host } 9 | .Example 10 | On MySignal { "fire!" | Out-host } 11 | 12 | New-Event MySignal 13 | .Link 14 | Get-EventSource 15 | .Link 16 | Register-ObjectEvent 17 | .Link 18 | Register-EngineEvent 19 | .Link 20 | Get-EventSubscriber 21 | .Link 22 | Unregister-Event 23 | #> 24 | [CmdletBinding(PositionalBinding=$false)] 25 | [OutputType([nullable],[PSObject])] 26 | param() 27 | 28 | dynamicParam { 29 | #region Handle Input Dynamically 30 | # Watch-Event is written in an unusually flexible way: 31 | # It has no real parameters, only dynamic ones. 32 | $DynamicParameters = [Management.Automation.RuntimeDefinedParameterDictionary]::new() 33 | 34 | # The first step of our challenge is going to be determining the Event Source. 35 | $eventSource = $null 36 | # Since we only want to make one pass, 37 | # we also want to start tracking the maximum position we've found for a parameter. 38 | $maxPosition = -1 39 | # We also might want to know if we created the SourceIdentifier parameter yet. 40 | $SourceIDParameterCreated = $false 41 | 42 | #region Prepare to Add SourceID Parameter 43 | # Since we might add SourceID at a couple of different points in the code below, 44 | # we'll declare a [ScriptBlock] to add the source parameter whenever we want. 45 | $AddSourceIdParameter = { 46 | $SourceIDParameterCreated = $true # The -SourceIdentifier 47 | $sourceIdParameter = [Management.Automation.ParameterAttribute]::new() 48 | $sourceIdParameter.Mandatory = $true # will be mandatory 49 | $sourceIdParameter.Position = 0 # and will be first. 50 | # Add the dynamic parameter whenever this is called 51 | $DynamicParameters.Add("SourceIdentifier", 52 | [Management.Automation.RuntimeDefinedParameter]::new( 53 | "SourceIdentifier", [string[]], $sourceIdParameter)) 54 | $maxPosition = 0 # and set MaxPosition to 0 for the next parameter. 55 | } 56 | #endregion Prepare to Add SourceID Parameter 57 | 58 | #region Find the Event Source and Map Dynamic Parameters 59 | 60 | #region Determine Invocation Name 61 | # In order to find the event source, we need to use information about how we were called. 62 | # This information is stored in $myInvocation, which can change. So we capture it's value right now. 63 | $myInv = $MyInvocation 64 | $InvocationName = '' # Our challenge is to determine the final $invocationName to use throughout the script. 65 | 66 | 67 | if ($myInv.InvocationName -ne $myInv.MyCommand.Name) { # If we're calling this command with an alias 68 | $invocationName = @($myInv.InvocationName -split '@', 2)[1] # the invocationname is what's after @. 69 | } 70 | 71 | 72 | if (-not $invocationName) { # If we don't have an invocation name, 73 | # we can peek at how we are being called (by looking at the $myInvocation.Line). 74 | $index = $myInv.Line.IndexOf($myInv.InvocationName) 75 | 76 | if ($index -ge 0) { # If our command is in the line, 77 | $myLine = $myInv.Line.Substring($index) # we can use some regex 78 | # to "peek" at positional parameters before the parameter even exists. 79 | 80 | # In this case, that regex is currently 81 | # the invocation name, followed by whitespace and a SourceIdentifier. 82 | if ($myLine -match "\s{0,}$($myInv.InvocationName)\s{1,}(?\w+)") { 83 | 84 | $invocationName = $Matches.SourceIdentifier 85 | # If we match, use that as the $invocationName 86 | # and add the sourceID parameter (so the positional parameter exists). 87 | . $AddSourceIdParameter 88 | } 89 | } 90 | } 91 | 92 | if (-not $InvocationName) { 93 | $InvocationName= $myInv.MyCommand.Name 94 | } 95 | 96 | #endregion Determine Invocation Name 97 | if ($invocationName -ne $myInv.MyCommand.Name) { 98 | #region Find Event Source 99 | 100 | # If we're being called with a smart alias, determine what the underlying command is. 101 | # But first, let's save a pointer to $executionContext.SessionState.InvokeCommand.GetCommand 102 | $getCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand 103 | 104 | # We want the next code to match quickly and not try too many codepaths, 105 | # so we'll run this in a [ScriptBlock] 106 | $eventSource = 107 | . { 108 | 109 | 110 | # Our first possibility is that we have an @Function or Alias. 111 | # Such a function would have to be defined using the provider syntax 112 | # (e.g. ${function:@EventSource} = {}) so this is _very_ unlikely to happen by mistake. 113 | $atFunction = $getCmd.Invoke("@$invocationName", 'Function,Alias') 114 | if ($atFunction) { 115 | if ($atFunction.ResolvedCommand) { 116 | return $atFunction.ResolvedCommand 117 | } else { 118 | return $atFunction 119 | } 120 | 121 | } 122 | 123 | 124 | # The next possibility is an @.ps1 script. 125 | $atFile = "@$($invocationName).ps1" 126 | # This could be in a few places: the current directory first, 127 | $localCmd = $getCmd.invoke( 128 | (Get-ChildItem -LiteralPath $pwd -Filter $atFile).FullName, 'ExternalScript') 129 | if ($localCmd) { return $localCmd } 130 | # then the directory this script is located in, 131 | $myRoot = $myInv.MyCommand.ScriptBlock.File | Split-Path -ErrorAction SilentlyContinue 132 | $atRootCmd = $getCmd.invoke( 133 | (Get-ChildItem -LiteralPath $myRoot -Filter $atFile).FullName, 'ExternalScript') 134 | if ($atRootCmd) {return $atRootCmd} 135 | 136 | if ($myInv.MyCommand.Module) { # then the module root 137 | $MyModuleRoot = $myInv.MyCommand.Module | Split-Path -ErrorAction SilentlyContinue 138 | if ($MyModule -ne $myRoot) { # (if different from $myroot). 139 | $atModuleRootCmd = 140 | $getCmd.Invoke( 141 | (Get-ChildItem -LiteralPath $MyModuleRoot -Filter $atFile).FullName, 'ExternalScript') 142 | if ($atModuleRootCmd) { $atModuleRootCmd; continue } 143 | } 144 | 145 | 146 | 147 | # Last, we want to look for extensions. 148 | $myModuleName = $myInv.MyCommand.Module.Name 149 | foreach ($loadedModule in Get-Module) { # 150 | if ( # If a module has a [Hashtable]PrivateData for this module 151 | $loadedModule.PrivateData.$myModuleName 152 | ) { 153 | $thisModuleRoot = [IO.Path]::GetDirectoryName($loadedModule.Path) 154 | $extensionData = $loadedModule.PrivateData.$myModuleName 155 | if ($extensionData -is [Hashtable]) { # that is a [Hashtable] 156 | foreach ($ed in $extensionData.GetEnumerator()) { # walk thru the hashtable 157 | if ($invocationName -eq $ed.Key) { # find out event source name 158 | $extensionCmd = 159 | if ($ed.Value -like '*.ps1') { # and map it to a script or command. 160 | $getCmd.Invoke( 161 | (Join-Path $thisModuleRoot $ed.Value), 162 | 'ExternalScript' 163 | ) 164 | } else { 165 | $loadedModule.ExportedCommands[$ed.Value] 166 | } 167 | 168 | # If we could map it, return it before we keep looking thru modules. 169 | if ($extensionCmd) { 170 | return $extensionCmd 171 | } 172 | } 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | 180 | } 181 | #endregion Find Event Source 182 | #region Map Dynamic Parameters 183 | if ($eventSource) { 184 | # We only need to map dynamic parameters if there is an event source. 185 | $eventSourceMetaData = [Management.Automation.CommandMetaData]$eventSource 186 | # Determine if we need to offset positional parameters, 187 | $positionOffset = [int]$SourceIDParameterCreated 188 | # then walk over the parameters from that event source. 189 | foreach ($kv in $eventSourceMetaData.Parameters.GetEnumerator()) { 190 | 191 | $attributes = 192 | if ($positionOffset) { # If we had to offset the position of a parameter 193 | @(foreach ($attr in $kv.value.attributes) { 194 | if ($attr -isnot [Management.Automation.ParameterAttribute] -or 195 | $attr.Position -lt 0 196 | ) { 197 | # we can passthru any non-parameter attributes and parameter attributes without position, 198 | $attr 199 | } else { 200 | # but parameter attributes with position need to copied and offset. 201 | $attrCopy = [Management.Automation.ParameterAttribute]::new() 202 | # (Side note: without a .Clone, copying is tedious.) 203 | foreach ($prop in $attrCopy.GetType().GetProperties('Instance,Public')) { 204 | if (-not $prop.CanWrite) { continue } 205 | if ($null -ne $attr.($prop.Name)) { 206 | $attrCopy.($prop.Name) = $attr.($prop.Name) 207 | } 208 | } 209 | 210 | 211 | # Once we have a parameter copy, offset it's position. 212 | $attrCopy.Position+=$positionOffset 213 | $pos = $attrCopy.Position 214 | $attrCopy 215 | } 216 | }) 217 | } else { 218 | $pos = foreach ($a in $kv.value.attributes) { 219 | if ($a.position -ge 0) { $a.position ; break } 220 | } 221 | $kv.Value.Attributes 222 | } 223 | 224 | # Add the parameter and it's potentially modified attributes. 225 | $DynamicParameters.Add($kv.Key, 226 | [Management.Automation.RuntimeDefinedParameter]::new( 227 | $kv.Value.Name, $kv.Value.ParameterType, $attributes 228 | ) 229 | ) 230 | # If the parameter position was bigger than maxPosition, update maxPosition. 231 | if ($pos -ge 0 -and $pos -gt $maxPosition) { $maxPosition = $pos } 232 | } 233 | } 234 | #endregion Map Dynamic Parameters 235 | } 236 | 237 | #endregion Find the Event Source and Map Dynamic Parameters 238 | 239 | 240 | #region Add Common Parameters 241 | 242 | # If we don't have an Event Source at this point and we haven't already, 243 | if (-not $eventSource -and -not $SourceIDParameterCreated) { # add the SourceIdentifier parameter. 244 | . $addSourceIdParameter 245 | } 246 | 247 | # Also, if we don't have an event source 248 | if (-not $eventSource) { 249 | # then we can add an InputObject parameter. 250 | $inputObjectParameter = [Management.Automation.ParameterAttribute]::new() 251 | $inputObjectParameter.ValueFromPipeline = $true 252 | $DynamicParameters.Add("InputObject", 253 | [Management.Automation.RuntimeDefinedParameter]::new( 254 | "InputObject", [PSObject], $inputObjectParameter 255 | ) 256 | ) 257 | 258 | } 259 | 260 | # All calls will always have two additional parameters: 261 | $maxPosition++ 262 | $thenParam = [Management.Automation.ParameterAttribute]::new() 263 | $thenParam.Mandatory = $true #* [ScriptBlock]$then 264 | $thenParam.Position = $maxPosition 265 | $thenActionAlias = [Management.Automation.AliasAttribute]::new("Action") 266 | $DynamicParameters.Add("Then", 267 | [Management.Automation.RuntimeDefinedParameter]::new( 268 | "Then", [ScriptBlock], @($thenParam, $thenActionAlias) 269 | ) 270 | ) 271 | $maxPosition++ 272 | $WhenParam = [Management.Automation.ParameterAttribute]::new() 273 | $whenParam.Position = $maxPosition #* [ScriptBlock]$then 274 | $DynamicParameters.Add("When", 275 | [Management.Automation.RuntimeDefinedParameter]::new( 276 | "When", [ScriptBlock], $WhenParam 277 | ) 278 | ) 279 | 280 | #endregion Add Common Parameters 281 | 282 | 283 | # Now that we've got all of the dynamic parameters ready 284 | $DynamicParameterNames = $DynamicParameters.Keys -as [string[]] 285 | return $DynamicParameters # return them. 286 | #endregion Handle Input Dynamically 287 | } 288 | 289 | 290 | 291 | process { 292 | 293 | $in = $_ 294 | $registerCmd = $null 295 | $registerParams = @{} 296 | $parameterCopy = @{} + $PSBoundParameters 297 | if ($DebugPreference -ne 'silentlycontinue') { 298 | Write-Debug @" 299 | Watch-Event: 300 | Dynamic Parameters: $DynamicParameterNames 301 | Bound Parameters: 302 | $($parameterCopy | Out-String) 303 | "@ 304 | } 305 | 306 | #region Run Event Source and Map Parameters 307 | if ($eventSource) { # If we have an Event Source, now's the time to run it. 308 | $eventSourceParameter = [Ordered]@{} + $PSBoundParameters # Copy whatever parameters we have 309 | $eventSourceParameter.Remove('Then') # and remove -Then, 310 | $eventSourceParameter.Remove('When') # -When, 311 | $eventSourceParameter.Remove('SourceIdentifier') # and -SourceIdentifier. 312 | $eventSourceOutput = & $eventSource @eventSourceParameter # Then run the generator. 313 | $null = $PSBoundParameters.Remove('SourceIdentifier') 314 | 315 | 316 | if (-not $eventSourceOutput) { # If it didn't output, 317 | # we're gonna assume it it's gonna by signal by name. 318 | # Set it up so that later code will subscribe to this SourceIdentifier. 319 | $PSBoundParameters["SourceIdentifier"] = $eventSource.Name -replace 320 | '^@' -replace '\.event\.ps1$' -replace '\.ps1$' 321 | } 322 | elseif ($eventSourceOutput.SourceIdentifier) 323 | { 324 | 325 | # If the eventSource said what SourceIdentifier(s) it will send, we will listen. 326 | $PSBoundParameters["SourceIdentifier"] = $eventSourceOutput.SourceIdentifier 327 | } else { 328 | # Otherwise, let's see if the eventSource returned an eventName 329 | $eventName = $eventSourceOutput.EventName 330 | 331 | if (-not $eventName) { # If it didn't, 332 | $eventName = 333 | # Look at the generator script block's attibutes 334 | foreach ($attr in $eventSource.ScriptBlock.Attributes) { 335 | if ($attr.TypeId.Name -eq 'EventSourceAttribute') { 336 | # Return any [Diagnostics.Tracing.EventSource(Name='Value')] 337 | $attr.Name 338 | } 339 | } 340 | } 341 | 342 | 343 | if (-not $eventName) { # If we still don't have an event name. 344 | # check the output for events. 345 | $eventNames = @(foreach ($prop in $eventSourceOutput.psobject.members) { 346 | if ($prop.MemberType -eq 'event') { 347 | $prop.Name 348 | } 349 | }) 350 | 351 | # If there was more than one 352 | if ($eventNames.Count -gt 1) { 353 | # Error out (but output the generator's output, in case that helps). 354 | $eventSourceOutput 355 | Write-Error "Source produced an object with multiple events, but did not specify a '[Diagnostics.Tracing.EventSource(Name=)]'." 356 | return 357 | } 358 | 359 | 360 | $eventName = $eventNames[0] 361 | } 362 | 363 | # Default the Register- command to Register-ObjectEvent. 364 | $registerCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Register-ObjectEvent','Cmdlet') 365 | $registerParams['InputObject']= $eventSourceOutput # Map the InputObject 366 | $registerParams['EventName'] = $eventName # and the EventName. 367 | } 368 | } 369 | #endregion Run Event Source and Map Parameters 370 | 371 | #region Handle -SourceIdentifier and -InputObject 372 | if ($PSBoundParameters['SourceIdentifier']) { # If we have a -SourceIdentifier 373 | if ($PSBoundParameters['InputObject'] -and -not $eventSource) { # and an -InputObject (but not not an eventsource) 374 | # then the register command is Register-ObjectEvent. 375 | $registerCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Register-ObjectEvent','Cmdlet') 376 | 377 | # We map our -SourceIdentifier to Register-ObjectEvent's -EventName, 378 | $registerParams['EventName'] = $PSBoundParameters['SourceIdentifier'] 379 | # and Register-ObjectEvent's InputObject to -InputObject 380 | $registerParams['InputObject'] = $PSBoundParameters['InputObject'] 381 | } 382 | else # If we have a -SourceIdentifier, but no -InputObject 383 | { 384 | # the register command is Register-EngineEvent. 385 | $registerCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand('Register-EngineEvent','Cmdlet') 386 | # and we map our -SourceIdentifier parameter to Register-EngineEvent's -SourceIdentifier. 387 | $registerParams['SourceIdentifier'] = $PSBoundParameters['SourceIdentifier'] 388 | } 389 | } 390 | #endregion Handle -SourceIdentifier and -InputObject 391 | 392 | #region Handle When and Then 393 | # Assign When and Then for simplicity. 394 | $Then = $PSBoundParameters['Then'] 395 | $when = $PSBoundParameters['When'] 396 | 397 | if ($When) { # If -When was provided 398 | if ($when -is [ScriptBlock]) { # and it was a script 399 | # Rewrite -Then to include -When. 400 | # Run -When in a subexpression so it can return from the event handler (not itself) 401 | $then = [ScriptBlock]::Create(@" 402 | `$shouldHandle = `$( 403 | $when 404 | ) 405 | if (-not `$shouldHandle) { return } 406 | $then 407 | "@) 408 | } 409 | } 410 | 411 | if ("$then" -replace '\s') { # If -Then is not blank. 412 | $registerParams["Action"] = $Then # this maps to the -Action parameter the Register- comamnd. 413 | } 414 | #endregion Handle When and Then 415 | 416 | #region Subscribe to Event 417 | 418 | # Now we create the event subscription. 419 | $eventSubscription = 420 | 421 | 422 | # If there's a Register- command. 423 | if ($registerCmd) { 424 | if ($registerParams["EventName"]) { # and we have -EventName 425 | $evtNames = $registerParams["EventName"] 426 | $registerParams.Remove('EventName') 427 | 428 | # Call Register-Object event once 429 | foreach ($evtName in $evtNames) { # for each event name ( 430 | # give it a logical SourceIdentifier. 431 | $sourceId = $registerParams["InputObject"].GetType().FullName + ".$evtName" 432 | $existingSubscribers = @(Get-EventSubscriber -SourceIdentifier "${sourceID}*") 433 | if ($existingSubscribers) { # (If subscribers exist, increment the source ID)) 434 | $maxSourceId = 0 435 | foreach ($es in $existingSubscribers) { 436 | if ($es.SourceIdentifier -match '\.\d+$') { 437 | $esId = [int]($matches.0 -replace '\.') 438 | if ($esId -gt $maxSourceId) { 439 | $maxSourceId = $esId 440 | } 441 | } 442 | } 443 | $sourceID = $sourceId + ".$($maxSourceId + 1)" 444 | } 445 | 446 | # Then call Register-ObjectEvent 447 | & $registerCmd @registerParams -EventName $evtName -SourceIdentifier $sourceId 448 | } 449 | } 450 | elseif ($registerParams["SourceIdentifier"]) # 451 | { 452 | $sourceIdList = $registerParams["SourceIdentifier"] 453 | $null = $registerParams.Remove('SourceIdentifier') 454 | # If we don't have an action, don't run anything (this will let the events "bubble up" to the runspace). 455 | if ($registerParams.Action) { 456 | # If we do have an action, call Register-Engine event with each source identifier. 457 | foreach ($sourceId in $sourceIdList) { 458 | & $registerCmd @registerParams -SourceIdentifier $sourceId 459 | } 460 | } 461 | } 462 | } 463 | #endregion Subscribe to Event 464 | 465 | 466 | #region Keep track of Subscriptions by EventSource 467 | # Before we're done, let's track what we subscribed to. 468 | 469 | if ($eventSource) { 470 | # Make sure a cache exists. 471 | if (-not $script:SubscriptionsByEventSource) { 472 | $script:SubscriptionsByEventSource = @{} 473 | } 474 | $eventSourceKey = # Then, if the event source was a script, 475 | if ($eventSource -is [Management.Automation.ExternalScriptInfo]) { 476 | $eventSource.Path # the key is the path. 477 | } elseif ($eventSource.Module) { # If it was from a module 478 | $eventSource.Module + '\' + $eventSource.Name # it's the module qualified name. 479 | } else { 480 | $eventSource.Name # Otherwise, it's just the function name. 481 | } 482 | $script:SubscriptionsByEventSource[$eventSourceKey] = 483 | if ($eventSubscription -is [Management.Automation.Job]) { 484 | Get-EventSubscriber -SourceIdentifier $eventSubscription.Name -ErrorAction SilentlyContinue 485 | } else { 486 | $eventSubscription 487 | } 488 | 489 | $eventSourceInitializeAttribute= $eventSource.ScriptBlock.Attributes | 490 | Where-Object TypeID -EQ ([ComponentModel.InitializationEventAttribute]) 491 | if ($eventSourceOutput.InitializeEvent -and $eventSourceOutput.InitializeEvent -is [string]) { 492 | $eventSourceOutput.$($eventSourceOutput.InitializeEvent).Invoke() 493 | } 494 | elseif ($eventSourceOutput.InitializeEvent -and $eventSourceOutput.InitializeEvent -is [ScriptBlock]) { 495 | $this = $sender = $eventSourceOutput 496 | & ([ScriptBlock]::Create($eventSourceInitializeAttribute.EventName)) 497 | } 498 | elseif ($eventSourceInitializeAttribute.EventName -match '^[\w\-]+$') { 499 | $eventSourceOutput.($eventSourceInitializeAttribute.EventName).Invoke() 500 | } else { 501 | $this = $sender = $eventSourceOutput 502 | & ([ScriptBlock]::Create($eventSourceInitializeAttribute.EventName)) 503 | } 504 | } 505 | 506 | #endregion Keep track of Subscription 507 | 508 | 509 | #region Passthru if needed 510 | if ($myInv.PipelinePosition -lt $myInv.PipelineLength) { # If this is not the last step of the pipeline 511 | $in # pass down the original object. (This would let one set of arguments pipe to multiple calls) 512 | } 513 | else { 514 | 515 | } 516 | #endregion Passthru if needed 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /en-us/Understanding_Event_Sources.help.txt: -------------------------------------------------------------------------------- 1 | Understanding Event Sources 2 | --------------------------- 3 | Event Sources are scripts that produce events. 4 | 5 | They are generally named @NameOfSource.ps1. 6 | 7 | Events in PowerShell can be produced in two ways: 8 | * .NET Objects can produce events. 9 | * An event can be sent by PowerShell. 10 | 11 | An event source script can return any object with events, 12 | and indicate which events to subscribe to either by addding a 13 | [Diagnostics.Tracing.EventSource(Name='EventName')] attribute 14 | or by adding a noteproperty called "EventName" to the return. 15 | 16 | Event sources can be found a few places: 17 | 18 | * In the current directory 19 | * In any function whose name starts with @ 20 | * In the directory where Watch-Event is defined 21 | * In the module root where Watch-Event is defined 22 | * In an .OnQ [Hashtable] within a module manifest's private data 23 | 24 | You can see the event sources currently available with: 25 | 26 | ~~~PowerShell 27 | Get-EventSource 28 | ~~~ -------------------------------------------------------------------------------- /en-us/about_OnQ.help.txt: -------------------------------------------------------------------------------- 1 | ### Getting Started with OnQ 2 | 3 | 4 | OnQ is a PowerShell module that helps you use events to script asynchronously. 5 | 6 | It gives you an easy syntax to describe events handlers in PowerShell, and provides a platform to build custom event sources. 7 | 8 | You can watch for events to occur with Watch-Event. 9 | Watch-Event is aliased to "On". For example: 10 | 11 | ~~~PowerShell 12 | # Run in a second 13 | Watch-Event Delay "00:00:01" { "In A Second!" | Out-Host } 14 | 15 | # Or, using the alias 'On' 16 | on delay "00:05:00" { "Five more minutes!" | Out-Host} 17 | ~~~ 18 | 19 | These example use the built-in event source script 'Delay'. 20 | 21 | OnQ gives you a way to write scripts that will produce events. These are event sources. 22 | 23 | OnQ ships with several event sources. To see what event sources are available to you, run: 24 | 25 | ~~~PowerShell 26 | Get-EventSource # See event sources 27 | ~~~ 28 | 29 | 30 | Each event source discovered when OnQ loads creates a smart alias that makes it easier to discover parameters, for example: 31 | 32 | ~~~PowerShell 33 | # Run in 30 seconds 34 | On@Delay "00:00:30" { "This Message Will Self-Destruct in 30 seconds" | Out-Host } 35 | 36 | 37 | # Run at 5:00 PM 38 | On@Time "5:00 PM" { "End Of Day!" | Out-Host } 39 | 40 | # Run every 2 minutes 41 | On@Repeat "00:02:00" { "Every other minute" | Out-Host } 42 | 43 | # Run whenever a file changes within the current directory 44 | On@FileChange { "Files Changed:$($event.SenderEventArgs)" | Out-Host } 45 | ~~~ 46 | 47 | 48 | OnQ also allows you to handle an arbitrary signal. 49 | In the example below, we set up a handler for "MySignal", and the use the alias send to Send-Event(s). 50 | 51 | ~~~PowerShell 52 | On MySignal {"Fire $($event.MessageData)!" | Out-Host } 53 | 54 | # Send-Event can accept pipeline input for MessageData, and will not output unless -PassThru is specified. 55 | 1..3 | Send MySignal 56 | ~~~ 57 | 58 | --- 59 | 60 | [Understanding Event Sources](Understanding_Event_Sources.md) --------------------------------------------------------------------------------