Files
pxe-server/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1
cproudlock 5fe7e7767f Install-FromManifest: PCTypes alias map for rename reorg
Phase 1 of the gea-shopfloor-* rename per project-shopfloor-rename-reorg.
Manifests can use either old names (Standard, Standard-Machine, CMM,
Keyence, etc.) or new names (gea-shopfloor-collections,
gea-shopfloor-cmm, gea-shopfloor-keyence, etc.) interchangeably.

Equivalence sets defined inline. Each set is a list of names that all
match the same identity. The match logic resolves the current PC's
identity AND each PCTypes entry into their alias sets, then matches
if the sets intersect.

Standard maps to all three new shopfloor variants (collections,
nocollections, common) so an existing PCTypes=['Standard'] manifest
entry still applies when PC pc-type.txt becomes any of the three.
Standard-Machine maps to (collections, nocollections) only since
Timeclock subtype is now collapsed under common.

Smoke-tested on win11 VM as SYSTEM via qga: dispatcher run with
PCType='gea-shopfloor-collections' against the existing common
manifest (Standard-only PCTypes filters) fires Oracle / FMS hosts pin
correctly. Same run with PCType='Standard' PCSubType='Machine' fires
identically.

Phases 3+4 (repo folder renames + startnet.cmd menu reorg) deferred to
the next session - high breakage risk, must ship atomically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:15:57 -04:00

620 lines
28 KiB
PowerShell

# 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.3 - PCTypes filter accepts old (Standard, Standard-Machine, CMM, ...)
# and new (gea-shopfloor-collections, gea-shopfloor-cmm, ...) names
# interchangeably via alias sets. Transitional for the rename reorg.
# 2.2 - added TargetMachineNumbers filter (reads C:\Enrollment\machine-number.txt
# then falls back to DNC registry HKLM\...\GE Aircraft Engines\DNC\General\MachineNo)
# 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 = 3
$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.
# ---------------------------------------------------------------------------
# PCTypes alias map for the 2026-05-03 rename reorg. Manifests may use
# either old names (Standard, Standard-Machine, CMM, etc.) or new names
# (gea-shopfloor-collections, gea-shopfloor-cmm, etc.). Each entry below
# is a set of names that all match the same identity. The match logic
# resolves the current PC's identity AND each PCTypes entry into their
# alias sets, then matches if the sets intersect. See
# project-shopfloor-rename-reorg memory for the full rename plan.
$script:_pcTypeAliasGroups = @(
@('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', 'gea-shopfloor-common'),
@('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
@('Standard-Timeclock', 'gea-shopfloor-common'),
@('CMM', 'gea-shopfloor-cmm'),
@('Keyence', 'gea-shopfloor-keyence'),
@('Lab', 'gea-shopfloor-common'),
@('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat')
)
# Returns every alias set (each itself a string array) that contains $name.
# Multiple groups can return the same name (e.g. "Standard" appears in the
# super-group covering all three new shopfloor variants AND has its own
# subtype-specific groups - by design, matches widely).
function Get-PCTypeAliasSets {
param([string]$Name)
$hits = @()
foreach ($g in $script:_pcTypeAliasGroups) {
foreach ($n in $g) {
if ($n -ieq $Name) { $hits += ,$g; break }
}
}
return ,$hits
}
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 }
# Build the set of strings the CURRENT PC matches: bare PCType,
# "Type-SubType", and every alias-set member of either.
$myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)
[void]$myNames.Add($Type)
if ($SubType) { [void]$myNames.Add("$Type-$SubType") }
foreach ($n in @($Type, "$Type-$SubType") | Where-Object { $_ }) {
foreach ($g in (Get-PCTypeAliasSets -Name $n)) {
foreach ($alias in $g) { [void]$myNames.Add($alias) }
}
}
foreach ($t in $App.PCTypes) {
if ($t -eq '*') { return $true }
if ($myNames.Contains($t)) { return $true }
# Manifest entry's PCTypes value may itself be an alias - expand it
# and check overlap with the PC's identity set.
foreach ($g in (Get-PCTypeAliasSets -Name $t)) {
foreach ($alias in $g) {
if ($myNames.Contains($alias)) { 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
}
# Machine-number filter. Stable identifier tied to the bay; survives PC
# replacement at the same machine. Source of truth = the value the tech
# entered at the PXE menu, persisted to C:\Enrollment\machine-number.txt
# by startnet.cmd. Falls back to the DNC registry if that file is missing
# (covers PCs that pre-date this filter being introduced).
$script:_cachedMachineNumber = $null
function Get-CurrentMachineNumber {
if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber }
$candidates = @(
'C:\Enrollment\machine-number.txt'
)
foreach ($p in $candidates) {
if (Test-Path -LiteralPath $p) {
$v = (Get-Content -LiteralPath $p -ErrorAction SilentlyContinue | Select-Object -First 1)
if ($v) { $script:_cachedMachineNumber = $v.Trim(); return $script:_cachedMachineNumber }
}
}
foreach ($r in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
if (Test-Path $r) {
$p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue
if ($p.MachineNo) { $script:_cachedMachineNumber = [string]$p.MachineNo; return $script:_cachedMachineNumber }
}
}
$script:_cachedMachineNumber = ''
return ''
}
function Test-MachineNumberMatches {
param($App)
if (-not $App.TargetMachineNumbers -or $App.TargetMachineNumbers.Count -eq 0) { return $true }
$myNumber = Get-CurrentMachineNumber
if (-not $myNumber) { return $false } # entry restricts by machine #, but PC has no machine # -> exclude
foreach ($n in $App.TargetMachineNumbers) {
if ([string]$n -ieq $myNumber) { return $true }
}
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 (-not (Test-MachineNumberMatches -App $app)) {
$myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - 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