PSScriptInfo.psm1

#Region '.\prefix.ps1' -1

# The content of this file will be prepended to the top of the psm1 module file. This is useful for custom module setup is needed on import.
#EndRegion '.\prefix.ps1' 2
#Region '.\Private\Assert-FolderExist.ps1' -1

function Assert-FolderExist
{
    <#
    .SYNOPSIS
        Verify and create folder
    .DESCRIPTION
        Verifies that a folder path exists, if not it will create it
    .PARAMETER Path
        Defines the path to be validated
    .EXAMPLE
        'C:\Temp' | Assert-FolderExist
 
        This will verify that the path exists and if it does not the folder will be created
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]
        $Path
    )

    process
    {
        $exists = Test-Path -Path $Path -PathType Container
        if (!$exists)
        {
            $null = New-Item -Path $Path -ItemType Directory
        }
    }
}
#EndRegion '.\Private\Assert-FolderExist.ps1' 31
#Region '.\Private\Get-PSScriptInfoLegacy.ps1' -1

function Get-PSScriptInfoLegacy
{
    <#
        .DESCRIPTION
            Collect and parse psscriptinfo from file
        .PARAMETER FilePath
            Defines the path to the file from which to get psscriptinfo from.
        .EXAMPLE
            Get-PSScriptInfoLegacy -FilePath C:\temp\file.ps1
            Description of example
    #>


    [CmdletBinding()] # Enabled advanced function support
    param(
        [ValidateScript( { Test-Path -Path $_ -PathType Leaf })][Parameter(Mandatory)][string]$FilePath
    )

    PROCESS
    {
        try
        {
            $PSScriptInfo = [ordered]@{ }
            New-Variable astTokens -Force
            New-Variable astErr -Force
            $null = [System.Management.Automation.Language.Parser]::ParseFile($FilePath, [ref]$astTokens, [ref]$astErr)
            $FileContent = $astTokens.where{ $_.kind -eq 'comment' -and $_.text.Replace("`r", '').Split("`n")[0] -like '<#PSScriptInfo*' } | Select-Object -ExpandProperty text
            $FileContent = $FileContent.Replace("`r", '').Split("`n")
            $FileContent | Select-Object -Skip 1 | ForEach-Object {
                $CurrentRow = $PSItem
                if ($CurrentRow.Trim() -like '.*')
                {
                    # New attribute found, extract attribute name
                    $Attribute = $CurrentRow.Split('.')[1].Split(' ')[0]

                    # Check if row has value
                    if ($CurrentRow.Trim().Replace($Attribute, '').TrimStart('.').Trim().Length -gt 0)
                    {

                        # Value on same row
                        $Value = $CurrentRow.Trim().Split(' ', 2)[1].Trim()

                        # Datetime
                        if (@('CREATEDDATE' -contains $Attribute))
                        {
                            $Value = $Value -as [string]
                        }
                        # System version
                        if (@('VERSION' -contains $Attribute))
                        {
                            $Value = $Value -as [string]
                        }
                        # guid
                        if (@('GUID' -contains $Attribute))
                        {
                            $Value = $Value -as [guid]
                        }

                        if (@('UNITTEST' -contains $Attribute))
                        {
                            if ($Value -eq 'false')
                            {
                                $Value = $false
                            }
                            elseif ($Value -eq 'true')
                            {
                                $Value = $true
                            }
                            else
                            {
                                $Value = $null
                            }
                        }

                        # Add attribute and value to PSScriptInfo
                        $null = $PSScriptInfo.Add($Attribute, $Value)
                    }
                    else
                    {
                        # If no value is provided populate PSScriptInfo with attribute and an empty collection as value
                        $null = $PSScriptInfo.Add($Attribute, [collections.arraylist]::New())
                    }
                }
            }
            Write-Output ([pscustomobject]$PSScriptInfo)
        }
        catch
        {
            Write-Error -Message 'No valid PSScriptInfo was found in file' -ErrorRecord $_
        }
    }
}
#EndRegion '.\Private\Get-PSScriptInfoLegacy.ps1' 92
#Region '.\Private\Invoke-GarbageCollect.ps1' -1

