Three optimization batches from the pipeline audit:
1. Shared Update-MachineNumber.ps1 helper (lib/)
Extracts duplicated machine-number update logic from Configure-PC.ps1,
Check-MachineNumber.ps1, and Set-MachineNumber.ps1 into a shared
dot-sourceable helper at Shopfloor/lib/Update-MachineNumber.ps1.
Exports:
Get-CurrentMachineNumber → @{ Udc = $string; Ednc = $string }
Update-MachineNumber -NewNumber <n> [-Site <s>] → @{ UdcUpdated; EdncUpdated; Errors }
All three consumers now dot-source the helper instead of duplicating
~50 lines each. Set-MachineNumber.ps1 also migrated from inline
Get-SiteConfig to dot-sourcing Get-PCProfile.ps1 for consistency.
2. Site-config integration for remaining scripts
Setup-OpenText.ps1: exclude lists (profiles + shortcuts) now read from
site-config.json opentext section, falling back to West Jefferson
defaults. Inline Get-SiteConfig since the script runs from
C:\PreInstall\installers\opentext\ (can't dot-source Get-PCProfile).
00-PreInstall-MachineApps.ps1: after parsing preinstall.json, scans
InstallArgs for "West Jefferson" and replaces with site-config
siteName if different. Inline Get-SiteConfig for same reason.
3. Placeholder type-specific directories
Created skeleton 01-Setup-*.ps1 scripts for all PC types so the
directory structure is in place and Run-ShopfloorSetup's type-specific
loop has something to iterate over:
Genspect/01-Setup-Genspect.ps1
Keyence/01-Setup-Keyence.ps1
WaxAndTrace/01-Setup-WaxAndTrace.ps1
Lab/01-Setup-Lab.ps1
Each logs a "no type-specific apps configured yet" banner and exits.
Fill in app installs when details are finalized; for share-based
installs, copy the CMM/01-Setup-CMM.ps1 pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
402 lines
17 KiB
PowerShell
402 lines
17 KiB
PowerShell
# 00-PreInstall-MachineApps.ps1 - Install pre-staged apps from C:\PreInstall (Shopfloor baseline)
|
|
#
|
|
# Reads C:\PreInstall\preinstall.json (staged by startnet.cmd during WinPE phase) and
|
|
# installs each app whose PCTypes filter matches the current PC type. Detection runs first
|
|
# so already-installed apps are skipped. Numbered 00- so it runs before all other Shopfloor
|
|
# baseline scripts - apps need to exist before later scripts (e.g. 01-eDNC.ps1) reference
|
|
# them or rely on their state.
|
|
#
|
|
# Failure mode: log + continue. Intune DSC (Simple-Install.ps1) is the safety net for any
|
|
# install that fails here.
|
|
#
|
|
# Logs to C:\Logs\PreInstall\install.log (wiped each run).
|
|
|
|
$preInstallDir = "C:\PreInstall"
|
|
$jsonPath = Join-Path $preInstallDir "preinstall.json"
|
|
$installerDir = Join-Path $preInstallDir "installers"
|
|
$logDir = "C:\Logs\PreInstall"
|
|
$logFile = Join-Path $logDir "install.log"
|
|
|
|
# --- Inline site-config reader (same pattern as Setup-OpenText.ps1; this script
|
|
# runs from C:\Enrollment\shopfloor-setup\Shopfloor\ but Get-PCProfile.ps1 may
|
|
# not be available yet, so keep the lookup self-contained) ---
|
|
function Get-SiteConfig {
|
|
$configPath = 'C:\Enrollment\site-config.json'
|
|
if (-not (Test-Path $configPath)) { return $null }
|
|
try {
|
|
return Get-Content $configPath -Raw | ConvertFrom-Json
|
|
} catch {
|
|
return $null
|
|
}
|
|
}
|
|
|
|
# --- Setup logging ---
|
|
# IMPORTANT: do NOT wipe the log on each run. The runner can get killed by an
|
|
# installer-triggered reboot mid-execution. On the next autologon, the dispatcher
|
|
# re-runs us - if we wipe the log here, we destroy the forensic record from the
|
|
# previous (interrupted) run. Instead, append a session header so runs are visible
|
|
# but history is preserved.
|
|
if (-not (Test-Path $logDir)) {
|
|
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
|
}
|
|
|
|
function Write-PreInstallLog {
|
|
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, even if a system
|
|
# reboot kills the process right after this call returns. Bypasses the OS write
|
|
# cache via FileOptions.WriteThrough.
|
|
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 {
|
|
# Last-resort fallback if the FileStream open fails for any reason
|
|
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# Session header so multiple runs are visually distinguishable in the log
|
|
Write-PreInstallLog "================================================================"
|
|
Write-PreInstallLog "=== Runner session start (PID $PID) ==="
|
|
Write-PreInstallLog "================================================================"
|
|
|
|
# --- Bail early if no preinstall bundle staged ---
|
|
if (-not (Test-Path $jsonPath)) {
|
|
Write-PreInstallLog "No preinstall.json at $jsonPath - skipping (no bundle staged for this PC)."
|
|
exit 0
|
|
}
|
|
|
|
# --- Read PCTYPE from C:\Enrollment\pc-type.txt (set by startnet.cmd) ---
|
|
$pcTypeFile = "C:\Enrollment\pc-type.txt"
|
|
if (-not (Test-Path $pcTypeFile)) {
|
|
Write-PreInstallLog "No pc-type.txt at $pcTypeFile - skipping" "WARN"
|
|
exit 0
|
|
}
|
|
$pcType = (Get-Content $pcTypeFile -First 1).Trim()
|
|
if (-not $pcType) {
|
|
Write-PreInstallLog "pc-type.txt is empty - skipping" "WARN"
|
|
exit 0
|
|
}
|
|
Write-PreInstallLog "PC type: $pcType"
|
|
|
|
# --- Parse JSON ---
|
|
try {
|
|
$config = Get-Content $jsonPath -Raw | ConvertFrom-Json
|
|
} catch {
|
|
Write-PreInstallLog "Failed to parse $jsonPath : $_" "ERROR"
|
|
exit 0
|
|
}
|
|
|
|
if (-not $config.Applications) {
|
|
Write-PreInstallLog "No Applications in preinstall.json"
|
|
exit 0
|
|
}
|
|
|
|
Write-PreInstallLog "Staged installer dir: $installerDir"
|
|
Write-PreInstallLog "Found $($config.Applications.Count) app entries in preinstall.json"
|
|
|
|
# --- Site-name override: replace "West Jefferson" in InstallArgs if site-config says otherwise ---
|
|
$siteConfig = Get-SiteConfig
|
|
if ($siteConfig -and $siteConfig.siteName -and $siteConfig.siteName -ne 'West Jefferson') {
|
|
Write-PreInstallLog "Site config loaded - siteName: $($siteConfig.siteName)"
|
|
foreach ($app in $config.Applications) {
|
|
if ($app.InstallArgs -and $app.InstallArgs -match 'West Jefferson') {
|
|
$app.InstallArgs = $app.InstallArgs -replace 'West Jefferson', $siteConfig.siteName
|
|
Write-PreInstallLog " Overrode site name in $($app.Name) args: $($app.InstallArgs)"
|
|
}
|
|
}
|
|
} else {
|
|
Write-PreInstallLog "No site-config override for siteName (using defaults in preinstall.json)"
|
|
}
|
|
|
|
# --- Detection helper (mirrors Simple-Install.ps1's Test-ApplicationInstalled) ---
|
|
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
|
|
}
|
|
default {
|
|
Write-PreInstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
|
|
return $false
|
|
}
|
|
}
|
|
} catch {
|
|
Write-PreInstallLog " Detection check threw: $_" "WARN"
|
|
return $false
|
|
}
|
|
}
|
|
|
|
# --- Iterate apps ---
|
|
$installed = 0
|
|
$skipped = 0
|
|
$failed = 0
|
|
|
|
foreach ($app in $config.Applications) {
|
|
# Cancel any reboot a previous installer scheduled (some respect /norestart, some
|
|
# don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot
|
|
# despite the flag). Doing this BEFORE each install protects the rest of the loop.
|
|
cmd /c "shutdown /a 2>nul" *>$null
|
|
|
|
Write-PreInstallLog "==> $($app.Name)"
|
|
|
|
# Filter by PCTypes
|
|
$allowedTypes = @($app.PCTypes)
|
|
$matchesType = ($allowedTypes -contains "*") -or ($allowedTypes -contains $pcType)
|
|
if (-not $matchesType) {
|
|
Write-PreInstallLog " PCTypes filter excludes '$pcType' (allowed: $($allowedTypes -join ', ')) - skipping"
|
|
$skipped++
|
|
continue
|
|
}
|
|
|
|
# Detection check
|
|
if (Test-AppInstalled -App $app) {
|
|
Write-PreInstallLog " Already installed - skipping"
|
|
$skipped++
|
|
continue
|
|
}
|
|
|
|
# Locate installer file
|
|
$installerPath = Join-Path $installerDir $app.Installer
|
|
if (-not (Test-Path $installerPath)) {
|
|
Write-PreInstallLog " Installer file not found: $installerPath" "ERROR"
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
Write-PreInstallLog " Installing from $installerPath"
|
|
if ($app.InstallArgs) {
|
|
Write-PreInstallLog " InstallArgs: $($app.InstallArgs)"
|
|
}
|
|
|
|
# Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
|
|
# into the runner log on failure so we don't need to dig through C:\Logs manually.
|
|
$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 }
|
|
|
|
try {
|
|
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
|
|
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
|
|
# Process object's OS handle is disposed when control returns to the script,
|
|
# causing ExitCode to always read as $null even after WaitForExit() succeeds.
|
|
# Calling Process.Start() directly returns a Process object with a live handle
|
|
# that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
|
|
# hangs forever after spawning a hidden WPF window) can be killed via the
|
|
# detection-during-install path.
|
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
$psi.UseShellExecute = $false
|
|
$psi.CreateNoWindow = $true
|
|
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
|
|
|
if ($app.Type -eq "MSI") {
|
|
$psi.FileName = "msiexec.exe"
|
|
$psi.Arguments = "/i `"$installerPath`""
|
|
if ($app.InstallArgs) {
|
|
$psi.Arguments += " " + $app.InstallArgs
|
|
}
|
|
$psi.Arguments += " /L*v `"$msiLog`""
|
|
Write-PreInstallLog " msiexec verbose log: $msiLog"
|
|
}
|
|
elseif ($app.Type -eq "EXE") {
|
|
$psi.FileName = $installerPath
|
|
if ($app.InstallArgs) {
|
|
$psi.Arguments = $app.InstallArgs
|
|
}
|
|
# If the JSON entry specifies a LogFile (per-installer log written by
|
|
# the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup-
|
|
# OpenText.log), surface it on failure the same way we surface the
|
|
# msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually
|
|
# report what went wrong inside.
|
|
if ($app.LogFile) {
|
|
Write-PreInstallLog " Installer log: $($app.LogFile)"
|
|
}
|
|
}
|
|
else {
|
|
Write-PreInstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
|
|
# Poll for completion: process exit OR detection success (whichever happens first)
|
|
$timeoutSec = 600 # 10 min hard cap per app
|
|
$pollInterval = 5
|
|
$elapsed = 0
|
|
$killedAfterDetect = $false
|
|
|
|
while ($elapsed -lt $timeoutSec) {
|
|
if ($proc.HasExited) { break }
|
|
|
|
# Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
|
|
# in the JSON entry. It's only safe for installers that hang forever after
|
|
# creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
|
|
# and never exits). For normal installers (Oracle, msiexec, etc.) the
|
|
# detection registry key often gets written midway through the install AND
|
|
# msiexec is still doing post-install cleanup AND msiserver is still holding
|
|
# the install mutex - killing msiexec at that point leaves the system in a
|
|
# bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_
|
|
# RUNNING. So opt-out by default; only kill apps that explicitly need it.
|
|
if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) {
|
|
Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)"
|
|
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
|
|
|
|
# UDC's installer auto-launches UDC.exe in silent mode. Kill that too
|
|
# so it can't write the placeholder MachineNumber to udc_settings.json.
|
|
if ($app.Name -eq "UDC") {
|
|
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
|
|
try { $_.Kill(); $_.WaitForExit(2000) | Out-Null } catch { }
|
|
}
|
|
}
|
|
|
|
$killedAfterDetect = $true
|
|
break
|
|
}
|
|
|
|
Start-Sleep -Seconds $pollInterval
|
|
$elapsed += $pollInterval
|
|
}
|
|
|
|
if (-not $proc.HasExited -and -not $killedAfterDetect) {
|
|
Write-PreInstallLog " TIMEOUT after $timeoutSec seconds - killing installer" "ERROR"
|
|
try { $proc.Kill() } catch { }
|
|
if ($app.Name -eq "UDC") {
|
|
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
|
|
try { $_.Kill() } catch { }
|
|
}
|
|
}
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
if ($killedAfterDetect) {
|
|
Write-PreInstallLog " SUCCESS (verified via detection during install)"
|
|
$installed++
|
|
}
|
|
else {
|
|
# Start-Process -PassThru returns a Process object whose ExitCode is null
|
|
# until WaitForExit() is called - even if HasExited is already true. Without
|
|
# this, every install that goes through the exit-code branch gets reported
|
|
# as "Exit code - FAILED" (empty) regardless of the real result.
|
|
try { $proc.WaitForExit() } catch { }
|
|
$exitCode = $proc.ExitCode
|
|
|
|
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
|
|
Write-PreInstallLog " Exit code $exitCode after $elapsed s - SUCCESS"
|
|
if ($exitCode -eq 3010) {
|
|
Write-PreInstallLog " (Reboot required for $($app.Name))"
|
|
}
|
|
$installed++
|
|
}
|
|
else {
|
|
Write-PreInstallLog " Exit code $exitCode - FAILED" "ERROR"
|
|
|
|
# If the JSON entry specifies a LogFile for an EXE install (e.g.
|
|
# Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup-
|
|
# OpenText.log), tail it here so the runner log surfaces the actual
|
|
# cause without us having to dig through C:\Logs manually.
|
|
if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
|
|
Write-PreInstallLog " --- last 30 lines of $($app.LogFile) ---"
|
|
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
|
|
Write-PreInstallLog " $_"
|
|
}
|
|
Write-PreInstallLog " --- end installer log tail ---"
|
|
}
|
|
|
|
# Surface the *meaningful* lines from the verbose msiexec log (not just
|
|
# the tail, which is rollback/cleanup noise). The actual root-cause lines
|
|
# are: MSI error notes (Note: 1: <code>), failed action returns (return
|
|
# value 3), errors logged by custom actions, and any line containing the
|
|
# word "Error". Grep these out so the runner log shows the smoking gun.
|
|
if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
|
|
Write-PreInstallLog " --- meaningful lines from $msiLog ---"
|
|
$patterns = @(
|
|
'Note: 1: ',
|
|
'return value 3',
|
|
'Error \d+\.',
|
|
'CustomAction .* returned actual error',
|
|
'Failed to ',
|
|
'Installation failed',
|
|
'Calling custom action',
|
|
'1: 2262', # Database not found
|
|
'1: 2203', # Cannot open database
|
|
'1: 2330' # Insufficient permissions
|
|
)
|
|
$regex = ($patterns -join '|')
|
|
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
|
|
Select-Object -First 30
|
|
if ($matches) {
|
|
foreach ($m in $matches) {
|
|
Write-PreInstallLog " $($m.Line.Trim())"
|
|
}
|
|
} else {
|
|
Write-PreInstallLog " (no error patterns matched - falling back to last 25 lines)"
|
|
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
|
|
Write-PreInstallLog " $_"
|
|
}
|
|
}
|
|
Write-PreInstallLog " --- end MSI log scan ---"
|
|
}
|
|
$failed++
|
|
}
|
|
}
|
|
} catch {
|
|
Write-PreInstallLog " Install threw: $_" "ERROR"
|
|
$failed++
|
|
}
|
|
}
|
|
|
|
Write-PreInstallLog "============================================"
|
|
Write-PreInstallLog "PreInstall complete: $installed installed, $skipped skipped, $failed failed"
|
|
Write-PreInstallLog "============================================"
|
|
|
|
# Final reboot cancel - if the last installer in the loop scheduled one, the
|
|
# dispatcher's later `shutdown /a` won't fire until the next baseline script starts.
|
|
# Cancel here so control returns cleanly to Run-ShopfloorSetup.ps1.
|
|
cmd /c "shutdown /a 2>nul" *>$null
|
|
|
|
exit 0
|