Files
MBN_STOCK_WEBVIEW/scripts/New-LegacyPgmPreparePlan.ps1

403 lines
22 KiB
PowerShell

#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $SpecificationPath,
[Parameter(Mandatory = $true)]
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$utf8NoBom = [Text.UTF8Encoding]::new($false, $true)
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1'
$nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'
$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'))
$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git'
$expectedUrl = 'https://legacy-parity.mbn.local/index.html'
$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'
$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s'
$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix'
$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367'
$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json'
$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8'))
$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q'))
$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA='))
$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA'))
function Fail([string] $Message) {
throw [InvalidOperationException]::new($Message)
}
function FullPath([string] $Path, [string] $Name) {
if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) {
Fail "$Name must be an absolute path."
}
return [IO.Path]::GetFullPath($Path)
}
function SamePath([string] $Left, [string] $Right) {
return [string]::Equals(
[IO.Path]::GetFullPath($Left).TrimEnd('\'),
[IO.Path]::GetFullPath($Right).TrimEnd('\'),
[StringComparison]::OrdinalIgnoreCase)
}
function Assert-NoReparse([string] $Path, [string] $Name) {
$current = [IO.Path]::GetFullPath($Path)
while (-not [string]::IsNullOrWhiteSpace($current)) {
if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) {
if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
Fail "$Name contains a reparse point."
}
}
$parent = [IO.Directory]::GetParent($current)
if ($null -eq $parent) { break }
$current = $parent.FullName
}
}
function Read-Json([string] $Path, [string] $Name) {
$full = FullPath $Path $Name
if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
Assert-NoReparse $full $Name
$bytes = [IO.File]::ReadAllBytes($full)
try {
if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." }
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
Fail "$Name must be UTF-8 without BOM."
}
try { $text = $utf8NoBom.GetString($bytes) }
catch { Fail "$Name is not strict UTF-8." }
try { return $text | ConvertFrom-Json -ErrorAction Stop }
catch { Fail "$Name is not valid JSON." }
}
finally { [Array]::Clear($bytes, 0, $bytes.Length) }
}
function Require([object] $Object, [string] $Name, [string] $Context) {
if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) {
Fail "$Context.$Name is required."
}
return $Object.$Name
}
function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) {
if ($null -eq $Object) { Fail "$Context is required." }
$actual = @($Object.PSObject.Properties.Name)
if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." }
foreach ($name in $Expected) {
if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) {
Fail "$Context property set is not exact: $name."
}
}
}
function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) {
if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." }
}
function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) {
if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." }
}
function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) {
if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." }
}
function New-FilePin([string] $Path, [string] $Name) {
$full = FullPath $Path $Name
if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
Assert-NoReparse $full $Name
$item = Get-Item -LiteralPath $full -Force
return [ordered]@{
path = $full
length = [int64]$item.Length
sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash
}
}
function Get-RuntimeTreePin([string] $Root) {
$fullRoot = FullPath $Root 'package.installLocation'
if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' }
Assert-NoReparse $fullRoot 'package.installLocation'
foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) {
Assert-NoReparse $directory 'package runtime directory'
}
$files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' }
$relative = New-Object 'Collections.Generic.List[string]'
$rootPrefix = $fullRoot.TrimEnd('\') + '\'
foreach ($file in $files) {
Assert-NoReparse $file 'package runtime file'
if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
Fail 'A package runtime file escaped the install root.'
}
$name = $file.Substring($rootPrefix.Length).Replace('\', '/')
if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) {
Fail 'A package runtime relative path cannot be represented safely.'
}
[void]$relative.Add($name)
}
$names = $relative.ToArray()
[Array]::Sort($names, [StringComparer]::Ordinal)
$builder = [Text.StringBuilder]::new()
foreach ($name in $names) {
$file = Join-Path $fullRoot $name.Replace('/', '\')
$item = Get-Item -LiteralPath $file -Force
$hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
[void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n")
}
$manifestBytes = $utf8NoBom.GetBytes($builder.ToString())
try {
$algorithm = [Security.Cryptography.SHA256]::Create()
try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') }
finally { $algorithm.Dispose() }
}
finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) }
return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest }
}
function Assert-GateConfig([string] $Path, [string] $SceneDirectory) {
$config = Read-Json $Path 'configuration.gate'
$names = @(
'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex',
'legacySceneFadeDuration','legacySceneBackgroundKind',
'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount',
'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory',
'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist',
'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds',
'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds',
'processPollIntervalMilliseconds','reconnectDelayMilliseconds',
'maximumReconnectAttempts','reconnectEnabled',
'maximumAutomaticRefreshesPerTakeIn')
Assert-ExactProperties $config $names 'configuration.gate JSON'
if ($config.testSceneAllowlist -isnot [Array]) {
Fail 'configuration.gate.testSceneAllowlist must be a JSON array.'
}
$allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON')
Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode'
Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host'
Assert-JsonInt $config.port 30001 'configuration.gate.port'
Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode'
Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort'
Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex'
Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration'
Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind'
Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount'
Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite'
Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled'
Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity'
Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds'
Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds'
Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds'
Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds'
Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds'
Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts'
Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled'
Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn'
if (
$null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or
$null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or
$null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or
-not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or
$null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or
$allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') {
Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.'
}
}
$specificationPath = FullPath $SpecificationPath 'SpecificationPath'
$outputPath = FullPath $OutputPath 'OutputPath'
if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.'
}
if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) {
Fail 'OutputPath already exists; a frozen plan is never overwritten.'
}
if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' }
if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) {
Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.'
}
$spec = Read-Json $specificationPath 'SpecificationPath'
Assert-ExactProperties $spec @(
'schemaVersion','roundId','repository','package','automation','configuration',
'cut','k3d','pgm','webView') 'specification'
Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion'
$roundId = [string](Require $spec 'roundId' 'specification')
if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' }
$repo = Require $spec 'repository' 'specification'
Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository'
if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or
[string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or
[string]$repo.remoteUrl -cne $expectedRemote -or
[string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' }
$package = Require $spec 'package' 'specification'
Assert-ExactProperties $package @(
'name','familyName','fullName','publisher','version','architecture',
'applicationId','installLocation','executablePath','msixPath') 'package'
if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or
[string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or
[string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or
[string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or
[string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') {
Fail 'package identity is not the exact LegacyParity Release x64 package.'
}
$installLocation = FullPath ([string]$package.installLocation) 'package.installLocation'
$expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
if (-not (SamePath $installLocation $expectedInstallLocation)) {
Fail 'package.installLocation is not the exact Release x64 output.'
}
$executablePath = FullPath ([string]$package.executablePath) 'package.executablePath'
if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) {
Fail 'package executable is outside the exact install root.'
}
$msixPath = FullPath ([string]$package.msixPath) 'package.msixPath'
if (-not (SamePath $msixPath $approvedMsixPath)) {
Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.'
}
if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) {
Fail 'The approved Release x64 MSIX SHA-256 changed.'
}
$automation = Require $spec 'automation' 'specification'
Assert-ExactProperties $automation @('nodePath') 'automation'
if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) {
Fail 'automation.nodePath is not the approved bundled Node runtime.'
}
$configuration = Require $spec 'configuration' 'specification'
Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration'
$originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath'
$gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath'
$sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory'
if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) {
Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.'
}
if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' }
Assert-GateConfig $gateConfigPath $sceneDirectory
$cut = Require $spec 'cut' 'specification'
Assert-ExactProperties $cut @('path') 'cut'
$cutPath = FullPath ([string]$cut.path) 'cut.path'
if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' }
if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) {
Fail 'The approved original 5001.t2s SHA-256 changed.'
}
$k3d = Require $spec 'k3d' 'specification'
Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d'
$pgm = Require $spec 'pgm' 'specification'
Assert-ExactProperties $pgm @(
'processId','startTimeUtc','processName','executablePath','windowTitle',
'listenerAddress','host','port') 'pgm'
if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or
[string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or
[string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' }
$webView = Require $spec 'webView' 'specification'
Assert-ExactProperties $webView @('address','port','url') 'webView'
if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or
[int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' }
$plan = [ordered]@{
schemaVersion = 1
kind = 'LegacyPgmPrepareGatePlan'
roundId = $roundId
createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture)
repository = [ordered]@{
path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit
upstream = 'origin/main'; remoteUrl = $expectedRemote
}
package = [ordered]@{
name = [string]$package.name; familyName = [string]$package.familyName
fullName = [string]$package.fullName; publisher = [string]$package.publisher
version = [string]$package.version; architecture = 'X64'
applicationId = [string]$package.applicationId; installLocation = $installLocation
executable = New-FilePin $executablePath 'package executable'
msix = New-FilePin $msixPath 'package MSIX'
runtimeTree = Get-RuntimeTreePin $installLocation
}
automation = [ordered]@{
orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator'
node = New-FilePin ([string]$automation.nodePath) 'Node runtime'
helper = New-FilePin $nodeHelperPath 'Node Gate helper'
}
configuration = [ordered]@{
original = New-FilePin $originalConfigPath 'original DryRun config'
gate = New-FilePin $gateConfigPath 'Gate DryRun config'
database = New-FilePin $approvedDatabaseConfigPath 'database config'
sceneDirectory = $sceneDirectory
}
cut = [ordered]@{
sceneCode = '5001'; pageNumberOneBased = 1
file = New-FilePin $cutPath '5001 cut'
}
selector = [ordered]@{
marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName
stockCode = '005930'; cutLabel = $expectedCutLabel
graphicType = $expectedGraphicType; subtype = $expectedSubtype
normalizedSubtype = 'CURRENT'; sceneAlias = '5001'
}
k3d = [ordered]@{
native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary'
interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly'
}
pgm = [ordered]@{
processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc
processName = [string]$pgm.processName
executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable'
windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001
}
webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl }
approvalPolicy = [ordered]@{
requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5
}
budgets = [ordered]@{
connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0
takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0
}
safety = [ordered]@{
automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1
nodeHelperFailStopRequired = $true
packageWrapperFailStopRequired = $true
persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true
nativeOneShotPrepareGateRequired = $true
leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true
automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true
}
}
$parent = [IO.Path]::GetDirectoryName($outputPath)
if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' }
[void][IO.Directory]::CreateDirectory($parent)
Assert-NoReparse $parent 'OutputPath parent'
$json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine
$bytes = $utf8NoBom.GetBytes($json)
try {
$stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) }
finally { $stream.Dispose() }
}
finally { [Array]::Clear($bytes, 0, $bytes.Length) }
[pscustomobject][ordered]@{
result = 'PLAN_CREATED_COMMAND_FREE'
roundId = $roundId
planPath = $outputPath
planLength = (Get-Item -LiteralPath $outputPath).Length
planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash
authorizationCreated = $false
appCommandsIssued = 0
vendorCommandsIssued = 0
}