function Invoke-GarbageCollect
{
    <#
    .SYNOPSIS
        Calls system.gc collect method. Purpose is mainly for readability.
    .DESCRIPTION
        Calls system.gc collect method. Purpose is mainly for readability.
    .EXAMPLE
        Invoke-GarbageCollect
    #>

    [system.gc]::Collect()
}
#EndRegion '.\Private\Invoke-GarbageCollect.ps1' 13
#Region '.\Private\pslog.ps1' -1

function pslog
{
    <#
    .SYNOPSIS
        This is simple logging function that automatically log to file. Logging to console is maintained.
    .DESCRIPTION
        This is simple logging function that automatically log to file. Logging to console is maintained.
    .PARAMETER Severity
        Defines the type of log, valid vales are, Success,Info,Warning,Error,Verbose,Debug
    .PARAMETER Message
        Defines the message for the log entry
    .PARAMETER Source
        Defines a source, this is useful to separate log entries in categories for different stages of a process or for each function, defaults to default
    .PARAMETER Throw
        Specifies that when using severity error pslog will throw. This is useful in catch statements so that the terminating error is propagated upwards in the stack.
    .PARAMETER LogDirectoryOverride
        Defines a hardcoded log directory to write the log file to. This defaults to %appdatalocal%\<modulename\logs.
    .PARAMETER DoNotLogToConsole
        Specifies that logs should only be written to the log file and not to the console.
    .EXAMPLE
        pslog Verbose 'Successfully wrote to logfile'
        Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Sole purpose of function is logging, including console')]
    [cmdletbinding()]
    param(
        [parameter(Position = 0)]
        [ValidateSet('Success', 'Info', 'Warning', 'Error', 'Verbose', 'Debug')]
        [Alias('Type')]
        [string]
        $Severity,

        [parameter(Mandatory, Position = 1)]
        [string]
        $Message,

        [parameter(position = 2)]
        [string]
        $source = 'default',

        [parameter(Position = 3)]
        [switch]
        $Throw,

        [parameter(Position = 4)]
        [string]
        $LogDirectoryOverride,

        [parameter(Position = 5)]
        [switch]
        $DoNotLogToConsole
    )

    begin
    {
        if (-not $LogDirectoryOverride)
        {
            $localappdatapath = [Environment]::GetFolderPath('localapplicationdata') # ie C:\Users\<username>\AppData\Local
            $modulename = $MyInvocation.MyCommand.Module
            $logdir = "$localappdatapath\$modulename\logs"
        }
        else
        {
            $logdir = $LogDirectoryOverride
        }
        $logdir | Assert-FolderExist -Verbose:$VerbosePreference
        $timestamp = (Get-Date)
        $logfilename = ('{0}.log' -f $timestamp.ToString('yyy-MM-dd'))
        $timestampstring = $timestamp.ToString('yyyy-MM-ddThh:mm:ss.ffffzzz')
    }

    process
    {
        switch ($Severity)
        {
            'Success'
            {
                "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                if (-not $DoNotLogToConsole)
                {
                    Write-Host -Object "SUCCESS: $timestampstring`t$source`t$message" -ForegroundColor Green
                }
            }
            'Info'
            {
                "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                if (-not $DoNotLogToConsole)
                {
                    Write-Information -MessageData "$timestampstring`t$source`t$message"
                }
            }
            'Warning'
            {
                "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                if (-not $DoNotLogToConsole)
                {
                    Write-Warning -Message "$timestampstring`t$source`t$message"
                }
            }
            'Error'
            {
                "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                if (-not $DoNotLogToConsole)
                {
                    Write-Error -Message "$timestampstring`t$source`t$message"
                }
                if ($throw)
                {
                    throw
                }
            }
            'Verbose'
            {
                if ($VerbosePreference -ne 'SilentlyContinue')
                {
                    "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                }
                if (-not $DoNotLogToConsole)
                {
                    Write-Verbose -Message "$timestampstring`t$source`t$message"
                }
            }
            'Debug'
            {
                if ($DebugPreference -ne 'SilentlyContinue')
                {
                    "$timestampstring`t$psitem`t$source`t$message" | Add-Content -Path "$logdir\$logfilename" -Encoding utf8 -WhatIf:$false
                }
                if (-not $DoNotLogToConsole)
                {
                    Write-Debug -Message "$timestampstring`t$source`t$message"
                }
            }
        }
    }
}
#EndRegion '.\Private\pslog.ps1' 137
#Region '.\Private\Set-PSScriptInfo.ps1' -1

function Set-PSScriptInfo
{
    <#
        .DESCRIPTION
            Adds a PSScriptInfo block to a file
        .PARAMETER FilePath
            FilePath for file to set PSScriptInfo for
        .PARAMETER JSON
            String value containing json formatted PSScriptInfo
        .PARAMETER InsertAt
            Defines the row number to insert the PSScriptInfo block.
        .EXAMPLE
            Set-PSScriptInfo -Filepath C:\Script\Get-Test.ps1 -JSON $JSON
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'No system state changed')]
    [CmdletBinding()]
    param(
        [ValidateScript( { Test-Path $_.FullName -PathType Leaf })]
        [Parameter(Mandatory)]
        [System.IO.FileInfo]
        $FilePath,

        [Parameter(Mandatory)]
        [string]
        $JSON,

        [int]
        $InsertAt = 0
    )

    try
    {
        $null = $JSON | ConvertFrom-Json -ErrorAction Stop
        Write-Verbose 'Tested JSON input for valid JSON'
    }
    catch
    {
        throw 'Failed to parse input JSON, input is not valid JSON'
    }

    # Increase indent from two to four
    $JSON.Replace(' ', ' ')

    $JSON = ("<#PSScriptInfo$([system.environment]::NewLine){0}$([system.environment]::NewLine)PSScriptInfo#>$([system.environment]::NewLine)" -f $JSON)
    Write-Verbose 'Added prefix and suffix to JSON block'

    try
    {
        $FileContent = Get-Content -Path $FilePath -ErrorAction Stop
        Write-Verbose -Message ('Read content from filepath')
    }
    catch
    {
        throw ('Failed to read content from filepath with error: {0}' -f $_.exception.message)
    }

    $StringBuilder = [System.Text.StringBuilder]::new(($FileContent) -join ([system.environment]::NewLine))
    Write-Verbose -Message ('Created stringbuilder')

    $null = $StringBuilder.Insert($InsertAt, ($JSON))
    Write-Verbose -Message ('Inserted PSScriptInfo at beginning of content block')

    try
    {
        $StringBuilder.ToString() | Set-Content -Path $FilePath -Encoding utf8 -ErrorAction Stop
        Write-Verbose -Message ('Successfully wrote content block back to file')
    }
    catch
    {
        throw ('Failed to write content block back to file with error: {0}' -f $_.exception.message)
    }
}
#EndRegion '.\Private\Set-PSScriptInfo.ps1' 73
#Region '.\Private\Write-PSProgress.ps1' -1

