# Install-FromManifest.ps1 - Generic JSON-manifest installer for CMM apps. # # Duplicated (by design, for now) from Shopfloor\00-PreInstall-MachineApps.ps1 # with the Standard-PC-specific bits stripped out: # - no PCTypes filter (every CMM manifest entry is CMM-only) # - no site-name / machine-number placeholder replacement # - no KillAfterDetection shortcut (Hexagon Burn bundles exit cleanly) # # A future pass will unify both runners behind one library; keeping them # separate now avoids touching the Standard PC imaging path. # # Called from: # - 09-Setup-CMM.ps1 at imaging time with InstallerRoot=C:\CMM-Install # - CMM-Enforce.ps1 on logon with InstallerRoot= # # Returns via exit code: 0 if every required app is either already installed # or installed successfully; non-zero if any install failed. param( [Parameter(Mandatory=$true)] [string]$ManifestPath, [Parameter(Mandatory=$true)] [string]$InstallerRoot, [Parameter(Mandatory=$true)] [string]$LogFile ) $ErrorActionPreference = 'Continue' $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 # Synchronous write-through so each line hits disk immediately, mirroring # the preinstall runner's approach - protects forensic trail if an installer # triggers a reboot mid-loop. 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" 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) { Write-InstallLog "No Applications in manifest - nothing to do" exit 0 } Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)" # Detection helper - mirrors the preinstall runner's logic. Registry path + # optional value name + optional exact value. The exact-value compare is how # version-pinned drift detection works: bumping DetectionValue in the manifest # makes the current install "fail" detection and reinstall. 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" { # Compare a file's VersionInfo.FileVersion against the # manifest's expected value. Used for version-pinned MSI/EXE # installs where existence alone doesn't tell you whether # the right release is on disk. Exact string match - the # manifest must carry the exact version the vendor stamps # into the binary. 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" { # Compare SHA256 of the on-disk file against the manifest's # expected value. Used for content-versioned files that do not # expose a DisplayVersion (secrets like eMxInfo.txt). Bumping # DetectionValue in the manifest and replacing the file on the # share is the entire update workflow. 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) } default { Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN" return $false } } } catch { Write-InstallLog " Detection check threw: $_" "WARN" return $false } } $installed = 0 $skipped = 0 $failed = 0 foreach ($app in $config.Applications) { # Cancel any pending reboot scheduled by a previous installer, same as the # preinstall runner. Some Burn bundles schedule a reboot even with /norestart # (chained bootstrapper ignores the flag for some internal prereqs). cmd /c "shutdown /a 2>nul" *>$null Write-InstallLog "==> $($app.Name)" if (Test-AppInstalled -App $app) { Write-InstallLog " Already installed at expected version - skipping" $skipped++ continue } $installerPath = Join-Path $InstallerRoot $app.Installer if (-not (Test-Path -LiteralPath $installerPath)) { Write-InstallLog " Installer file not found: $installerPath" "ERROR" $failed++ continue } Write-InstallLog " Installing from $installerPath" if ($app.InstallArgs) { Write-InstallLog " InstallArgs: $($app.InstallArgs)" } try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden if ($app.Type -eq "MSI") { $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 verbose log: $msiLog" } elseif ($app.Type -eq "EXE") { $psi.FileName = $installerPath if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs } if ($app.LogFile) { Write-InstallLog " Installer log: $($app.LogFile)" } } else { Write-InstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR" $failed++ continue } # Use Process.Start directly rather than Start-Process because PS 5.1's # Start-Process -PassThru disposes the process handle when control returns, # making ExitCode read as $null. Direct Process.Start gives us a live handle. $proc = [System.Diagnostics.Process]::Start($psi) # No per-app timeout here - PC-DMIS bundles can run 20+ minutes on slow # disks and we don't want to kill them mid-chain. The calling script # controls overall session timing. $proc.WaitForExit() $exitCode = $proc.ExitCode # Burn and MSI exit codes: # 0 success # 1641 success, reboot initiated # 3010 success, reboot pending if ($exitCode -eq 0 -or $exitCode -eq 1641 -or $exitCode -eq 3010) { Write-InstallLog " Exit code $exitCode - SUCCESS" if ($exitCode -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" } if ($exitCode -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" } $installed++ } else { Write-InstallLog " Exit code $exitCode - FAILED" "ERROR" if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) { Write-InstallLog " --- last 30 lines of $($app.LogFile) ---" Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } Write-InstallLog " --- end installer log tail ---" } if ($app.Type -eq "MSI" -and $msiLog -and (Test-Path $msiLog)) { Write-InstallLog " --- meaningful lines from $msiLog ---" $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 $msiLog -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30 if ($matches) { foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" } } else { Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" } } Write-InstallLog " --- end MSI log scan ---" } $failed++ } } catch { Write-InstallLog " Install threw: $_" "ERROR" $failed++ } } Write-InstallLog "============================================" Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed" Write-InstallLog "============================================" cmd /c "shutdown /a 2>nul" *>$null if ($failed -gt 0) { exit 1 } exit 0