# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps. # # Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and # Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds # optional PCTypes filtering (entry skipped if current pc-type.txt doesn't # match any value in the entry's PCTypes array). # # Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them): # ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as # immediate-apply today; Stage 2b adds shift-window gating and # close-and-reopen behavior. # # Consumers: # - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with # both logon and periodic triggers) # - Legacy *-Enforce.ps1 that still point here (will be retired) # # Exit codes: # 0 = every required entry either already satisfied or installed successfully # 1 = at least one entry failed # 2 = manifest or installer-root could not be read param( [Parameter(Mandatory=$true)] [string]$ManifestPath, [Parameter(Mandatory=$true)] [string]$InstallerRoot, [Parameter(Mandatory=$true)] [string]$LogFile, [Parameter(Mandatory=$false)] [string]$PCType, [Parameter(Mandatory=$false)] [string]$PCSubType ) $ErrorActionPreference = 'Continue' # Lib's supported manifest schema version. Bump the MAJOR part whenever a # manifest field or behavior changes in a way older libs can't handle. # Bump the MINOR part for additive, backward-compatible additions (new # optional field, new Type, new DetectionMethod). Manifests tagged with # a newer MAJOR than the lib get processed best-effort with unknown Types # logged; manifests tagged with a newer MINOR are fine. # # Changelog: # 2.1 - added TargetHostnames filter (exact + -like wildcards) # 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types, # Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter $LIB_MANIFEST_MAJOR = 2 $LIB_MANIFEST_MINOR = 1 $logDir = Split-Path -Parent $LogFile if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null } function Write-InstallLog { param( [Parameter(Mandatory=$true, Position=0)] [string]$Message, [Parameter(Position=1)] [ValidateSet('INFO','WARN','ERROR')] [string]$Level = 'INFO' ) $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $line = "[$stamp] [$Level] $Message" Write-Host $line try { $fs = New-Object System.IO.FileStream( $LogFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read, 4096, [System.IO.FileOptions]::WriteThrough ) $bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n") $fs.Write($bytes, 0, $bytes.Length) $fs.Flush() $fs.Dispose() } catch { Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue } } Write-InstallLog '================================================================' Write-InstallLog "=== Install-FromManifest session start (PID $PID) ===" Write-InstallLog "Manifest: $ManifestPath" Write-InstallLog "InstallerRoot: $InstallerRoot" if ($PCType) { Write-InstallLog "PCType: $PCType" } if ($PCSubType) { Write-InstallLog "PCSubType: $PCSubType" } Write-InstallLog '================================================================' if (-not (Test-Path -LiteralPath $ManifestPath)) { Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR' exit 2 } if (-not (Test-Path -LiteralPath $InstallerRoot)) { Write-InstallLog "InstallerRoot not found: $InstallerRoot" 'ERROR' exit 2 } try { $config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json } catch { Write-InstallLog "Failed to parse manifest: $_" 'ERROR' exit 2 } if (-not $config.Applications -or $config.Applications.Count -eq 0) { Write-InstallLog 'No Applications in manifest - nothing to do' exit 0 } # --------------------------------------------------------------------------- # Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest # declares a MAJOR higher than the lib supports, log a clear warning so the # operator sees "upgrade the lib on this PC" instead of weird silent # skip-everything behavior. The lib still tries to run (unknown Types will # log as ERROR and count as failed, but supported entries work). # --------------------------------------------------------------------------- $manifestMajor = 0 $manifestMinor = 0 if ($config.Version) { $parts = "$($config.Version)".Split('.') if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] } if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] } } if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) { Write-InstallLog " Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN' } elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) { Write-InstallLog " Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO' } Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)" # --------------------------------------------------------------------------- # Detection # --------------------------------------------------------------------------- function Test-AppInstalled { param($App) if (-not $App.DetectionMethod) { return $false } try { switch ($App.DetectionMethod) { 'Registry' { if (-not (Test-Path $App.DetectionPath)) { return $false } if ($App.DetectionName) { $value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue if (-not $value) { return $false } if ($App.DetectionValue) { return ($value.$($App.DetectionName) -eq $App.DetectionValue) } return $true } return $true } 'File' { return Test-Path $App.DetectionPath } 'FileVersion' { if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionValue) { Write-InstallLog ' FileVersion detection requires DetectionValue - treating as not installed' 'WARN' return $false } $actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion if (-not $actual) { return $false } return ($actual -eq $App.DetectionValue) } 'Hash' { if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionValue) { Write-InstallLog ' Hash detection requires DetectionValue - treating as not installed' 'WARN' return $false } $actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash return ($actual -ieq $App.DetectionValue) } 'MarkerFile' { # Used for one-shot PS1 scripts. Presence of the marker file # means the script already ran successfully. The lib writes # the marker automatically after a 0 exit on PS1/BAT/CMD. if (-not $App.DetectionPath) { return $false } return Test-Path $App.DetectionPath } 'ValueMatches' { # For Type=Registry entries. Check that the registry value # equals the desired RegValue (read from the app entry). if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not $App.DetectionName) { Write-InstallLog ' ValueMatches detection requires DetectionName' 'WARN' return $false } $p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue if (-not $p) { return $false } return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)") } 'pnputil' { # For INF drivers. Run pnputil /enum-drivers once and grep. if (-not $App.DetectionPattern) { Write-InstallLog ' pnputil detection requires DetectionPattern' 'WARN' return $false } $drivers = & pnputil /enum-drivers 2>&1 | Out-String return ($drivers -match $App.DetectionPattern) } 'Always' { # Never considered installed - entry runs every cycle. # Useful for idempotent scripts like the VNC firewall rule. return $false } default { Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN' return $false } } } catch { Write-InstallLog " Detection check threw: $_" 'WARN' return $false } } # --------------------------------------------------------------------------- # Action dispatch # --------------------------------------------------------------------------- function Invoke-InstallerAction { param($App) $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden switch ($App.Type) { 'MSI' { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " MSI not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $safeName = $App.Name -replace '[^a-zA-Z0-9]','_' $msiLog = Join-Path $logDir "msi-$safeName.log" if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue } $psi.FileName = 'msiexec.exe' $psi.Arguments = "/i `"$installerPath`"" if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs } $psi.Arguments += " /L*v `"$msiLog`"" Write-InstallLog " msiexec: $installerPath" Write-InstallLog " verbose log: $msiLog" $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog } } 'EXE' { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " EXE not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $psi.FileName = $installerPath if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs } Write-InstallLog " exe: $installerPath" if ($App.InstallArgs) { Write-InstallLog " args: $($App.InstallArgs)" } $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile } } { $_ -eq 'CMD' -or $_ -eq 'BAT' } { $installerPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $psi.FileName = 'cmd.exe' $psi.Arguments = "/c `"$installerPath`"" if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs } Write-InstallLog " cmd /c $installerPath" $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile } } 'PS1' { $scriptPath = Join-Path $InstallerRoot ($App.Script) if (-not (Test-Path -LiteralPath $scriptPath)) { Write-InstallLog " PS1 not found: $scriptPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $psi.FileName = 'powershell.exe' $psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`"" if ($App.Args) { $psi.Arguments += " " + $App.Args } Write-InstallLog " ps1: $scriptPath" if ($App.Args) { Write-InstallLog " args: $($App.Args)" } $proc = [System.Diagnostics.Process]::Start($psi) $proc.WaitForExit() return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null } } 'INF' { $infPath = Join-Path $InstallerRoot $App.Installer if (-not (Test-Path -LiteralPath $infPath)) { Write-InstallLog " INF not found: $infPath" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } Write-InstallLog " pnputil /add-driver `"$infPath`" /install" $out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String Write-InstallLog " pnputil output: $($out.Trim())" # pnputil exits 0 on success, 259 if already installed, etc. return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null } } 'File' { # Copy a file from the share (configs/*) to an absolute on-PC path. $source = Join-Path $InstallerRoot $App.Source if (-not (Test-Path -LiteralPath $source)) { Write-InstallLog " File source not found: $source" 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } if (-not $App.Destination) { Write-InstallLog ' File entry missing Destination' 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } $destDir = Split-Path -Parent $App.Destination if ($destDir -and -not (Test-Path $destDir)) { New-Item -Path $destDir -ItemType Directory -Force | Out-Null } try { Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop Write-InstallLog " copied $source -> $($App.Destination)" return [pscustomobject]@{ ExitCode = 0; LogRef = $null } } catch { Write-InstallLog " Copy failed: $_" 'ERROR' return [pscustomobject]@{ ExitCode = 1; LogRef = $null } } } 'Registry' { # Write a single registry value. if (-not $App.RegPath -or -not $App.RegName) { Write-InstallLog ' Registry entry missing RegPath/RegName' 'ERROR' return [pscustomobject]@{ ExitCode = -1; LogRef = $null } } if (-not (Test-Path $App.RegPath)) { New-Item -Path $App.RegPath -Force | Out-Null } $type = if ($App.RegType) { $App.RegType } else { 'String' } try { Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop Write-InstallLog " set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)" return [pscustomobject]@{ ExitCode = 0; LogRef = $null } } catch { Write-InstallLog " Registry write failed: $_" 'ERROR' return [pscustomobject]@{ ExitCode = 1; LogRef = $null } } } default { Write-InstallLog " Unsupported Type: $($App.Type)" 'ERROR' return [pscustomobject]@{ ExitCode = -2; LogRef = $null } } } } # --------------------------------------------------------------------------- # Entry-applies filter. An entry applies to this PC only if: # - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND # - TargetHostnames is omitted OR matches current COMPUTERNAME # (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*"). # Both filters are ANDed so they compose: scope to a type AND a hostname # subset, or either alone. Case-insensitive throughout. # --------------------------------------------------------------------------- function Test-PCTypeMatches { param($App, [string]$Type, [string]$SubType) if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true } if (-not $Type) { return $true } foreach ($t in $App.PCTypes) { if ($t -eq '*') { return $true } if ($t -eq $Type) { return $true } if ($SubType -and $t -eq "$Type-$SubType") { return $true } } return $false } function Test-HostnameMatches { param($App) if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true } # [System.Environment]::MachineName reads the live NetBIOS name from the # kernel. $env:COMPUTERNAME is cached in the process environment at PS # startup and is stale after a PC rename until the next reboot - which # matters on Intune-managed PCs that get renamed post-imaging. $myName = [System.Environment]::MachineName foreach ($h in $App.TargetHostnames) { if ($h -ieq $myName) { return $true } if ($myName -ilike $h) { return $true } # glob patterns: WJS-*, *-SHOP-* } return $false } # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- $installed = 0 $skipped = 0 $failed = 0 $pcFiltered = 0 foreach ($app in $config.Applications) { # Cancel any reboot that a prior MSI queued, so the enforcer never # triggers an unexpected restart on a shopfloor PC. cmd /c 'shutdown /a 2>nul' *>$null Write-InstallLog "==> $($app.Name)" if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) { Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping" $pcFiltered++ continue } if (-not (Test-HostnameMatches -App $app)) { Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping" $pcFiltered++ continue } if (Test-AppInstalled -App $app) { Write-InstallLog ' Already installed at expected version - skipping' $skipped++ continue } # --- InUseCheck (partial Stage 2b): ForceClose / CloseAndReopen --- # Before install, if the entry declares processes that would block the # install, close them. Stage 2a supports ForceClose: polite WM_CLOSE # with a timeout then hard Kill. "CloseAndReopen" is currently treated # the same (Stage 2b will add the user-session relaunch trick). No # reopen happens today; operator relaunches the app or the enforcer # leaves it for Windows Explorer / Start-Menu shortcut behavior. if ($app.InUseCheck -and $app.InUseCheck.Behavior -in @('ForceClose','CloseAndReopen')) { foreach ($p in ($app.InUseCheck.Processes | Where-Object { $_ })) { $procs = Get-Process -Name $p.Name -ErrorAction SilentlyContinue foreach ($proc in $procs) { $timeout = if ($p.GracefulCloseTimeoutSec) { [int]$p.GracefulCloseTimeoutSec } else { 10 } try { Write-InstallLog " InUseCheck: $($p.Name) (PID $($proc.Id)) asked to close (timeout ${timeout}s)" $null = $proc.CloseMainWindow() if (-not $proc.WaitForExit([int]($timeout * 1000))) { Write-InstallLog " InUseCheck: $($p.Name) did not exit gracefully - killing" 'WARN' $proc.Kill() $proc.WaitForExit(5000) | Out-Null } } catch { Write-InstallLog " InUseCheck: close/kill of $($p.Name) threw: $_" 'WARN' } } } } $result = Invoke-InstallerAction -App $app $rc = $result.ExitCode if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) { Write-InstallLog " Exit $rc - SUCCESS" if ($rc -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" } if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" } if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' } $installed++ # Auto-write marker file for MarkerFile-detected entries that just # completed successfully. Keeps one-shot PS1 scripts from running # twice (idempotent scripts can skip this by using Always detection). if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) { $markerDir = Split-Path -Parent $app.DetectionPath if ($markerDir -and -not (Test-Path $markerDir)) { New-Item -Path $markerDir -ItemType Directory -Force | Out-Null } try { Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop Write-InstallLog " marker written: $($app.DetectionPath)" } catch { Write-InstallLog " marker write failed: $_" 'WARN' } } } else { Write-InstallLog " Exit $rc - FAILED" 'ERROR' if ($result.LogRef -and (Test-Path $result.LogRef)) { if ($app.Type -eq 'MSI') { Write-InstallLog " --- meaningful lines from $($result.LogRef) ---" $patterns = @( 'Note: 1: ', 'return value 3', 'Error \d+\.', 'CustomAction .* returned actual error', 'Failed to ', 'Installation failed', '1: 2262', '1: 2203', '1: 2330' ) $regex = ($patterns -join '|') $matches = Select-String -Path $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30 if ($matches) { foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" } } else { Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } } Write-InstallLog ' --- end MSI log scan ---' } else { Write-InstallLog " --- last 30 lines of $($result.LogRef) ---" Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } Write-InstallLog ' --- end installer log tail ---' } } $failed++ } } Write-InstallLog '============================================' Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered" Write-InstallLog '============================================' cmd /c 'shutdown /a 2>nul' *>$null if ($failed -gt 0) { exit 1 } exit 0