function Write-PSProgress
{
    <#
    .SYNOPSIS
        Wrapper for PSProgress
    .DESCRIPTION
        This function will automatically calculate items/sec, eta, time remaining
        as well as set the update frequency in case the there are a lot of items processing fast.
    .PARAMETER Activity
        Defines the activity name for the progressbar
    .PARAMETER Id
        Defines a unique ID for this progressbar, this is used when nesting progressbars
    .PARAMETER Target
        Defines a arbitrary text for the currently processed item
    .PARAMETER ParentId
        Defines the ID of a parent progress bar
    .PARAMETER Completed
        Explicitly tells powershell to set the progress bar as completed removing
        it from view. In some cases the progress bar will linger if this is not done.
    .PARAMETER Counter
        The currently processed items counter
    .PARAMETER Total
        The total number of items to process
    .PARAMETER StartTime
        Sets the start datetime for the progressbar, this is required to calculate items/sec, eta and time remaining
    .PARAMETER DisableDynamicUpdateFrquency
        Disables the dynamic update frequency function and every item will update the status of the progressbar
    .PARAMETER NoTimeStats
        Disables calculation of items/sec, eta and time remaining
    .EXAMPLE
        1..10000 | foreach-object -begin {$StartTime = Get-Date} -process {
            Write-PSProgress -Activity 'Looping' -Target $PSItem -Counter $PSItem -Total 10000 -StartTime $StartTime
        }
        Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Standard')]
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Completed')]
        [string]
        $Activity,

        [Parameter(Position = 1, ParameterSetName = 'Standard')]
        [Parameter(Position = 1, ParameterSetName = 'Completed')]
        [ValidateRange(0, 2147483647)]
        [int]
        $Id,

        [Parameter(Position = 2, ParameterSetName = 'Standard')]
        [string]
        $Target,

        [Parameter(Position = 3, ParameterSetName = 'Standard')]
        [Parameter(Position = 3, ParameterSetName = 'Completed')]
        [ValidateRange(-1, 2147483647)]
        [int]
        $ParentId,

        [Parameter(Position = 4, ParameterSetname = 'Completed')]
        [switch]
        $Completed,

        [Parameter(Mandatory = $true, Position = 5, ParameterSetName = 'Standard')]
        [long]
        $Counter,

        [Parameter(Mandatory = $true, Position = 6, ParameterSetName = 'Standard')]
        [long]
        $Total,

        [Parameter(Position = 7, ParameterSetName = 'Standard')]
        [datetime]
        $StartTime,

        [Parameter(Position = 8, ParameterSetName = 'Standard')]
        [switch]
        $DisableDynamicUpdateFrquency,

        [Parameter(Position = 9, ParameterSetName = 'Standard')]
        [switch]
        $NoTimeStats
    )

    # Define current timestamp
    $TimeStamp = (Get-Date)

    # Define a dynamic variable name for the global starttime variable
    $StartTimeVariableName = ('ProgressStartTime_{0}' -f $Activity.Replace(' ', ''))

    # Manage global start time variable
    if ($PSBoundParameters.ContainsKey('Completed') -and (Get-Variable -Name $StartTimeVariableName -Scope Global -ErrorAction SilentlyContinue))
    {
        # Remove the global starttime variable if the Completed switch parameter is users
        try
        {
            Remove-Variable -Name $StartTimeVariableName -ErrorAction Stop -Scope Global
        }
        catch
        {
            throw $_
        }
    }
    elseif (-not (Get-Variable -Name $StartTimeVariableName -Scope Global -ErrorAction SilentlyContinue))
    {
        # Global variable do not exist, create global variable
        if ($null -eq $StartTime)
        {
            # No start time defined with parameter, use current timestamp as starttime
            Set-Variable -Name $StartTimeVariableName -Value $TimeStamp -Scope Global
            $StartTime = $TimeStamp
        }
        else
        {
            # Start time defined with parameter, use that value as starttime
            Set-Variable -Name $StartTimeVariableName -Value $StartTime -Scope Global
        }
    }
    else
    {
        # Global start time variable is defined, collect and use it
        $StartTime = Get-Variable -Name $StartTimeVariableName -Scope Global -ErrorAction Stop -ValueOnly
    }

    # Define frequency threshold
    $Frequency = [Math]::Ceiling($Total / 100)
    switch ($PSCmdlet.ParameterSetName)
    {
        'Standard'
        {
            # Only update progress is any of the following is true
            # - DynamicUpdateFrequency is disabled
            # - Counter matches a mod of defined frequecy
            # - Counter is 0
            # - Counter is equal to Total (completed)
            if (($DisableDynamicUpdateFrquency) -or ($Counter % $Frequency -eq 0) -or ($Counter -eq 1) -or ($Counter -eq $Total))
            {

                # Calculations for both timestats and without
                $Percent = [Math]::Round(($Counter / $Total * 100), 0)

                # Define count progress string status
                $CountProgress = ('{0}/{1}' -f $Counter, $Total)

                # If percent would turn out to be more than 100 due to incorrect total assignment revert back to 100% to avoid that write-progress throws
                if ($Percent -gt 100)
                {
                    $Percent = 100
                }

                # Define write-progress splat hash
                $WriteProgressSplat = @{
                    Activity         = $Activity
                    PercentComplete  = $Percent
                    CurrentOperation = $Target
                }

                # Add ID if specified
                if ($Id)
                {
                    $WriteProgressSplat.Id = $Id
                }

                # Add ParentID if specified
                if ($ParentId)
                {
                    $WriteProgressSplat.ParentId = $ParentId
                }

                # Calculations for either timestats and without
                if ($NoTimeStats)
                {
                    $WriteProgressSplat.Status = ('{0} - {1}%' -f $CountProgress, $Percent)
                }
                else
                {
                    # Total seconds elapsed since start
                    $TotalSeconds = ($TimeStamp - $StartTime).TotalSeconds

                    # Calculate items per sec processed (IpS)
                    $ItemsPerSecond = ([Math]::Round(($Counter / $TotalSeconds), 2))

                    # Calculate seconds spent per processed item (for ETA)
                    $SecondsPerItem = if ($Counter -eq 0)
                    {
                        0
                    }
                    else
                    {
 ($TotalSeconds / $Counter)
                    }

                    # Calculate seconds remainging
                    $SecondsRemaing = ($Total - $Counter) * $SecondsPerItem
                    $WriteProgressSplat.SecondsRemaining = $SecondsRemaing

                    # Calculate ETA
                    $ETA = $(($Timestamp).AddSeconds($SecondsRemaing).ToShortTimeString())

                    # Add findings to write-progress splat hash
                    $WriteProgressSplat.Status = ('{0} - {1}% - ETA: {2} - IpS {3}' -f $CountProgress, $Percent, $ETA, $ItemsPerSecond)
                }

                # Call writeprogress
                Write-Progress @WriteProgressSplat
            }
        }
        'Completed'
        {
            Write-Progress -Activity $Activity -Id $Id -Completed
        }
    }
}
#EndRegion '.\Private\Write-PSProgress.ps1' 214
#Region '.\Public\Add-PSScriptInfo.ps1' -1

