From ee7d3bad66fda4219b9e63941eee95d16f1354e9 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 11 Apr 2026 12:58:47 -0400 Subject: [PATCH] Shopfloor imaging: CMM type, Configure-PC override fix, serial drivers - CMM imaging pipeline: WinPE-staged bootstrap + on-logon enforcer against tsgwp00525 share, manifest-driven installer runner shared via Install-FromManifest.ps1. Installs PC-DMIS 2016/2019 R2, CLM 1.8, goCMM; enables .NET 3.5 prereq; registers GE CMM Enforce logon task for ongoing version enforcement. - Shopfloor serial drivers: StarTech PCIe serial + Prolific PL2303 USB-to-serial via Install-Drivers.cmd wrapper calling pnputil /add-driver /subdirs /install. Scoped to Standard PCs. - OpenText extended to CMM/Keyence/Genspect/WaxAndTrace via preinstall.json PCTypes; Defect Tracker added to CMM profile desktopApps + taskbarPins. - Configure-PC startup-item toggle now persists across the logon sweep via C:\\ProgramData\\GE\\Shopfloor\\startup-overrides.json; 06-OrganizeDesktop Phase 3 respects suppressed items. - Get-ProfileValue helper added to Shopfloor/lib/Get-PCProfile.ps1; distinguishes explicit empty array from missing key (fixes Lab getting Plant Apps in startup because empty array was falsy). - 06-OrganizeDesktop gains transcript logging at C:\\Logs\\SFLD\\ 06-OrganizeDesktop.log and now deletes the stale Shopfloor Intune Sync task when C:\\Enrollment\\sync-complete.txt is present (task was registered with Limited principal and couldn't self-unregister). - startnet.cmd CMM xcopy block (gated on pc-type=CMM) stages the bundle to W:\\CMM-Install during WinPE. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../preinstall/drivers/Install-Drivers.cmd | 46 +++ playbook/preinstall/preinstall.json | 13 +- playbook/shopfloor-setup/CMM/01-Setup-CMM.ps1 | 331 ++++++++++-------- playbook/shopfloor-setup/CMM/CMM-Enforce.ps1 | 133 +++++++ .../shopfloor-setup/CMM/cmm-manifest.json | 54 +++ .../CMM/lib/Install-FromManifest.ps1 | 270 ++++++++++++++ .../Shopfloor/06-OrganizeDesktop.ps1 | 82 ++++- .../Shopfloor/07-TaskbarLayout.ps1 | 6 +- .../Shopfloor/08-EdgeDefaultBrowser.ps1 | 6 +- .../Shopfloor/Configure-PC.ps1 | 67 +++- .../Shopfloor/lib/Get-PCProfile.ps1 | 21 +- .../Shopfloor/lib/Monitor-IntuneProgress.ps1 | 8 +- playbook/shopfloor-setup/site-config.json | 10 +- playbook/startnet.cmd | 17 + playbook/sync-cmm.sh | 131 +++++++ playbook/sync-preinstall.sh | 1 + startnet-template.cmd | 69 +++- 17 files changed, 1069 insertions(+), 196 deletions(-) create mode 100644 playbook/preinstall/drivers/Install-Drivers.cmd create mode 100644 playbook/shopfloor-setup/CMM/CMM-Enforce.ps1 create mode 100644 playbook/shopfloor-setup/CMM/cmm-manifest.json create mode 100644 playbook/shopfloor-setup/CMM/lib/Install-FromManifest.ps1 create mode 100755 playbook/sync-cmm.sh diff --git a/playbook/preinstall/drivers/Install-Drivers.cmd b/playbook/preinstall/drivers/Install-Drivers.cmd new file mode 100644 index 0000000..d905962 --- /dev/null +++ b/playbook/preinstall/drivers/Install-Drivers.cmd @@ -0,0 +1,46 @@ +@echo off +REM Install-Drivers.cmd - Adds bundled INF driver packages to the Windows driver store +REM via pnputil. Runs from C:\PreInstall\installers\drivers\ where each vendor +REM subdir (prolific\, startech\) sits alongside this script. /subdirs walks into +REM them so one call covers every bundled driver. +REM +REM Writes C:\ProgramData\PXEDrivers\drivers-installed.marker on success so the +REM preinstall runner's File DetectionMethod can skip re-runs. + +setlocal + +set LOGDIR=C:\Logs\PreInstall +set LOG=%LOGDIR%\Install-Drivers.log +set MARKER_DIR=C:\ProgramData\PXEDrivers +set MARKER=%MARKER_DIR%\drivers-installed.marker + +if not exist "%LOGDIR%" mkdir "%LOGDIR%" +if not exist "%MARKER_DIR%" mkdir "%MARKER_DIR%" + +echo ================================================================ >> "%LOG%" +echo [%date% %time%] Install-Drivers session start >> "%LOG%" +echo Source dir: %~dp0 >> "%LOG%" +echo ================================================================ >> "%LOG%" + +REM pnputil /add-driver with /subdirs recurses into vendor subdirs. /install +REM binds the driver to any matching hardware present now; drivers without a +REM matching device still land in the driver store and bind on plug-in later. +pnputil.exe /add-driver "%~dp0*.inf" /subdirs /install >> "%LOG%" 2>&1 +set RC=%ERRORLEVEL% + +echo [%date% %time%] pnputil exit code: %RC% >> "%LOG%" + +REM pnputil exit codes: +REM 0 success +REM 259 ERROR_NO_MORE_ITEMS - no INFs matched (treat as failure) +REM 3010 at least one driver installed, reboot recommended (treat as success) +if "%RC%"=="0" goto :ok +if "%RC%"=="3010" goto :ok + +echo [%date% %time%] FAILED (exit %RC%) >> "%LOG%" +exit /b %RC% + +:ok +echo [%date% %time%] SUCCESS >> "%LOG%" +echo installed %date% %time% > "%MARKER%" +exit /b 0 diff --git a/playbook/preinstall/preinstall.json b/playbook/preinstall/preinstall.json index bddbcf8..bc20790 100644 --- a/playbook/preinstall/preinstall.json +++ b/playbook/preinstall/preinstall.json @@ -97,7 +97,7 @@ "Type": "EXE", "InstallArgs": "", "LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log", - "PCTypes": ["Standard"] + "PCTypes": ["Standard", "CMM", "Keyence", "Genspect", "WaxAndTrace"] }, { "_comment": "UDC_Setup.exe spawns a hidden WPF window (UDC.exe) after install and never exits, so the runner needs KillAfterDetection: true to terminate UDC_Setup.exe + UDC.exe once the registry detection passes. This is an OPT-IN flag - normal installers should NOT set it because killing msiexec mid-install leaves msiserver holding the install mutex and the next msiexec call returns 1618 (Oracle hit this exact bug).", @@ -109,6 +109,17 @@ "DetectionMethod": "Registry", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "PCTypes": ["Standard"] + }, + { + "_comment": "Shopfloor Standard serial-port drivers: StarTech PCIe serial adapter (MosChip-based) + Prolific PL2303 USB-to-serial. Install-Drivers.cmd runs pnputil /add-driver with /subdirs /install so every bundled INF under drivers/ lands in the Windows driver store and auto-binds to matching hardware present now or plugged in later. Scoped to Standard PCs (both Machine + Timeclock) because the PCTypes filter is type-level only; installing a serial driver on a Timeclock without the hardware is harmless - it just sits in the driver store.", + "Name": "Shopfloor Serial Drivers", + "Installer": "drivers\\Install-Drivers.cmd", + "Type": "EXE", + "InstallArgs": "", + "LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log", + "DetectionMethod": "File", + "DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker", + "PCTypes": ["Standard"] } ] } diff --git a/playbook/shopfloor-setup/CMM/01-Setup-CMM.ps1 b/playbook/shopfloor-setup/CMM/01-Setup-CMM.ps1 index 7c46555..02124b7 100644 --- a/playbook/shopfloor-setup/CMM/01-Setup-CMM.ps1 +++ b/playbook/shopfloor-setup/CMM/01-Setup-CMM.ps1 @@ -1,173 +1,198 @@ -# 01-Setup-CMM.ps1 - CMM-specific setup (runs after Shopfloor baseline) +# 01-Setup-CMM.ps1 - CMM type setup (runs during shopfloor-setup phase). # -# Installs Hexagon CMM applications from a network share using credentials -# stored in the SFLD registry by SetupCredentials.ps1 (PPKG phase). +# At imaging time the tsgwp00525 SFLD share is NOT yet reachable - Azure DSC +# has not provisioned the share credentials that early. So we install from a +# WinPE-staged local copy at C:\CMM-Install (put there by startnet.cmd when +# the tech picks pc-type=CMM), then register a logon-triggered scheduled +# task that runs CMM-Enforce.ps1 for ongoing updates from the share. # -# Unlike Standard PC apps (which are pre-staged locally via preinstall.json -# or pulled from Azure Blob via DSC), CMM apps live on a file share and -# are installed directly from there. The share credentials come from the -# PPKG's YAML config and are already in the registry by the time this -# script runs. +# Sequence: +# 1. Enable .NET Framework 3.5 (PC-DMIS 2016 prereq on Win10/11 where 3.5 +# is an off-by-default optional feature). +# 2. Run Install-FromManifest against C:\CMM-Install\cmm-manifest.json. +# 3. Stage Install-FromManifest.ps1 + CMM-Enforce.ps1 + the manifest to +# C:\Program Files\GE\CMM so the scheduled task has them after imaging. +# 4. Register a SYSTEM scheduled task "GE CMM Enforce" that runs +# CMM-Enforce.ps1 on any user logon. +# 5. Delete C:\CMM-Install to reclaim the ~2 GB of bootstrap installers. +# The share-side enforcer takes over from here. # -# The share path and app list are read from site-config.json's CMM profile -# when available, with hardcoded West Jefferson defaults as fallback. -# -# PLACEHOLDER: specific app installers (PC-DMIS, CLM License, etc.) are -# not yet finalized. The framework below handles credential lookup, share -# mounting, and has slots for each install step. +# Log: C:\Logs\CMM\01-Setup-CMM.log (stdout from this script) plus the +# install-time log at C:\Logs\CMM\install.log written by Install-FromManifest. -Write-Host "=== CMM Setup ===" +$ErrorActionPreference = 'Continue' -# --- Load site config + PC profile --- -. "$PSScriptRoot\..\Shopfloor\lib\Get-PCProfile.ps1" +$stagingRoot = 'C:\CMM-Install' +$stagingMani = Join-Path $stagingRoot 'cmm-manifest.json' +$libSource = Join-Path $PSScriptRoot 'lib\Install-FromManifest.ps1' +$enforceSource = Join-Path $PSScriptRoot 'CMM-Enforce.ps1' -# --- Configuration --- -# Share path for Hexagon CMM installers. Read from profile config, -# fall back to the known West Jefferson path. -$defaultSharePath = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\cmm\hexagon\machineapps' +$runtimeRoot = 'C:\Program Files\GE\CMM' +$runtimeLibDir = Join-Path $runtimeRoot 'lib' +$runtimeLib = Join-Path $runtimeLibDir 'Install-FromManifest.ps1' +$runtimeEnforce = Join-Path $runtimeRoot 'CMM-Enforce.ps1' -$sharePath = $defaultSharePath -if ($pcProfile -and $pcProfile.cmmSharePath) { - $sharePath = $pcProfile.cmmSharePath -} elseif ($siteConfig -and $siteConfig.pcProfiles -and $siteConfig.pcProfiles.CMM -and $siteConfig.pcProfiles.CMM.cmmSharePath) { - $sharePath = $siteConfig.pcProfiles.CMM.cmmSharePath +$logDir = 'C:\Logs\CMM' +$logFile = Join-Path $logDir 'install.log' +$transcriptLog = Join-Path $logDir '01-Setup-CMM.log' + +if (-not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null } -Write-Host " Share: $sharePath" +# Independent transcript in addition to whatever Run-ShopfloorSetup.ps1 is +# capturing at the top level. Lets a tech open C:\Logs\CMM\01-Setup-CMM.log +# and see the entire CMM-type setup run without scrolling through the +# monolithic shopfloor-setup.log. +try { Start-Transcript -Path $transcriptLog -Append -Force | Out-Null } catch {} -# ============================================================================ -# Credential lookup - reads from HKLM:\SOFTWARE\GE\SFLD\Credentials\* -# Written by SetupCredentials.ps1 during the PPKG phase. We scan all -# credential entries and find one whose TargetHost matches the share's -# server name. -# ============================================================================ -function Get-SFLDCredential { - param([string]$ServerName) - - $basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials' - if (-not (Test-Path $basePath)) { - Write-Warning "SFLD credential registry not found at $basePath" - return $null - } - - $entries = Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue - foreach ($entry in $entries) { - $props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue - if (-not $props) { continue } - - $targetHost = $props.TargetHost - if (-not $targetHost) { continue } - - # Match by hostname (with or without domain suffix) - if ($targetHost -eq $ServerName -or - $targetHost -like "$ServerName.*" -or - $ServerName -like "$targetHost.*") { - return @{ - Username = $props.Username - Password = $props.Password - TargetHost = $targetHost - KeyName = $entry.PSChildName - } - } - } - - Write-Warning "No SFLD credential found for server '$ServerName'" - return $null +function Write-CMMLog { + param([string]$Message, [string]$Level = 'INFO') + $stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Host "[$stamp] [$Level] $Message" } -# ============================================================================ -# Mount the share -# ============================================================================ -# Extract server name from UNC path: \\server\share\... -> server -$serverName = ($sharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1 +Write-CMMLog "================================================================" +Write-CMMLog "=== CMM Setup (imaging-time) session start (PID $PID) ===" +Write-CMMLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" +Write-CMMLog "================================================================" -$cred = Get-SFLDCredential -ServerName $serverName -$driveLetter = 'S:' - -if ($cred) { - Write-Host " Credential: $($cred.KeyName) (user: $($cred.Username))" -} else { - Write-Host " No credential found for $serverName - attempting guest/current-user access" -} - -# Disconnect any stale mapping -net use $driveLetter /delete /y 2>$null | Out-Null - -$mountOk = $false -if ($cred -and $cred.Username -and $cred.Password) { - $result = & net use $driveLetter $sharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host " Mounted $sharePath as $driveLetter" - $mountOk = $true +# Diagnostic dump - knowing WHY the script took a branch is half the battle. +Write-CMMLog "Script root: $PSScriptRoot" +foreach ($file in @('pc-type.txt','pc-subtype.txt','machine-number.txt')) { + $path = "C:\Enrollment\$file" + if (Test-Path -LiteralPath $path) { + $content = (Get-Content -LiteralPath $path -First 1 -ErrorAction SilentlyContinue).Trim() + Write-CMMLog " $file = $content" } else { - Write-Warning " net use failed (exit $LASTEXITCODE): $result" + Write-CMMLog " $file = (not present)" + } +} +if (Test-Path $stagingRoot) { + $bootstrapFiles = @(Get-ChildItem -LiteralPath $stagingRoot -File -ErrorAction SilentlyContinue) + Write-CMMLog "Bootstrap staging: $stagingRoot ($($bootstrapFiles.Count) files)" + foreach ($f in $bootstrapFiles) { + Write-CMMLog " - $($f.Name) ($([math]::Round($f.Length/1MB)) MB)" } } else { - # Try without explicit credentials (rely on CredMan or current user) - $result = & net use $driveLetter $sharePath /persistent:no 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host " Mounted $sharePath as $driveLetter (no explicit creds)" - $mountOk = $true + Write-CMMLog "Bootstrap staging: $stagingRoot (DOES NOT EXIST - startnet.cmd did not stage it)" "ERROR" +} + +# ============================================================================ +# Step 1: Enable .NET Framework 3.5 +# ============================================================================ +# PC-DMIS 2016 lists .NET 3.5 as a prereq for some older components. On Win10/ +# Win11 it's an optional Windows feature that is OFF by default. Enable- +# WindowsOptionalFeature pulls the payload from Windows Update when the PC +# has internet; sources from the installed Windows image otherwise. Idempotent +# (no-op if already enabled). We swallow failures because if internet and +# media are both unavailable this becomes a known gap rather than an imaging +# blocker - we'd still rather try to install PC-DMIS and surface the real +# failure in its log. +Write-CMMLog "Checking .NET Framework 3.5 state..." +try { + $netfx = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -ErrorAction Stop + if ($netfx.State -eq 'Enabled') { + Write-CMMLog " .NET 3.5 already enabled" } else { - Write-Warning " net use failed (exit $LASTEXITCODE): $result" + Write-CMMLog " .NET 3.5 state is $($netfx.State) - enabling now (may take a minute)..." + $result = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart -ErrorAction Stop + Write-CMMLog " Enable-WindowsOptionalFeature RestartNeeded=$($result.RestartNeeded)" + } +} catch { + Write-CMMLog " Failed to enable .NET 3.5: $_" "WARN" + Write-CMMLog " Continuing anyway - PC-DMIS installers will surface any hard dependency." +} + +# ============================================================================ +# Step 2: Install apps from the WinPE-staged bootstrap at C:\CMM-Install +# ============================================================================ +if (-not (Test-Path $stagingRoot)) { + Write-CMMLog "$stagingRoot does not exist - startnet.cmd did not stage CMM installers" "ERROR" + Write-CMMLog "Skipping install. The logon enforcer will pick up from the share when SFLD creds are available." +} +elseif (-not (Test-Path $stagingMani)) { + Write-CMMLog "$stagingMani missing - staging directory is incomplete" "ERROR" +} +elseif (-not (Test-Path $libSource)) { + Write-CMMLog "Shared library not found at $libSource" "ERROR" +} +else { + Write-CMMLog "Running Install-FromManifest against $stagingRoot" + & $libSource -ManifestPath $stagingMani -InstallerRoot $stagingRoot -LogFile $logFile + $rc = $LASTEXITCODE + Write-CMMLog "Install-FromManifest returned $rc" +} + +# ============================================================================ +# Step 3: Stage runtime scripts to C:\Program Files\GE\CMM +# ============================================================================ +# These files survive past the bootstrap cleanup so the logon-triggered +# scheduled task can run them. The manifest is staged as well so the enforcer +# has a fallback in case the share copy is unreachable on first logon. +Write-CMMLog "Staging runtime scripts to $runtimeRoot" +foreach ($dir in @($runtimeRoot, $runtimeLibDir)) { + if (-not (Test-Path $dir)) { + New-Item -Path $dir -ItemType Directory -Force | Out-Null + } +} +Copy-Item -Path $libSource -Destination $runtimeLib -Force +Copy-Item -Path $enforceSource -Destination $runtimeEnforce -Force + +# ============================================================================ +# Step 4: Register "GE CMM Enforce" scheduled task (logon trigger, SYSTEM) +# ============================================================================ +$taskName = 'GE CMM Enforce' + +# Drop any stale version first so re-imaging is idempotent. +$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($existing) { + Write-CMMLog "Removing existing scheduled task '$taskName'" + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue +} + +Write-CMMLog "Registering scheduled task '$taskName' (logon trigger, SYSTEM)" +try { + $action = New-ScheduledTaskAction ` + -Execute 'powershell.exe' ` + -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$runtimeEnforce`"" + + $trigger = New-ScheduledTaskTrigger -AtLogOn + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Hours 2) ` + -MultipleInstances IgnoreNew + + Register-ScheduledTask ` + -TaskName $taskName ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description 'GE CMM: enforce Hexagon apps against tsgwp00525 SFLD share on user logon' | Out-Null + + Write-CMMLog "Scheduled task registered" +} catch { + Write-CMMLog "Failed to register scheduled task: $_" "ERROR" +} + +# ============================================================================ +# Step 5: Clean up the bootstrap staging dir +# ============================================================================ +# ~2 GB reclaimed. From here on, CMM-Enforce.ps1 runs against the tsgwp00525 +# share, which is the canonical source for ongoing updates. +if (Test-Path $stagingRoot) { + Write-CMMLog "Deleting bootstrap staging at $stagingRoot" + try { + Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop + Write-CMMLog "Bootstrap cleanup complete" + } catch { + Write-CMMLog "Failed to delete $stagingRoot : $_" "WARN" } } -if (-not $mountOk) { - Write-Warning "Cannot access $sharePath - skipping CMM app installs." - Write-Host "=== CMM Setup Complete (share unavailable) ===" - exit 0 -} - -# ============================================================================ -# Install apps from the share -# -# PLACEHOLDER: uncomment and adjust when app details are finalized. -# Each block follows the pattern: -# 1. Find installer on the share -# 2. Run it with silent args -# 3. Check exit code -# 4. Log result -# ============================================================================ - -$installRoot = $driveLetter - -# --- Example: CLM Tools (license manager, install first) --- -# $clm = Get-ChildItem -Path $installRoot -Filter "CLM_*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 -# if ($clm) { -# Write-Host "Installing CLM Tools: $($clm.Name)..." -# $p = Start-Process -FilePath $clm.FullName -ArgumentList "-q -norestart" -Wait -PassThru -# Write-Host " CLM Tools exit code: $($p.ExitCode)" -# } else { -# Write-Warning "CLM Tools installer not found (expected CLM_*.exe)" -# } - -# --- Example: PC-DMIS 2016 --- -# $pcdmis16 = Get-ChildItem -Path $installRoot -Filter "Pcdmis2016*x64.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 -# if ($pcdmis16) { -# Write-Host "Installing PC-DMIS 2016: $($pcdmis16.Name)..." -# $p = Start-Process -FilePath $pcdmis16.FullName -ArgumentList "-q INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 HEIP=0 -norestart" -Wait -PassThru -# Write-Host " PC-DMIS 2016 exit code: $($p.ExitCode)" -# } else { -# Write-Warning "PC-DMIS 2016 installer not found" -# } - -# --- Example: PC-DMIS 2019 R2 --- -# $pcdmis19 = Get-ChildItem -Path $installRoot -Filter "Pcdmis2019*x64.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 -# if ($pcdmis19) { -# Write-Host "Installing PC-DMIS 2019 R2: $($pcdmis19.Name)..." -# $p = Start-Process -FilePath $pcdmis19.FullName -ArgumentList "-q INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 HEIP=0 -norestart" -Wait -PassThru -# Write-Host " PC-DMIS 2019 exit code: $($p.ExitCode)" -# } else { -# Write-Warning "PC-DMIS 2019 installer not found" -# } - -Write-Host " (no apps configured yet - uncomment install blocks when ready)" - -# ============================================================================ -# Cleanup -# ============================================================================ -Write-Host "Disconnecting $driveLetter..." -net use $driveLetter /delete /y 2>$null | Out-Null - -Write-Host "=== CMM Setup Complete ===" +Write-CMMLog "=== CMM Setup Complete ===" +try { Stop-Transcript | Out-Null } catch {} diff --git a/playbook/shopfloor-setup/CMM/CMM-Enforce.ps1 b/playbook/shopfloor-setup/CMM/CMM-Enforce.ps1 new file mode 100644 index 0000000..e457a5f --- /dev/null +++ b/playbook/shopfloor-setup/CMM/CMM-Enforce.ps1 @@ -0,0 +1,133 @@ +# CMM-Enforce.ps1 - On-logon CMM app enforcer (the mini-DSC side). +# +# Runs under a SYSTEM scheduled task triggered at user logon. Mounts the +# tsgwp00525 SFLD share using creds written to HKLM by Azure DSC, reads +# cmm-manifest.json from the share, and hands off to Install-FromManifest.ps1 +# which installs anything whose detection fails. +# +# Why logon trigger: shopfloor operators log in at shift start; the PC may +# have been off or DSC may not have provisioned the SFLD creds until the +# Intune side ran post-PPKG. Logon is a natural catch-up point. Once detection +# passes for every app, each run is ~seconds of no-ops. +# +# Why SYSTEM: installers need machine-wide rights and registry access. The +# task is triggered by logon but runs outside the user's session. +# +# Graceful degradation: +# - SFLD creds missing (Azure DSC hasn't run yet) -> log + exit 0 +# - Share unreachable (network, VPN) -> log + exit 0 +# - Install failure on any one app -> log + continue with the rest +# +# Never returns non-zero to the task scheduler; failures show up in the log. + +$ErrorActionPreference = 'Continue' + +$installRoot = 'C:\Program Files\GE\CMM' +$libPath = Join-Path $installRoot 'lib\Install-FromManifest.ps1' +$logDir = 'C:\Logs\CMM' +$logFile = Join-Path $logDir ('enforce-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) +$driveLetter = 'S:' + +if (-not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +function Write-EnforceLog { + param([string]$Message, [string]$Level = 'INFO') + $line = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message + Write-Host $line + Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue +} + +Write-EnforceLog "================================================================" +Write-EnforceLog "=== CMM-Enforce session start (PID $PID, user $env:USERNAME) ===" +Write-EnforceLog "================================================================" + +# --- Load site-config + pcProfile (for cmmSharePath) --- +# Dot-source the same Get-PCProfile.ps1 used during imaging. It walks +# C:\Enrollment\site-config.json into $pcProfile/$siteConfig script variables. +$getProfileScript = 'C:\Enrollment\shopfloor-setup\Shopfloor\lib\Get-PCProfile.ps1' +if (-not (Test-Path $getProfileScript)) { + Write-EnforceLog "Get-PCProfile.ps1 not found at $getProfileScript - is this a CMM PC?" "ERROR" + exit 0 +} +. $getProfileScript + +if (-not $pcProfile -or -not $pcProfile.cmmSharePath) { + Write-EnforceLog "No cmmSharePath in profile - nothing to enforce" "WARN" + exit 0 +} + +$sharePath = $pcProfile.cmmSharePath +Write-EnforceLog "Share: $sharePath" + +# --- SFLD credential lookup (written by Azure DSC post-PPKG). Bail gracefully +# if the creds haven't been provisioned yet - next logon will retry. --- +function Get-SFLDCredential { + param([string]$ServerName) + $basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials' + if (-not (Test-Path $basePath)) { return $null } + + foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) { + $props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue + if (-not $props -or -not $props.TargetHost) { continue } + if ($props.TargetHost -eq $ServerName -or + $props.TargetHost -like "$ServerName.*" -or + $ServerName -like "$($props.TargetHost).*") { + return @{ + Username = $props.Username + Password = $props.Password + TargetHost = $props.TargetHost + KeyName = $entry.PSChildName + } + } + } + return $null +} + +$serverName = ($sharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1 +$cred = Get-SFLDCredential -ServerName $serverName + +if (-not $cred -or -not $cred.Username -or -not $cred.Password) { + Write-EnforceLog "No SFLD credential for $serverName yet (Azure DSC has not provisioned it) - will retry at next logon" + exit 0 +} + +Write-EnforceLog "Credential: $($cred.KeyName) (user: $($cred.Username))" + +# --- Mount the share --- +net use $driveLetter /delete /y 2>$null | Out-Null + +$netResult = & net use $driveLetter $sharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-EnforceLog "net use failed (exit $LASTEXITCODE): $netResult" "WARN" + Write-EnforceLog "Share unreachable - probably off-network. Will retry at next logon." + exit 0 +} +Write-EnforceLog "Mounted $sharePath as $driveLetter" + +try { + $manifestOnShare = Join-Path $driveLetter 'cmm-manifest.json' + if (-not (Test-Path $manifestOnShare)) { + Write-EnforceLog "cmm-manifest.json not found on share - nothing to enforce" "WARN" + return + } + + if (-not (Test-Path $libPath)) { + Write-EnforceLog "Install-FromManifest.ps1 not found at $libPath" "ERROR" + return + } + + Write-EnforceLog "Handing off to Install-FromManifest.ps1 (InstallerRoot=$driveLetter)" + & $libPath -ManifestPath $manifestOnShare -InstallerRoot $driveLetter -LogFile $logFile + $rc = $LASTEXITCODE + Write-EnforceLog "Install-FromManifest returned $rc" +} +finally { + net use $driveLetter /delete /y 2>$null | Out-Null + Write-EnforceLog "Unmounted $driveLetter" + Write-EnforceLog "=== CMM-Enforce session end ===" +} + +# Always return 0 so the scheduled task never shows "last run failed" noise. +exit 0 diff --git a/playbook/shopfloor-setup/CMM/cmm-manifest.json b/playbook/shopfloor-setup/CMM/cmm-manifest.json new file mode 100644 index 0000000..27b428d --- /dev/null +++ b/playbook/shopfloor-setup/CMM/cmm-manifest.json @@ -0,0 +1,54 @@ +{ + "Version": "1.0", + "_comment": "CMM machine-app manifest. Consumed by both 01-Setup-CMM.ps1 (at imaging time, reading from C:\\CMM-Install\\) and CMM-Enforce.ps1 (on logon, reading from the tsgwp00525 share). Detection uses the WiX Burn bundle Registration GUIDs pulled from each installer's .wixburn PE section + embedded BurnManifest.xml; bumping DetectionValue (and uploading a matching installer) is how IT ships version updates post-imaging. Install order matters: PC-DMIS 2016 first (it chains an older CLM Tools MSI), then PC-DMIS 2019 R2 (chains CLM 1.7), then standalone CLM 1.8.73 (final CLM Admin upgrade), then goCMM. CLM is left unlicensed - a tech activates via clmadmin.exe post-imaging.", + "Applications": [ + { + "_comment": "PC-DMIS 2016 - WiX Burn bundle. INSTALLPDFCONVERTER=0 skips Nitro PDF (saves ~100MB, avoids third-party registration). HEIP=0 disables Hexagon telemetry/error reporting. 2016 does not expose INSTALLOFFLINEHELP (added in 2019). Chained MSIs: CLM Tools + PC-DMIS. Chained EXEs: Win Installer 4.5, VS 2010 x64, VS 2012 x64, .NET 4.6.1, PDF Converter.", + "Name": "PC-DMIS 2016", + "Installer": "Pcdmis2016.0_Release_11.0.1179.0_x64.exe", + "Type": "EXE", + "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\Pcdmis2016.log\" INSTALLPDFCONVERTER=0 HEIP=0", + "LogFile": "C:\\Logs\\CMM\\Pcdmis2016.log", + "DetectionMethod": "Registry", + "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{b3f85c1a-ba25-43d7-a295-7f2775d83526}", + "DetectionName": "DisplayVersion", + "DetectionValue": "11.0.1179.0" + }, + { + "_comment": "PC-DMIS 2019 R2 - WiX Burn bundle. INSTALLOFFLINEHELP=0 skips the ~1-2GB offline help content. INSTALLUNIVERSALUPDATER=0 disables Hexagon's auto-updater (keeps the version frozen until IT bumps it here). INSTALLPDFCONVERTER=0 + HEIP=0 same as 2016. Chained MSIs: CLM Tools 1.7, Protect Viewer, PC-DMIS. Chained EXEs: Win Installer 4.5, VS 2012 x64, VS 2015 x64, .NET 4.6.1, PDF Converter, Universal Updater.", + "Name": "PC-DMIS 2019 R2", + "Installer": "Pcdmis2019_R2_14.2.728.0_x64.exe", + "Type": "EXE", + "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\Pcdmis2019.log\" INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 INSTALLUNIVERSALUPDATER=0 HEIP=0", + "LogFile": "C:\\Logs\\CMM\\Pcdmis2019.log", + "DetectionMethod": "Registry", + "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{4c816d47-9e18-420a-a7d5-0b38ecdabf50}", + "DetectionName": "DisplayVersion", + "DetectionValue": "14.2.728.0" + }, + { + "_comment": "CLM Admin 1.8.73 standalone - upgrades the CLM Tools chained from the PC-DMIS bundles to the latest admin tool. Installs after both PC-DMIS versions so its upgrade path is clean. Chained EXE: VS 2015 x64 redist. No license activation at install time - a tech activates via clmadmin.exe (or the CLM Admin desktop shortcut) post-imaging against licensing.wilcoxassoc.com.", + "Name": "CLM 1.8.73", + "Installer": "CLM_1.8.73.0_x64.exe", + "Type": "EXE", + "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\CLM.log\"", + "LogFile": "C:\\Logs\\CMM\\CLM.log", + "DetectionMethod": "Registry", + "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{a55fecde-0776-474e-a5b3-d57ea93d6a9f}", + "DetectionName": "DisplayVersion", + "DetectionValue": "1.8.73.0" + }, + { + "_comment": "goCMM 1.1.6718 - Hexagon's CMM job launcher utility. Chained EXEs: .NET 4.5.1 (NDP451), VC++ x86 redist. Independent of CLM/PC-DMIS, install order doesn't matter but we install it last.", + "Name": "goCMM", + "Installer": "goCMM_1.1.6718.31289.exe", + "Type": "EXE", + "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\goCMM.log\"", + "LogFile": "C:\\Logs\\CMM\\goCMM.log", + "DetectionMethod": "Registry", + "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{94f02b85-bbca-422e-9b8b-0c16a769eced}", + "DetectionName": "DisplayVersion", + "DetectionValue": "1.1.6710.18601" + } + ] +} diff --git a/playbook/shopfloor-setup/CMM/lib/Install-FromManifest.ps1 b/playbook/shopfloor-setup/CMM/lib/Install-FromManifest.ps1 new file mode 100644 index 0000000..58c195f --- /dev/null +++ b/playbook/shopfloor-setup/CMM/lib/Install-FromManifest.ps1 @@ -0,0 +1,270 @@ +# 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: +# - 01-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 + } + 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 diff --git a/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1 b/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1 index 732c485..2d8c20e 100644 --- a/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/06-OrganizeDesktop.ps1 @@ -46,6 +46,50 @@ if (-not $isAdmin) { exit 1 } +# Transcript. 06 runs as a SYSTEM scheduled task at every logon via the +# 'Organize Public Desktop' task, so without a transcript its decisions +# (what got added, what got suppressed) are invisible. Append mode keeps +# a history across logons; session header + PID makes individual runs +# easy to find. +$transcriptDir = 'C:\Logs\SFLD' +if (-not (Test-Path $transcriptDir)) { + try { New-Item -ItemType Directory -Path $transcriptDir -Force | Out-Null } catch {} +} +$transcriptPath = Join-Path $transcriptDir '06-OrganizeDesktop.log' +try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {} +Write-Host "" +Write-Host "================================================================" +Write-Host "=== 06-OrganizeDesktop session start (PID $PID, $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ===" +Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" +Write-Host "================================================================" + +# ============================================================================ +# Cleanup: remove 'Shopfloor Intune Sync' task after sync completes +# +# Monitor-IntuneProgress.ps1 registers that task as BUILTIN\Users + RunLevel +# Limited (needs a user context to show the QR code / Configure-PC prompt), +# and a Limited task cannot Unregister-ScheduledTask itself. So when sync +# completes it just writes C:\Enrollment\sync-complete.txt as a marker and +# the task stays around firing on every logon as a no-op. +# +# 06 runs as SYSTEM via its own sweep task, which DOES have permission to +# delete any task. When we see the marker, drop the stale task so Task +# Scheduler doesn't keep the dead entry forever. +# ============================================================================ +$syncCompleteMarker = 'C:\Enrollment\sync-complete.txt' +$syncTaskName = 'Shopfloor Intune Sync' +if (Test-Path -LiteralPath $syncCompleteMarker) { + $syncTask = Get-ScheduledTask -TaskName $syncTaskName -ErrorAction SilentlyContinue + if ($syncTask) { + try { + Unregister-ScheduledTask -TaskName $syncTaskName -Confirm:$false -ErrorAction Stop + Write-Host "Removed stale '$syncTaskName' task (sync complete, marker present)." + } catch { + Write-Warning "Failed to remove '$syncTaskName' task: $_" + } + } +} + # Load site config + PC profile . "$PSScriptRoot\lib\Get-PCProfile.ps1" @@ -280,11 +324,9 @@ function Add-ShopfloorToolsApps { # # Kind = 'exe' -> build a fresh .lnk from ExePath # Kind = 'existing' -> copy an existing .lnk via Find-ExistingLnk - $cfgApps = if ($pcProfile -and $pcProfile.desktopApps) { $pcProfile.desktopApps } - elseif ($siteConfig -and $siteConfig.desktopApps) { $siteConfig.desktopApps } - else { $null } + $cfgApps = Get-ProfileValue 'desktopApps' - if ($cfgApps) { + if ($null -ne $cfgApps -and $cfgApps.Count -gt 0) { $apps = @($cfgApps | ForEach-Object { $entry = @{ Name = $_.name; Kind = $_.kind } if ($_.kind -eq 'exe') { $entry.ExePath = $_.exePath } @@ -490,16 +532,27 @@ Add-ShopfloorToolsApps Write-Host "" Write-Host "=== Phase 3: auto-apply startup items from PC profile ===" # Creates .lnk files in AllUsers Startup folder based on the profile's -# startupItems. This is the auto-apply step that eliminates the need -# for Configure-PC to ask about startup items during the imaging chain. -# Configure-PC.bat on the desktop still lets the tech modify them later. +# startupItems. This runs as SYSTEM at every logon via the sweep scheduled +# task, so any item the tech removes via Configure-PC would bounce right +# back unless we remember the removal. startup-overrides.json tracks labels +# the tech has explicitly suppressed; items in that list are skipped here +# and re-enabling them via Configure-PC removes them from the list. $startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup' +$overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json' -$cfgStartup = if ($pcProfile -and $pcProfile.startupItems) { $pcProfile.startupItems } - elseif ($siteConfig -and $siteConfig.startupItems) { $siteConfig.startupItems } - else { $null } +$suppressed = @() +if (Test-Path -LiteralPath $overridesPath) { + try { + $overrides = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json + if ($overrides.suppressed) { $suppressed = @($overrides.suppressed) } + } catch { + Write-Warning " Failed to parse $overridesPath : $_" + } +} -if ($cfgStartup -and $cfgStartup.Count -gt 0) { +$cfgStartup = Get-ProfileValue 'startupItems' + +if ($null -ne $cfgStartup -and $cfgStartup.Count -gt 0) { if (-not (Test-Path $startupDir)) { New-Item -ItemType Directory -Path $startupDir -Force | Out-Null } @@ -509,6 +562,10 @@ if ($cfgStartup -and $cfgStartup.Count -gt 0) { ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 foreach ($si in $cfgStartup) { + if ($suppressed -contains $si.label) { + Write-Host " suppressed: $($si.label) (tech disabled via Configure-PC)" + continue + } $lnkPath = Join-Path $startupDir "$($si.label).lnk" # Skip if already exists (idempotent - don't overwrite user changes) if (Test-Path -LiteralPath $lnkPath) { @@ -567,4 +624,7 @@ Write-Host "" Write-Host "=== Phase 6: register logon sweeper scheduled task ===" Register-SweepScheduledTask -ScriptPath $scriptPath +Write-Host "" +Write-Host "=== 06-OrganizeDesktop session end ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) ===" +try { Stop-Transcript | Out-Null } catch {} exit 0 diff --git a/playbook/shopfloor-setup/Shopfloor/07-TaskbarLayout.ps1 b/playbook/shopfloor-setup/Shopfloor/07-TaskbarLayout.ps1 index 7cf0077..cb1ca56 100644 --- a/playbook/shopfloor-setup/Shopfloor/07-TaskbarLayout.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/07-TaskbarLayout.ps1 @@ -45,11 +45,9 @@ $layoutXmlPath = Join-Path $defaultUserShell 'LayoutModification.xml' # it; all other entries reference C:\Users\Public\Desktop\Shopfloor Tools\ # which 06-OrganizeDesktop.ps1 populates. # ============================================================================ -$cfgPins = if ($pcProfile -and $pcProfile.taskbarPins) { $pcProfile.taskbarPins } - elseif ($siteConfig -and $siteConfig.taskbarPins) { $siteConfig.taskbarPins } - else { $null } +$cfgPins = Get-ProfileValue 'taskbarPins' -if ($cfgPins) { +if ($null -ne $cfgPins -and $cfgPins.Count -gt 0) { $pinSpec = @($cfgPins | ForEach-Object { @{ Name = $_.name diff --git a/playbook/shopfloor-setup/Shopfloor/08-EdgeDefaultBrowser.ps1 b/playbook/shopfloor-setup/Shopfloor/08-EdgeDefaultBrowser.ps1 index c4c1fbe..2031b4d 100644 --- a/playbook/shopfloor-setup/Shopfloor/08-EdgeDefaultBrowser.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/08-EdgeDefaultBrowser.ps1 @@ -199,13 +199,11 @@ function Resolve-StartupUrl { # Resolve startup tabs: profile > site-wide > hardcoded fallback # Different PC types can have different tabs (e.g. Lab has Webmail # instead of Plant Apps, Standard-Machine has Plant Apps). -$cfgTabs = if ($pcProfile -and $pcProfile.edgeStartupTabs) { $pcProfile.edgeStartupTabs } - elseif ($siteConfig -and $siteConfig.edgeStartupTabs) { $siteConfig.edgeStartupTabs } - else { $null } +$cfgTabs = Get-ProfileValue 'edgeStartupTabs' $startupTabs = @() -if ($cfgTabs) { +if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) { foreach ($tab in $cfgTabs) { $fallback = if ($tab.fallbackUrlKey -and $siteConfig.urls) { $siteConfig.urls.$($tab.fallbackUrlKey) } else { '' } $url = Resolve-StartupUrl -BaseName $tab.baseName -Fallback $fallback diff --git a/playbook/shopfloor-setup/Shopfloor/Configure-PC.ps1 b/playbook/shopfloor-setup/Shopfloor/Configure-PC.ps1 index cc2f9fd..5948f2e 100644 --- a/playbook/shopfloor-setup/Shopfloor/Configure-PC.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/Configure-PC.ps1 @@ -42,6 +42,31 @@ Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurren $startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup' $publicDesktop = 'C:\Users\Public\Desktop' +$overridesPath = 'C:\ProgramData\GE\Shopfloor\startup-overrides.json' + +# Persist user-toggled startup overrides so the logon-triggered sweep +# (06-OrganizeDesktop.ps1 Phase 3) doesn't re-create .lnks the tech +# explicitly removed. Labels in $suppressed are the ones currently OFF. +function Get-SuppressedStartup { + if (-not (Test-Path -LiteralPath $overridesPath)) { return @() } + try { + $data = Get-Content -LiteralPath $overridesPath -Raw | ConvertFrom-Json + if ($data.suppressed) { return @($data.suppressed) } + } catch { + Write-Warning " Failed to parse $overridesPath : $_" + } + return @() +} + +function Set-SuppressedStartup { + param([string[]]$Labels) + $dir = Split-Path -Parent $overridesPath + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + $payload = @{ suppressed = @($Labels | Sort-Object -Unique) } + $payload | ConvertTo-Json | Set-Content -LiteralPath $overridesPath -Encoding UTF8 +} # ============================================================================ # Helpers @@ -116,11 +141,9 @@ $edgePath = @( # Resolved from: pcProfile.startupItems > siteConfig.startupItems > hardcoded # ============================================================================ -$cfgItems = if ($pcProfile -and $pcProfile.startupItems) { $pcProfile.startupItems } - elseif ($siteConfig -and $siteConfig.startupItems) { $siteConfig.startupItems } - else { $null } +$cfgItems = Get-ProfileValue 'startupItems' -if ($cfgItems) { +if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) { $items = @() $num = 0 foreach ($si in $cfgItems) { @@ -345,21 +368,38 @@ if ($selection) { $selected = $selection -split '[,\s]+' | Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ } # Process startup items 1-5 + $suppressed = @(Get-SuppressedStartup) + $suppressedChanged = $false + foreach ($item in $items) { if ($selected -notcontains $item.Num) { continue } - if (-not $item.Available) { - Write-Host " $($item.Label): not installed, skipping" -ForegroundColor DarkGray - continue - } - $existingLnk = Join-Path $startupDir "$($item.Label).lnk" if (Test-Path -LiteralPath $existingLnk) { + # Toggling OFF: remove the .lnk AND record the suppression so + # the logon sweep in 06-OrganizeDesktop doesn't re-create it. try { Remove-Item -LiteralPath $existingLnk -Force Write-Host " $($item.Label): REMOVED from startup" -ForegroundColor Yellow } catch { Write-Warning " Failed to remove $($item.Label): $_" } + + if ($suppressed -notcontains $item.Label) { + $suppressed += $item.Label + $suppressedChanged = $true + } } else { + # Toggling ON: drop any suppression first so the sweep won't + # fight us next logon, then create the .lnk. + if ($suppressed -contains $item.Label) { + $suppressed = @($suppressed | Where-Object { $_ -ne $item.Label }) + $suppressedChanged = $true + } + + if (-not $item.Available) { + Write-Host " $($item.Label): not installed, cannot add to startup" -ForegroundColor DarkGray + continue + } + $result = & $item.CreateLnk if ($result) { Write-Host " $($item.Label): ADDED to startup" -ForegroundColor Green @@ -367,6 +407,15 @@ if ($selection) { } } + if ($suppressedChanged) { + try { + Set-SuppressedStartup -Labels $suppressed + Write-Host " (saved override state to $overridesPath)" -ForegroundColor DarkGray + } catch { + Write-Warning " Failed to write override state: $_" + } + } + # Process item 6: machine number logon task if ($selected -contains 6) { if ($machineNumTaskExists) { diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Get-PCProfile.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Get-PCProfile.ps1 index 56b6f85..0355bfa 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Get-PCProfile.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Get-PCProfile.ps1 @@ -18,9 +18,24 @@ # 3. (hardcoded fallback in the calling script) # # Usage pattern in consuming scripts: -# $items = if ($pcProfile -and $pcProfile.startupItems) { $pcProfile.startupItems } -# elseif ($siteConfig -and $siteConfig.startupItems) { $siteConfig.startupItems } -# else { $null } # trigger hardcoded fallback +# $items = Get-ProfileValue 'startupItems' +# if ($null -ne $items) { ... } else { } +# +# Get-ProfileValue distinguishes "not set" ($null) from "explicitly empty" +# (@()) so a profile can opt out of a site-wide default by setting the +# key to []. Do NOT use truthiness checks like `if ($pcProfile.startupItems)` +# because an empty array is falsy and would fall through to site defaults. + +function Get-ProfileValue { + param([Parameter(Mandatory)][string]$Key) + if ($pcProfile -and ($pcProfile.PSObject.Properties.Name -contains $Key)) { + return ,@($pcProfile.$Key) + } + if ($siteConfig -and ($siteConfig.PSObject.Properties.Name -contains $Key)) { + return ,@($siteConfig.$Key) + } + return $null +} function Get-SiteConfig { $configPath = 'C:\Enrollment\site-config.json' diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 index 6d7409e..c8e43ba 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 @@ -561,9 +561,11 @@ function Invoke-SetupComplete { if ($AsTask) { # Write completion marker so future logon-triggered runs exit - # immediately. We can't Unregister-ScheduledTask because the task - # runs as BUILTIN\Users (Limited) which lacks permission to delete - # tasks. The marker file makes the task a harmless no-op. + # immediately. We can't Unregister-ScheduledTask ourselves because + # this task runs as BUILTIN\Users (Limited) which lacks permission + # to delete tasks. 06-OrganizeDesktop.ps1 (which runs as SYSTEM via + # its own sweep task on every logon) watches for this marker and + # deletes the stale Shopfloor Intune Sync task when it sees us. try { Set-Content -LiteralPath $syncCompleteMarker -Value (Get-Date -Format 'o') -Force Write-Host "Sync complete marker written." -ForegroundColor Green diff --git a/playbook/shopfloor-setup/site-config.json b/playbook/shopfloor-setup/site-config.json index 967ac13..62699d8 100644 --- a/playbook/shopfloor-setup/site-config.json +++ b/playbook/shopfloor-setup/site-config.json @@ -97,17 +97,19 @@ }, "CMM": { - "_comment": "TODO: add PC-DMIS, CLM License, Hexagon CMM tools when app details are known", - "cmmSharePath": "\\\\tsgwp00525.wjs.geaerospace.net\\shared\\dt\\shopfloor\\cmm\\hexagon\\machineapps", + "_comment": "Hexagon CMM apps (CLM 1.8, goCMM, PC-DMIS 2016, PC-DMIS 2019 R2). At imaging time they install from a WinPE-staged local bootstrap at C:\\CMM-Install (put there by startnet.cmd when pc-type=CMM, source is the PXE server enrollment share). Post-imaging, the 'GE CMM Enforce' scheduled task runs CMM-Enforce.ps1 on user logon and enforces versions against the tsgwp00525 share below (the SFLD creds Azure DSC provisions unlock the mount). cmmSharePath is the ongoing-enforcement source, not the imaging-time source.", + "cmmSharePath": "\\\\tsgwp00525.wjs.geaerospace.net\\shared\\dt\\shopfloor\\cmm\\machineapps", "startupItems": [ { "label": "WJ Shopfloor", "type": "existing", "sourceLnk": "WJ Shopfloor.lnk" } ], "taskbarPins": [ { "name": "Microsoft Edge", "lnkPath": "%ALLUSERSPROFILE%\\Microsoft\\Windows\\Start Menu\\Programs\\Microsoft Edge.lnk" }, - { "name": "WJ Shopfloor", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\WJ Shopfloor.lnk" } + { "name": "WJ Shopfloor", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\WJ Shopfloor.lnk" }, + { "name": "Defect_Tracker", "lnkPath": "%PUBLIC%\\Desktop\\Shopfloor Tools\\Defect_Tracker.lnk" } ], "desktopApps": [ - { "name": "WJ Shopfloor", "kind": "existing", "sourceName": "WJ Shopfloor.lnk" } + { "name": "WJ Shopfloor", "kind": "existing", "sourceName": "WJ Shopfloor.lnk" }, + { "name": "Defect_Tracker", "kind": "existing", "sourceName": "Defect_Tracker.lnk" } ], "edgeHomepage": "http://tsgwp00524.logon.ds.ge.com/", "edgeStartupTabs": [ diff --git a/playbook/startnet.cmd b/playbook/startnet.cmd index c445924..575ca72 100644 --- a/playbook/startnet.cmd +++ b/playbook/startnet.cmd @@ -318,6 +318,23 @@ if exist "Y:\preinstall\preinstall.json" ( ) else ( echo No preinstall bundle on PXE server - skipping. ) + +REM --- Stage CMM bootstrap bundle (CMM-type PCs only) --- +REM Copies the Hexagon installer bundle (~1.9 GB) from the PXE server enrollment +REM share onto the target disk so 01-Setup-CMM.ps1 can install from local disk. +REM The tsgwp00525 SFLD share that holds the canonical copy is not yet reachable +REM during shopfloor-setup (Azure DSC provisions those creds later), so this +REM bootstrap exists to get the first-install through. Post-imaging, the logon- +REM triggered CMM-Enforce.ps1 takes over from the share. +if /i not "%PCTYPE%"=="CMM" goto skip_cmm_stage +if exist "Y:\cmm-installers\cmm-manifest.json" ( + mkdir W:\CMM-Install 2>NUL + xcopy /E /Y /I "Y:\cmm-installers" "W:\CMM-Install\" + echo Staged CMM bootstrap to W:\CMM-Install. +) else ( + echo WARNING: Y:\cmm-installers not found - CMM PC cannot install Hexagon apps at imaging time. +) +:skip_cmm_stage :pctype_done :cleanup_enroll diff --git a/playbook/sync-cmm.sh b/playbook/sync-cmm.sh new file mode 100755 index 0000000..82a468e --- /dev/null +++ b/playbook/sync-cmm.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# sync-cmm.sh - Push CMM bootstrap bundle to the PXE server enrollment share. +# +# Copies the Hexagon installers + cmm-manifest.json from the local workstation +# to /srv/samba/enrollment/cmm-installers on the PXE server. That directory +# becomes visible as \\10.9.100.1\enrollment\cmm-installers so startnet.cmd +# can xcopy it onto the target disk during WinPE phase. +# +# Run this on the workstation (not on the PXE server) any time: +# - You update cmm-manifest.json in playbook/shopfloor-setup/CMM/ +# - You drop new/updated installers into /home/camp/pxe-images/cmm/hexagon/ +# +# Same pattern as sync-preinstall.sh. The remote tree is updated in place and +# the next imaged CMM PC picks up the new files automatically. +# +# Usage: +# ./playbook/sync-cmm.sh +# +# Requires: sshpass (apt install sshpass), scp, ssh + +set -euo pipefail + +# --- Config --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PXE_HOST="${PXE_HOST:-10.9.100.1}" +PXE_USER="${PXE_USER:-pxe}" +PXE_PASS="${PXE_PASS:-pxe}" + +MANIFEST_SRC="$PROJECT_ROOT/playbook/shopfloor-setup/CMM/cmm-manifest.json" +INSTALLERS_SRC="${CMM_INSTALLERS_DIR:-/home/camp/pxe-images/cmm/hexagon}" + +REMOTE_DIR="/srv/samba/enrollment/cmm-installers" +REMOTE_TEMP="/tmp/cmm-stage" + +# --- Helpers --- +ssh_run() { + sshpass -p "$PXE_PASS" ssh -o StrictHostKeyChecking=no -o LogLevel=ERROR "$PXE_USER@$PXE_HOST" "$@" +} + +scp_to() { + sshpass -p "$PXE_PASS" scp -o StrictHostKeyChecking=no -o LogLevel=ERROR "$1" "$PXE_USER@$PXE_HOST:'$2'" +} + +# --- Validate sources --- +echo "Validating source files..." + +if [ ! -f "$MANIFEST_SRC" ]; then + echo "ERROR: cmm-manifest.json not found at $MANIFEST_SRC" >&2 + exit 1 +fi +printf " OK %10d cmm-manifest.json\n" "$(stat -c %s "$MANIFEST_SRC")" + +if [ ! -d "$INSTALLERS_SRC" ]; then + echo "ERROR: installers dir not found: $INSTALLERS_SRC" >&2 + echo " Set CMM_INSTALLERS_DIR env var to override the default" >&2 + exit 1 +fi + +installers=() +while IFS= read -r f; do + installers+=("$f") +done < <(find "$INSTALLERS_SRC" -maxdepth 1 -type f -name '*.exe' | sort) + +if [ "${#installers[@]}" -eq 0 ]; then + echo "ERROR: no .exe files under $INSTALLERS_SRC" >&2 + exit 1 +fi + +total_bytes=0 +for f in "${installers[@]}"; do + size=$(stat -c %s "$f") + total_bytes=$((total_bytes + size)) + printf " OK %10d %s\n" "$size" "$(basename "$f")" +done +printf " Total payload: %d bytes (%.1f GB)\n" "$total_bytes" "$(echo "scale=2; $total_bytes/1073741824" | bc)" + +# --- Verify PXE server reachable --- +echo "Pinging PXE server $PXE_HOST..." +if ! ping -c 1 -W 2 "$PXE_HOST" >/dev/null 2>&1; then + echo "ERROR: PXE server $PXE_HOST not reachable" >&2 + exit 1 +fi + +# --- Stage files to /tmp on PXE, then sudo install to enrollment share --- +echo "Staging files to $PXE_HOST:$REMOTE_TEMP..." +ssh_run "rm -rf $REMOTE_TEMP && mkdir -p $REMOTE_TEMP/files" + +echo " -> cmm-manifest.json" +scp_to "$MANIFEST_SRC" "$REMOTE_TEMP/files/cmm-manifest.json" + +for f in "${installers[@]}"; do + base="$(basename "$f")" + echo " -> $base" + scp_to "$f" "$REMOTE_TEMP/files/$base" +done + +# --- Build remote install script (runs under sudo on PXE) --- +LOCAL_TEMP_SCRIPT="$(mktemp /tmp/sync-cmm-remote.XXXXXX.sh)" +trap 'rm -f "${LOCAL_TEMP_SCRIPT:-}"' EXIT + +cat > "$LOCAL_TEMP_SCRIPT" <NUL +REM --- Copy site config (drives site-specific values in all setup scripts) --- +if exist "Y:\site-config.json" ( + copy /Y "Y:\site-config.json" "W:\Enrollment\site-config.json" + echo Copied site-config.json. +) else ( + echo WARNING: site-config.json not found on enrollment share. +) + REM --- Copy PPKG if selected --- if "%PPKG%"=="" goto copy_pctype copy /Y "Y:\%PPKG%" "W:\Enrollment\%PPKG%" @@ -223,7 +265,7 @@ if errorlevel 1 ( echo WARNING: Failed to copy enrollment package. goto copy_pctype ) -copy /Y "Y:\run-enrollment.ps1" "W:\run-enrollment.ps1" +copy /Y "Y:\run-enrollment.ps1" "W:\Enrollment\run-enrollment.ps1" REM --- Create enroll.cmd at drive root as manual fallback --- > W:\enroll.cmd ( @@ -242,6 +284,8 @@ REM --- Copy shopfloor PC type setup scripts --- if "%PCTYPE%"=="" goto cleanup_enroll echo %PCTYPE%> W:\Enrollment\pc-type.txt if not "%DISPLAYTYPE%"=="" echo %DISPLAYTYPE%> W:\Enrollment\display-type.txt +if not "%PCSUBTYPE%"=="" echo %PCSUBTYPE%> W:\Enrollment\pc-subtype.txt +if not "%MACHINENUM%"=="" echo %MACHINENUM%> W:\Enrollment\machine-number.txt copy /Y "Y:\shopfloor-setup\Run-ShopfloorSetup.ps1" "W:\Enrollment\Run-ShopfloorSetup.ps1" REM --- Always copy Shopfloor baseline scripts --- mkdir W:\Enrollment\shopfloor-setup 2>NUL @@ -274,6 +318,23 @@ if exist "Y:\preinstall\preinstall.json" ( ) else ( echo No preinstall bundle on PXE server - skipping. ) + +REM --- Stage CMM bootstrap bundle (CMM-type PCs only) --- +REM Copies the Hexagon installer bundle (~1.9 GB) from the PXE server enrollment +REM share onto the target disk so 01-Setup-CMM.ps1 can install from local disk. +REM The tsgwp00525 SFLD share that holds the canonical copy is not yet reachable +REM during shopfloor-setup (Azure DSC provisions those creds later), so this +REM bootstrap exists to get the first-install through. Post-imaging, the logon- +REM triggered CMM-Enforce.ps1 takes over from the share. +if /i not "%PCTYPE%"=="CMM" goto skip_cmm_stage +if exist "Y:\cmm-installers\cmm-manifest.json" ( + mkdir W:\CMM-Install 2>NUL + xcopy /E /Y /I "Y:\cmm-installers" "W:\CMM-Install\" + echo Staged CMM bootstrap to W:\CMM-Install. +) else ( + echo WARNING: Y:\cmm-installers not found - CMM PC cannot install Hexagon apps at imaging time. +) +:skip_cmm_stage :pctype_done :cleanup_enroll