function Add-PSScriptInfo
{
    <#
        .DESCRIPTION
            Add new PSScriptInfo to file
        .PARAMETER FilePath
            File to add PSScriptInfo to
        .PARAMETER Properties
            HashTable (ordered dictionary) containing key value pairs for properties that should be included in PSScriptInfo
        .PARAMETER Force
            Use force to replace any existing PSScriptInfo block
        .EXAMPLE
            Add-PSScriptInfo -FilePath C:\Scripts\Do-Something.ps1 -Properties @{Version='1.0.0';Author='Jane Doe';DateCreated='2021-01-01'}
            Adds a PSScriptInfo block containing the properties version and author. Resulting PSScriptInfo block
            that would be added to the beginning of the file would look like:
 
            <#PSScriptInfo
            {
                "Version" : "1.0.0",
                "Author" : "Jane Doe",
                "DateCreated" : "2021-01-01"
            }
            PSScriptInfo#>

    #>

    [CmdletBinding()] # Enabled advanced function support
    param(
        [ValidateScript( { Test-Path $_.FullName -PathType Leaf })]
        [Parameter(Mandatory)]
        [System.IO.FileInfo]
        $FilePath,

        [hashtable]
        $Properties,

        [switch]
        $Force
    )

    BEGIN
    {
        # If PSScriptInfo exists and force is not specified; throw
        if ((Get-PSScriptInfo -FilePath $FilePath.FullName -ErrorAction SilentlyContinue) -and -not $Force)
        {
            throw 'PSScriptInfo already exists, use Update-PSScriptInfo to modify. Use force to overwrite existing PSScriptInfo'
        }
        elseif ((Get-PSScriptInfo -FilePath $FilePath.FullName -ErrorAction SilentlyContinue) -and $Force)
        {
            # If PSScriptInfo exists and force is specified remove PSScriptInfo before adding new
            try
            {
                Remove-PSScriptInfo -FilePath $FilePath.FullName -ErrorAction Stop
                Write-Verbose -Message 'Successfully removed PSScriptInfo'
            }
            catch
            {
                throw ('Failed to remove PSScriptInfo from file with error: {0}' -f $_.exception.message)
            }
        }
    }

    PROCESS
    {
        # Try build json text
        try
        {
            $JSON = $Properties | ConvertTo-Json -ErrorAction Stop
        }
        catch
        {
            throw ('Failed to generate JSON object with error: {0}' -f $_.exception.message)
        }

        # Set PSScriptInfo
        try
        {
            Set-PSScriptInfo -FilePath $FilePath.FullName -JSON $JSON -ErrorAction Stop
        }
        catch
        {
            throw ('Failed to set PSScriptInfo with error: {0}' -f $_.exception.message)
        }
    }
}
#EndRegion '.\Public\Add-PSScriptInfo.ps1' 85
#Region '.\Public\Get-PSScriptInfo.ps1' -1

function Get-PSScriptInfo
{
    <#
        .DESCRIPTION
            Collect and parse psscriptinfo from file
        .PARAMETER FilePath
            Defines the path to the file from which to get psscriptinfo from.
        .EXAMPLE
            Get-PSScriptInfo -FilePath C:\temp\file.ps1
    #>


    [CmdletBinding()] # Enabled advanced function support
    param(
        [ValidateScript( { Test-Path -Path $_.FullName -PathType Leaf })]
        [Parameter(Mandatory)]
        [system.io.fileinfo]
        $FilePath
    )

    PROCESS
    {
        # Read ast tokens from file
        try
        {
            New-Variable astTokens -Force -ErrorAction Stop
            New-Variable astErr -Force -ErrorAction Stop
            $null = [System.Management.Automation.Language.Parser]::ParseFile($FilePath, [ref]$astTokens, [ref]$astErr)
            Write-Verbose -Message 'Read file content'
        }
        catch
        {
            throw "Failed to read file content with error: $PSItem"
        }

        # Find PSScriptInfo comment token
        $PSScriptInfoText = $astTokens.where{ $_.kind -eq 'comment' -and $_.text.Replace("`r", '').Split("`n")[0] -like '<#PSScriptInfo*' } | Select-Object -ExpandProperty text -ErrorAction stop
        Write-Verbose -Message 'Parsed powershell script file and extracted raw PSScriptInfoText'

        if (-not $PSScriptInfoText)
        {
            throw 'No PSScriptInfo found in file'
        }

        # Extract PSScriptInfo from JSON
        try
        {
            $PSScriptInfoRaw = @($PSScriptInfoText.Split("`n") | Select-Object -Skip 1 -ErrorAction Stop | Select-Object -SkipLast 1 -ErrorAction Stop)
            $PSScriptInfo = $PSScriptInfoRaw | ConvertFrom-Json -ErrorAction Stop
            Write-Verbose -Message 'Parsed PSScriptInfo to JSON'
        }
        catch
        {
            if (($PSScriptInfoRaw[0].Trim() -like '.*') -and ($_.exception.message -like '*Invalid JSON primitive*' -or $_.exception.message -like '*Unexpected character encountered while parsing number*'))
            {
                # Legacy PSScriptInfo
                Write-Verbose -Message 'Standard JSON parsing failed, trying legacy...'
                $PSScriptInfo = Get-PSScriptInfoLegacy -FilePath $FilePath
            }
            else
            {
                throw "Failed to parse PSScriptInfo to JSON with error: $PSItem"
            }
        }

        return $PSScriptInfo
    }
}
#EndRegion '.\Public\Get-PSScriptInfo.ps1' 68
#Region '.\Public\Remove-PSScriptInfo.ps1' -1

function Remove-PSScriptInfo
{
    <#
        .DESCRIPTION
            Removes a PSScriptInfo block from a script file
        .PARAMETER FilePath
            Path to file where PSScriptInfo block should be removed
        .EXAMPLE
            Remove-PSScriptInfo -FilePath C:\Script\file.ps1
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'No system state changed')]
    [CmdletBinding()] # Enabled advanced function support
    param(
        [ValidateScript( { Test-Path -Path $_.FullName -PathType Leaf })]
        [Parameter(Mandatory)]
        [system.io.fileinfo]
        $FilePath
    )

    PROCESS
    {

        # Read ast tokens from file
        try
        {
            New-Variable astTokens -Force -ErrorAction Stop
            New-Variable astErr -Force -ErrorAction Stop
            $null = [System.Management.Automation.Language.Parser]::ParseFile($FilePath, [ref]$astTokens, [ref]$astErr)
            Write-Verbose -Message 'Read file content'
        }
        catch
        {
            throw "Failed to read file content with error: $PSItem"
        }

        # Find PSScriptInfo comment token
        $PSScriptInfo = $astTokens.where{ $_.kind -eq 'comment' -and $_.text.Replace("`r", '').Split("`n")[0] -like '<#PSScriptInfo*' }
        Write-Verbose -Message 'Parsed powershell script file and extracted raw PSScriptInfoText'


        if (-not $PSScriptInfo)
        {
            throw 'No PSScriptInfo found in file'
        }

        $StartLine = $PSScriptInfo.extent.StartLineNumber
        $EndLine = $PSScriptInfo.extent.EndLineNumber

        # Read file
        try
        {
            $FileContent = Get-Content -Path $FilePath -ErrorAction Stop
            Write-Verbose -Message 'Collected file content'
        }
        catch
        {
            throw "Failed to read file content with error: $PSItem"
        }

        # Exclude PSScriptInfo
        $NewContent = @($FileContent | Select-Object -First ($StartLine - 1) -ErrorAction stop) + @($FileContent | Select-Object -Skip ($EndLine) -ErrorAction Stop)
        Write-Verbose -Message 'Concatinated content around removed PSScriptInfo'

        # Write content back to file
        try
        {
            $NewContent | Set-Content -Path $FilePath -ErrorAction Stop
            Write-Verbose -Message 'Wrote content to back to file'
        }
        catch
        {
            throw "Failed to write content back to file with error: $PSItem"
        }

        return ([pscustomobject]@{
                StartLine   = $StartLine
                EndLine     = $EndLine
                StartOffset = $PSScriptInfo.extent.StartOffset
                EndOffset   = $PSScriptInfo.extent.EndOffset
            })

    }
}
#EndRegion '.\Public\Remove-PSScriptInfo.ps1' 84
#Region '.\Public\Update-PSScriptInfo.ps1' -1

function Update-PSScriptInfo
{
    <#
        .DESCRIPTION
            Replaces PSScriptInfo settings. Properties defined the properties
            parameter that do not exist in the existing PSScriptInfo are added,
            already existing settings set to $null are removed and existing
            properties with a non-null value are updated.
        .PARAMETER FilePath
            File path to file to update PSScriptInfo in.
        .PARAMETER Properties
            Hashtable with properties to add,remove and change.
        .EXAMPLE
            Update-PSScriptInfo -Filepath C:\Script\Get-Test.ps1 -Properties @{Version="1.0.0.1";IsPreRelease=$null;IsReleased=$true}
 
            Assuming that the specified file contains a PSScriptInfo block with the properties Version:"0.0.1.4" and IsPreRelease="true" this example would
            - Update version
            - Remove IsPreRelease
            - Add IsReleased
 
            <#PSScriptInfo
            {
                "Version":"1.0.0.1",
                "IsReleased":"true"
            }
            PSScriptInfo#>

    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'No system state changed')]
    [CmdletBinding()] # Enabled advanced function support
    param(
        [ValidateScript( { Test-Path -Path $_.FullName -PathType Leaf })]
        [Parameter(Mandatory)]
        [system.io.fileinfo]
        $FilePath,

        [hashtable]
        $Properties
    )

    PROCESS
    {
        try
        {
            $PSScriptInfo = Get-PSScriptInfo -FilePath $FilePath -ErrorAction Stop
            Write-Verbose 'Found existing PSScriptInfo'
        }
        catch
        {
            throw "Could not collect existing PSScriptInfo to update. Error: $PSItem"
        }

        foreach ($key in $Properties.keys)
        {
            # Missing attribute, add
            if ($PSScriptInfo.PSObject.Properties.Name -notcontains $key)
            {
                $PSScriptInfo | Add-Member -Name $Key -MemberType NoteProperty -Value $Properties[$key]
            }
            # Existing attribute
            else
            {
                # Remove if property is set to null
                if ($null -eq $Properties[$key])
                {
                    $PSScriptInfo = $PSScriptInfo | Select-Object -Property * -ExcludeProperty $key
                }
                # Not null, update value
                else
                {
                    $PSScriptInfo.$Key = $Properties[$key]
                }
            }
        }

        try
        {
            $JSON = $PSScriptInfo | ConvertTo-Json -ErrorAction Stop
            Write-Verbose -Message 'Converted updated PSScriptInfo to JSON'
        }
        catch
        {
            throw 'Failed to convert new PSScriptInfo to JSON'
        }

        try
        {
            $RemovedPosition = Remove-PSScriptInfo -FilePath $FilePath -ErrorAction Stop
            Write-Verbose -Message 'Removed old PSScriptInfo from file'
        }
        catch
        {
            throw "Failed to remove old PSScriptInfo from file with error: $PSItem"
        }

        try
        {
            Set-PSScriptInfo -FilePath $FilePath -JSON $JSON -InsertAt $RemovedPosition.StartOffSet -ErrorAction Stop
            Write-Verbose -Message 'Added updated PSScriptInfo to file'
        }
        catch
        {
            throw "Failed to add updated PSScriptInfo to file with error: $PSItem"
        }
    }
}
#endregion
#EndRegion '.\Public\Update-PSScriptInfo.ps1' 108
#Region '.\suffix.ps1' -1

# The content of this file will be appended to the top of the psm1 module file. This is useful for custom procesedures after all module functions are loaded.
#EndRegion '.\suffix.ps1' 2