Compare commits

112 Commits

Author SHA1 Message Date
cproudlock
d441abd20f CMM: goCMM restore - canonicalize the SHARE segment case, not just the host
goCMM showed an empty parts list after restore though the bay reached the share.
Decompiled goCMM: PartGroupViewModel matches the registry Selected Part Group
against ApplicationSettings.xml <PartGroup FullName> with a CASE-SENSITIVE compare,
then enumerates that FullName for the parts. The host-canon rewrite fixed only the
hostname, leaving xml '\shared' (lowercase) vs registry '\SHARED' (uppercase) ->
Find null -> SelectedPartGroup null -> empty list. Fix spans the share segment too,
pinning both to \tsgwp00525.wjs.geaerospace.net\SHARED. Verified in PowerShell
(-ceq True). Runs at imaging in Restore-CMM, so all captured backups are fixed on
restore with no re-backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:12:07 -04:00
cproudlock
9145023440 heal/fetch: suppress benign net-use-delete error during imaging
The pre-mount `net use Z: /delete /y` in Fetch-StagingPayload and
Verify-And-Heal-Staging emits "The network connection could not be found" when
Z: is not yet mapped (the normal first-attempt case). PowerShell surfaces that
native stderr as a NativeCommandError (System.Management.Automation.Remote-
Exception) at the call site EVEN WITH `2>$null` - it prints a red error during
the FirstLogonCommands run, alarming the tech and able to mask a real fault.
The mount then succeeds, so it was always cosmetic.

Wrap the cleanup in cmd.exe (`cmd /c "net use $drive /delete /y >/dev/null 2>&1"`) so
net.exe's stderr is redirected to nul INSIDE cmd and never reaches PowerShell as
an error record. Verified on the win11 VM: old pattern leaves $Error.Count=4
(RemoteException); new pattern leaves $Error.Count=0. All four call sites fixed
(both scripts' Mount-Share + end-of-run unmount).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:50:27 -04:00
cproudlock
51edf98e7d heal: verify file completeness (size/timestamp), not just presence
The shallow present-check passed a file that merely existed, so a partially
transferred payload (e.g. a truncated PC-DMIS MSI) looked PRESENT and was never
re-pulled - then failed to install because it was incomplete. Replace it with a
per-item robocopy that compares size + timestamp on every file and re-pulls
anything missing OR partial, skipping ones already complete. VerifyOnly uses /L
to report INCOMPLETE without changing anything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:20:32 -04:00
cproudlock
e3a3fa6794 startnet: stage Verify-And-Heal-Staging.ps1 to C:\Enrollment
The live boot.wim startnet did not stage Fetch-StagingPayload.ps1 (rebuilt from a
stale source), so the unattend Fetch (Order 4) + Verify-And-Heal (Order 5) steps
had no script on disk and never ran - imaging lost payloads with no recovery.

Stage Verify-And-Heal-Staging.ps1 directly here (alongside the existing
Fetch-StagingPayload copy) so the Order 5 heal runs even if Fetch itself fails to
land. Requires re-injecting this startnet.cmd into boot.wim to take effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:31:32 -04:00
cproudlock
a165a79f95 imaging: force shopfloor unattend deploy (was force:no -> went stale)
The shopfloor unattend deploy used force:no, so once a live copy existed the
playbook never overwrote it. That let the live gea-shopfloor unattend drift for
weeks - missing the Fetch + Verify-And-Heal staging steps - which is why imaging
lost payloads (CMM bundle/backups). Flip to force:yes so the repo stays the
source of truth, matching the standard/engineer unattend task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:12:06 -04:00
cproudlock
41cace17e8 imaging: run Verify-And-Heal-Staging at first logon before the network switch
Wire the staging self-heal into the imaging flow so a bay re-pulls any missing
payload while still on the imaging LAN (172.16.9.1), before wait-for-internet
takes it to the production network.

- FlatUnattendW10-shopfloor.xml: insert Verify-And-Heal-Staging.ps1 as
  FirstLogonCommands Order 5 (right after Fetch-StagingPayload Order 4, before
  wait-for-internet); renumber the rest 6-10. Run-ShopfloorSetup stays last and
  is NOT the heal point - it runs post-network-switch when the imaging LAN is
  gone.
- Fetch-StagingPayload.ps1: also pull the small Verify-And-Heal-Staging.ps1 to
  C:\Enrollment so the Order 5 step has it on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:01:59 -04:00
cproudlock
e97e5bd049 shopfloor: CMM PC-DMIS version gate, ShopDB reporter fixes, staging self-heal
- lib Install-FromManifest 2.5->2.6: add _CmmVersion per-entry filter (reads
  C:\Enrollment\cmm\version.txt). Lifted the version gate out of 09-Setup-CMM
  into the shared lib so imaging and GE-Enforce apply it identically and cannot
  drift (root cause of PC-DMIS 2016 installing on every CMM).
- Install-goCMMSettings: canonicalize the part-group share host to the FQDN in
  both the registry and ApplicationSettings.xml. Handles bare \\tsgwp00525\ and
  the legacy rd.ds.ge.com domain; idempotent. VM-tested.
- Report-AssetToShopDB: resolve the machine number eDNC registry first, then fall
  back to C:\Enrollment\machine-number.txt (matches the lib resolution order) so
  a freshly imaged PC still reports its number for the PC-machine relationship.
- Add Update-CMMEnforcer.ps1/.bat: update one CMM's local lib to the gated
  version and self-heal its PC-DMIS version.
- Add Debug-ShopDBReporting.ps1/.bat: one-shot reporter triage (preconditions,
  client log, live test POST, verdict).
- Add Verify-And-Heal-Staging.ps1/.bat: post-boot check that every imaging
  payload arrived and re-pull anything missing from the share, including the CMM
  bundle and the selected bay's backup (the payload that times out in WinPE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:14:54 -04:00
cproudlock
c2538a05c5 CMM: wire per-bay settings restore into the imaging flow
Restore-CMM.ps1 (new) restores a CMM's PC-DMIS + goCMM settings at imaging from
its staged backup. Self-gating: reads C:\Enrollment\cmm\{cmmid,version,doda,
partgroup}.txt, skips DODA bays and bays with no staged backup, and restores
ONLY the config-version PC-DMIS zip via the existing Install-*Settings scripts.
Same-bay restore (cmmid match) so the backed-up controller CommPort is this
bay's own value - no cross-bay clobber.

Version selection matches the VERSION FIELD of the zip name, anchored on the
trailing timestamp, so version=2026 does not false-match a 2019/2016 zip whose
backup timestamp (20260612...) merely contains "2026".

09-Setup-CMM.ps1: new Step 2.8 calls Restore-CMM after app install + first-run
init (so a restored config is not clobbered by PC-DMIS defaults) and before the
C:\CMM-Install cleanup (the backup set lives under <stagingRoot>\backups\<cmmid>).
Best-effort: Restore-CMM always exits 0, imaging never fails on a restore.

startnet.cmd: stage ONLY the picked bay's backup into C:\CMM-Install\backups\
%CMMID% (the bulk robocopy now /XD-excludes the backups tree, which holds every
bay's backup - some 240 MB each - to avoid copying GBs to every imaged CMM).
Also bump the PPKG to v4.16 (the live boot.wim was already v4.16; the repo had
drifted to v4.14).

sync-cmm-backups.sh: source the backups from pxe-images/cmm/backups (where
Backup-CMM writes via the pulled-down copies), not the old cmm-bk path.

Smoke tested on the win11 VM against CMM3's real backup: version=2019 restored
the 2019 R2 zip (not 2016.0), imported HKLM+HKCU reg, converted the part-group
S:\ path to the tsgwp00525 UNC, created C:\geaofi, exit 0; version=2026 correctly
found no matching zip (anchor works).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:34:10 -04:00
cproudlock
59deaea714 CMM: Backup-CMM defaults to S: record-retention path + prompts for CMM#
Default output is now S:\2 WJ Scans Record Retention\backup\cmm\<CmmId>\ instead
of C:\Logs. If S: is not mapped/reachable it falls back to C:\Logs\CMM\cmm-backup
so the backup still runs. When -CmmId is not passed the script prompts for it
(loops until non-empty) since it names the per-bay folder.

Smoke tested on the win11 VM: S: fallback path + the Read-Host prompt (fed via
redirected stdin) both produce the correctly-named per-CMM folder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:34:10 -04:00
cproudlock
f49fa0f940 CMM: install PC-DMIS PDF converter (Amyuni) the standalone-MSI bypassed
Our patched standalone PC-DMIS MSI never installs the Amyuni Document
Converter 500 (system printer "PC-DMIS 50 Converter"). INSTALLPDFCONVERTER
is a Burn-bundle property the main MSI never reads (0 of 153 custom actions
reference it; not in the Property table), and the patched-MSI strategy
bypasses the bundle that would have chained the Amyuni install. The MSI only
lays the installer on disk at <installdir>\PDFDriverInstallFiles\
BatFileInstallPDF50.zip and nothing runs it.

Install-PCDMISPDFConverter.ps1 runs it: scans Program Files\Hexagon (and
Wai) for the laid-down zip, extracts it, parses the InstallPDF50.exe
invocation from the shipped bat (printer name + Wilcox licensee + license
code, read not hardcoded), and runs the exe directly from the extract dir
so sibling DLLs resolve. The shipped bat ends in `pause` (hangs under /qn)
so we never run it. InstallPDF50.exe creates the printer then hangs (same
trait as the bundle), so we poll for the printer and kill the stuck exe
once it appears. Idempotent: printer already present -> exit 0.

Wired as a PS1 manifest entry placed after the PC-DMIS MSIs (files must
exist on disk first), no _CmmVersion (one shared printer covers every
version), MarkerFile detection for one-shot at imaging.

Smoke tested on the win11 VM as SYSTEM: fresh install 7.2s (printer +
driver created), idempotent re-run 0.6s, both exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:10:54 -04:00
cproudlock
c8e704b595 CMM: add backup staging (sync-cmm-backups.sh) + write cmmid.txt at resolve
sync-cmm-backups.sh pushes per-CMM backup sets (goCMM + PC-DMIS zips produced
by Backup-CMM) from pxe-images/cmm-bk/<cmm_id>/ to the PXE share at
installers-post/cmm/backups/<cmm_id>/, atomic-swap with a timestamped prior
copy. Distinct from sync-cmm.sh (which stages the CMM installer bundle).

resolve-cmm-bay-config.ps1 now also writes cmmid.txt alongside version/doda/
partgroup, so 09-Setup-CMM can locate this bay's staged backup for
restore-by-machine-number.

The 09-Setup-CMM restore block + startnet staging line are intentionally NOT
added yet - the restore needs manual end-to-end validation on a real CMM
before auto-running at imaging (per the live-bay restore issues we hit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:47:21 -04:00
cproudlock
1d65103cc0 CMM: add PC-DMIS + combined CMM backup/restore + diagnostic scripts
Adds the PC-DMIS settings/probe backup-restore set alongside the existing
goCMM scripts, plus a single combined CMM backup and the diagnostics built
while debugging the live bays:

- Backup-PCDMISSettings / Install-PCDMISSettings: capture+restore PC-DMIS
  registry + data/probe/cal files per installed version (2016/2019/2026).
  Hardened from real-bay failures: detect install dir via Program Files
  fallback; capture compens.dat (not just comp.dat) + interfac.dll; identify
  the controller by hash-matching interfac.dll to its source DLL AND reading
  the PE OriginalFilename (covers rename-without-copy); EXCLUDE the whole
  Homepage state (Recent/Favorites/DetailsView) which null-refs PC-DMIS on
  launch via stale routine paths; restore routes HKCU into the target user's
  hive (-TargetUser ShopFloor), fails loud on a non-backup path, and applies
  the legacy->new FQDN rewrite across reg + data files incl .bas.
- Backup-CMM: one wrapper running goCMM + PC-DMIS (all versions) into one
  per-CMM folder + index, for staging on PXE and restore-by-machine-number.
- Clear-PCDMISRecent: fixes the Homepage recent-list NullReferenceException
  crash on an already-broken bay.
- pcdmis-probe-debug / Export-PCDMISCrashEvents: diagnostics for the
  custom-probe-not-showing and crash investigations.
- Modify-PCDMISRights / Grant-FullControl: grant the operator the registry +
  filesystem access PC-DMIS needs under lockdown.
- Install-goCMMSettings: add .bas to the FQDN-rewrite include list.

Not yet wired into 09-Setup-CMM auto-restore - staging + the gated restore
block come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:42:32 -04:00
cproudlock
bfe17fe123 CMM: add goCMM settings backup/restore + debug scripts
goCMM settings live in two places: 3 pointer values at
HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM, and the real content of all
7 Settings tabs (PC-DMIS, Quindos, Modus, Machine Definition, User Input,
Notifications, Part Groups) in C:\geaofi\ApplicationSettings.xml. Capture-replay
pair, mirroring the Wax/Trace Backup/Install scripts:

- Backup-goCMMSettings.ps1/.bat: on a live legacy bay (admin), zips the registry
  key + the C:\geaofi tree (minus transient LocalProgramCopies/logs) to
  gocmm_backup_<PC>_<ts>.zip.
- Install-goCMMSettings.ps1/.bat: restore at imaging (admin). Imports the key +
  lays down C:\geaofi, then grants BUILTIN\Users WriteKey on the reg key and
  Modify on C:\geaofi - goCMM's RegistrySettings.GetRegistryString opens the key
  with writable:true even to READ, so a locked-down operator throws a
  SecurityException without the grant (the post-lockdown 'registry access not
  allowed' error). Applies a built-in legacy->new FQDN rewrite
  (rd.ds.ge.com -> wjs.geaerospace.net) automatically across the registry values
  and ApplicationSettings.xml (incl PartGroup FullName); -NoDefaultRewrite skips
  it, /replace adds an extra pair, -SelectedPartGroup overrides per bay.
- gocmm-debug.ps1/.bat: run as the operator to reproduce the SecurityException
  and dump the goCMM key ACL (confirms whether lockdown stripped the grant).

All round-trip + FQDN-rewrite verified on the win11 VM. NOTE: covers goCMM only;
PC-DMIS probe calibrations / custom tip angles / machine comp are owned by
PC-DMIS (Hexagon) and not captured here. Not yet wired into 09-Setup-CMM
auto-discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:27:30 -04:00
cproudlock
da380fbcd7 Wax/Trace: prefer backup ZIP over cal ISO when both exist
Standardize the calibration source. Step 3b already restores a per-asset
backup ZIP (HKLM + data + config, incl. the probe cal tables) for any bay
that has one - which is now every migrated bay. When that ZIP is staged,
skip the redundant and fragile cal-ISO mount/vendor-Setup step so all
bays follow one flow (app ISO + backup restore), matching the bays that
never had a cal ISO. A future bay with a cal ISO but no backup still
falls through to the ISO path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:13:03 -04:00
cproudlock
a935c04e1f Wax/Trace WJF00450: set user_id, pin 6.213 app-ISO choice
Fill in user_id 0992830128 (was a MISSING_DATA placeholder pending a
dongle read). Keep ftpak_version 6.213: the bay runs FormTracePak
6.2.0.45 but no V6.204 app ISO exists, so it installs the V6.213 app and
restores the 6.2.0.45 config/cal from its per-asset backup ZIP - the same
pattern as WJF00461 (6.213 ISO / 6.2.0.51 backup). Cal .txt tables are
version-independent so they apply onto the 6.213 binaries. hw_id stays
MISSING_DATA (metadata, not consumed by the resolver). Sync the scripts/
copy so the resolver and the backup tools see identical data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:30 -04:00
cproudlock
1e17a0564f eDNC: standardize per-type fallback installers on 6.4.7
Replace per-type eDNC_6-4-5.msi with eDNC-6.4.7.msi (ProductVersion
6.4.7.0) in collections, nocollections, heattreat, and partmarker.
01-eDNC.ps1 already prefers the single shared installer at
C:\PreInstall\installers\dnc (6.4.7); aligning the per-type fallback
means an empty shared dir can no longer install a stale 6.4.5. All DNC
types now resolve to 6.4.7 on either path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:19 -04:00
cproudlock
99e7679e87 HeatTreat: bump DNC app 6.2.1 -> 6.4.9
Replace HeatTreat_6.2.1.msi with HeatTreat_6-4-9.msi (ProductVersion
6.4.9.0, ProductCode {9E603EFE-888A-4E3F-8CF5-7F03B7029919}). The install
script globs HeatTreat*.msi so no logic change; the MSI's NOT
NEWERVERSIONDETECTED LaunchCondition makes 6.2.1 -> 6.4.9 a clean
in-place upgrade. Update version references in 02-Setup-HeatTreat.ps1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:09:05 -04:00
cproudlock
1e6603d331 eDNC: default to single shared installer (C:\PreInstall\installers\dnc\eDNC-6.4.7.msi)
01-eDNC.ps1 (all 4 DNC types) now installs eDNC from the shared pre-install copy
staged by startnet.cmd to C:\PreInstall\installers\dnc - one source of truth
(currently 6.4.7) instead of a per-type bundled msi. Falls back to the per-type
eDNC\ folder when the shared copy is absent (older images). eMxInfo.txt, the
x86->64 mirror, and Site/MachineNo registry steps are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:45:24 -04:00
cproudlock
e844ff367c site-config: common shortcut base on all shopfloor types + per-type apps; new homepage host
- Homepage/start-menu + Edge URLs: http://tsgwp00524.logon.ds.ge.com/ ->
  https://tsgwp00525.wjs.geaerospace.net (8 refs across site-config + 08-EdgeDefaultBrowser).
- Every shopfloor working type now carries the same common base: Edge + WJ
  Shopfloor + Defect_Tracker pins/desktop, WJ Shopfloor + Plant Apps startup,
  3 web tabs. Timeclock/Genspect/Keyence/CMM upgraded to match.
- Per-type specialized apps: CMM (PC-DMIS 2016/2019 R2, CLM Admin, goCMM),
  WaxAndTrace (Formtracepak), partmarker (Mark + Backup420 only - dropped
  Telesis Backup400/470), heattreat (HeatTreat).
- eDNC/UDC/NTLARS listed only on bays that can run them; 06-OrganizeDesktop +
  07-TaskbarLayout gate every exe entry on Test-Path, so uninstalled apps are
  silently skipped (the "eDNC if it exists / UDC if it exists" behavior).
- Lab + Display-* left as kiosk/lab, not given the shopfloor base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:08:32 -04:00
cproudlock
913c807142 preinstall: OpenText + serial drivers on all PC types; Oracle on DNC bays + CMM
- Shopfloor Serial Drivers: PCTypes ["Standard"] -> ["*"]. Serial hardware
  appears across bays; a driver where the hardware is absent is harmless (sits
  in the driver store). PartMarker/HeatTreat were getting skipped.
- OpenText HostExplorer ShopFloor: PCTypes -> ["*"]. Every shopfloor PC type
  should get HostExplorer + its profiles/EB/keymaps/menus + desktop shortcuts.
- Oracle Client 11.2: scoped to the DNC-bearing types (collections,
  nocollections, partmarker, heattreat) + CMM (metrology tooling links the
  Oracle home). Dropped Genspect/Keyence/WaxAndTrace/Display/Timeclock, which
  have no DNC and don't need the Oracle client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:40:11 -04:00
cproudlock
1853db0903 preinstall: run OpenText on Part Marker bays (profiles, EB macros, desktop shortcuts)
OpenText HostExplorer was skipped on Part Marker PCs because its PCTypes
filter in preinstall.json omitted PartMarker, so Setup-OpenText.ps1 never
ran - no MSI, no per-user profile/keymap/menu/macro fan-out, no .eb macros
into ProgramData\Hummingbird\Connectivity\15.00\Shared, and no public
desktop shortcuts.

- preinstall.json: add PartMarker + gea-shopfloor-partmarker to OpenText PCTypes
- 00-PreInstall-MachineApps.ps1: add PartMarker alias group to the preinstall
  matcher (parity with Install-FromManifest.ps1, which already had it)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:49:36 -04:00
cproudlock
7a67716fcc manifest engine: null-safe PS1 dispatch (accept Script or Installer, log resolved path)
PS1 entries were crashing with a cryptic "Cannot bind LiteralPath because it is
null" when the resolved script path came back null - the per-entry try/catch
caught it (so the scope survived) but the cause was opaque. Now the PS1 branch
accepts either Script or Installer, null-guards before Join-Path/Test-Path, and
logs the resolved relative path, so a bad/empty entry is skipped with a clear
"has no Script/Installer value" line instead of a null-bind throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:38:16 -04:00
cproudlock
995909042b preinstall: install Oracle Client 11.2 on Part Marker + HeatTreat bays
The preinstall Oracle entry (run off the PXE pre-install share during shopfloor
setup, NOT the runtime enforcer) gated PCTypes to the old taxonomy only -
Standard/CMM/Genspect/Keyence/WaxAndTrace/Display - so gea-shopfloor-partmarker
and gea-shopfloor-heattreat bays were pc-filtered and never installed Oracle.
These two pctypes were added after the rename reorg and only exist as
gea-shopfloor-* names, so they were missing from the legacy-named list. Added
both. Already deployed live to the PXE enrollment share's preinstall.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:04:17 -04:00
cproudlock
a351160520 manifest engine: reg-first machine number + per-app crash isolation; add start-layout DSC
Install-FromManifest.ps1:
- Get-CurrentMachineNumber reads the eDNC/DNC registry FIRST (reassignment-
  authoritative), falling back to C:\Enrollment\machine-number.txt. The txt is
  written once at imaging and is NOT updated on reassignment, so txt-first
  gated reassigned bays on a stale number.
- Per-entry try/catch in the app loop: a single entry that throws no longer
  aborts the whole scope (skipping every later entry + the status write). It is
  logged, counted failed, and the loop continues. This was silently killing the
  collections scope at the MTConnect Makino entry, which also stopped the
  ShopDB asset reporter (a later entry) from ever running.

Deploy-ShopfloorStartLayout.ps1 (new): local-DSC port of the Intune
desktop-weblinks + Start-menu pins (copies .url/.lnk to Public Desktop +
All-Users Start Menu, writes the ConfigureStartPins JSON policy, resets
start2.bin + restarts the shell). Verified on Win11: pins render after logon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:40 -04:00
cproudlock
a380b17112 collections: report host + IP + machine number to ShopDB each enforce cycle
Adds Report-AssetToShopDB.ps1 (Type=PS1, DetectionMethod=Always manifest entry)
for collections PCs. Reads hostname, BIOS serial, eDNC MachineNo and the corp
NIC IPv4 (filtered to WJ corp ranges, controller NIC dropped) and POSTs
action=updateCompleteAsset to ShopDB api.asp, which upserts the machine, stores
the IP, and links the PC to its machine-number equipment. manifest-entry-report-asset.json
is the snippet to merge into the SFLD share collections manifest (+ stage the
script under apps/). Note: relies on the ShopDB api.asp LogToFile Err-leak fix
(separate shopdb repo commit) to create the relationship reliably.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:04:33 -04:00
cproudlock
5211861409 manifest: add PartMarker pctype alias so common entries reach Part Marker PCs
PartMarker was missing from the PCType alias map, so gea-shopfloor-partmarker
matched no alias set and common manifest entries gated by PCTypes (notably
Oracle Client 11.2) were pc-filtered out - Part Marker PCs never installed
Oracle. Adds @('PartMarker','gea-shopfloor-partmarker') to the alias groups.
The companion fix (adding partmarker+heattreat to Oracle's PCTypes list) lives
in the SFLD share common/manifest.json. Verified on the win11 VM: with PCType
gea-shopfloor-partmarker the Oracle entry is now evaluated (0 pc-filtered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:04:33 -04:00
cproudlock
db6be99d43 samba: deadtime=0 (was 5) so WIM-apply idle does not drop the staging mount
WinPE maps the enrollment share early, then idles for minutes while the WIM
applies. deadtime=5 dropped that idle session, so the post-apply staging
copies failed (bay left with only site-config.json staged). VM test against
the live share copied everything fine on a fresh mount; the only difference
was the idle. deadtime=0 disables idle auto-disconnect. Applied live to the
current box (smbd reloaded); this makes it permanent + covers the second box
on the next playbook run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:04:37 -04:00
cproudlock
a6fa21589b Imaging: defer bulk staging to first-logon Fetch (fresh mount) - Phase 1
WinPE maps Y: early then idles for minutes during the WIM apply; samba
deadtime drops the idle session, so the WinPE staging copies failed (bay
left with only site-config.json). Add Fetch-StagingPayload.ps1, run from the
unattend FirstLogonCommands at first logon on a FRESH share mount (full
Windows, no prior idle), to pull the shopfloor-setup tree + preinstall
bundle. Detailed per-item log (exit code, counts, timing, mount retries) at
C:\Logs\Fetch\ - the old WinPE staging was opaque.

- Fetch runs as Order 4, BEFORE wait-for-internet.ps1 (Order 5) which switches
  the bay to the production network and off the imaging LAN. So Fetch still
  reaches \172.16.9.1\enrollment.
- WinPE bulk staging kept as best-effort fail-fast fallback (Phase 1); the
  post-boot Fetch is now the authoritative path. Remove the WinPE bulk once
  validated. Heavy per-type payloads (CMM/Keyence/WaxTrace) stay in WinPE for
  now - Phase 2.
- startnet stages Fetch-StagingPayload.ps1 + writes fetch-source.txt
  (UNC/user/pass) for the post-boot mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:01:19 -04:00
cproudlock
f570e92847 startnet: robocopy (fail-fast) for staging copies so a stalled Y: cannot hang imaging
A Display bay staged only site-config.json then nothing - winpe-staging.log
ended at 'Copied site-config.json'. PCTYPE/PPKG were both set, so it was not
a menu bug: execution froze at the very next step, the PPKG copy
  copy /Y Y:\ppkgs\... W:\Enrollment\...
A bare 'copy' from a network drive has no timeout; when the Y: SMB handle
stalled it blocked forever, so pc-type.txt / shopfloor-setup / preinstall
never ran (Display kiosk + Common OpenText both depend on the preinstall
phase). Convert every bare 'copy /Y' in the staging path to
'robocopy /R:1 /W:1' (fail-fast retry, no overwrite/dir prompts). robocopy
exit codes are inverse of copy (0-7 ok, 8+ fail) so the PPKG check flips to
errorlevel 8, and since robocopy keeps the source name the PPKG gets a ren
to its BPRT-tagged target. A stalled share now fails one step and continues
instead of freezing the whole stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:21:39 -04:00
cproudlock
ea136687e0 site-config: add Part Marker + HeatTreat pcProfiles (DNC shortcuts/pins + apps)
Neither new PC type had a pcProfile, so they fell back to the top-level
defaults, which deliberately exclude eDNC. Result: no DNC desktop shortcut
or taskbar pin on either type. Add profiles mirroring gea-shopfloor-
nocollections (eDNC + NTLARS baseline) plus the type-specific apps:

- gea-shopfloor-partmarker: Mark (C:\Program Files (x86)\Mark\bin\Mark.exe)
  + Telesis Backup400/420/470 (C:\Program Files (x86)\Telesis\Backup4xx\).
- gea-shopfloor-heattreat: HeatTreat
  (C:\Program Files (x86)\HeatTreat\bin\HeatTreat.exe).

Deployed to the live enrollment share (config/ + shopfloor-setup/).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:59:03 -04:00
cproudlock
57fae57d3b CMM/DODA: fix DODA paths + CyberArk EPM policy doc
- 09-Setup-CMM.ps1: Step 2.5 ACL list targeted C:\Program Files\DODA (a path
  that never exists), so the BUILTIN\Users write grant on DODA was silently
  skipped. Corrected to C:\Apps\DODA, where Install-DODA.ps1 actually extracts.
- Install-DODA.ps1: create C:\Apps\DODA\PreProcess after extract. The DODA
  zip unpacks flat without it; MergeFiles.exe expects it and crashed with
  DirectoryNotFoundException (MergeFiles.GetDoDAFolder) when absent.
- docs/cyberark-cmm-doda-policy.md: EPM admin reference for elevating the CMM
  report toolchain. CyberArk EPM elevation is per-process and not inherited, so
  the external tools PC-DMIS spawns (MergeFiles/PCDToIGES/RotateProbeVector/
  DovetailAnalysis) run un-elevated and fail. Doc gives the Application Group
  (by SHA-256), the Elevate policy, scope, verify steps, and the
  CREATE_PDF_FROM_RTF.BAS rework that drops Word/Reader from the elevation set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:38:34 -04:00
cproudlock
c88b2b0ab8 Wax/Trace: classify restore .reg by content root, not filename
Install-FormtracepakSettings decided per-user vs HKLM by matching the
filename against 'HKEY_USERS'. The backup names files after their source
path, so an operator pref captured from HKCU:\ lands in a file named
"HKCU_..." with content root HKEY_CURRENT_USER - which the filename match
missed entirely, dropping it from the restore.

Read each .reg once and classify by its content root(s):
  [HKEY_CURRENT_USER...  -> per-user, remap root to HKEY_USERS\<targetSid>
  [HKEY_USERS\<srcSid>... -> per-user, remap srcSid -> targetSid
  [HKEY_LOCAL_MACHINE...  -> HKLM
The temp rewrite (UTF-16-LE for reg.exe import) is only written when the
content actually changes; otherwise the file imports as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:54:00 -04:00
cproudlock
1a5852f7ff CMM: per-bay path is goCMM "Selected Part Group" UNC, not Shared Data Directory
A capture from a working CMM4 bay showed the goCMM registry holds two
distinct values under HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM:
  Shared Data Directory = C:\geaofi\                  (constant on every bay)
  Selected Part Group   = \\tsgwp00525...\SHARED\...  (the per-bay UNC path)

The prior commit (f6d970c) put the per-bay path into "Shared Data Directory",
which is wrong. Correct that:
- bay-config column shared_data_dir -> part_group
- resolve-cmm-bay-config emits partgroup.txt (was shareddatadir.txt)
- 09-Setup-CMM seeds "Shared Data Directory" to the constant C:\geaofi\ and
  "Selected Part Group" to the per-bay path, converting the friendly S:\...
  form to the \\tsgwp00525.wjs.geaerospace.net\SHARED UNC at apply time.
  Users write grant on the key is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:53:53 -04:00
cproudlock
0b116e3ecf HeatTreat: import per-machine DNC .reg by machine number
HeatTreat bays pick a machine number (6601-6604) at imaging; 02-Setup-
HeatTreat now imports the matching reg\<machine-number>.reg after the MSI,
rewritten to WOW6432Node (DNC is 32-bit; reg import does not honor /reg:32),
mirroring the Part Marker WJPRT.reg flow.

- startnet.cmd: route gea-shopfloor-heattreat to the machine-number prompt
  (was defaulting to skip), so machine-number.txt is written for the picker.
- The 6601-6604 .reg files are gitignored (they carry a DNC FtpPasswd
  credential) and deploy via the enrollment share from the working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 11:21:58 -04:00
cproudlock
f6d970c08d CMM: seed goCMM Shared Data Directory per bay + grant Users write
goCMM (.NET x86) stores its program-source path in HKLM\SOFTWARE\
WOW6432Node\General Electric\goCMM value 'Shared Data Directory'. Being
HKLM, a non-admin shopfloor user cannot set it via goCMM's UI (nor save a
Selected Part Group switch). 09-Setup-CMM Step 2.7 now seeds the per-bay
path (admin context at imaging) and grants BUILTIN\Users write on the key,
mirroring the existing Step 2.5 install-dir ACL grant.

- cmm-bay-config.csv: add shared_data_dir column (per-bay paths, CMM1-12).
- resolve-cmm-bay-config.ps1: write C:\Enrollment\cmm\shareddatadir.txt
  (space-safe; e.g. CMM8 'Venture CMM8').
- 09-Setup-CMM.ps1: Step 2.7 reg seed + Users ACL on the goCMM key.

Not yet deployed to the live server (held).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:59:55 -04:00
cproudlock
59b1a9fb65 Add Part Marker + HeatTreat shopfloor PC types (eDNC + vendor MSI)
New gea-shopfloor-partmarker type (startnet menu option 10) and fill the
gea-shopfloor-heattreat stub. Both follow the collections eDNC pattern:
01-eDNC.ps1 installs DNC, then a 02-Setup script installs the vendor MSI.

Part Marker (02-Setup-PartMarker.ps1):
- msiexec Mark-6.2.1.msi /qn /norestart LAUNCHNTLARS=false (the LaunchNtlars
  custom action otherwise fires under /qn and launches NTLARS mid-install,
  same as eDNC).
- After install: import WJPRT.reg rewritten to WOW6432Node (reg import does
  not honor /reg:32; DNC is 32-bit and reads the redirected hive), then copy
  the Mark overlay + eMxInfo.txt into C:\Program Files (x86)\Mark.

HeatTreat (02-Setup-HeatTreat.ps1):
- msiexec HeatTreat_6.2.1.msi /qn /norestart LAUNCHNTLARS=false. Existing
  09-Setup-Heattreat.ps1 (OpenText) still runs after. Optional .reg/file
  copy left as a marked TODO pending confirmation.

Both MSIs decompiled: WiX/GE Aviation, no forced reboot, only LaunchCondition
is NOT NEWERVERSIONDETECTED. utilpassword.txt is gitignored (secret, deployed
via the enrollment share from the working tree).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:59:45 -04:00
cproudlock
69a1682a7f webapp: imaging UX overhaul + image management CRUD
Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
  Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
  inferred sessions keyed by MAC for bays that have only touched the boot
  chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
  list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
  30); sidecar .log file per serial records every log_lines push uncapped
  (read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
  Client fetches /imaging/tiles (HTML partial) on hash change and swaps
  #imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
  and any LAPS input the operator is mid-pasting (swap skipped when a
  laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
  header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
  /imaging/tiles SSE refresh. Inferred bays render with yellow border +
  log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
  <serial>. Session metadata grid, stage timeline table, full sidecar log
  with truncation indicator, Copy-support-summary button. Linked from each
  client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
  Intune device-ID QRs in the swapped-in tiles.

Image management
- services/image_registry.py: JSON registry of image types at
  {SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
  config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
  mutate the file then call reload() which rewrites config.IMAGE_TYPES +
  config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
  optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
  Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
  box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
  dirs).

Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
  upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
  /images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
  drivers card-footer rebuilt as interactive list with per-row Adopt inline
  form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
  appends a packages.json entry.

Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
  DNSMASQ_SYSLOG for the log-tailer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:06 -04:00
cproudlock
c74148a222 Blancco boot configs: update IP from 10.9.100.1 to 172.16.9.1
grub-blancco.cfg was still referencing the old PXE LAN IP for kernel,
initrd, and archiso_http_srv. Also synced repo copy to match the live
BDE-kernel TFTP-based config. Updated iPXE scripts (blancco.ipxe,
blancco-debug.ipxe, blancco-chain.ipxe) for the same IP change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:17:14 -04:00
cproudlock
191083e440 preinstall.json: fix Oracle 11.2 detection path (add WOW6432Node)
Oracle Client 11.2 is 32-bit. On 64-bit Windows the registry key lives
under WOW6432Node but the preinstall detection was reading the native
64-bit path, which doesn't exist. Detection always failed, causing a
redundant reinstall on every re-image. The GE-Enforce manifest had the
correct path since 2026-05-01 but preinstall.json was missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:09:46 -04:00
cproudlock
1860a92afa 09-Setup-WaxAndTrace: bypass vendor cal Setup.exe for all 218-378-13 probes
The .NET cal Setup.exe crashes (0xE0434352) on 218-378-13 series ISOs
even when filenames are clean (no trailing-space bug). Previously only
bypassed when the ' _' trailing-space signature was detected. Now
detects by probe series ID in the data filenames too and always does
direct file copy for 218-378-13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:06:57 -04:00
cproudlock
9ad467ba6c 09-Setup-WaxAndTrace: fix stale $backupDir in no-backup log message
The variable was renamed to $backupDirCandidates + $bd loop but the
else-branch log message still referenced the old $backupDir, producing
a malformed path like "\.zip" in the log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:05:14 -04:00
cproudlock
485fe1c7c4 startnet.cmd: log all WinPE staging operations to winpe-staging.log
Every copy/robocopy during the W: staging phase now appends to
W:\Enrollment\winpe-staging.log (persists as C:\Enrollment\ post-boot).
robocopy gets /LOG+ to append its file list; echo lines log timestamps
for each stage. Helps diagnose missing-file issues post-imaging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:49:57 -04:00
cproudlock
9b46d0279f select-waxtrace-asset: fix blank serial/probe columns in bay picker
The picker was reading unit_serial and probe_part from the old INDEX.csv
format. bay-config.csv uses different column names (ftpak_version, model,
host). Updated Select-Object and display format to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:20:37 -04:00
cproudlock
5e13d38512 CMM per-bay PC-DMIS version selection + DODA deploy
Add bay picker (same arrow-key pattern as waxtrace) that maps CMM1-12
to a PC-DMIS version (2016/2019/2026) and DODA flag via cmm-bay-config.csv.

startnet.cmd: replace Standard/DODA submenu with bay picker. Writes
CMMID (e.g. CMM4) to machine-number.txt so the existing
TargetMachineNumbers filter on the SFLD share manifest gates per-bay
entries with no lib changes.

09-Setup-CMM: reads resolved version.txt and filters cmm-manifest.json
by _CmmVersion tag at imaging time so only the matched PC-DMIS version
installs.

cmm-manifest.json: add PC-DMIS 2026.1 entry (patched MSI, product code
{81BACE1B-FB08-4DCF-8100-79911AD3EC1E}) and DODA entry (flat zip extract
to C:\Apps\DODA\). Existing 2016/2019 entries tagged with _CmmVersion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:01:27 -04:00
cproudlock
55c1ab4814 CMM first-run-as-admin, controller credential user-context fix, IE compat hash
09-Setup-CMM: add Step 2.6 that launches each installed PC-DMIS
version once as admin before the PPKG locks the machine down. Also
adds PC-DMIS 2026.1 to the ACL directory list.

Controller credential: cmdkey /add under SYSTEM stored creds in the
wrong vault. Switch to a Register script (MarkerFile detection, runs
once) that creates an AtLogOn scheduled task under BUILTIN\Users so
cmdkey runs in the ShopFloor user's session.

IE compat: update test matrix hash for the new site list that adds
wjfms3.apps.wlm.geaerospace.net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 07:03:32 -04:00
cproudlock
5c3db71879 startnet.cmd: replace remaining xcopy calls with robocopy /MT:16
Waxtrace staging already used robocopy (8e1f81b, f95d305) but the
shopfloor-setup baseline, common, _ntlars-backups, type-specific,
pre-install, CMM, and Keyence copies still used xcopy with zero error
visibility. Switch them all to robocopy /E /MT:16 /R:1 /W:1 with
errorlevel-8 warnings so copy failures surface on the WinPE console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 09:44:44 -04:00
cproudlock
97b9e58d23 Configure-PC + nocollections ACL: align with two-task machine number design
- gea-shopfloor-nocollections/02-MachineNumberACLs.ps1: gut to no-op
  matching the collections variant. SYSTEM Apply task no longer needs
  per-user ACLs on the eDNC reg key or UDC ProgramData dir.

- Configure-PC.ps1 item 6 (Machine number logon prompt toggle):
  Stop duplicating Register-ScheduledTask logic inline. Call the
  shared Register-CheckMachineNumberTask.ps1 registrar so both the
  Prompt user-task and Apply SYSTEM-task are installed with matching
  SDDL config. Existence check now treats EITHER the new "Prompt
  Machine Number" task OR the legacy "Check Machine Number" task as
  "ON" so old bays still register correctly. Toggle OFF unregisters
  all three names (Prompt + Apply + legacy) for clean removal.
2026-05-24 18:36:35 -04:00
cproudlock
7298d433eb 9999 machine-number prompt: split into user-context Prompt + SYSTEM-context Apply
OLD design: a single 'Check Machine Number' scheduled task ran as the
logged-in user (BUILTIN\Users, Limited) on AtLogOn. It both showed the
InputBox AND tried to update HKLM\SOFTWARE\WOW6432Node\GE Aircraft
Engines\DNC\General + C:\ProgramData\UDC\udc_settings.json. To make
those non-admin writes possible, 02-MachineNumberACLs.ps1 pre-granted
BUILTIN\Users SetValue + Modify on those targets during imaging.

Three problems with that:
  1. SECURITY: any logged-in user could overwrite the machine-identity
     reg key.
  2. FRAGILE: ACL grants raced with eDNC install timing on some bays
     (eDNC reg key didn't exist yet when 02-MachineNumberACLs ran;
     OpenSubKey returned null, ACL silently skipped, Check-MachineNumber
     later failed with PermissionDenied).
  3. SILENT-SUCCESS BUG: Update-MachineNumber's Set-ItemProperty calls
     lacked -ErrorAction Stop. PermissionDenied is a non-terminating
     error in PS5.1, so the try/catch never fired. The script set
     $out.EdncUpdated=$true anyway and the dialog reported success
     while the reg value stayed at 9999. WJF capture log on FGY07FZ3
     shows this exact pattern.

NEW design - two scheduled tasks split by responsibility:

  - "Prompt Machine Number" : AtLogOn trigger, BUILTIN\Users (Limited).
    Reads current values (read-only). If 9999, shows InputBox. Writes
    typed number to C:\Logs\SFLD\machine-number-request.txt. Triggers
    SYSTEM Apply via schtasks /run. Polls for result JSON (60s timeout).
    Shows result MessageBox with TopMost so it isn't hidden behind
    other windows.

  - "Apply Machine Number" : on-demand, SYSTEM (Highest). Reads the
    request file, calls Update-MachineNumber (full HKLM + ProgramData
    access from SYSTEM context). Pulls per-machine NTLARS .reg + UDC
    settings JSON + UDC live data from the SFLD share if site-config
    has share paths. Writes result JSON. Removes request file.
    Unregisters the Prompt task on full success (Prompt itself can't
    self-unregister - Limited users can't delete a SYSTEM-owned task).

  - Default task SDDL only allows Admins + SYSTEM to read/run a
    SYSTEM-owned task. Added BUILTIN\Users GR+GX ACE via COM
    SetSecurityDescriptor so the Limited Prompt task can schtasks /run
    Apply on demand. They can read + execute it; not modify or delete.

  - Update-MachineNumber.ps1 writes now have -ErrorAction Stop so
    PermissionDenied actually fires the catch block instead of being
    swallowed.

  - 02-MachineNumberACLs.ps1 gutted to a no-op (left in place for
    Stage-Dispatcher discovery; no longer grants the ACLs). Old bays'
    existing grants are harmless since SYSTEM ignores them.

  - Register-CheckMachineNumberTask.ps1 now installs both tasks AND
    unregisters the legacy 'Check Machine Number' task name on
    re-imaging. Run-ShopfloorSetup.ps1's $skipInBaseline list now
    includes Prompt-MachineNumber.ps1 + Apply-MachineNumber.ps1 so
    they aren't auto-run during the baseline pass (only via the
    scheduled tasks).

Smoke tested end-to-end on win11 VM with ShopFloor (Limited) logging in
interactively: AtLogOn trigger fired Prompt, dialog rendered, tech
typed 7777, schtasks /run succeeded (the SDDL fix lets Limited users
trigger SYSTEM tasks), Apply ran as SYSTEM, eDNC reg + machine-number.txt
both updated to 7777, result MessageBox shown, Prompt task auto-
unregistered by Apply's cleanup step. No ACL grants needed on any user.

Apply also re-tested with -ErrorAction Stop confirming non-terminating
PermissionDenied now properly throws into the catch + populates Errors[]
+ flips $out.EdncUpdated to false - so any future write failures will
report honestly instead of silently claiming success.
2026-05-24 17:08:59 -04:00
cproudlock
de7d41f5e5 Wax/Trace: defer HKEY_USERS per-user prefs restore to first ShopFloor logon via SYSTEM scheduled task
Bay's ShopFloor user account exists but has never logged in at imaging
time, so its NTUSER.DAT doesn't exist yet and we can't reg-load its
hive to remap source SID -> ShopFloor SID. The in-line restore at
09-Setup Step 3b handles HKLM (controller config, device-map) + files,
but per-user prefs (LouteditS Layout, Page margins, Recent Files, ~2700
rows in a typical WJF capture) get skipped.

Fix: register a SYSTEM-context scheduled task at imaging time that
fires AtLogOn UserId=ShopFloor. When ShopFloor first logs in, Windows
loads their NTUSER.DAT automatically; task fires (running as SYSTEM
so lockdown policies on ShopFloor's user-context don't block HKLM
writes via the same Install script); SID-remap path finds the live
hive and writes prefs into HKEY_USERS\<ShopFloor-sid>. Task writes a
flag file + unregisters itself after one successful run.

Pieces:
- Install-FormtracepakSettings.ps1: new -HKEYUsersOnly switch that
  skips the HKLM .reg files + HKLM CSV rows (already restored at
  imaging time). Fallback user chain ShopFloor->SupportUser->$USERNAME.
- Schedule-WaxTracePerUserRestore.ps1: registers the task, writes
  C:\WaxTrace-Install\Run-WaxTracePerUserRestore.ps1 task action which
  invokes Install with -HKEYUsersOnly and self-cleans on success.
- 09-Setup-WaxAndTrace.ps1 Step 3b: in-line restore now uses
  -RestoreRegistry -RestoreData -RestoreConfig (HKLM + files now);
  calls Schedule-WaxTracePerUserRestore.ps1 to queue HKEY_USERS for
  first ShopFloor logon.
- sync-waxtrace.sh: pushes Schedule-WaxTracePerUserRestore.ps1 to
  PXE share alongside Install-FormtracepakSettings.ps1.

Smoke tested on win11 VM partially: task registration works, manual
trigger fires + self-unregisters cleanly, flag file lands. Real per-
user SID-remap happens at first ShopFloor logon (can't simulate from
qga without an interactive ShopFloor session).
2026-05-24 16:19:45 -04:00
cproudlock
f95d305cca startnet.cmd: robocopy /MT:16 + /J for faster waxtrace stage
Three speed bumps on the waxtrace WinPE stage:
- /MT:16 on the bundle robocopy (was single-thread). Parallelizes the
  small-files-many-of-them part (config + backups + prereqs).
- /J on the single-file FTPak ISO cherry-pick. Unbuffered I/O is
  measurably faster than xcopy on 2 GB-ish files over SMB; xcopy goes
  through the buffered I/O path which double-copies in kernel.
- Swap the FTPak ISO cherry-pick from xcopy to robocopy single-file
  syntax (source dir + dest dir + filename pattern) so we can use /J.

No behavioral change otherwise. Backup ZIP cherry-pick stays on xcopy
(it's ~1 MB, doesn't benefit from /J).
2026-05-24 14:09:06 -04:00
cproudlock
8e1f81b942 startnet.cmd: robocopy /XD instead of xcopy /EXCLUDE:NUL for waxtrace stage
WJF00159 imaging pass on 2026-05-24 logged:
  [ERROR] waxtrace-manifest.json not found at
          C:\WaxTrace-Install\waxtrace-manifest.json
even though the manifest is present on the PXE share. Root cause: the
xcopy approach copied EVERYTHING including the 12+ GB formtracepak\
directory of vendor ISOs, then deleted formtracepak\ afterward and
re-created it with just the one matched ISO. The /EXCLUDE:NUL placeholder
("pass NUL device as exclude-file") was silently ignored by xcopy in
WinPE, and the redirection >/dev/null 2>/dev/null hid any errors. The copy was
either aborting mid-stream (running out of patience / disk / connection)
or the manifest landed but got clobbered in the rmdir/recreate dance.

Switch to robocopy /XD formtracepak which excludes the dir up front so
we never even attempt the 12 GB of ISOs. Keep /NFL /NDL to suppress
per-file listing but leave overall output visible (no >/dev/null) so any
failure is debuggable from the install log. Check errorlevel >= 8
explicitly (robocopy exit codes 0-7 are all "OK").

Smoke tested on win11 VM with a representative tree (manifest +
bay-config + backups\WJF00159.zip + prereqs\hasp.exe + dummy
formtracepak\ ISOs): formtracepak\ excluded, all other files +
sub-dirs copied cleanly, exit 1 (= OK).

Pushed to boot.wim.with-ps (the correct PowerShell-bearing wim - the
one I had been editing before was missing powershell.exe) and deployed
to /var/www/html/win11/sources/boot.wim on 172.16.9.1.
2026-05-24 14:07:45 -04:00
cproudlock
77c917157d select-waxtrace-asset.ps1: read bay-config.csv (17 bays) by default
Picker was still pointed at calibrations/INDEX.csv, which only listed
14 bays that have a per-asset cal ISO ripped. Three bays we just
refreshed into bay-config.csv (WJF00450, WJF00461, WJRP0423) had no
cal-disc entry, so they fell off the menu and tech had to drop to the
free-text prompt to type the asset by hand - felt like a regression.

Two changes:
- select-waxtrace-asset.ps1: prefer bay-config.csv when -IndexPath
  points there (now the default). Auto-detect schema by checking for
  the ftpak_version column. Display columns become
  ASSET / FTPAK / MODEL / USER ID so the tech can confirm bay metadata
  at a glance before pressing Enter. Falls back to
  calibrations/INDEX.csv if bay-config.csv missing.
- startnet.cmd: invoke the picker with -IndexPath bay-config.csv.

Pushed: both boot.wim copies refreshed via wimupdate, new
select-waxtrace-asset.ps1 deployed to PXE share, new boot.wim landed
at /var/www/html/win11/sources/boot.wim on 172.16.9.1.

bay-config.csv parent-dir copy synced with scripts/bay-config.csv so
resolve-bay-config.ps1 (called from startnet.cmd in WinPE) and the
picker both see the same 17-bay set.
2026-05-24 13:12:03 -04:00
cproudlock
d0dcce5427 Wax/Trace: auto-restore captured backup ZIP during imaging
Wires the three pieces that make the per-bay backup restore happen as
part of the PXE imaging pass, no manual post-imaging step required:

1. sync-waxtrace.sh: stage per-asset backup ZIPs from
   /home/camp/pxe-images/wt/<asset>/formtracepak_backup_*.zip (newest)
   into installers-post/waxtrace/backups/<asset>.zip on the PXE share.
   Also pushes scripts/Install-FormtracepakSettings.ps1 alongside the
   bootstrap bundle so 09-Setup can call it post-vendor-install.
2. startnet.cmd: after the FTPak ISO cherry-pick, xcopy
   Y:\installers-post\waxtrace\backups\%MACHINENUM%.zip to
   W:\WaxTrace-Install\backup\%MACHINENUM%.zip. Logs INFO if no per-asset
   ZIP exists - 09-Setup will then skip the restore step.
3. 09-Setup-WaxAndTrace.ps1 Step 3b: between cal ISO and OpenText
   auto-start steps, look for C:\WaxTrace-Install\backup\<asset>.zip
   and invoke Install-FormtracepakSettings.ps1 -BackupPath ...
   -RestoreData -RestoreConfig -Force. Registry restore is intentionally
   omitted - captured HKLM is overwritten by the vendor MSI install in
   Step 2 anyway, and captured HKEY_USERS would land at the source
   bay's SID (which doesn't exist on the freshly imaged bay).

bay-config.csv refresh: 17 captured bays with full version/model/
user_id/hw_sn/hw_id/host. Versions stick to the original bay-config.csv
target values where the live binary drifted to a release we don't have
an ISO for (e.g. WJRP2035 live 5.7.0.82 -> imaging targets 6.0).
WJF00450 flagged MISSING_DATA in user_id + hw_id columns so the
imaging path aborts cleanly until the dongle is read.

Smoke tested on win11 VM with WJF00545's real capture: staged the
expected bay-side layout (C:\WaxTrace-Install\Install-FormtracepakSettings.ps1
+ C:\WaxTrace-Install\backup\WJF00545.zip), invoked the resolve +
call path from a simulated Step 3b - 17 files restored cleanly, 0
errors.
2026-05-24 12:55:37 -04:00
cproudlock
6602afde38 Backup-FormtracepakSettings: timeout-fence reg.exe + recursive walk
WJF00052 / WJF00083 / WJF00084 / WJF00159 produced 0-byte ZIPs on the
2026-05-24 capture pass. Export-FormtracepakInventory was the primary
culprit (Get-Service hang, fixed in d359563), but Backup has the same
root failure mode for the same bays: reg.exe export via Start-Process
-Wait and the recursive Get-ChildItem walk over HKLM:\SOFTWARE\
WOW6432Node\Mitutoyo (95k+ values) both lack timeouts, so a degraded
SCM / antivirus interception / WMI repository on the bay can wedge the
script and the operator kills the .bat - same outcome as the inventory
hang.

Two timeout fences:
- reg.exe export: switch from Start-Process -PassThru -Wait to
  [System.Diagnostics.Process]::Start + WaitForExit(300_000). If the
  process hasn't exited after 5 minutes, Kill() it and log + count an
  error; the .reg file for that one root is skipped but other roots
  + the CSV fallback + the file/data captures continue. Bonus: the
  ProcessStartInfo path also gives reliable $proc.ExitCode access; the
  Start-Process -PassThru object sometimes returned a stub with
  unpopulated ExitCode, producing a false "reg.exe exit  for ..."
  warning even on successful exports.
- Recursive Get-ChildItem CSV walk: move into a Start-Job + Wait-Job
  -Timeout 600 (10 min). If the walk hangs, Stop-Job, log, increment
  error count, .reg file remains authoritative for that root.

Also fixed a subtle Start-Job return-shape bug introduced in the same
edit: emitting the rows via the pipeline inside the job + Receive-Job
flattens correctly, whereas `return ,$list` wrapped the whole List in a
single-element array, so the outer foreach was treating the list-of-95k
as a single row. Net effect was Registry Values: 4 instead of 95152 in
the manifest. Verified fixed via a full real-install run on the VM:
95152 values captured cleanly in 22 s, 0 errors.

Net behaviour: the failed-bay re-runs (WJF00052/00083/00084/00159)
should now either produce real ZIPs or print a clear warning naming the
specific reg root that hung, instead of leaving an empty ZIP behind.
2026-05-24 11:43:42 -04:00
cproudlock
d359563a4c Export-FormtracepakInventory: drop Get-Service entirely (SCM hang)
WJF00052 / WJF00083 / WJF00084 / WJF00159 hung indefinitely on step [5/5]
during the 2026-05-24 capture pass and the operator killed the .bat,
leaving empty inventory CSVs. The earlier Start-Job + Wait-Job -Timeout 30
guard (commit fce6680) was insufficient: Stop-Job on a Get-Service that's
blocked on a degraded Service Control Manager can itself block in the SCM
call (Sentinel HASP driver service in particular has been observed to
wedge Get-Service for minutes), so the main script never unblocked even
after the 30s timeout fired.

Service state isn't load-bearing for identifying the install on a bay
(version + model + DeviceName come from disk + registry, not the SCM),
so the cleanest fix is to drop the Get-Service block entirely. Get-Process
is fast and stays. Step [5/5] label changed from "Checking running
processes and services" to "Checking running processes".

Smoke tested on win11 VM: full Export against a real v6.213 / AVANT
install completes in ~25 s (was 2.9 s with no install, 25 s now reflects
the legitimate ~95k registry walk that dominates the runtime - not the
service hang).
2026-05-24 11:03:20 -04:00
cproudlock
cb149ed8cd Backup-FormtracepakSettings: empirically-grounded version + model detection, HKEY_USERS sweep, manifest evidence stamp
Installed FormTracePak v6.213 on the win11 VM (picking FORMTRACER Avant
in the dialogs) and probed the resulting registry / disk layout to find
out what evidence a real FormTracePak install actually carries. Two
empirical findings:

1. ACTIVE MODEL lives at
   HKLM:\SOFTWARE\WOW6432Node\Mitutoyo\FORMPAK\Config\device map\DeviceName
   (string value). For the AVANT install the value is "FORMTRACER Avant";
   for the CV/SV/CS controllers the value contains the matching model id.
   The Surfpak\FormMes\MachineInfo\Machine\Machine* subtree lists EVERY
   supported machine and is NOT the active selection - the previous
   heuristic that scanned uninstall-entry DisplayName picked up bogus
   WinUSB driver-package entries from "Mitutoyo Corporation" instead.

2. INSTALLED BINARY VERSION is in Formtracepak.exe VersionInfo:
   FileVersion=6.2.0.51, ProductVersion=6.2.0.0. This does NOT match the
   Mitutoyo MSI release label ("6.213") that bay-config.csv uses. The
   uninstall entry's DisplayVersion is empty. So bay-config.csv stays
   canonical for the per-asset marketing version; exe FileVersion is a
   concrete cross-check.

Backup rewrites:
- Replace the previous one-shot version detection with evidence reading:
  bay-config.csv (asset->version+model), Formtracepak.exe VersionInfo,
  device-map\DeviceName. The over-broad uninstall-reg regex is gone.
- Normalize DeviceName ("FORMTRACER Avant" / "CV-4500" / "CV-3200" /
  "Contracer" / "Surftest") to bay-config notation (AVANT / CV-4500 / ...).
- Emit BayConfigMatch flag - true when bay-config-predicted model agrees
  with the device-map\DeviceName on disk. False = drift, tech rechecks
  before restoring to a new bay.
- manifest.json now stamps: AssetNumber, FormtracepakVersion, Model,
  BayConfigVersion, BayConfigModel, BayConfigSource, InstalledExeVersion,
  InstalledExePath, InstalledDeviceName, InstalledModelNormalized,
  BayConfigMatch.

HKEY_USERS sweep:
- Wax/Trace bays log in as a per-site user (lg782713sd at WJ today,
  ShopFloor post-SFLD-2.0, other accounts at other sites). The previous
  HKCU:\ scan only captured the script's running user. Sweep every
  loaded HKEY_USERS hive whose SID matches S-1-5-21-* (real user SIDs)
  for Software\Mitutoyo / FORMTRACEPAK / FORMPAK / SURFPAK subkeys,
  add them to $RegistryRoots. Username-agnostic - works at any site
  without changes.

reg.exe export now also accepts the Registry::HKEY_USERS\<sid>\... PSPath
form by stripping the "Registry::" prefix when building the reg.exe
argument (previously emitted "Invalid key name" errors on HKEY_USERS roots).

Smoke tested against a real v6.213 / FORMTRACER Avant install on win11
VM: bay-config lookup matches, exe FileVersion read, device-map
normalized to AVANT, all four .reg files (HKLM, HKLM-WOW6432Node, HKCU,
HKEY_USERS\<interactive-sid>) exported clean, 0 errors.

Restore-side SID translation (HKEY_USERS\<src-sid>\... -> target user's
SID or HKCU on the new bay) is a follow-up. HKLM tree carries the
critical device-map\DeviceName, controller config, and machine settings;
the HKEY_USERS hive captures per-user UI prefs only.
2026-05-24 09:58:24 -04:00
cproudlock
821e3179d1 Wax/Trace triad: switch to SHA256 hashes (FIPS-compliant) + separate hash-failure path from copy-failure path
Backup-FormtracepakSettings observed 17 Errors on a real shopfloor PC
(G5PRTW04ESF / WJF00159 capture) - all of the form:
  WARNING: Failed to copy ...App.ini: Exception calling ".ctor" with "0"
  argument(s): "This implementation is not part of the Windows Platform
  FIPS validated cryptographic algorithms."

Cause: Windows FIPS policy is enabled on West Jefferson shopfloor PCs.
The per-file Get-FileHash -Algorithm MD5 call throws a hard .NET exception
that bypasses -ErrorAction SilentlyContinue (the throw is from the MD5
constructor, not the cmdlet's parameter binder). That exception was caught
by the broad try/catch around both Copy-Item + manifest add, producing a
misleading "Failed to copy" message even though Copy-Item already succeeded.
Net effect: files copied fine, but manifest rows were missing for those
files (Install would fall back to its bulk-copy path).

Two fixes:
- Switch the hash algorithm from MD5 to SHA256 in both Backup (manifest
  row capture) and Install (Restore-FileItem hash-skip compare). SHA256
  is Get-FileHash's default and is FIPS-compliant. Old MD5-hashed backups
  remain restorable because Install computes hashes fresh from disk at
  restore time and does not read the Hash column from file_manifest.csv.
- Split the broad try/catch in Backup's Copy-ToStaging into two
  try/catches: the first wraps only Copy-Item (real copy failure -> Errors
  counter + skip the file), the second wraps only Get-FileHash (hash
  failure -> log warning, manifest row gets a null Hash and is still
  recorded). A hash failure no longer pretends the copy failed.
- Install's hash compare is wrapped in try/catch too so a hash exception
  falls through to overwrite-mode rather than crashing the restore.

Smoke tested on win11 VM: SHA256 round-trip works (64-char hashes in
file_manifest.csv), Backup reports 0 Errors, Install hash-skip path
correctly skips Identical files on second-run idempotency check.
2026-05-24 09:23:53 -04:00
cproudlock
fce6680c6f Wax/Trace triad: relocate backup path + harden service enum against SCM hangs
Two operator-driven fixes.

1. Backup target moves from S:\2 WJ Scans Record Retention\backup\waxtrace
   to S:\DT\Shopfloor\backup\waxandtrace per the canonical SFLD layout.
   Backup creates the per-asset folder if missing; Install reads from the
   same path by default.

2. Export-FormtracepakInventory hung on step [5/5] when run on a shopfloor
   PC. The original `Get-Service | Where-Object { DisplayName -match ... }`
   pattern materializes every service via the Service Control Manager + post
   filters in PowerShell, which can block indefinitely when any single
   service (Sentinel HASP driver, GE-Enforce agent, etc.) is in a degraded
   state. Two-part fix:
   - Switch to Get-Service -DisplayName 'Mitutoyo*','*FORMTRACEPAK*',...
     so the SCM only materializes matching services (server-side wildcard
     filter, faster + lower blast radius).
   - Wrap the enumeration in Start-Job + Wait-Job -Timeout 30 so a
     degraded SCM aborts gracefully with a warning rather than wedging
     the whole inventory pass.

Smoke tested on win11 VM: full Export run with the new code completes in
2.9 s and emits the inventory CSV correctly.
2026-05-24 08:51:30 -04:00
cproudlock
ed12988591 Wax/Trace triad: harden against empty $PSScriptRoot
Tech ran Export-FormtracepakInventory.ps1 from S:\DT\shopfloor\scripts\
waxandtrace\ and the picker fired correctly but Export-Csv failed with
'Cannot bind argument to parameter Path because it is an empty string'.
Root cause: $OutputPath defaulted to $PSScriptRoot and $PSScriptRoot came
through empty in that invocation path (suspected ISE / IEX-style host or
remote wrapper). On a [string] param, $null/empty default coerces to ''
and Join-Path then errors.

Fix in all three triad scripts: resolve a local $scriptDir via a fallback
chain ($PSScriptRoot -> $PSCommandPath -> Get-Location), and use that
instead of $PSScriptRoot for sibling lookups (Select-WaxtraceAsset.ps1,
bay-config.csv).

Export additionally:
- Drops the $OutputPath = $PSScriptRoot param default in favor of the
  same fallback chain.
- Tests / creates $OutputPath BEFORE the 90k-item registry scan so a bad
  output dir surfaces immediately instead of after a long scan.

Smoke tested on win11 VM: explicit -OutputPath '' now resolves to a
writable directory and the CSV writes successfully.
2026-05-24 08:00:00 -04:00
cproudlock
b8bb00e2fe Wax/Trace triad: arrow-key bay picker + S: backup path
Two operator-UX improvements for the Backup / Export / Install triad.

1. Backup target moves from \\tsgwp00525\...\formtracepac to S:\2 WJ Scans
   Record Retention\backup\waxtrace\<asset>\. S: is mapped at shopfloor
   imaging time and stays mapped post-categorization, so the same default
   path works whether the operator runs the backup on an old bay (manual
   pre-image capture) or a freshly imaged one. The destination directory
   is created if missing.

2. New Select-WaxtraceAsset.ps1 - arrow-key bay picker patterned after
   the WinPE select-waxtrace-asset.ps1. Reads bay-config.csv (sibling
   file), shows asset_tag + ftpak_version + model + user_id per row, and
   returns the selected asset_tag via stdout. Falls back to a manual
   entry prompt if the CSV is missing or the operator picks "Other".

   Backup / Export / Install now invoke the picker when interactive AND
   bay-config.csv is alongside the script. Non-interactive paths
   (qga / SYSTEM / scheduled task) keep silently defaulting to
   COMPUTERNAME so unattended runs are unchanged.

   Export gained an -AssetNumber parameter and stamps it into the output
   CSV filename so multiple inventories from the same host stay
   distinguishable when the operator is auditing several bays in a row.

bay-config.csv is copied into the scripts\ dir so the picker has a
source of truth that ships next to the scripts (and into pxe-images
for tech distribution).

Smoke tested on win11 VM: all four PS1 parse-clean, non-interactive
backup path still produces a valid ZIP (silent COMPUTERNAME default),
picker handles missing-CSV gracefully (manual-entry fallback). The
arrow-key UX itself is operator-verifiable only on a real terminal.
2026-05-24 07:41:25 -04:00
cproudlock
a104cfdebb Wax/Trace triad: fix registry corruption + cover v6.213 vendor install path
Three fixes in Backup / Export / Install, validated end-to-end on the win11 VM
against a seeded HKCU\SOFTWARE\Mitutoyo\Formtracepak key carrying all five
registry value types (String, DWord, ExpandString, MultiString, Binary).

1. Registry corruption on REG_BINARY / REG_MULTI_SZ restore
   Backup wrote those values to registry_values.csv via [string]$val, which
   lossily coerces a byte[] to "System.Byte[]" and a string[] to a
   space-joined scalar. Install's CSV restore loop runs AFTER the .reg file
   import (which is lossless), so the CSV pass overwrites the good values
   with corrupted strings. Two-part fix:
   - Backup: skip Binary / MultiString / None / Unknown when writing the CSV.
     Only String, ExpandString, DWord, QWord roundtrip cleanly through
     New-ItemProperty -PropertyType, so capture only those. The .reg file
     remains authoritative for the rest.
   - Install: defensive filter on the CSV restore loop that skips any row
     whose Type is not in {String, ExpandString, DWord, QWord}. This catches
     legacy CSVs already on the share that were taken before this fix.

2. v6.213 vendor install path not scanned / not restored to
   The per-bay FormTracePak install (commit 54dddaa) lands under
   C:\Program Files (x86)\MitutoyoApp\Formtracepak, but the search-path
   lists in Backup + Export only covered C:\...\Mitutoyo (no MitutoyoApp).
   Result: a backup taken on a freshly imaged v6.213 bay produced Config
   Files = 0 because the script never looked at the actual install dir.
   Added MitutoyoApp (x86 + native ProgramFiles) ahead of the legacy
   paths in all three scripts.

3. Install $DefaultAppTargets fallback didn't include MitutoyoApp either,
   so a restore from an OLDER bay (source path C:\Mitutoyo\...) onto a
   freshly imaged v6.213 bay would fall back to ProgramFiles\Mitutoyo
   (does not exist), miss the MitutoyoApp\Formtracepak tree, and write
   the restored files into the first existing legacy path. Added the
   MitutoyoApp entries at the top of the ordered fallback table.

Smoke tested on win11 VM: backup of all 5 reg types, then corrupt every
value, then Install -RestoreAll restores all 5 byte-exact (incl. REG_BINARY
DE-AD-BE-EF-CA-FE-BA-BE-01-02-03-04 and REG_MULTI_SZ alpha.smp/beta.smp/
gamma.smp). Verified legacy poison-CSV path triggers the defensive filter
and the .reg-imported values survive untouched. -DryRun confirmed
non-mutating. Idempotency confirmed via hash-skip.
2026-05-24 07:29:27 -04:00
cproudlock
b57ba0fb6f webapp: add CSRF token to imaging Clear-all form
The dashboard Clear-all button posts to /imaging/delete-all but the form
was missing the hidden _csrf_token input that the rest of the webapp's
POST forms include, so the endpoint would reject the request when CSRF
enforcement is active.
2026-05-24 07:04:20 -04:00
cproudlock
54dddaa760 Wax/Trace: per-bay FormTracePak version via bay-config.csv
Bays span 7 FormTracePak versions (5.510 - 6.213) and 3 sub-versions
(AVANT / CV-4500 / CV-3200), each with a unique licensing USER ID. Previously
all bays got v6.213 with no model/USER hint to the tech.

- bay-config.csv: 15 rows mapping asset_tag to ftpak_version + model + user_id.
- resolve-bay-config.ps1: WinPE-runnable resolver. Looks up the asset and
  writes version.txt / model.txt / userid.txt / bay-info.txt under
  W:\Enrollment\waxtrace\.
- startnet.cmd: xcopy WaxTrace bundle minus formtracepak\, invoke the
  resolver with %MACHINENUM%, then cherry-pick only the matching
  FORMTRACEPAK-V<ver>.iso (~2 GB local vs ~12 GB if all were staged).
- 09-Setup-WaxAndTrace.ps1: read the per-bay files, mount the right ISO,
  drop <asset>-FTPak-install-info.txt on SupportUser's desktop, and print
  a banner with MODEL + USER ID so the tech has them top-of-mind when
  Setup.exe dialogs come up.
- sync-waxtrace.sh: loop over all FORMTRACEPAK-V*.iso instead of hard-coding
  v6.213; also push bay-config.csv + resolve-bay-config.ps1 to the share.
2026-05-24 07:04:15 -04:00
cproudlock
00d4105956 04-SetControllerNicIP: broaden adapter enum + store-mismatch fix + netsh fallback
- Drop -Physical + MediaType filter from Get-NetAdapter; some OEM driver
  stacks report HardwareInterface=False or localize MediaType, hiding the
  Realtek controller NIC from the previous query.
- Refine corp-vs-controller classification: skip only if a gateway is set
  AND it's not 192.168.1.x, OR if the IP looks corp (10.x / 172.16-31.x).
  Keep candidates that are unconfigured, link-local, or 192.168.x.
- Disable DHCP in both PersistentStore and ActiveStore before New-NetIPAddress
  to avoid "Inconsistent parameters PolicyStore PersistentStore and Dhcp
  enabled" failures.
- Fall back to netsh interface ip set address when the PS cmdlets still
  fight each other; netsh writes both stores cleanly.
2026-05-24 07:04:02 -04:00
cproudlock
86fbc132dd GE-Enforce: backfill Keyence pc-subtype.txt from installed ProductCode
Pre-2026-05 Keyence images didn't write pc-subtype.txt via startnet.cmd. Without
a subtype the share manifest's per-model PCTypes gate falls back to the default
(VR-6000), causing the wrong model to install on VR-3000 / VR-5000 boxes.
Detect the installed VR-3000/5000/6000 by its uninstall ProductCode and
persist the subtype so subsequent GE-Enforce cycles + the share manifest gate
route correctly.
2026-05-24 07:03:54 -04:00
cproudlock
4015adeb33 utilities/waxtrace-recovery: ship cal diagnostic + repair scripts
Pair of operational tools used when a wax/trace bay's cal apply fails
(218-378-13 series cal Setup.exe crash, or any future variant). Were
living in /home/camp/pxe-images/ on the workstation; promoting to the
repo so they ship with the codebase, get version-controlled, and can
be pushed onto each PXE server's enrollment share via the standard
sync flow.

debug-waxtrace-cal.ps1 (+ .bat launcher):
- 9-section forensic walkthrough that runs on the bay as admin.
- Autodetects FormTracePak install location, dumps data/ dir contents,
  finds + mounts the per-asset cal ISO, lists its contents, checks the
  on-disk 09-Setup-WaxAndTrace.ps1 for the direct-copy bypass marker,
  greps the imaging-time log for cal lines, pulls the last 24h of
  .NET Runtime / Application Error / WER events related to Setup.exe.
- Output: C:\Logs\WaxTrace\debug-waxtrace-cal.log

fix-waxtrace-cal.ps1 (+ .bat launcher):
- Idempotent recovery: mounts the bay's cal ISO, unconditionally copies
  data\* into FormTracePak's data dir, renames any filename containing
  ' _' (space-underscore) to drop the embedded space, clears read-only,
  dismounts. Works on both 218-378-13 (broken filenames) and 218-458A
  (clean filenames) since the rename is a no-op when no space is
  present. Bypasses the buggy vendor cal Setup.exe entirely.
- Output: C:\Logs\WaxTrace\fix-waxtrace-cal.log

Both already pushed to \\172.16.9.1\enrollment\tools\ on both PXE
servers earlier today; this commit lands them in the repo as the
source of truth so future PXE server builds + ad-hoc rsyncs pick
them up automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:23 -04:00
cproudlock
de3018512a 09-Setup-WaxAndTrace: fix broken-filename detection (Get-ChildItem -Filter)
The direct-copy bypass added earlier was never firing - Get-ChildItem -Filter
uses Win32 filename filtering and does NOT honor PowerShell wildcards or
character classes. The previous detection filter '*[0-9] _*.txt' matched
literal bracket-zero-through-nine text, which never appears in any
filename. $hasBrokenFilenames was therefore always False, and every
218-378-13 series cal apply fell through to the vendor setup.exe which
crashes with System.ArgumentException (exit -532462766).

Confirmed via debug-waxtrace-cal.ps1 log on WJF00159: section 6 reports
the on-disk script has the direct-copy fix, section 7 shows the actual
runtime log line 'running cal Setup.exe' followed by exit -532462766.
The "fix" was never executing because the gate was broken.

Replace -Filter with Get-ChildItem -File + Where-Object regex match on
' _\d+\.txt$' which catches the actual buggy filename pattern (space-
underscore-digits-.txt at end of name) regardless of probe series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:09:10 -04:00
cproudlock
7f93347f74 CMM: park DODA entry under _pending_doda_entry until binary arrives
Removed the placeholder DODA entry from Applications so a bay imaged
with the 'With DODA' submenu choice today does not log a 'Installer
not found: DODA-PLACEHOLDER.exe' error per cycle. Wiring is otherwise
unchanged: startnet.cmd still offers the With-DODA submenu, pc-subtype.txt
is still written as 'doda', and 09-Setup-CMM.ps1 still passes PCSubType
through to Install-FromManifest. When the DODA installer is sourced,
move the entry from _pending_doda_entry back into Applications (engine
ignores any top-level field other than Version + Applications).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:59:05 -04:00
cproudlock
548d85fed5 CMM: subtype gating + relax exact-version + conditional cleanup + DODA placeholder
Carries over the lessons learned from wax/trace + Keyence imaging today
and threads the same pattern through the CMM path.

09-Setup-CMM.ps1:
- Pass PCType + PCSubType to Install-FromManifest so the manifest's
  per-entry PCTypes filter is honored. Without this every entry runs
  regardless of bay variant - the same bug Keyence had before per-model
  gating was added.
- Move bootstrap cleanup to a conditional that only deletes
  C:\CMM-Install once every (filter-applicable) manifest entry detects
  as installed. If a Hexagon installer forces an unplanned reboot
  mid-install, the new Run-ShopfloorSetup self-resume RunOnce fires on
  the next auto-login; the staging dir needs to still be on disk for
  the re-run to recover. Logs "retained ... not all entries installed
  yet - will retry on next self-resumed run" when partial.

cmm-manifest.json:
- Drop exact DetectionValue from PC-DMIS 2016, PC-DMIS 2019 R2, CLM
  1.8.73, and goCMM. Detection is now uninstall-key presence only, so
  a Hexagon security patch that bumps the DisplayVersion does not
  trigger a re-install loop with exit 1638 every GE-Enforce cycle.
  Bumping the installer in apps/ is the upgrade path - manifest engine
  detection should not also be a version drift catcher for vendor MSIs
  whose backward-compat is established by the vendor.
- Specific to goCMM: the installer filename version (1.1.6718.31289)
  does not match what the installer registers under its uninstall key.
  Dropping DetectionValue silences the false-mismatch loop the prior
  version would have triggered.
- Add DODA placeholder entry gated to PCTypes=["cmm-doda"]. Real
  Installer filename, args, and DetectionPath still TODO once the
  DODA binary is sourced + dropped at installers-post/cmm/.

startnet.cmd:
- Add :cmm_submenu after the user picks gea-shopfloor-cmm from the
  main menu. Two options: Standard (default PC-DMIS + CLM + goCMM
  + Protect Viewer) or With DODA. Mirrors :keyence_submenu pattern.
- Write CMMVARIANT to W:\Enrollment\pc-subtype.txt so Install-FromManifest
  on the bay can apply the PCTypes filter against gea-shopfloor-cmm-doda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:48:16 -04:00
cproudlock
45f39fd431 startnet.cmd: suppress 'System error 85' on duplicate Y: mount
The :prompt_waxtrace_asset picker block maps Y: to the enrollment share
so select-waxtrace-asset.ps1 can read INDEX.csv. The later :skip_machinenum
block also runs `net use Y: \\172.16.9.1\enrollment ...` to keep the share
mounted through the rest of imaging, but Y: is already mapped at that
point so the second net use throws "System error 85: The local device
name is already in use". Harmless (script proceeds and "the command
completed successfully" prints right after) but visible noise to the
operator during PXE imaging.

Gate the second net use behind `if exist Y:\` so we only map when Y: is
not already mounted. Same end state, no error message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:38:34 -04:00
cproudlock
b86b830568 Run-ShopfloorSetup: self-resume RunOnce + top up AutoLogonCount
Imaging chain stalled on WJF00159 after FormTracePak Setup.exe forced
a reboot: SupportUser auto-logged in fine, briefly flashed something
(an HKLM\Run logon hook), then idle - no resume of Run-ShopfloorSetup.
Confirmed via diag-dispatcher.ps1: bay had AutoLogon working, RunOnce
empty, no Stage-Dispatcher.ps1 on disk, no setup-stage.txt.

Root cause: Run-ShopfloorSetup launches once from unattend XML's
FirstLogonCommands and has no self-resume mechanism. If anything cuts
it off mid-flight (FormTracePak Setup, eDNC MSI, Oracle install with
forced reboot, etc) the chain dies and nothing brings it back.
Stage-Dispatcher.ps1 in the repo is academic infrastructure that was
never wired into the live flow - startnet.cmd does not stage it and
nothing creates setup-stage.txt.

Fix: have Run-ShopfloorSetup register ITSELF as RunOnce at the top of
the script. The script is idempotent throughout (detection checks
skip already-done work) so re-entry post-reboot picks up cleanly.
Normal completion path removes the RunOnce so it does not re-fire
after the planned end-of-script reboot.

Also top up AutoLogonCount to 10 at script start. The unattend XML's
LogonCount=7 budget gets consumed across typical imaging reboots
(Office, Oracle, FormTracePak, Run-ShopfloorSetup explicit, sync-intune)
and an unplanned FormTracePak forced reboot pushes the counter past 0,
clearing AutoAdminLogon and parking the bay at the login screen.
Restoring the budget every Run-ShopfloorSetup entry keeps SupportUser
auto-logging in across any number of forced reboots until normal
completion. Lockdown's Autologon.exe sets its own AutoAdminLogon for
the ShopFloor user when it runs, so the post-completion natural
decrement to 0 does not affect the final state.

Verified via diag-dispatcher.ps1 capture on WJF00159 today - that bay
had AutoLogonCount=4 and no Stage-Dispatcher.ps1 on disk, which both
match this root cause + fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:17:07 -04:00
cproudlock
a22d2f0313 Stage-Dispatcher: top up AutoLogonCount during shopfloor-setup stage
Unattend XML sets LogonCount=7 for SupportUser autologon. Each interactive
login decrements the counter; at 0 Windows clears AutoAdminLogon and the
bay parks at the login screen with no one to fire RunOnce -> dispatcher
never re-runs -> imaging chain stalls.

Typical imaging burns several logons: Windows OOBE first logon,
post-Office reboot, Oracle install reboot, FormTracePak Setup.exe forced
reboot, Run-ShopfloorSetup's own shutdown /r at end of script, stage
advances. The unplanned FormTracePak reboot pushes the count past 0 on
some bays - the exact failure mode the WJF00159 imaging today hit, where
Stage-Dispatcher.ps1 had the new defensive RunOnce-re-register fix
landed but the dispatcher still never re-fired because there was no
auto-logon to trigger it.

Top up AutoLogonCount to 10 every time the dispatcher hits the
'shopfloor-setup' stage. Restores the autologon budget across any
vendor-forced reboots that fire during the stage. When sync-intune
finishes the whole pipeline, AutoLogonCount is left to decrement
naturally; by then lockdown's Autologon.exe has set its own
AutoAdminLogon for the ShopFloor user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:59:22 -04:00
cproudlock
44554b95b0 gea-shopfloor-waxtrace: stage Mitutoyo Backup/Install/Export triad
User received three PowerShell scripts from a Mitutoyo source for
backing up + restoring FormTracePak settings per asset:

  Export-FormtracepakInventory.ps1 - audit: enumerate files + reg keys
  Backup-FormtracepakSettings.ps1  - capture: config + data + reg into
                                     timestamped ZIP, manifest-driven
  Install-FormtracepakSettings.ps1 - restore: replay ZIP to a new bay,
                                     hash-skip identicals, backup
                                     existing as .pre_restore_bak

Cleanup pass over the vendor-shipped versions:
- Strip Unicode box-drawing characters from banners (ASCII-only policy)
- Install: switch to [ordered]@{} for DefaultAppTargets/DefaultDataTargets
  so fallback priority is deterministic
- Install: add -AssetNumber gate that defaults to per-asset SFLD path
  \\tsgwp00525...\Shopfloor\backup\formtracepac\<AssetNumber>
- Install: timestamp the .pre_restore_bak filename so re-runs don't
  clobber the previous backup
- Install: handle BackupPath being a directory containing
  formtracepak_backup_*.zip files (picks newest)
- .bat launchers for each PS1 (bypass execution policy, double-click)

Not yet wired into 09-Setup-WaxAndTrace.ps1; pending reference-backup
capture from a known-good bay before promoting to imaging path. Today
the V6.213 vendor MSI install + per-asset cal ISO still handle the
imaging-time setup directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:39:24 -04:00
cproudlock
02499cf74b docs: post-deploy checklist + COM 2/4 PCIe serial-port flowchart update
New post-deploy-checklist.md (+ live.html + static.html siblings):
- Four-section terse coach checklist run after a freshly imaged PC
  reaches the login screen. Pairs with the existing post-deploy debug
  flowchart for failure paths.
- Sections: Common Shop Floor opens + connects, controller comms
  (ping 192.168.1.1 / NTLARS General+FMS), UDC COM port matches
  physical socket + no machine-comm error dialog + Tools > Retry
  Connection succeeds, printers ready (Genspect-specific note).

post-deploy-debug-flowchart.md:
- COM port mapping: PCIe add-in card now listed as 'COM 2 or COM 4'
  (Windows enumeration varies by hardware) instead of fixed COM 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:39:15 -04:00
cproudlock
27045d5e4a gea-shopfloor-collections: controller NIC auto-IP + credential break-glass
04-SetControllerNicIP.ps1 (imaging-time, runs once via Run-ShopfloorSetup):
- Finds the Realtek physical Ethernet adapter (controller NIC on every
  collections bay; corp LAN is Intel)
- Skips any candidate with a DHCP default gateway (that one is the corp
  LAN, not the controller)
- Skips any candidate already on 192.168.1.2
- Sets static 192.168.1.2/24, no gateway, clears DNS - matches the
  manual procedure documented in post-deploy-debug-flowchart.md section 2B
- Refuses to guess when multiple Realtek NICs remain ambiguous
- Imaging-time only, not enforced via GE-Enforce so the tech can override
  on a specific bay if needed without the drift-catcher reverting

Set-ControllerCredential.ps1 + manifest-entry-controller-credential.json:
- Break-glass cmdkey /add for the controller SMB share (\\192.168.1.1\md1
  used by DNC). Scoped to the 12 Okuma LOC650 machine numbers (3201-3212).
- Manifest entry is detection-less so it runs every enforce cycle if the
  script is armed (.ps1 extension); disarmed by default (.ps1.bak on the
  share) so a coach can rename when a bay loses its credential without
  the enforcer overwriting per-bay deviations between events.
- Smoke-tested end-to-end on win11 VM via QGA: SYSTEM context cmdkey /add
  succeeds, cmdkey /list shows the entry. DNC service runs as LocalSystem
  so SYSTEM vault is the right target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:59 -04:00
cproudlock
1f60c86ec8 Shopfloor: auto-register 9999-placeholder machine number prompt
If a bay is imaged with the 9999 placeholder (tech leaves the WinPE
prompt blank or types 9999), the lockdown+auto-login chain ends up at
the ShopFloor user with no real machine number. We had Check-MachineNumber.ps1
written - InputBox + Update-MachineNumber pulls per-machine NTLARS .reg
+ udc_settings_<N>.json from the SFLD share - but it only got registered
when a tech manually ran Configure-PC + toggled item 6. Fresh 9999 bays
never got the prompt, leaving the bay stuck on placeholder values until
someone noticed.

New Register-CheckMachineNumberTask.ps1 auto-registers the logon task
at imaging time. Gated on C:\Enrollment\machine-number.txt == 9999;
bays imaged with a real number never get the task (and any stale task
from a prior 9999-imaging on the same disk is cleaned up).

Wired into Run-ShopfloorSetup.ps1 right after the S: drive logon mapper
register. Skipped for self-contained types (display kiosks have no
machine number).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:48 -04:00
cproudlock
44d2f0afd5 Stage-Dispatcher: re-register RunOnce BEFORE calling Run-ShopfloorSetup
Setup chains we do not control (FormTracePak Setup.exe, eDNC MSI, any
vendor installer that forces an immediate reboot) cut & $script off
mid-flight. Without this, the dispatcher never returns from the call
and the post-call Register-NextRun never fires, leaving the next boot
with no RunOnce + a stalled imaging chain. Observed today on WJF00159
where the FormTracePak v6.213 Setup.exe rebooted the bay before the
dispatcher could advance the stage.

Register the dispatcher's own RunOnce defensively before invoking the
sub-script. If a reboot interrupts the call, the next boot re-fires the
same dispatcher, which re-reads the still-'shopfloor-setup' stage file,
re-runs Run-ShopfloorSetup (every step is idempotent + detects already-
installed state), and converges. The existing post-call Register-NextRun
+ stage advance still run on the happy path - cheap, idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:39 -04:00
cproudlock
5891a1966f Wax/Trace: heal 218-378-13 cal disc filename bug + VC++ 2017 + picker
09-Setup-WaxAndTrace.ps1 Step 3:
- Detect Mitutoyo's burn-time typo on 218-378-13 series cal discs
  (filenames carry a trailing space inside the probe ID component,
  e.g. "Linear_X_218-378-13 _100072210.txt"). Their own .NET Setup.exe
  calls FileSystemInfo.set_Attributes on the source path and throws
  System.ArgumentException because the path contains an embedded space
  component, crashing every cal apply on 218-378-13 bays (exit
  -532462766 = 0xE0434352, .NET unhandled exception). Confirmed via
  WER Event 1026 captured during today's WJF00159 imaging.
- When the buggy filenames are detected, bypass the broken vendor
  Setup.exe and direct-copy data\*.* into
  C:\Program Files (x86)\MitutoyoApp\Formtracepak\data\, renaming
  each file to strip ' _' (space-underscore) -> '_'. Clear read-only
  attr on each landed file. Older 218-458A discs have clean filenames
  and still use the vendor Setup.exe path.

waxtrace-manifest.json:
- Drop DetectionValue=v14.15.26706 from both VC++ 2017 redist entries.
  Windows Update routinely bumps the VS14 runtime to 14.16+ / 14.3x+,
  the older Mitutoyo redist refuses to install over the newer (exit
  1638 'Another version already installed') and the manifest engine
  marked it as failed even though the runtime was fine. Detection is
  now by registry-key+name presence, which any VC++ 2015-2022 redist
  satisfies (they are backward-compatible).

startnet.cmd:prompt_waxtrace_asset:
- Replace free-text input with select-waxtrace-asset.ps1 arrow-key
  picker driven from installers-post/waxtrace/calibrations/INDEX.csv.
- Map Y: enrollment share early so the picker can read INDEX.csv.
- Replace parens-in-parens block (echo of '(e.g. WJRP2335)' inside
  the if-paren caused 'to was unexpected at this time' parse error
  observed by tech mid-imaging) with goto-flow.
- Fall back to free-text prompt if picker unavailable or operator
  presses Esc.

select-waxtrace-asset.ps1:
- Sort bays descending by asset tag so WJRP* lands at top of menu.
- Also staged as gea-shopfloor-waxtrace/select-waxtrace-asset.ps1 so
  sync-waxtrace.sh ships it to installers-post/waxtrace/ on the share.

sync-waxtrace.sh:
- Push select-waxtrace-asset.ps1 next to INDEX.csv on the share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:38:19 -04:00
cproudlock
e1ea6b7c62 Wax/Trace: switch baseline to FormTracePak v6.213 vendor install
Replace the V6.0 captured-binary replay (pf-x86-MitutoyoApp.zip +
c-MitutoyoApp.zip + hklm-wow-mitutoyo.reg.gz) with a real vendor install
from FORMTRACEPAK-V6.213.iso mounted via Mount-DiskImage.

09-Setup-WaxAndTrace.ps1:
- Step 2 rewritten: Mount-DiskImage on
  C:\WaxTrace-Install\formtracepak\FORMTRACEPAK-V6.213.iso, run the VB6
  Setup.exe wrapper from the assigned drive letter (DRIVE_CDROM check
  satisfied by virtual mount, no real CD needed), then Dismount.
- Header rewritten: drop captured/ description, note legacy fallback
  remains in the repo (captured-binary/ unchanged) for manual recovery
  if the v6.213 vendor install fails on a bay.

sync-waxtrace.sh:
- Push formtracepak/FORMTRACEPAK-V6.213.iso (2.0 GB) into the bundle
  instead of captured/ payload. Override path via $FTPAK_ISO env var if
  needed (e.g. testing a v6.213 patch ISO).
- Sanity check no longer demands pf-x86-MitutoyoApp.zip; only requires
  prereqs/ + the manifest + dispatcher PS1.

playbook/utilities/convert-cal-iso.sh:
- New helper. Rebuilds a Linux-dd of a multi-session UDF cal disc into
  a clean ISO9660+UDF hybrid that Windows Mount-DiskImage reads. mkisofs
  reads the file via loop-udf, repacks single-session with the asset tag
  as volume label. Run when `file CAL-*.iso` reports "data" (multi-session
  UDF dd produced a Linux-readable but Windows-unreadable container).
  Single-session 218-458A discs from dd are already ISO9660 and don't
  need this.

Verified on win11 VM via qga: V6.213 ISO mounts, Setup.exe locatable.
14 cal ISOs all converted to ISO9660 (md5s refreshed in INDEX.csv),
re-synced to /srv/samba/enrollment/installers-post/waxtrace/calibrations/.
PXE share bundle now 2.0 GB total (V6.213 ISO + 14 cal ISOs + prereqs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:44:42 -04:00
cproudlock
2a0b4885fe Keyence VR-3000 G2: imaging-time FIPS opt-out for .exe.configs
Under Intune-enforced LSA FIPS policy, Profilometer / VRAnalyzer /
VRInspection apps crash at device init when MD5CryptoServiceProvider's
ctor is called to verify the probe EEPROM calibration (see keyence3000.txt
+ .png in pxe-images for the dialog + stack).

Patch each .exe.config under C:\Program Files\KEYENCE\<model>\ with
<runtime><enforceFIPSPolicy enabled="false"/></runtime>. Scope is app-CLR
only; OS-wide Lsa FIPS policy stays enforced. CMMC posture: scoped
exception, non-CUI integrity hash, documented in SSP. Each affected bay's
hostname must be on InfoSec's FIPS-exception list before imaging.

09-Setup-Keyence.ps1 gates the patch behind model=vr3000 only. vr5000 /
vr6000 bays do not auto-apply. Verified on win11 VM via qga: 29 configs
across vr5000+vr6000 layouts (vr3000 install was incomplete on VM),
patched + idempotent on re-run, existing <runtime> children preserved.
Also verified on a real PC: 27 patched + 2 skipped (Keyence pre-shipped
the element in two configs), 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:35:29 -04:00
cproudlock
37357eee43 Shopfloor images: add Wax/Trace + Keyence per-model variants
Wax/Trace (gea-shopfloor-waxtrace):
- captured/ holds master FormTracePak v6.0 state (Program Files reg dump
  gzipped, ARP entries) taken from a win11 VM where the CD-ROM-bound VB6
  wrapper was driven to completion. xcopy + reg-import replays the install
  on real bays without running the wrapper itself.
- 09-Setup-WaxAndTrace.ps1 rewrites the stub: installs prereqs via manifest
  (VC++ 2008/2017 x86+x64, Sentinel HASP), expands the captured zips into
  C:\Program Files (x86)\MitutoyoApp + C:\MitutoyoApp, imports the reg
  hive, then mounts the bay's per-machine cal ISO (matched by asset tag
  in machine-number.txt) and runs its Setup.exe.
- waxtrace-manifest.json lists the 5 prereqs with InstallShield-style
  silent flags verified on the win11 VM.
- sync-waxtrace.sh ships captured-binary/ + prereqs + cal ISOs from
  /home/camp/pxe-images/iso/mitutoyo-cal/ to
  /srv/samba/enrollment/installers-post/waxtrace/ on the PXE box.
- select-waxtrace-asset.ps1 arrow-key bay picker for WinPE (parses
  INDEX.csv from the cal share, offers "Other (new bay)" fallback).
- startnet.cmd: prompt_waxtrace_asset prompt, skip_waxtrace_stage xcopy
  block (mirrors :skip_cmm_stage), machine-number.txt write covers bay
  asset tag (WJRP*).

Keyence (gea-shopfloor-keyence) - now multi-model:
- vr3000/manifest.json + vr5000/manifest.json + vr6000/manifest.json
  (current single-model VR-6000 moved into vr6000/ subdir). Each ships
  the model's MSI silent-install + DetectionPath via ProductCode.
  Big payloads (Data1.cab, Data11.cab) gitignored, staged via
  sync-keyence.sh from /home/camp/pxe-images/iso/keyence/.
- 09-Setup-Keyence.ps1 dispatches by C:\Enrollment\keyence-model.txt
  (written by startnet.cmd in :keyence_submenu) and points
  InstallerRoot at C:\KeyenceInstall\<model>. DXSETUP probe widened
  to all three Program Files paths (VR-3000 G2, VR-5000, VR-6000).
- startnet.cmd: :keyence_submenu picks vr3000/vr5000/vr6000,
  :skip_keyence_stage xcopy block selectively stages chosen model bundle,
  pc-subtype.txt also written = drops directly into existing GE-Enforce
  PCSubType wiring (looks for gea-shopfloor-keyence-<model>\manifest.json
  on the tsgwp00525 share for ongoing enforcement, no dispatcher change
  needed).
- sync-keyence.sh mirrors sync-waxtrace.sh pattern.

Verified silent MSI install for VR-3000 G2 v2.5.0 and VR-5000 v3.3.1 on
the win11 VM 2026-05-18 with /qn /norestart ALLUSERS=1 REBOOT=ReallySuppress
TRANSFORMS=1033.mst. boot.wim on 172.16.9.1 wimupdate'd with the new
startnet.cmd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:34:20 -04:00
cproudlock
3aabd47571 imaging dashboard: add Clear all button + endpoint
New /imaging/delete_all endpoint wipes every per-bay JSON in IMAGING_DIR
via imaging_status.delete_all_sessions(). Template adds "Clear all"
outline-danger button next to the count badge, gated on sessions list
non-empty, with confirm() prompt naming the count.

Deployed via scp + systemctl restart pxe-webapp on 172.16.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:58:31 -04:00
cproudlock
7f097013fc BIOS sub-stage: switch to flag-file signaling, move push after W: copies
check-bios.cmd: drop literal `^` from BIOS_STATUS (caret survives quoted
SET so substring search for `->` never matched). Write X:\bios-fired.flag
on flash_done + staged paths so startnet.cmd can detect via if-exist.

startnet.cmd: replace `call set` substring-replace with `if exist
X:\bios-fired.flag`. Move push to after W:\Enrollment xcopy completes
(before Y: cleanup) so dashboard reflects "BIOS firmware update" stage
once file staging is done, matching user mental model of imaging order.

Tested flag-file logic in win11 VM cmd.exe: missing -> SKIPS, present
-> FIRES, removed -> SKIPS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:58:25 -04:00
cproudlock
9108b495c9 startnet.cmd: fix BIOS sub-stage detection (substring-replace trick)
Original used 'echo %BIOS_STATUS% | findstr /C:"->"' to detect whether
check-bios.cmd actually applied a firmware update. The '>' inside the
value (from check-bios writing e.g. 'updated 1.5.0 -> 1.6.0') gets
parsed by cmd.exe as a redirect operator BEFORE the pipe is set up.
Result: echo wrote a file named '1.6.0' in cwd instead of piping to
findstr. findstr saw no input, returned errorlevel=1, block never
fired. Plus a stray file got created in X:\Windows\System32.

Confirmed empirically in the win11 VM with test bat:
 - echo|findstr approach: SKIPS even when '->' present  (bug)
 - substring-replace: FIRES iff '->' present  (correct)

Fix: replace echo/pipe/findstr with a substring-replace test:

  call set "BIOS_STATUS_STRIPPED=%%BIOS_STATUS:->=%%"
  if not "%BIOS_STATUS%"=="%BIOS_STATUS_STRIPPED%" ( ... )

The '>' inside %VAR:->=...% is parsed as part of the substring
substitution token, not as a redirect. Yields true diff only when
the arrow was actually in BIOS_STATUS.

xcopy region was never impacted by the bug because subsequent
if-exist blocks didn't depend on the previous errorlevel state.
But the BIOS sub-stage push to the dashboard was silently broken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:50:12 -04:00
cproudlock
d8c64bef2b Add conditional BIOS-update sub-stage on idx=1
winpe-status-push.ps1 now accepts -CurrentStage / -StageIndex
params so callers can override the default "WinPE: PESetup / WIM
apply" string. Backwards compatible.

startnet.cmd: after the existing initial WinPE status push,
inspect $BIOS_STATUS for the "->" marker that check-bios.cmd
writes when an update was actually applied or staged. If present,
fire a second idx=1 push with stage="WinPE: BIOS firmware update -
<status>". No-op for clean "up to date" / "no update in catalog"
runs.

imaging.html: at stage_idx=1 with "bios" in current_stage, swap
friendly label to "Updating BIOS firmware" with a do-NOT-power-off
hint. Bays without firmware updates show the default "Booting from
PXE" label as before.

boot.wim startnet.cmd updated via wimupdate so live PXE clients
pick it up at next boot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:26:31 -04:00
cproudlock
76a3ba513c Monitor: drop cert pre-gate + force Report IP after AESFMA connect
Two fixes for the AESFMA swap path:

1. Removed the X509Chain root-thumbprint pre-check. Bay user reported
   "claims connect not yet operational, but i was able to manually
   connect" - meaning the cert IS in LocalMachine\My but
   $chain.Build() returns a partial chain (probably missing an
   intermediate in the local trust store), so our root-thumbprint
   match returned false and Monitor never even tried the netsh
   connect. Letting netsh attempt directly - it's the source of
   truth on whether EAP-TLS auth succeeds. Rate-limited to 30s
   between attempts to avoid log spam when AESFMA truly isn't
   reachable.

2. Bumped post-connect verify sleep 8s -> 15s. WLAN auth + DHCP can
   take longer than 8s on first attempt.

3. New: once Test-AESFMAConnected returns true and INTERNETACCESS
   is deleted, force-run GE_ReportIP_3_v1.EXE /ForceUpdate=True /S
   so the webhook gets the corp-AESFMA IP immediately instead of
   waiting for the next DHCP-change trigger (which may never fire
   if AESFMA was the bay's first 10.x lease). $script:cache.
   ReportIpForced caches the one-shot fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:25:37 -04:00
cproudlock
3385bc87aa Monitor + imaging: per-phase sub-stages within idx=7
Monitor's Get-Snapshot already tracks Phase 1-5 (Intune Registration,
Device Configuration, Software Deployment, Credentials, Lockdown).
The webapp dashboard only saw a single idx=7 push for the entire
post-PPKG / pre-lockdown window, so the friendly label couldn't
reflect "where is this bay actually". Operator looking at the
dashboard had no idea whether to assign category or hit ARTS for
lockdown next.

Monitor now pushes additional idx=7 entries as it crosses Phase
boundaries:
 - On DeviceId capture: "Intune Device ID captured" (existing)
 - On Phase 2 done (SFLD policy delivered = category was assigned):
   "Phase 2 SFLD policy delivered (device configuration)"
 - On Phase 1-4 all complete: "Phases 1-4 complete - ready for
   lockdown (ARTS request)"
 - On lockdown done: idx=8 (existing)

imaging.html maps the stage_string substring to friendly labels:
 - default idx=7         -> "Registered - assign category"
 - 'sfld policy' / 'phase 2' -> "Phase 2 - device configuration"
 - 'credentials' / 'phase 4' -> "Phase 3 / 4 - DSC + credentials"
 - 'ready for lockdown' / 'request lockdown' -> "Ready - request
                              lockdown" (hint: click ARTS request)

Operator now knows exactly when to act vs when to wait.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:20 -04:00
cproudlock
220c5db5b9 webapp: LAPS clear actually removes the password from session JSON
Previous /imaging/<serial>/laps clear path used update_session() to
re-feed state minus laps_password. But update_session MERGES payload
into existing state - it cannot delete a key the existing state
already has. The laps_password persisted on disk across the "clear"
POST, then came back into the page on next reload.

Fix: bypass update_session for the clear case. Read the session JSON
directly, pop laps_password, write via atomic tempfile-rename. Same
write pattern update_session uses for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:33:07 -04:00
cproudlock
8debc4ddb3 imaging: LAPS input always visible, not gated on intune_device_id
Was hiding LAPS QR section until idx=7 pushed with a DeviceId.
Operator couldn't paste a password if Monitor hadn't gotten around
to capturing the DeviceId yet. The QR encoding doesn't depend on
DeviceId - it's just the password being encoded - so the section is
useful any time the bay is past the LAPS reboot.

Drop the {% if s.intune_device_id %} gate. LAPS section now appears
in every expanded tile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:30:48 -04:00
cproudlock
036090348c imaging: persist tile expanded state across page refresh
localStorage-backed set of serials at key 'imaging-expanded'. On
DOMContentLoaded, walk each .imaging-card; if its data-serial is in
the set, set card.open=true. On every <details> toggle, update the
set. Refresh no longer collapses the tile the operator was looking
at.

Per-browser state (localStorage), no server round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:29:45 -04:00
cproudlock
b9f66687ac imaging: search now also matches stage name, stage-N, and status
Extended the data-filter attribute to include:
 - friendly stage label (e.g. "awaiting intune lockdown")
 - "stage-N" token (e.g. type "stage-7" to find idx=7 bays)
 - status string (in_progress / succeeded / failed)

Use cases:
 - Find all bays waiting on lockdown: type "lockdown"
 - Find all bays at the same stage: "stage-7"
 - Find failed bays: "failed"
 - Find succeeded bays: "succeeded"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:28:08 -04:00
cproudlock
65eeead5a0 imaging: collapsible tile + ARTS link + reword stage 7
Tile is now <details>. Always-visible summary:
  - QR (96px)
  - serial / hostname / pctype / machine# / status badge
  - friendly stage label + N/M badge + pct
  - progress bar

Click to expand. Body shows:
  - friendly stage hint
  - Intune device id row with [copy] [set category] [ARTS request]
  - metadata one-liner (started / last / MAC / raw current_stage)
  - error banner (if any)
  - LAPS password QR generator
  - log tail
  - Clear button

ARTS button links to https://arts.dw.geaerospace.net/requests/type
for kicking off a new lockdown request (Intune-side step happens
externally; this is a deep-link for convenience).

Stage 7 wording: "Awaiting Intune lockdown" (was "awaiting category /
lockdown" - confusing when category was already assigned). Hint
explicitly mentions category check for cases where it isn't yet set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:27:35 -04:00
cproudlock
a9a7478d5a imaging: organize tile metadata into deterministic rows
Previously last_updated, MAC, started, Intune device id, and the
raw current_stage string were sprinkled around the card in
hard-to-track positions. Reorganized:

Row 1 (header): serial | hostname | pctype | machine# | status badge
Row 2 (stage):  friendly label + N/M badge | pct% (right)
Row 3:          full-width progress bar
Row 4:          friendly hint (optional)
Row 5:          Intune device id + copy + set-category (optional)
Row 6:          started ... last ... MAC ... raw current_stage

Each row consistent across all cards regardless of which fields
are populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:16:34 -04:00
cproudlock
1c361e138b imaging: compact tile + search filter + stage 7 label tweak
Tile shrunk for fleet density:
 - QR: 160px -> 96px
 - Drop big h4 for serial, use fs-6 strong instead
 - DeviceId + buttons + MAC + started time consolidated into one
   small grey row instead of three separate sections
 - Progress bar 1.2rem -> 0.7rem
 - mb-4 -> mb-2 between cards
 - card-body py-2 for tighter vertical rhythm

Search:
 - Sticky search input above the card list
 - Filters live on serial, hostname, pctype, machinenumber,
   intune_device_id via lowercase substring match on a data-filter
   attribute
 - Visible-count badge updates as you type ("3/12")
 - Auto-refresh paused while query has text or while input is focused

Stage 7 label: was "assign category" only, now "awaiting category /
lockdown" to reflect that bays past category assignment are still
waiting on the Intune-driven LAPS-prompt reboot before lockdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:14:51 -04:00
cproudlock
ca647cb690 imaging: redesign tile + LAPS persist + 15s refresh
Tile redesign:
 - QR (or placeholder if not yet captured) on the left as a fixed 160px block
 - Right side: header (serial / hostname / pctype / machinenumber / status)
   then stage label as a big h4 with stage badge + % on the same row,
   then full-width progress bar, then friendly stage hint
 - Intune device id row with copy + set-category buttons consolidated
   under the progress section
 - Footer one-liner: started / last / MAC / raw current_stage (small grey)
 - LAPS QR + log tail still expandable below
 - shadow-sm for visual lift, no card-header line splitting

LAPS persist: POST password to /imaging/<serial>/laps so it survives
the dashboard refresh. Auto-renders QR on page load if the session
already has a stored password. Clear button POSTs empty string to
wipe server-side. No more 60s auto-clear - stays until cleared (or
daily server reset).

Refresh: 5s -> 15s. Reduces polling jitter + gives the eye time to
read before page flickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:11:58 -04:00
cproudlock
5f322d1110 imaging: operator-friendly stage labels per bay card
Was showing the raw push label (e.g. "Run-ShopfloorSetup: handoff to
Monitor-IntuneProgress") which only makes sense if you know the
playbook internals. Added a stage_index -> (label, hint) lookup table:

  1  Booting from PXE                    WinPE loaded
  2  Configuring Windows                 First boot baseline scripts
  3  Installing apps                     09-Setup-<pctype>
  4  Apps installed                      preparing for enrollment
  5  Enrolling in Intune                 PPKG + AAD/Intune join
  6  Waiting on first Intune sync        post-PPKG settle (~120s)
  7  Registered - assign category        idx=7 with QR + set-category btn
  8  Imaging complete                    lockdown applied

Friendly label + one-line hint shown bold, raw stage string shown
underneath in small monospace for techs who want the playbook
breadcrumb. Stage index/total folded into a badge next to the
"Current stage" header so it doesn't need its own column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:08:05 -04:00
cproudlock
520d4aa791 Monitor: fix AESFMA-connected detection + stop retrying once connected
Two bugs causing "AESFMA cert detected, connecting AESFMA..." to log
over and over even after AESFMA is already up:

1. Regex 'SSID\s*:\s*AESFMA.*?State\s*:\s*connected' required SSID
   line BEFORE State line. Actual netsh wlan show interfaces order
   on Win11 is "Name / State / SSID" - State comes FIRST. The non-
   greedy match never succeeded. Always thought AESFMA wasn't
   connected. Refactor to a Test-AESFMAConnected helper that splits
   output into per-adapter blocks and checks SSID + State independently,
   tolerating either order.

2. Added a fast-path at top of the WiFi-swap block: if AESFMA is
   already connected (no help needed from us), just delete
   INTERNETACCESS if still present and flip the cache flag to stop
   running this block. Previously the block only set the flag after a
   successful connect-then-verify-then-delete cycle; if AESFMA was
   already up at first check, the cycle "succeeded" each tick but
   the flag never flipped, producing the log spam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:06:00 -04:00
cproudlock
894305e906 Monitor: drop AESFMA-connected from Phase 1 done; webapp: LAPS endpoint
1. Phase 1 done gate was requiring 'AESFMA WLAN connected' in addition
   to the data-side signals (AAD + Intune + EmTask + baseline). If the
   bay never reached AESFMA (cert never landed, RADIUS unreachable),
   Phase 1 stayed IN PROGRESS forever even though Intune registration
   was actually complete. Reverting to the data-side-only definition.

2. New webapp endpoint POST /imaging/<serial>/laps stores a LAPS
   password in the session JSON so it survives the 5s dashboard
   auto-refresh. Empty body clears the field. Daily reset of the
   server (cron/restart) is the lifetime cap on stored passwords.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:53:05 -04:00
cproudlock
1b7e1bfee4 imaging: pause page auto-refresh while a LAPS QR is showing
meta http-equiv=refresh fires every 5s and reloads the entire page,
wiping the LAPS QR state mid-scan. Replaced the meta tag with a
JS-driven setTimeout(location.reload, 5000) so renderLapsQR() can
clearTimeout it. Reload resumes when the QR is cleared (manual or
60s auto). Multi-bay safety: only resumes if no other bay still has
a QR rendered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:50:24 -04:00
cproudlock
d5398bdd74 imaging: LAPS-password-to-QR generator per bay card
Per-bay <details> section with:
 - Input field for LAPS password (paste from Intune portal manually,
   since deep-link to LAPS blade needs AAD objectId we can't obtain)
 - Make QR button generates a client-side QR from the input
 - QR displayed below at 280px with 4-cell quiet zone
 - Auto-clears input + QR after 60s with live countdown
 - Manual Clear button
 - Enter key on the input also triggers QR generation

Password never POSTs to server, never logged, never persists past the
60s window. Generated using the same qrcode-generator lib already
loaded for the device-id QR. Scan with a USB barcode scanner plugged
into the bay (HID keyboard mode) -> password types into bay login
field. Faster than reading off the Intune portal letter-by-letter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:48:43 -04:00
cproudlock
cdb6655e4a imaging: drop LAPS deep-link, keep only category
LAPS retrieval blade is keyed on AAD object id, not aadDeviceId /
mdmDeviceId. We capture aadDeviceId from dsregcmd; resolving to
objectId would require a Graph API call with Device.Read.All which
we don't have at WJ. Removed the LAPS button - operator goes to
Intune portal manually for LAPS as before.

set-category button stays - aadDeviceId works for that blade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:47:46 -04:00
cproudlock
74ba3d1339 imaging: deep-link buttons for Set Category + LAPS per bay
Two buttons next to the Intune device id on each bay card:
 - "set category" -> portal.azure.us Intune device blade properties
   via aadDeviceId/{deviceId}
 - "LAPS" -> intune.microsoft.us encryptionKeys blade via
   mdmDeviceId/{deviceId}

Both use the dsregcmd DeviceId we already capture - no Graph API
lookup or objectId resolution needed. One click from the dashboard
takes the tech to the right page for category assignment or LAPS
retrieval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:20:10 -04:00
cproudlock
4599c85509 Monitor: strip ANSI escape codes from dsregcmd output before regex
Smoking gun for "Monitor's on-screen QR works but no idx=7 push lands
on the PXE dashboard". Win11's dsregcmd emits ANSI VT100 escape codes
(e.g. \x1B[7mDeviceId\x1B[0m :) around field labels. Captured output
strings then have those codes between "DeviceId" and ":". The strict
regex 'DeviceId\s*:\s*<guid>' fails because \s* doesn't match ANSI
escape chars. $script:cache.DeviceId stays null, idx=7 push never
fires.

Build-QRCodeText was unaffected because it uses Select-String 'DeviceId'
(substring match, tolerates anything in between) then splits on ':'.

Fix: strip ANSI sequences via -replace '\x1B\[[0-9;]*[A-Za-z]', '' before
running the regex. Same pattern covers all CSI sequences dsregcmd uses.
Also force Out-String to get a single string back (was an array of lines
from 2>&1; -match on arrays returns matching elements but $matches
behavior across mixed objects is fragile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:17:05 -04:00
cproudlock
2d75935dfc PostPpkg settle 60s -> 120s
Empirical: a fresh-imaged bay often hasn't finished AAD-join + first
Intune sync by 60s, so the post-PPKG-reboot Monitor instance starts
without DeviceId visible to dsregcmd yet. Doubling the settle to 120s
gives MDM more time to land baseline policies before the reboot,
which means the post-reboot Monitor sees AAD-joined + DeviceId on
first tick and fires idx=7 immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:13:26 -04:00
cproudlock
3fb1d983df Stop moving OpenText / WJ Shopfloor shortcuts into Shopfloor Tools
OpenText / Host Explorer shortcut filenames vary by installed profile
(e.g. 'WJ Shopfloor OpenText.lnk', 'WJ Shopfloor.lnk', 'HostExplorer
ShopFloor.lnk'). The taskbar-pin path in site-config.json hardcodes
'Shopfloor Tools\WJ Shopfloor.lnk' - mismatches the actual filename
so 07-TaskbarLayout silently skips pinning it.

Drop OpenText/ShopFloor/HostExplorer pattern moves from 06's
categorization regex. Shortcuts stay at the public-desktop top
level where the OpenText installer placed them. Tech sees the
icon on the desktop, no taskbar pin (the variable filename made
the pin unreliable anyway).

Other categories (UDC, eDNC, NTLARS, etc with stable filenames)
still move into Shopfloor Tools and pin correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 18:10:16 -04:00
cproudlock
9beee842f1 Monitor: deterministic AESFMA cert check via X509Chain root match
Walk Cert:\LocalMachine\My, build each cert's chain, look for chain
element with thumbprint 27F0C9A22B28CE7687B115A29E31BF4B3ABB180F.
That's the AESFMA.xml TrustedRootCA value = the GE Aerospace
FreeRADIUS root that AESFMA EAP-TLS validates against. A client cert
chained to that root is the SCEP-provisioned AESFMA machine cert.

Combined with the verify-before-delete connect attempt, this gives
two gates:
 1. Cert deterministically exists + chains correctly
 2. netsh wlan connect to AESFMA actually reports State=connected

Only after both pass does INTERNETACCESS get deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:48:00 -04:00
cproudlock
f013aa2bff Monitor: AESFMA verify-before-delete - keep INTERNETACCESS until cert ready
Old gate (SCEP cert in LocalMachine\My with Client Auth EKU) was both
too loose (matches non-AESFMA certs) and unable to verify the cert
chains to GE's RADIUS root. INTERNETACCESS got deleted before AESFMA
could actually authenticate, orphaning the bay.

New flow: when Phase 1 essentials (AAD + Intune + EmTask + baseline)
are complete, ATTEMPT netsh wlan connect AESFMA with INTERNETACCESS
still up as fallback. Wait 8s, parse netsh wlan show interfaces for
SSID=AESFMA + State=connected. Only delete INTERNETACCESS after
operational verification. If AESFMA connect fails (cert not provisioned
yet, RADIUS server unreachable, etc), keep INTERNETACCESS and retry
next tick. Loop runs every 5s while DeviceIdReported is false, so the
swap fires as soon as AESFMA is operationally viable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:46:19 -04:00
cproudlock
a9260ecadd Monitor: 5s tight poll while DeviceId still missing
DeviceId may not be in dsregcmd output the moment Monitor starts after
PPKG reboot - takes a few minutes for AAD-join to settle. Default 30s
PollSecs leaves wide gaps where Monitor isn't checking. Sleep 5s
instead while DeviceIdReported is still false. Once captured + idx=7
push lands, falls back to PollSecs (30s) for the rest of the loop.

Worst case for QR-on-dashboard latency: ~5 seconds after dsregcmd
starts returning a DeviceId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:08:12 -04:00
cproudlock
ab3e1c98f6 Monitor: fire idx=7 immediately on DeviceId capture (beat LAPS reboot)
User constraint: GE-issued LAPS-prompt reboot lands ~1 minute after
Report IP posts its log. Need the QR on the PXE dashboard BEFORE
that reboot or the operator has no way to look up the device for
LAPS retrieval.

Previously idx=7 was gated on Phase 1 essentials (AAD + Intune
enrolled + EmTask + baseline policies >=5). Those flips happen
later than DeviceId capture (dsregcmd shows DeviceId the instant
AAD-join completes during PPKG). Dropping the gate so idx=7
fires the moment the cache has a DeviceId. Phase 1 row on the
on-bay Monitor display still has its own AESFMA-required gate
for operational completeness; only the dashboard push is moved
earlier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:06:02 -04:00
cproudlock
842ef88ccb Monitor: gate WiFi swap on SCEP cert + Phase 1 done on AESFMA connected
Two related fixes for the WiFi handoff timing:

1. WiFi swap (delete INTERNETACCESS + connect AESFMA) was firing on
   Phase 1 essentials being green (AAD + Intune + EmTask + baseline
   policies >=5). That signal flips ~minutes BEFORE the Intune SCEP
   machine cert actually lands in LocalMachine\My. Without the cert,
   AESFMA EAP-TLS auth fails and the bay has no path at all (we just
   deleted INTERNETACCESS). Stuck.

   New gate: walk Cert:\LocalMachine\My for any cert with Client
   Authentication EKU (1.3.6.1.5.5.7.3.2). When that's present, SCEP
   has delivered, AESFMA EAP-TLS will succeed. Swap then fires safely.

2. Phase 1 row on the on-bay Monitor display now ALSO requires
   AESFMA to be actively connected (parsed from netsh wlan show
   interfaces: SSID=AESFMA + State=connected). Phase 1 stays IN
   PROGRESS until the bay is operationally on corp WLAN, not just
   data-side enrolled. Matches user request "not complete phase 1
   until AESFMA is ready".

idx=7 dashboard push still fires on the original Phase 1 essentials
gate so the QR appears as soon as Intune registers the device,
independent of AESFMA join timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:04:09 -04:00
cproudlock
a17b3fae6a Retire wired-disable/re-enable dance now that PXE LAN is 172.16.9.0/24
GE Report IP filters Get-NetIPAddress on StartsWith("10.") and PXE LAN
addresses are now 172.16.9.x which the filter skips naturally. The
disable-then-re-enable workaround was only needed when PXE LAN was
10.9.100.x and bays leaked that IP to the GE webhook. With the renumber
that whole flow is dead weight.

Removed:
 - playbook/shopfloor-setup/Shopfloor/lib/Disable-WiredNics.ps1 (file)
 - Run-ShopfloorSetup: Disable-WiredNics call after PPKG returns
 - Run-ShopfloorSetup: "GE Re-enable Wired NICs" SYSTEM task registration
 - Monitor-IntuneProgress: reportIpLog-gated wired re-enable + idx=7 retry
 - Monitor-IntuneProgress: reportIpDone gate on Phase 1 done check

Side benefit: stages 2-6 dashboard pushes no longer go dark mid-flow
(used to die between idx=6 and idx=7 when wired was off). Phase 1 row
on the Monitor screen now flips COMPLETE on the natural AAD + Intune
+ EmTask + baseline-policies condition instead of waiting on the
Report IP log file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:45:54 -04:00
cproudlock
ce604adcda Renumber PXE LAN from 10.9.100.0/24 to 172.16.9.0/24
Single-site bay-stuck issue at WJ: GE Intune Report IP script filters
Get-NetIPAddress on StartsWith("10.") and posts everything matching
to the GE Tines webhook. Bays at WJ get the PXE LAN 10.9.100.x IP
captured and reported -> GE backend tags bays as on a non-corp 10.x
subnet -> dynamic group eligibility for SFLD policy never matches.
Other GE sites work because their PXE LANs aren't on 10.x at all.

Renumber PXE LAN to RFC1918 172.16.9.0/24 so the GE filter naturally
skips wired PXE addresses without any disable-NIC dance.

Server-side already in flight (netplan dual-bound, dnsmasq scope +
boot URL repointed, blancco preferences + grub.cfg + iPXE GetPxeScript
all sed'd to 172.16.9.1). This commit is the playbook / scripts /
docs side: 109 hits across 35 files sed'd in one shot.

After this lands + boot.wim is rebuilt + bays renumber off DHCP,
the 10.9.100.1 binding will be dropped from netplan as the final
cleanup step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:30:32 -04:00
236 changed files with 13605 additions and 841 deletions

18
.gitignore vendored
View File

@@ -101,6 +101,20 @@ playbook/shopfloor-setup/Shopfloor/PrinterInstallerMap.exe
# /home/camp/pxe-images/keyence/Logs/Keyence/install.log for the signature). # /home/camp/pxe-images/keyence/Logs/Keyence/install.log for the signature).
# Canonical source on the GE-Enforce SFLD share: # Canonical source on the GE-Enforce SFLD share:
# tsgwp00525\sfld$\v2\shared\dt\shopfloor\gea-shopfloor-keyence\apps\Data1.cab # tsgwp00525\sfld$\v2\shared\dt\shopfloor\gea-shopfloor-keyence\apps\Data1.cab
# Stage to playbook/shopfloor-setup/gea-shopfloor-keyence/installers/Data1.cab
# before building the USB image. # before building the USB image.
playbook/shopfloor-setup/gea-shopfloor-keyence/installers/Data1.cab playbook/shopfloor-setup/gea-shopfloor-waxtrace/captured-binary/
# Keyence per-model installer payloads - too big for git, staged via sync-keyence.sh
playbook/shopfloor-setup/gea-shopfloor-keyence/vr3000/installers/Data*.cab
playbook/shopfloor-setup/gea-shopfloor-keyence/vr3000/installers/*.msi
playbook/shopfloor-setup/gea-shopfloor-keyence/vr5000/installers/Data*.cab
playbook/shopfloor-setup/gea-shopfloor-keyence/vr5000/installers/*.msi
playbook/shopfloor-setup/gea-shopfloor-keyence/vr6000/installers/Data1.cab
# Part Marker (Telesis) utility password - secret, deployed via the enrollment
# share from the working tree, never committed.
playbook/shopfloor-setup/gea-shopfloor-partmarker/PartMarker/Mark/utilpassword.txt
# HeatTreat per-machine DNC .reg exports (6601-6604) - contain DNC FtpPasswd
# credentials. Deployed via the enrollment share from the working tree.
playbook/shopfloor-setup/gea-shopfloor-heattreat/reg/*.reg

BIN
Binary/Binary.NewBinary1 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
Binary/Binary.NewBinary10 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary11 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary12 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Binary/Binary.NewBinary13 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary14 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary15 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary16 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary17 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary18 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

40
Binary/Binary.NewBinary19 Normal file
View File

@@ -0,0 +1,40 @@
Option Explicit
' アップグレードコードから、製品コードを取得
'
' 第1引数 : アップグレードコード(「{」、「}」、ハイフンあり)
Function GetProductCodeFromUpgradeCode(UpgCode)
Dim listProductCode
Dim szProductCode
' アップグレードコードから、関連する製品名のリストを取得
Set listProductCode = Session.Installer.RelatedProducts(UpgCode)
' 基本、1件のみヒットするものとする
For Each szProductCode In listProductCode
GetProductCodeFromUpgradeCode = szProductCode
' 1件目を取得した段階で抜ける
Exit For
Next
End Function
' アップグレードコードから既にインストール済みのアプリケーションのインストールパスを取得する
Sub GetInstallPath()
Dim WshShell
Dim szProductCode
Dim szInstallStringKey
Set WshShell = CreateObject("WScript.Shell")
' アップグレードコードから、製品コードを取得
szProductCode = GetProductCodeFromUpgradeCode(Session.Property("UpgradeCode"))
' レジストリのInstallLocationを取得
szInstallStringKey = WshShell.RegRead("HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + szProductCode + "\InstallLocation")
Session.Property("INSTALLDIR_FOR_MAJORUPGRADE") = szInstallStringKey
Set WshShell = nothing
End Sub

BIN
Binary/Binary.NewBinary2 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

22
Binary/Binary.NewBinary20 Normal file
View File

@@ -0,0 +1,22 @@
Option Explicit
Sub CheckOSVersion
Const HKEY_LOCAL_MACHINE = &H80000002
Dim WshShell,objRegistry
Dim strComputer, strKeyPath, strValue, strValueName
Set WshShell = CreateObject("WScript.Shell")
strComputer = "."
Set objRegistry = GetObject("winmgmts:{impersonationLevel=impersonate}!\\" & strComputer & "\root\default:StdRegProv")
strKeyPath = "SOFTWARE\Microsoft\Windows NT\CurrentVersion"
strValueName = "CurrentMajorVersionNumber"
objRegistry.GetDWORDValue HKEY_LOCAL_MACHINE,strKeyPath,strValueName,strValue
If (not IsNull(strValue)) and (strValue=10) Then
Session.Property("IsWindows10")="1"
else
Session.Property("IsWindows10")="0"
End If
End Sub

13
Binary/Binary.NewBinary21 Normal file
View File

@@ -0,0 +1,13 @@
Option Explicit
'レジストリに登録する日付をプロパティに設定
Sub SetInstallDate
Session.Property("INSTALLDATE") = YYYYMMDD
End Sub
'YYYYMMDD形式の日付を返す
Function YYYYMMDD
YYYYMMDD = Year(Date) & Right("0" & Month(Date), 2) & Right("0" & Day(Date), 2)
End Function

BIN
Binary/Binary.NewBinary3 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

BIN
Binary/Binary.NewBinary4 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
Binary/Binary.NewBinary5 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
Binary/Binary.NewBinary6 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
Binary/Binary.NewBinary7 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary8 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

BIN
Binary/Binary.NewBinary9 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -23,7 +23,7 @@ Client PXE boot (UEFI Secure Boot)
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (10.9.100.10-100, 12h lease) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100, 12h lease) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
@@ -32,8 +32,8 @@ Client PXE boot (UEFI Secure Boot)
### Network ### Network
- **PXE server IP:** `10.9.100.1/24` - **PXE server IP:** `172.16.9.1/24`
- **DHCP range:** `10.9.100.10` - `10.9.100.100` - **DHCP range:** `172.16.9.10` - `172.16.9.100`
- **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009) - **Firewall:** UFW deny-by-default, only service ports open (22, 67, 69, 80, 445, 4433, 9009)
## Quick Start ## Quick Start
@@ -85,12 +85,12 @@ Creates a bootable USB with two partitions:
4. After reboot, the first-boot script: 4. After reboot, the first-boot script:
- Installs all offline .deb packages - Installs all offline .deb packages
- Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp) - Runs the Ansible playbook (configures dnsmasq, Apache, Samba, UFW, webapp)
- Configures static IP `10.9.100.1/24` - Configures static IP `172.16.9.1/24`
5. Move the server's wired NIC to the isolated PXE switch 5. Move the server's wired NIC to the isolated PXE switch
### Step 5: Access the Web Interface ### Step 5: Access the Web Interface
Open `http://10.9.100.1:9009` from any machine on the isolated network. Open `http://172.16.9.1:9009` from any machine on the isolated network.
## Web Management Interface ## Web Management Interface
@@ -213,11 +213,11 @@ This creates `pxe-server-proxmox.iso` containing the Ubuntu installer, autoinsta
3. Attach the ISO as CD-ROM and start the VM 3. Attach the ISO as CD-ROM and start the VM
4. Ubuntu auto-installs with zero interaction (~10-15 minutes) 4. Ubuntu auto-installs with zero interaction (~10-15 minutes)
5. After reboot, first-boot configures all PXE services automatically 5. After reboot, first-boot configures all PXE services automatically
6. Access the web interface at `http://10.9.100.1:9009` 6. Access the web interface at `http://172.16.9.1:9009`
### Import WinPE Images ### Import WinPE Images
After the server is running, import deployment images via the web interface at `http://10.9.100.1:9009/import` or by mounting a USB drive with WinPE content. After the server is running, import deployment images via the web interface at `http://172.16.9.1:9009/import` or by mounting a USB drive with WinPE content.
## Samba Shares ## Samba Shares
@@ -235,7 +235,7 @@ All shares use guest access (no authentication) for ease of use on the isolated
Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi. Blancco Drive Eraser 7.15.1 boots via a native Ubuntu kernel with a custom initramfs (`blancco-init.sh`) that downloads and mounts the Blancco rootfs over HTTP. XML erasure reports are automatically saved to the PXE server's Samba share (`blancco-reports`). The server supports BMC cloud licensing for Blancco activation over WiFi.
Reports are viewable and downloadable from the web interface at `http://10.9.100.1:9009/reports`. Reports are viewable and downloadable from the web interface at `http://172.16.9.1:9009/reports`.
## Notes ## Notes

View File

@@ -18,7 +18,7 @@ Client PXE boot
| Service | Port | Purpose | | Service | Port | Purpose |
|-------------|-----------|------------------------------------------| |-------------|-----------|------------------------------------------|
| dnsmasq | 67/udp | DHCP (10.9.100.10-100) | | dnsmasq | 67/udp | DHCP (172.16.9.10-100) |
| dnsmasq | 69/udp | TFTP (serves ipxe.efi) | | dnsmasq | 69/udp | TFTP (serves ipxe.efi) |
| Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) | | Apache | 80/tcp | HTTP (wimboot, WinPE boot files, proxy) |
| Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) | | Apache | 4433/tcp | iPXE boot script (GetPxeScript.aspx) |
@@ -95,7 +95,7 @@ Move the server's wired NIC to the isolated switch for PXE clients.
### Step 6: Import WinPE Content (if not bundled in Step 3) ### Step 6: Import WinPE Content (if not bundled in Step 3)
**Option A:** Use the web interface at `http://10.9.100.1:9009` to import from USB. **Option A:** Use the web interface at `http://172.16.9.1:9009` to import from USB.
**Option B:** Manual copy: **Option B:** Manual copy:
```bash ```bash
@@ -107,7 +107,7 @@ sudo umount /mnt/usb2
## Web Management Interface ## Web Management Interface
Access at `http://10.9.100.1:9009` from any machine on the isolated network. Access at `http://172.16.9.1:9009` from any machine on the isolated network.
| Page | URL Path | Purpose | | Page | URL Path | Purpose |
|-------------------|-------------|-----------------------------------------------| |-------------------|-------------|-----------------------------------------------|
@@ -146,7 +146,7 @@ sudo ./test-vm.sh ~/Downloads/ubuntu-24.04.3-live-server-amd64.iso
# Watch progress (Ctrl+] to detach) # Watch progress (Ctrl+] to detach)
sudo virsh console pxe-test sudo virsh console pxe-test
# After install: ssh pxe@10.9.100.1 / http://10.9.100.1:9009 # After install: ssh pxe@172.16.9.1 / http://172.16.9.1:9009
# Clean up # Clean up
sudo ./test-vm.sh --destroy sudo ./test-vm.sh --destroy
@@ -215,8 +215,8 @@ pxe-server/
## Network Configuration ## Network Configuration
- PXE server static IP: `10.9.100.1/24` - PXE server static IP: `172.16.9.1/24`
- DHCP range: `10.9.100.10` - `10.9.100.100` - DHCP range: `172.16.9.10` - `172.16.9.100`
- Lease time: 12 hours - Lease time: 12 hours
- DNS: `8.8.8.8` (passed to clients, not used by server) - DNS: `8.8.8.8` (passed to clients, not used by server)
- Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp - Firewall: UFW deny-by-default, allow 67/udp 69/udp 80/tcp 445/tcp 4433/tcp 9009/tcp

View File

@@ -17,7 +17,7 @@ autoinstall:
match: match:
name: "en*" name: "en*"
addresses: addresses:
- 10.9.100.1/24 - 172.16.9.1/24
dhcp4: false dhcp4: false
dhcp6: false dhcp6: false
optional: true optional: true

View File

@@ -0,0 +1,14 @@
#!ipxe
dhcp
echo SAN booting Blancco ISO...
sanboot http://172.16.9.1/blancco/blancco.iso || goto failed
goto end
:failed
echo
echo FAILED!
prompt Press any key for iPXE shell...
shell
:end

View File

@@ -0,0 +1,48 @@
#!ipxe
echo =============================================
echo Blancco PXE Debug Boot
echo =============================================
echo
echo [1/4] Network configuration...
dhcp || echo DHCP FAILED
echo MAC: ${net0/mac}
echo IP: ${net0/ip}
echo GW: ${net0/gateway}
echo DNS: ${net0/dns}
echo
set server 172.16.9.1
echo [2/4] Testing HTTP connectivity...
imgfetch --name test http://${server}/blancco/config.img || echo HTTP FETCH FAILED
imgfree test
echo HTTP to ${server}: OK
echo
echo [3/4] Loading kernel and initrd...
echo Fetching vmlinuz-bde-linux...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initrd-combined.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 || goto failed
echo Kernel loaded OK.
echo
echo Fetching initrd-combined.img (43MB, may take a moment)...
initrd http://${server}/blancco/initrd-combined.img || goto failed
echo Initrd loaded OK.
echo
echo [4/4] About to call boot command...
echo
echo !! Note the LAST kernel line visible before any freeze !!
echo
prompt Press any key to boot (or Ctrl-C for iPXE shell)... && goto doboot || shell
:doboot
boot || goto failed
:failed
echo
echo !! BOOT FAILED !!
echo
prompt Press any key for iPXE shell...
shell

View File

@@ -0,0 +1,17 @@
#!ipxe
dhcp
set server 172.16.9.1
echo Loading Blancco kernel...
kernel http://${server}/blancco/vmlinuz-bde-linux initrd=initrd-combined.img archisobasedir=arch archiso_http_srv=http://${server}/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10 nomodeset || goto failed
echo Loading initrd (combined)...
initrd http://${server}/blancco/initrd-combined.img || goto failed
echo All files loaded. Booting now...
boot || goto failed
:failed
echo
echo Blancco boot FAILED.
prompt Press any key to drop to iPXE shell...
shell

View File

@@ -3,22 +3,10 @@ set timeout=0
insmod efinet insmod efinet
insmod net insmod net
insmod http
insmod tftp insmod tftp
net_bootp net_bootp
# Blancco via Ubuntu-kernel switch_root. This is the cmdline that produces
# the slim Ubuntu-kernel-chain grubx64.efi. DO NOT flip this back to
# vmlinuz-bde-linux / archiso_http_srv / copytoram=y - that was the Apr-14
# regression (commit d6776f7) that put us into Blancco's narrow-NIC-driver
# archiso path and hung on Dell Precision hardware. The Ubuntu kernel path
# with our verbose, full-drivers/net/-tree kexec-initrd.img is what works.
#
# kexec-initrd.img is built by the pxe_server_setup.yml "Build Blancco PXE
# initramfs" task (sweeps drivers/net/ + depmod). blancco-init.sh inside it
# handles the rest: modprobe all common NICs, DHCP, download airootfs.sfs,
# overlay mount, switch_root.
menuentry "Blancco Drive Eraser" { menuentry "Blancco Drive Eraser" {
linux (http,10.9.100.1)/blancco/vmlinuz-ubuntu ip=dhcp linux (tftp,172.16.9.1)/blancco/vmlinuz-bde-linux archisobasedir=arch archiso_http_srv=http://172.16.9.1/blancco/ copytoram=y cow_spacesize=50% memtest=00 vmalloc=400M ip=dhcp libata.allow_tpm=1 modprobe.blacklist=iwlwifi,iwlmvm,btusb rd.udev.timeout=10
initrd (http,10.9.100.1)/blancco/kexec-initrd.img initrd (tftp,172.16.9.1)/blancco/intel-ucode.img (tftp,172.16.9.1)/blancco/amd-ucode.img (tftp,172.16.9.1)/blancco/config.img (tftp,172.16.9.1)/blancco/initramfs-bde-linux.img
} }

View File

@@ -16,8 +16,8 @@ systems.
## Network layout ## Network layout
- PXE server static IP: `10.9.100.1/24` on an isolated subnet. - PXE server static IP: `172.16.9.1/24` on an isolated subnet.
- DHCP range served by dnsmasq: `10.9.100.10 - 10.9.100.100`, 12h leases. - DHCP range served by dnsmasq: `172.16.9.10 - 172.16.9.100`, 12h leases.
- Default gateway and DNS handed out via DHCP point at the PXE server itself. - Default gateway and DNS handed out via DHCP point at the PXE server itself.
- The subnet has no route to the corporate LAN. Client traffic (Blancco BMC - The subnet has no route to the corporate LAN. Client traffic (Blancco BMC
cloud, Intune enrollment) goes out via WiFi after Windows boots; PXE-time cloud, Intune enrollment) goes out via WiFi after Windows boots; PXE-time
@@ -166,7 +166,7 @@ USB installer (2 partitions: ISO + CIDATA)
Ubuntu auto-install + first-boot Ansible playbook Ubuntu auto-install + first-boot Ansible playbook
| |
v v
Configured PXE server (10.9.100.1) ----+ Configured PXE server (172.16.9.1) ----+
| |
Windows PCs running Upload-Image.ps1 --+--> Image content (SMB, webapp import) Windows PCs running Upload-Image.ps1 --+--> Image content (SMB, webapp import)
| |

View File

@@ -22,9 +22,9 @@ contribute a `config/sites/<sitename>.yaml` template back to the repo.
| Value | Default | Where it lives | | Value | Default | Where it lives |
|-------------------|----------------------|--------------------------------------------------------------------------------| |-------------------|----------------------|--------------------------------------------------------------------------------|
| PXE server IP | 10.9.100.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) | | PXE server IP | 172.16.9.1 | `playbook/pxe_server_setup.yml` (dnsmasq config, iPXE script, samba conf, webapp env), `playbook/startnet.cmd` (mount paths), `boot-tools/blancco/grub-blancco.cfg` (TFTP/HTTP URLs) |
| PXE subnet | 10.9.100.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) | | PXE subnet | 172.16.9.0/24 | Same as above, plus `playbook/pxe_server_setup.yml` (UFW rules) |
| DHCP range | 10.9.100.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) | | DHCP range | 172.16.9.10-100 | `playbook/pxe_server_setup.yml` (dnsmasq config) |
| Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) | | Hostname | pxeserver | `autoinstall/user-data` (identity.hostname) |
### Identity and credentials ### Identity and credentials
@@ -143,7 +143,7 @@ Blob storage account.
### Image-upload paths on Windows ### Image-upload paths on Windows
`scripts/Upload-Image.ps1` defaults to: `scripts/Upload-Image.ps1` defaults to:
- `\\10.9.100.1\image-upload` as the destination - `\\172.16.9.1\image-upload` as the destination
- `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source - `C:\ProgramData\GEAerospace\MediaCreator\Cache\` as the source
Update both for a different site. Update both for a different site.
@@ -156,10 +156,10 @@ A site config file should drive substitution at build time. Proposed schema:
# config/sites/<sitename>.yaml # config/sites/<sitename>.yaml
site: site:
name: westjeff name: westjeff
pxe_server_ip: 10.9.100.1 pxe_server_ip: 172.16.9.1
pxe_subnet: 10.9.100.0/24 pxe_subnet: 172.16.9.0/24
dhcp_range_start: 10.9.100.10 dhcp_range_start: 172.16.9.10
dhcp_range_end: 10.9.100.100 dhcp_range_end: 172.16.9.100
hostname: pxeserver hostname: pxeserver
credentials: credentials:

View File

@@ -0,0 +1,144 @@
# CyberArk EPM - CMM / DODA elevation policy
Reference for the CyberArk EPM admin. Fixes the "PC-DMIS is elevated but the
tools it calls error that they are not running elevated" problem on CMM bays.
## Problem (root cause)
CyberArk EPM elevation is per-process and is NOT inherited by child processes.
The existing policy elevates PC-DMIS (`PCDLRN.exe`), but the external `.exe`
files the PC-DMIS routine spawns (report, geometry, and DODA tools) launch with
the standard user token (Medium integrity) and fail their own "must run
elevated" check, even though the parent PC-DMIS is elevated. The elevation dies
at the process boundary.
## Flow that breaks
The PC-DMIS routine drives in-process `.BAS` scripts that shell out to separate
executables:
| Step | Process | Separate process? |
|------|---------|-------------------|
| Merge / sort report results | `MergeFiles.exe` (.NET), reads `C:\Apps\DODA\PreProcess\` | yes |
| Geometry export | `PCDToIGES.exe`, `RotateProbeVector.exe` | yes |
| DODA calculation | `DovetailAnalysis.exe` (+ embedded JVM and python) | yes |
| RTF -> PDF, display | `winword.exe`, `AcroRd32.exe` | yes (removed by the BAS rework below) |
| Folder create / save | `MAKEDIR.BAS`, `MAKEFOLDER.bas`, `SaveAsFolder.bas` | no (in-process, uses PC-DMIS token) |
The in-process file operations inherit PC-DMIS's elevated token. The separate
`.exe` files do not. Those are what get blocked.
## Fix: one Application Group + one Elevate policy
Do NOT use "elevate all child processes of PC-DMIS". That would elevate
`cmd.exe` and anything PC-DMIS launches, which is a large hole on a locked-down
shopfloor PC. Elevate only the named toolchain.
### Application Group: CMM-DODA-Tools
These are in-house, unsigned tools, so match by SHA-256 (or by path + filename
if the install directory is admin-write-only and the confirmed path is known).
| App | SHA-256 | Spawns children? |
|-----|---------|------------------|
| `MergeFiles.exe` | `e58ce7599d3bdba816c7ecb183d4f52b32ad8be0b8e4f41813824d8eb472d723` | no |
| `PCDToIGES.exe` | `7bdc961c406f7a0f6f8a10752988a17504bdfd691469c08d20f0d5b6673974cf` | no |
| `RotateProbeVector.exe` | `f8a1b5b0025769fe0d28dc12826ef5d1fbcdba3b29383799c1eb04b955abebdc` | no |
| `DovetailAnalysis.exe` | `86dcb0898bdef4687427ce339520a9c9f5a582890c2241d784fa985019eaaec1` | yes (JVM + python) |
### Elevate policy
- Target: the `CMM-DODA-Tools` Application Group
- Action: Elevate (run with administrator rights)
- Applies to: the CMM computer set + the `ShopFloor` user
- Child processes: elevate ONLY for `DovetailAnalysis.exe` (it launches the
embedded JVM and python scripts that do the actual file work). The other
three have no children.
- Match basis: SHA-256 hash
## Explicitly NOT in the policy
`winword.exe`, `AcroRd32.exe`, `cmd.exe`. The `CREATE_PDF_FROM_RTF.BAS` rework
(Word writes the PDF to user `%TEMP%`, PC-DMIS moves it to the final path with
its own elevated in-process token, display via the default PDF handler) removes
their need for elevation. Keep Office and Reader out of the elevation set.
## What the EPM admin needs from GE
- The computer group = the CMM bays (hostname list, OU, or AD group)
- The user = `ShopFloor` (local account)
- If using path matching instead of hash: the confirmed install directory of the
four exes on a bay (`where MergeFiles.exe`)
## Verify
- Before: from elevated PC-DMIS, spawn a child `cmd.exe`, then run
`whoami /groups | findstr Label`. Medium Mandatory Level confirms children are
not elevated (the bug).
- After: the four tools run at High Mandatory Level; report generation plus
copy/move/delete to C: and S: succeed; no "not elevated" error.
## Caveats
- Hash churn: rebuilding a tool changes its hash, so the Application Group hash
must be re-stamped. Path + filename matching avoids this IF the install
directory is admin-write-only (so a same-named spoof cannot be dropped there).
- Not an ACL fix: the tools hard-check elevation and bail before touching the
filesystem, so pre-granting NTFS ACLs alone will not unblock them. The EPM
elevation is the actual lever.
- Scope tight: match by hash and scope to the CMM computer group + ShopFloor so
this elevation never applies fleet-wide.
## Related code fixes (PXE imaging side)
- `09-Setup-CMM.ps1` Step 2.5 ACL list corrected from `C:\Program Files\DODA`
(nonexistent) to `C:\Apps\DODA` (where `Install-DODA.ps1` actually extracts).
- `MergeFiles.exe` expects `C:\Apps\DODA\PreProcess\`, which the DODA zip does
not create (it extracts flat). A missing `PreProcess` directory is the likely
cause of the historical `MergeFiles.GetDoDAFolder` `DirectoryNotFoundException`
crash (see `cmm-utilities` repo `dotNET event.txt`). Have `Install-DODA.ps1`
create the `PreProcess` subdir, or have whoever deploys the toolchain own it.
- The CMM tool chain (`MergeFiles.exe`, `PCDToIGES.exe`, `RotateProbeVector.exe`,
the `.BAS` scripts) lives in the separate `cmm-utilities` repo and is NOT
deployed by PXE imaging today. Decide whether imaging should own it so the
install path, ACLs, and `PreProcess` directory are consistent.
## CREATE_PDF_FROM_RTF.BAS rework (removes Word/Reader from the elevation set)
```vb
' CREATE_PDF_FROM_RTF.BAS - rev 1.0: convert in user temp, then move with
' PC-DMIS's own token; display via default handler (no elevation needed).
Sub Main(filename As String, displayReport As String)
Dim rtfFile As String, finalPdf As String, tempPdf As String
rtfFile = filename & ".RTF"
finalPdf = filename & ".PDF"
Dim base As String
base = Mid(filename, InStrRev(filename, "\") + 1)
tempPdf = Environ$("TEMP") & "\" & base & ".PDF"
' Word (un-elevated COM) can write to %TEMP% - a user-writable path.
Dim word As Object
Set word = CreateObject("word.application")
word.Visible = False
word.Documents.Open rtfFile
word.ActiveDocument.SaveAs2 tempPdf, 17 ' 17 = wdFormatPDF
word.Quit
Set word = Nothing
' Move temp -> final using PC-DMIS's in-process token (elevated via
' CyberArk), so the protected/S: destination is written without needing
' Word itself elevated. FileCopy works across volumes; Name does not.
If Dir(finalPdf) <> "" Then Kill finalPdf
FileCopy tempPdf, finalPdf
Kill tempPdf
' Display via the default PDF handler in the user's own context.
If UCase(displayReport) = "TRUE" Then
Dim sh As Object
Set sh = CreateObject("Shell.Application")
sh.ShellExecute finalPdf, "", "", "open", 1
End If
Kill rtfFile
End Sub
```

View File

@@ -196,7 +196,7 @@ Two separate copies of overlapping content with different roles:
| Path | Source | Used by | Updated when | | Path | Source | Used by | Updated when |
|------|--------|---------|--------------| |------|--------|---------|--------------|
| `C:\Enrollment\shopfloor-setup\` | PXE imaging copy from `\\10.9.100.1\enrollment\shopfloor-setup\` | Imaging-flow scripts: `Run-ShopfloorSetup.ps1`, `Stage-Dispatcher.ps1`, `Set-MachineNumber.ps1` -> `Update-MachineNumber.ps1` | Re-image only | | `C:\Enrollment\shopfloor-setup\` | PXE imaging copy from `\\172.16.9.1\enrollment\shopfloor-setup\` | Imaging-flow scripts: `Run-ShopfloorSetup.ps1`, `Stage-Dispatcher.ps1`, `Set-MachineNumber.ps1` -> `Update-MachineNumber.ps1` | Re-image only |
| SFLD share `\<scope>\` | Direct upload | GE-Enforce.ps1 / Install-FromManifest.ps1 (every logon) | Direct file upload to share | | SFLD share `\<scope>\` | Direct upload | GE-Enforce.ps1 / Install-FromManifest.ps1 (every logon) | Direct file upload to share |
Implication for hot-fixing scripts: a fix to `Restore-UDCData.ps1` needs to Implication for hot-fixing scripts: a fix to `Restore-UDCData.ps1` needs to

View File

@@ -52,11 +52,11 @@ Add a new entry (insert before the existing `D12 OptiPlex Family / 7090` entry):
the actual driver pack from Dell's catalog by model name (`extract_model_ids` the actual driver pack from Dell's catalog by model name (`extract_model_ids`
matches "7080") and downloads the latest pack at run time. matches "7080") and downloads the latest pack at run time.
### Side artifacts already on the live PXE server (10.9.100.1) ### Side artifacts already on the live PXE server (172.16.9.1)
- `\\10.9.100.1\winpeapps\_shared\BIOS\OptiPlex_7080_1.37.0.exe` (39.8 MB, BIOS update) - `\\172.16.9.1\winpeapps\_shared\BIOS\OptiPlex_7080_1.37.0.exe` (39.8 MB, BIOS update)
- `\\10.9.100.1\image-upload\Deploy\Out-of-box Drivers\Dell_11\OptiPlex\D11 OptiPlex Family\win11_70809ntr8_a09.zip` (Win11 driver pack, 2.6 GB) - `\\172.16.9.1\image-upload\Deploy\Out-of-box Drivers\Dell_11\OptiPlex\D11 OptiPlex Family\win11_70809ntr8_a09.zip` (Win11 driver pack, 2.6 GB)
- `\\10.9.100.1\winpeapps\_shared\BIOS\models.txt` includes the 7080 line. - `\\172.16.9.1\winpeapps\_shared\BIOS\models.txt` includes the 7080 line.
These persist regardless of `geastandardpbr/` rebuilds. Only the model-registry These persist regardless of `geastandardpbr/` rebuilds. Only the model-registry
edits need to be re-applied after a USB re-import. edits need to be re-applied after a USB re-import.

View File

@@ -0,0 +1,47 @@
<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Post-Deploy Verification Checklist</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css/github-markdown.css">
<style>
body { box-sizing: border-box; max-width: 980px; margin: 2em auto; padding: 0 2em; }
.markdown-body img { max-width: 100%; }
.markdown-body ul.task-list { list-style: none; padding-left: 0; }
.markdown-body li.task-list-item { list-style: none; }
.markdown-body li.task-list-item input[type=checkbox] { margin-right: .5em; transform: scale(1.2); }
@media print { body { max-width: none; margin: 0; padding: 1em; } }
</style>
</head><body class="markdown-body">
<h1 id="post-deploy-checklist">Post-Deploy Checklist</h1>
<p>Run after first boot. Sign off when all boxes checked. Fail any step, see <a href="post-deploy-debug-flowchart.md">post-deploy-debug-flowchart.md</a>.</p>
<h2 id="1-common-shop-floor">1. Common Shop Floor</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Shopfloor Tools</code> then <code>WJ Shopfloor</code> opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Login prompt and menu screen render</li>
</ul>
<p>Fail: check corp network, ping <code>WJFMS3.AE.GE.COM</code>.</p>
<h2 id="2-controller-skip-if-standalone-pc">2. Controller (skip if standalone PC)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>ping 192.168.1.1</code> returns 4 of 4 replies</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS General tab populated</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS FMS Host Primary set to <code>WJFMS3.AE.GE.COM</code> (FQDN)</li>
</ul>
<p>Fail ping, see <a href="post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip">2B NIC</a>.
Blank General, see <a href="post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields">2A reg load</a>.</p>
<h2 id="3-udc-com-port">3. UDC COM port</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> UDC opens with no machine-communication error dialog</li>
<li class="task-list-item"><input type="checkbox" disabled> <code>Tools</code> then <code>Retry Connection</code> succeeds (no error)</li>
<li class="task-list-item"><input type="checkbox" disabled> Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4</li>
<li class="task-list-item"><input type="checkbox" disabled> Data lines populate after reopen</li>
</ul>
<p>Fail, see <a href="post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port">Step 4</a>.</p>
<h2 id="4-printers-genspect-all-nearby">4. Printers (Genspect: all nearby)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Install Printers</code> shortcut opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Each nearby printer installed, status Ready</li>
<li class="task-list-item"><input type="checkbox" disabled> Test page prints</li>
<li class="task-list-item"><input type="checkbox" disabled> Genspect: default printer set</li>
</ul>
<p>Fail, see <a href="../printer-mapping.md">printer-mapping.md</a>.</p>
</body></html>

View File

@@ -0,0 +1,37 @@
# Post-Deploy Checklist
Run after first boot. Sign off when all boxes checked. Fail any step, see [post-deploy-debug-flowchart.md](post-deploy-debug-flowchart.md).
## 1. Common Shop Floor
- [ ] `Shopfloor Tools` then `WJ Shopfloor` opens
- [ ] Login prompt and menu screen render
Fail: check corp network, ping `WJFMS3.AE.GE.COM`.
## 2. Controller (skip if standalone PC)
- [ ] `ping 192.168.1.1` returns 4 of 4 replies
- [ ] NTLARS General tab populated
- [ ] NTLARS FMS Host Primary set to `WJFMS3.AE.GE.COM` (FQDN)
Fail ping, see [2B NIC](post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip).
Blank General, see [2A reg load](post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields).
## 3. UDC COM port
- [ ] UDC opens with no machine-communication error dialog
- [ ] `Tools` then `Retry Connection` succeeds (no error)
- [ ] Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4
- [ ] Data lines populate after reopen
Fail, see [Step 4](post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port).
## 4. Printers (Genspect: all nearby)
- [ ] `Install Printers` shortcut opens
- [ ] Each nearby printer installed, status Ready
- [ ] Test page prints
- [ ] Genspect: default printer set
Fail, see [printer-mapping.md](../printer-mapping.md).

View File

@@ -0,0 +1,59 @@
<!doctype html>
<html><head>
<meta charset="utf-8">
<title>Post-Deploy Verification Checklist</title>
<style>
body { font-family: -apple-system, system-ui, "Segoe UI", sans-serif; max-width: 980px; margin: 2em auto; padding: 0 2em; line-height: 1.5; color: #24292f; }
h1, h2, h3 { border-bottom: 1px solid #d0d7de; padding-bottom: .3em; scroll-margin-top: 1em; }
h1 { font-size: 2em; }
code { background: #f6f8fa; padding: .2em .4em; border-radius: 6px; font-size: 85%; }
pre { background: #f6f8fa; padding: 1em; border-radius: 6px; overflow: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; }
table th, table td { border: 1px solid #d0d7de; padding: 6px 13px; }
table tr:nth-child(2n) { background: #f6f8fa; }
img { max-width: 100%; height: auto; }
blockquote { border-left: .25em solid #d0d7de; padding: 0 1em; color: #57606a; }
ul.task-list, li.task-list-item { list-style: none; }
ul.task-list { padding-left: 0; }
li.task-list-item input[type=checkbox] { margin-right: .5em; transform: scale(1.2); }
hr { border: 0; border-top: 1px solid #d0d7de; margin: 2em 0; }
@media print {
body { max-width: none; margin: 0; padding: 1em; }
input[type=checkbox] { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head><body>
<h1 id="post-deploy-checklist">Post-Deploy Checklist</h1>
<p>Run after first boot. Sign off when all boxes checked. Fail any step, see <a href="post-deploy-debug-flowchart.md">post-deploy-debug-flowchart.md</a>.</p>
<h2 id="1-common-shop-floor">1. Common Shop Floor</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Shopfloor Tools</code> then <code>WJ Shopfloor</code> opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Login prompt and menu screen render</li>
</ul>
<p>Fail: check corp network, ping <code>WJFMS3.AE.GE.COM</code>.</p>
<h2 id="2-controller-skip-if-standalone-pc">2. Controller (skip if standalone PC)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>ping 192.168.1.1</code> returns 4 of 4 replies</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS General tab populated</li>
<li class="task-list-item"><input type="checkbox" disabled> NTLARS FMS Host Primary set to <code>WJFMS3.AE.GE.COM</code> (FQDN)</li>
</ul>
<p>Fail ping, see <a href="post-deploy-debug-flowchart.md#2b-controller-nic-has-no-static-ip">2B NIC</a>.
Blank General, see <a href="post-deploy-debug-flowchart.md#2a-ntlars-reg-file-never-imported-blank-general-tab-fields">2A reg load</a>.</p>
<h2 id="3-udc-com-port">3. UDC COM port</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> UDC opens with no machine-communication error dialog</li>
<li class="task-list-item"><input type="checkbox" disabled> <code>Tools</code> then <code>Retry Connection</code> succeeds (no error)</li>
<li class="task-list-item"><input type="checkbox" disabled> Port Name matches physical port: onboard COM1, PCIe card COM2 or COM4</li>
<li class="task-list-item"><input type="checkbox" disabled> Data lines populate after reopen</li>
</ul>
<p>Fail, see <a href="post-deploy-debug-flowchart.md#step-4-set-the-correct-com-port">Step 4</a>.</p>
<h2 id="4-printers-genspect-all-nearby">4. Printers (Genspect: all nearby)</h2>
<ul class="task-list">
<li class="task-list-item"><input type="checkbox" disabled> <code>Install Printers</code> shortcut opens</li>
<li class="task-list-item"><input type="checkbox" disabled> Each nearby printer installed, status Ready</li>
<li class="task-list-item"><input type="checkbox" disabled> Test page prints</li>
<li class="task-list-item"><input type="checkbox" disabled> Genspect: default printer set</li>
</ul>
<p>Fail, see <a href="../printer-mapping.md">printer-mapping.md</a>.</p>
</body></html>

View File

@@ -24,7 +24,7 @@ flowchart TD
U2 --> U3[Tools - Options - Serial tab] U2 --> U3[Tools - Options - Serial tab]
U3 --> U4{Which physical COM port is the cable in?} U3 --> U4{Which physical COM port is the cable in?}
U4 -->|Intel / onboard| U5[Set Port Name = COM 1] U4 -->|Intel / onboard| U5[Set Port Name = COM 1]
U4 -->|PCIe add-in card| U6[Set Port Name = COM 2] U4 -->|PCIe add-in card| U6[Set Port Name = COM 2 or COM 4]
U5 --> U7[Save - File - Exit - reopen UDC] U5 --> U7[Save - File - Exit - reopen UDC]
U6 --> U7 U6 --> U7
U7 --> U8([Verify data lines populate]) U7 --> U8([Verify data lines populate])
@@ -51,7 +51,7 @@ flowchart TD
click U3 "#step-3-open-options" "Step 3 - Options" click U3 "#step-3-open-options" "Step 3 - Options"
click U4 "#step-4-set-the-correct-com-port" "Step 4 - COM port" click U4 "#step-4-set-the-correct-com-port" "Step 4 - COM port"
click U5 "#step-4-set-the-correct-com-port" "Step 4 - COM 1" click U5 "#step-4-set-the-correct-com-port" "Step 4 - COM 1"
click U6 "#step-4-set-the-correct-com-port" "Step 4 - COM 2" click U6 "#step-4-set-the-correct-com-port" "Step 4 - COM 2 or COM 4"
click U7 "#step-5-exit-to-apply" "Step 5 - Exit" click U7 "#step-5-exit-to-apply" "Step 5 - Exit"
click D2 "#2a-ntlars-reg-file-never-imported-blank-general-tab-fields" "2A - Load reg backup" click D2 "#2a-ntlars-reg-file-never-imported-blank-general-tab-fields" "2A - Load reg backup"
@@ -113,7 +113,7 @@ Click the **Serial** tab on the left. Set **Port Name** to match the **physical*
![PC rear - onboard COM1 port](images/post-deploy/Figure_5_UDC_not_collecting_data_enable_admin_options_serial_intel_com1_physical.png) ![PC rear - onboard COM1 port](images/post-deploy/Figure_5_UDC_not_collecting_data_enable_admin_options_serial_intel_com1_physical.png)
- **COM 2** = PCIe add-in serial card - **COM 2 or COM 4** = PCIe add-in serial card (Windows enumeration varies by hardware - check Device Manager -> Ports if unsure)
Logical: Logical:
@@ -138,7 +138,7 @@ Reopen UDC. Data lines should start populating as the machine runs.
### If data still does not appear ### If data still does not appear
Once the COM port is correct, ~~rule out~~ check these in order: Once the COM port is correct, ~~rule out~~ check these in order:
- Wrong physical cable - cable is in COM1 socket but Port Name set to COM 2 (or vice versa). Re-check Step 4. - Wrong physical cable - cable is in COM1 socket but Port Name set to COM 2 / COM 4 (or vice versa). Re-check Step 4.
- Cable / connector damaged - swap with a known-good cable. - Cable / connector damaged - swap with a known-good cable.
- Machine controller side not transmitting - confirm at the controller HMI. - Machine controller side not transmitting - confirm at the controller HMI.

View File

@@ -6,7 +6,7 @@ Step-by-step for imaging a new (or replacement) shopfloor PC that will sit at a
- PC connected to the **PXE switch** (not the production network yet) - PC connected to the **PXE switch** (not the production network yet)
- USB mouse + keyboard connected - USB mouse + keyboard connected
- PXE server is running and reachable (verify by pinging `10.9.100.1` from another PC on the same switch) - PXE server is running and reachable (verify by pinging `172.16.9.1` from another PC on the same switch)
- **Target machine number** known (e.g., `7605`) — you can enter it at PXE time, or use `9999` as a placeholder if the PC will be configured at the bay later - **Target machine number** known (e.g., `7605`) — you can enter it at PXE time, or use `9999` as a placeholder if the PC will be configured at the bay later
- **ARTS Lockdown request submitted** for this PC (or know that you'll submit one mid-imaging) - **ARTS Lockdown request submitted** for this PC (or know that you'll submit one mid-imaging)
@@ -229,7 +229,7 @@ The script needs a desktop session. Won't run via WinRM/SSH/non-interactive. Mak
## Reference ## Reference
- **PXE server**: `10.9.100.1` - **PXE server**: `172.16.9.1`
- **SFLD share**: `\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\` - **SFLD share**: `\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`
- **Manifest engine log**: `C:\GE Aerospace\machineapps-enforce.log` - **Manifest engine log**: `C:\GE Aerospace\machineapps-enforce.log`
- **Intune sync transcript**: `C:\Logs\SFLD\sync_intune_transcript.txt` - **Intune sync transcript**: `C:\Logs\SFLD\sync_intune_transcript.txt`

View File

@@ -156,21 +156,36 @@
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>4</Order> <Order>4</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Fetch-StagingPayload.ps1"</CommandLine>
<Description>Fetch bulk staging (shopfloor-setup tree + preinstall bundle) from the PXE share on a fresh mount, BEFORE the production-network switch takes the bay off the imaging LAN. Detailed log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>5</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Verify-And-Heal-Staging.ps1"</CommandLine>
<Description>Verify every imaging payload arrived and re-pull anything missing from the PXE share (incl the CMM bundle + selected-bay backup) while still on the imaging LAN, BEFORE wait-for-internet switches the bay to the production network. Log at C:\Logs\Fetch\.</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>6</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\wait-for-internet.ps1"</CommandLine>
<Description>Prompt to connect production network then wait for TCP 443 connectivity</Description> <Description>Prompt to connect production network then wait for TCP 443 connectivity</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>5</Order> <Order>7</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\migrate-to-wifi.ps1"</CommandLine>
<Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description> <Description>Migrate from wired to WiFi if WiFi adapter present, else stay on wired</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>6</Order> <Order>8</Order>
<CommandLine>msiexec.exe /i "C:\PreInstall\installers\powershell7\PowerShell-7.5.4-win-x64.msi" /qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1</CommandLine>
<Description>Install PowerShell 7 BEFORE PPKG so Intune SetupCredentials Win32App finds pwsh.exe (race fix)</Description>
</SynchronousCommand>
<SynchronousCommand wcm:action="add">
<Order>9</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\run-enrollment.ps1"</CommandLine>
<Description>Run GCCH Enrollment</Description> <Description>Run GCCH Enrollment</Description>
</SynchronousCommand> </SynchronousCommand>
<SynchronousCommand wcm:action="add"> <SynchronousCommand wcm:action="add">
<Order>7</Order> <Order>10</Order>
<CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine> <CommandLine>powershell.exe -ExecutionPolicy Bypass -File "C:\Enrollment\Run-ShopfloorSetup.ps1"</CommandLine>
<Description>Run shopfloor PC type setup</Description> <Description>Run shopfloor PC type setup</Description>
</SynchronousCommand> </SynchronousCommand>

View File

@@ -80,10 +80,10 @@ echo " IFACE=$IFACE, bringing up..."
ip link set "$IFACE" up || ifconfig "$IFACE" up ip link set "$IFACE" up || ifconfig "$IFACE" up
sleep 2 sleep 2
SERVER=10.9.100.1 SERVER=172.16.9.1
ifconfig "$IFACE" 10.9.100.250 netmask 255.255.255.0 up ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up
sleep 1 sleep 1
echo " IP: 10.9.100.250 SERVER: $SERVER" echo " IP: 172.16.9.250 SERVER: $SERVER"
ip addr ip addr
echo "[3/5] Downloading airootfs.sfs (~756 MB)..." echo "[3/5] Downloading airootfs.sfs (~756 MB)..."

View File

@@ -176,7 +176,7 @@
<username encrypted="false">blancco</username> <username encrypted="false">blancco</username>
<password encrypted="false">blancco</password> <password encrypted="false">blancco</password>
<domain/> <domain/>
<hostname>10.9.100.1</hostname> <hostname>172.16.9.1</hostname>
<path>blancco-reports</path> <path>blancco-reports</path>
<protocols key="protocol" type="array"> <protocols key="protocol" type="array">
<protocol selected="true">smb</protocol> <protocol selected="true">smb</protocol>

View File

@@ -3,16 +3,16 @@
# Previously this disabled all wired NICs at first logon to keep PPKG / # Previously this disabled all wired NICs at first logon to keep PPKG /
# Intune enrollment routing internet traffic via WiFi. The wired NIC was # Intune enrollment routing internet traffic via WiFi. The wired NIC was
# preferred by Windows because the PXE dnsmasq was handing out a default # preferred by Windows because the PXE dnsmasq was handing out a default
# gateway (dhcp-option=3,10.9.100.1) which Windows installed as a default # gateway (dhcp-option=3,172.16.9.1) which Windows installed as a default
# route, and the lower interface metric of wired beat WiFi. Internet-bound # route, and the lower interface metric of wired beat WiFi. Internet-bound
# traffic then black-holed at 10.9.100.1 (the PXE server, which doesn't # traffic then black-holed at 172.16.9.1 (the PXE server, which doesn't
# forward). # forward).
# #
# That root cause was fixed by removing the dhcp-option=3 and =6 lines # That root cause was fixed by removing the dhcp-option=3 and =6 lines
# from /etc/dnsmasq.conf on the PXE server. Without an advertised gateway # from /etc/dnsmasq.conf on the PXE server. Without an advertised gateway
# on the PXE side, Windows can't add a default route via wired, so all # on the PXE side, Windows can't add a default route via wired, so all
# internet traffic uses WiFi by default and the wired NIC stays harmless # internet traffic uses WiFi by default and the wired NIC stays harmless
# for same-subnet PXE/SMB traffic to 10.9.100.1. # for same-subnet PXE/SMB traffic to 172.16.9.1.
# #
# Side effect of the original behavior was an eDNC race: eDNC autostart # Side effect of the original behavior was an eDNC race: eDNC autostart
# would fire while the wired NIC was still disabled and hit WSAEINVAL # would fire while the wired NIC was still disabled and hit WSAEINVAL

View File

@@ -3,17 +3,28 @@
"Site": "West Jefferson", "Site": "West Jefferson",
"Applications": [ "Applications": [
{ {
"_comment": "Oracle Client 11.2 Administrator - installed first because downstream apps (eDNC/NTLARS/UDC and CMM tooling) link against the Oracle home and fail cold if it's missing. Installer is a .cmd wrapper (Type=EXE is the preinstall runner's shim for non-MSI launchers, same pattern as OpenText Setup-OpenText.cmd). The wrapper expects Oracle_OracleDatabase_11r2_V03.zip (686 MB) staged next to it, unpacks to %TEMP%, runs Oracle Universal Installer silently with ge_client_install.rsp, then cleans up the staging dir. OUI exit 3 is treated as success (warnings-but-ok). Detection via the registered home key; downstream upgrades or version pins are handled by the runtime enforcer's Oracle Client 11.2 manifest entry in common/manifest.json.", "_comment": "PowerShell 7.5.4 - installed BEFORE PPKG via FlatUnattendW10-shopfloor.xml FirstLogonCommand Order 6 (race fix: Intune SetupCredentials Win32App install command starts with pwsh.exe; if PS7 not yet installed when that Win32App fires, it errors with FILE_NOT_FOUND 0x80070002 and IME's GRS retry never re-fires under V3Processor). This entry is a backstop - no-op via ProductCode detection if unattend Order 6 already installed it. PreEnrollment flag is informational; runner does not currently filter on it.",
"Name": "PowerShell 7.5.4",
"Installer": "powershell7\\PowerShell-7.5.4-win-x64.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart ADD_PATH=1 USE_MU=0 ENABLE_MU=0 DISABLE_TELEMETRY=1",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{E8159677-ACF8-4D64-9D36-5C36B8BBEA39}",
"PreEnrollment": true,
"PCTypes": ["*"]
},
{
"_comment": "Oracle Client 11.2 Administrator - installed first because downstream apps (eDNC/NTLARS/UDC and CMM tooling) link against the Oracle home and fail cold if it's missing. Installer is a .cmd wrapper (Type=EXE is the preinstall runner's shim for non-MSI launchers, same pattern as OpenText Setup-OpenText.cmd). The wrapper expects Oracle_OracleDatabase_11r2_V03.zip (686 MB) staged next to it, unpacks to %TEMP%, runs Oracle Universal Installer silently with ge_client_install.rsp, then cleans up the staging dir. OUI exit 3 is treated as success (warnings-but-ok). Detection via the registered home key; downstream upgrades or version pins are handled by the runtime enforcer's Oracle Client 11.2 manifest entry in common/manifest.json. Scoped to the DNC-bearing PC types (collections, nocollections, partmarker, heattreat) plus CMM, whose metrology tooling links the Oracle home; non-DNC types (Genspect, Keyence, WaxAndTrace, Display, Timeclock, Lab) do not get it.",
"Name": "Oracle Client 11.2", "Name": "Oracle Client 11.2",
"Installer": "oracle\\Install-Oracle11r2.cmd", "Installer": "oracle\\Install-Oracle11r2.cmd",
"Type": "EXE", "Type": "EXE",
"InstallArgs": "", "InstallArgs": "",
"LogFile": "C:\\Logs\\OracleClient\\install.log", "LogFile": "C:\\Logs\\OracleClient\\install.log",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Oracle\\KEY_OraClient11g_home1", "DetectionPath": "HKLM:\\SOFTWARE\\WOW6432Node\\Oracle\\KEY_OraClient11g_home1",
"DetectionName": "ORACLE_HOME_NAME", "DetectionName": "ORACLE_HOME_NAME",
"DetectionValue": "OraClient11g_home1", "DetectionValue": "OraClient11g_home1",
"PCTypes": ["Standard", "CMM", "Genspect", "Keyence", "WaxAndTrace", "Display"] "PCTypes": ["gea-shopfloor-collections", "gea-shopfloor-nocollections", "gea-shopfloor-partmarker", "gea-shopfloor-heattreat", "gea-shopfloor-cmm"]
}, },
{ {
"_comment": "VC++ 2008 SP1 x86 - the bootstrapper (vcredist2008_x86.exe) ignores /norestart and triggers an immediate Windows reboot when files are in use (per Aaron Stebner's MSDN docs). Fix: install the extracted vc_red.msi directly with REBOOT=ReallySuppress, which IS hard-honored by Windows Installer. msiexec may return 3010 (would-have-rebooted-but-suppressed) but won't actually reboot. cab name 'vc_red.cab' is hardcoded in the MSI's Media table - do not rename.", "_comment": "VC++ 2008 SP1 x86 - the bootstrapper (vcredist2008_x86.exe) ignores /norestart and triggers an immediate Windows reboot when files are in use (per Aaron Stebner's MSDN docs). Fix: install the extracted vc_red.msi directly with REBOOT=ReallySuppress, which IS hard-honored by Windows Installer. msiexec may return 3010 (would-have-rebooted-but-suppressed) but won't actually reboot. cab name 'vc_red.cab' is hardcoded in the MSI's Media table - do not rename.",
@@ -153,7 +164,7 @@
"Type": "EXE", "Type": "EXE",
"InstallArgs": "", "InstallArgs": "",
"LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log", "LogFile": "C:\\Logs\\PreInstall\\Setup-OpenText.log",
"PCTypes": ["Standard", "CMM", "Keyence", "Genspect", "WaxAndTrace", "Lab"] "PCTypes": ["*"]
}, },
{ {
"_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).", "_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).",
@@ -164,7 +175,9 @@
"KillAfterDetection": true, "KillAfterDetection": true,
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC",
"PCTypes": ["Standard-Machine"] "PCTypes": ["gea-shopfloor-collections"],
"PCTypesStrict": true,
"_pcTypesNote": "UDC = the C in 'collections'. nocollections does NOT collect data so MUST NOT install UDC. PCTypesStrict bypasses the alias-expansion matcher so a nocollections PC's myNames (which transitively contains gea-shopfloor-collections via the Standard group) still won't match this entry."
}, },
{ {
"_comment": "Display kiosk app (Lobby Display or Dashboard). Install-KioskApp.cmd wrapper reads C:\\Enrollment\\display-type.txt to determine which installer to run. Both GEAerospaceLobbyDisplaySetup.exe and GEAerospaceDashboardSetup.exe must be staged in the display\\ subtree alongside the wrapper. Inno Setup /VERYSILENT is idempotent so no detection needed.", "_comment": "Display kiosk app (Lobby Display or Dashboard). Install-KioskApp.cmd wrapper reads C:\\Enrollment\\display-type.txt to determine which installer to run. Both GEAerospaceLobbyDisplaySetup.exe and GEAerospaceDashboardSetup.exe must be staged in the display\\ subtree alongside the wrapper. Inno Setup /VERYSILENT is idempotent so no detection needed.",
@@ -186,7 +199,7 @@
"PCTypes": ["*"] "PCTypes": ["*"]
}, },
{ {
"_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.", "_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. Installed on every PC type (PCTypes ['*']) because serial hardware turns up across bays; a serial driver on a PC without the hardware is harmless - it just sits in the driver store until matching hardware is plugged in.",
"Name": "Shopfloor Serial Drivers", "Name": "Shopfloor Serial Drivers",
"Installer": "drivers\\Install-Drivers.cmd", "Installer": "drivers\\Install-Drivers.cmd",
"Type": "EXE", "Type": "EXE",
@@ -194,7 +207,7 @@
"LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log", "LogFile": "C:\\Logs\\PreInstall\\Install-Drivers.log",
"DetectionMethod": "File", "DetectionMethod": "File",
"DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker", "DetectionPath": "C:\\ProgramData\\PXEDrivers\\drivers-installed.marker",
"PCTypes": ["Standard"] "PCTypes": ["*"]
} }
] ]
} }

View File

@@ -3,7 +3,7 @@
# pxe-dhcp-hook.sh - dnsmasq dhcp-script hook. # pxe-dhcp-hook.sh - dnsmasq dhcp-script hook.
# #
# Runs every time a PXE client gets/changes/releases a DHCP lease on # Runs every time a PXE client gets/changes/releases a DHCP lease on
# 10.9.100.0/24. Flushes conntrack entries and drops any lingering # 172.16.9.0/24. Flushes conntrack entries and drops any lingering
# TCP sockets for that client IP. Prevents stale server-side state from # TCP sockets for that client IP. Prevents stale server-side state from
# causing "System error 53 - network path not found" when a WinPE client # causing "System error 53 - network path not found" when a WinPE client
# re-images the same machine without a clean SMB session teardown. # re-images the same machine without a clean SMB session teardown.

View File

@@ -14,7 +14,7 @@
# Step 2: restart nmbd (NetBIOS daemon - separate from smbd) # Step 2: restart nmbd (NetBIOS daemon - separate from smbd)
# Step 3: restart smbd (full smbd restart, kills all child sessions) # Step 3: restart smbd (full smbd restart, kills all child sessions)
# Step 4: kill any leftover smbd child processes that survived restart # Step 4: kill any leftover smbd child processes that survived restart
# Step 5: flush conntrack for 10.9.100.0/24 (kernel connection tracking) # Step 5: flush conntrack for 172.16.9.0/24 (kernel connection tracking)
# Step 6: flush ARP / neighbour cache on br-pxe # Step 6: flush ARP / neighbour cache on br-pxe
# Step 7: drop TCP sockets on port 445 via ss -K # Step 7: drop TCP sockets on port 445 via ss -K
# Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot) # Step 8: restart dnsmasq (DHCP/TFTP state as a last resort before reboot)
@@ -56,10 +56,10 @@ sleep 1
systemctl start smbd 2>&1 systemctl start smbd 2>&1
pause "Step 4 done" pause "Step 4 done"
echo "=== Step 5/8: flush conntrack entries for 10.9.100.0/24 ===" echo "=== Step 5/8: flush conntrack entries for 172.16.9.0/24 ==="
if command -v conntrack >/dev/null 2>&1; then if command -v conntrack >/dev/null 2>&1; then
conntrack -D -s 10.9.100.0/24 2>&1 || true conntrack -D -s 172.16.9.0/24 2>&1 || true
conntrack -D -d 10.9.100.0/24 2>&1 || true conntrack -D -d 172.16.9.0/24 2>&1 || true
else else
echo " conntrack tool not installed - skipping (apt install conntrack)" echo " conntrack tool not installed - skipping (apt install conntrack)"
fi fi

View File

@@ -72,7 +72,7 @@
loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}" loop: "{{ ansible_interfaces | select('match','^e(th|n)') | list }}"
ignore_errors: yes ignore_errors: yes
- name: "Find interface with 10.9.100.1 already configured" - name: "Find interface with 172.16.9.1 already configured"
set_fact: set_fact:
preconfigured_iface: >- preconfigured_iface: >-
{{ ansible_interfaces {{ ansible_interfaces
@@ -80,7 +80,7 @@
| map('regex_replace','^(.*)$','ansible_\1') | map('regex_replace','^(.*)$','ansible_\1')
| map('extract', hostvars[inventory_hostname]) | map('extract', hostvars[inventory_hostname])
| selectattr('ipv4','defined') | selectattr('ipv4','defined')
| selectattr('ipv4.address','equalto','10.9.100.1') | selectattr('ipv4.address','equalto','172.16.9.1')
| map(attribute='device') | map(attribute='device')
| list | list
| first | first
@@ -147,11 +147,11 @@
port=0 port=0
interface={{ pxe_iface }} interface={{ pxe_iface }}
bind-interfaces bind-interfaces
dhcp-range=10.9.100.10,10.9.100.100,12h dhcp-range=172.16.9.10,172.16.9.100,12h
# No default gateway (option 3) and no DNS (option 6) handed out: # No default gateway (option 3) and no DNS (option 6) handed out:
# the PXE network is isolated and the PXE server does not forward # the PXE network is isolated and the PXE server does not forward
# internet traffic. Previously we set both, which made imaged PCs # internet traffic. Previously we set both, which made imaged PCs
# add a default route via 10.9.100.1 and prefer it over WiFi (lower # add a default route via 172.16.9.1 and prefer it over WiFi (lower
# interface metric). PPKG / Intune enrollment then black-holed # interface metric). PPKG / Intune enrollment then black-holed
# internet-bound traffic. The fix used to be migrate-to-wifi.ps1 # internet-bound traffic. The fix used to be migrate-to-wifi.ps1
# disabling the wired NIC during first-logon, which created an # disabling the wired NIC during first-logon, which created an
@@ -163,7 +163,7 @@
# Important: dnsmasq DEFAULTS to sending its own listening address as # Important: dnsmasq DEFAULTS to sending its own listening address as
# both router and DNS when these options are unset. Commenting them # both router and DNS when these options are unset. Commenting them
# out is NOT the same as disabling - imaged PCs (and Blancco PXE # out is NOT the same as disabling - imaged PCs (and Blancco PXE
# clients) end up with 10.9.100.1 as gateway. The empty-value form # clients) end up with 172.16.9.1 as gateway. The empty-value form
# below explicitly suppresses both options. # below explicitly suppresses both options.
dhcp-option=3 dhcp-option=3
dhcp-option=6 dhcp-option=6
@@ -227,7 +227,7 @@
content: | content: |
#!ipxe #!ipxe
set server 10.9.100.1 set server 172.16.9.1
:menu :menu
menu GE Aerospace PXE Boot Menu menu GE Aerospace PXE Boot Menu
@@ -505,7 +505,7 @@
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/" - name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
# Path matches what startnet.cmd reads at WinPE boot: # Path matches what startnet.cmd reads at WinPE boot:
# net use B: \\10.9.100.1\winpeapps\_shared # net use B: \\172.16.9.1\winpeapps\_shared
# if exist B:\BIOS\check-bios.cmd ... # if exist B:\BIOS\check-bios.cmd ...
# Earlier deploy targeted enrollment/pre-install/bios/ (different share) # Earlier deploy targeted enrollment/pre-install/bios/ (different share)
# which startnet.cmd never read, so BIOS_STATUS perma-stuck on # which startnet.cmd never read, so BIOS_STATUS perma-stuck on
@@ -571,7 +571,11 @@
# the short-lived flows that PXE imaging produces. # the short-lived flows that PXE imaging produces.
socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY socket options = TCP_NODELAY SO_KEEPALIVE IPTOS_LOWDELAY
keepalive = 30 keepalive = 30
deadtime = 5 # deadtime=0 (disabled): WinPE maps the enrollment share early then
# idles for minutes during the WIM apply. A non-zero deadtime drops
# that idle session, so the post-apply staging copies failed (bay
# left with only site-config.json). 0 = never auto-disconnect idle.
deadtime = 0
- name: "Configure Samba shares" - name: "Configure Samba shares"
blockinfile: blockinfile:
@@ -681,7 +685,11 @@
src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml" src: "{{ usb_mount }}/FlatUnattendW10-shopfloor.xml"
dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml" dest: "{{ samba_share }}/{{ item }}/Deploy/FlatUnattendW10.xml"
mode: '0644' mode: '0644'
force: no # force: yes - repo is source of truth. force: no let the live shopfloor
# unattend go stale (missing the Fetch + Verify-And-Heal staging steps),
# and a playbook run never repaired it. Keep it in sync like the standard
# /engineer unattend below.
force: yes
loop: "{{ shopfloor_types }}" loop: "{{ shopfloor_types }}"
ignore_errors: yes ignore_errors: yes
@@ -899,7 +907,7 @@
shell: | shell: |
set -e set -e
python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")' python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")'
grep -q '<hostname>10.9.100.1</hostname>' "{{ web_root }}/blancco/preferences.xml" grep -q '<hostname>172.16.9.1</hostname>' "{{ web_root }}/blancco/preferences.xml"
grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml" grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml"
changed_when: false changed_when: false
@@ -1089,7 +1097,7 @@
# Single-NIC fresh-deploy default. Boxes that need higher throughput # Single-NIC fresh-deploy default. Boxes that need higher throughput
# (e.g. WJF prod uses a USB-C 5 Gbps NIC) override this with a bridge # (e.g. WJF prod uses a USB-C 5 Gbps NIC) override this with a bridge
# config bonding the USB NIC + onboard NIC into br-pxe. Live override # config bonding the USB NIC + onboard NIC into br-pxe. Live override
# currently deployed on 10.9.100.1 (do NOT re-run this task there # currently deployed on 172.16.9.1 (do NOT re-run this task there
# without first reviewing /etc/netplan/50-cloud-init.yaml.pre-gold-swap): # without first reviewing /etc/netplan/50-cloud-init.yaml.pre-gold-swap):
# #
# network: # network:
@@ -1101,7 +1109,7 @@
# bridges: # bridges:
# br-pxe: # br-pxe:
# interfaces: [enp128s31f6, enx34c8d6b11010] # interfaces: [enp128s31f6, enx34c8d6b11010]
# addresses: [10.9.100.1/24] # addresses: [172.16.9.1/24]
# parameters: # parameters:
# stp: false # stp: false
# #
@@ -1120,7 +1128,7 @@
ethernets: ethernets:
{{ pxe_iface }}: {{ pxe_iface }}:
dhcp4: no dhcp4: no
addresses: [10.9.100.1/24] addresses: [172.16.9.1/24]
notify: "Apply netplan" notify: "Apply netplan"
handlers: handlers:

View File

@@ -0,0 +1,142 @@
# select-waxtrace-asset.ps1 - Arrow-key bay picker for wax/trace imaging.
#
# Reads bay-config.csv on the PXE share to build the menu of known bays.
# Falls back to INDEX.csv (cal-disc index) if bay-config.csv is missing.
# Operator picks with Up/Down arrows + Enter. Always appends an
# "Other (new bay)" option at the end for unlisted bays - selecting it
# falls back to a free-text prompt.
#
# Writes the chosen asset tag to $OutFile (one line, no trailing newline).
# startnet.cmd reads that file back into the MACHINENUM batch var.
#
# Runs in WinPE PowerShell. Win10/11 WinPE ships powershell.exe with
# System.Console.ReadKey support. Tested 2026-05-18.
#
# Exit codes:
# 0 = asset tag written to $OutFile
# 1 = user cancelled (Esc) - $OutFile not written
# 2 = no readable bay source AND no fallback entered
param(
[string]$IndexPath = 'Y:\installers-post\waxtrace\bay-config.csv',
[Parameter(Mandatory=$true)][string]$OutFile
)
$ErrorActionPreference = 'Continue'
function Read-BayList {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return @() }
try {
$rows = @(Import-Csv -LiteralPath $Path)
# bay-config.csv has asset_tag,ftpak_version,model,user_id,hw_sn,hw_id,host,notes
# INDEX.csv (legacy) has asset_tag,unit_serial,probe_part,...
$isBayCfg = $rows.Count -gt 0 -and ($rows[0].PSObject.Properties.Name -contains 'ftpak_version')
return $rows | Sort-Object -Property asset_tag | ForEach-Object {
if ($isBayCfg) {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.ftpak_version
col2 = $_.model
col3 = $_.user_id
schema = 'bay-config'
}
} else {
[PSCustomObject]@{
asset_tag = $_.asset_tag
col1 = $_.unit_serial
col2 = $_.probe_part
col3 = ''
schema = 'index'
}
}
}
} catch {
return @()
}
}
function Show-Menu {
param([object[]]$Items, [int]$Selected, [string]$Title, [string]$Schema)
Clear-Host
Write-Host ""
Write-Host " ============================================================"
Write-Host " $Title"
Write-Host " ============================================================"
Write-Host ""
Write-Host " Up / Down arrows = navigate, Enter = select, Esc = cancel"
Write-Host ""
if ($Schema -eq 'bay-config') {
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f 'ASSET','FTPAK','MODEL','USER ID')
Write-Host (" {0,-10} {1,-8} {2,-10} {3}" -f '-----','-----','-----','-------')
} else {
Write-Host (" {0,-10} {1,-14} {2}" -f 'ASSET','SERIAL','PROBE')
Write-Host (" {0,-10} {1,-14} {2}" -f '-----','------','-----')
}
for ($i = 0; $i -lt $Items.Count; $i++) {
$item = $Items[$i]
if ($item -is [string]) {
$line = $item
} elseif ($Schema -eq 'bay-config') {
$line = "{0,-10} {1,-8} {2,-10} {3}" -f $item.asset_tag, $item.col1, $item.col2, $item.col3
} else {
$line = "{0,-10} {1,-14} {2}" -f $item.asset_tag, $item.col1, $item.col2
}
if ($i -eq $Selected) {
Write-Host (" > " + $line) -ForegroundColor Black -BackgroundColor White
} else {
Write-Host (" " + $line)
}
}
Write-Host ""
}
# Try bay-config.csv first; fall back to INDEX.csv if missing OR if the
# explicit -IndexPath argument points to INDEX.csv (legacy callers).
$bays = @(Read-BayList -Path $IndexPath)
if ($bays.Count -eq 0 -and $IndexPath -notmatch 'INDEX\.csv$') {
$fallback = 'Y:\installers-post\waxtrace\calibrations\INDEX.csv'
if (Test-Path -LiteralPath $fallback) {
Write-Host " (no bay-config.csv at $IndexPath - falling back to $fallback)"
$bays = @(Read-BayList -Path $fallback)
}
}
$schema = if ($bays.Count -gt 0) { $bays[0].schema } else { 'bay-config' }
$menuItems = @()
foreach ($b in $bays) { $menuItems += $b }
$menuItems += '** Other (new bay - enter asset tag manually) **'
$sel = 0
while ($true) {
Show-Menu -Items $menuItems -Selected $sel -Title 'Wax/Trace Asset Tag' -Schema $schema
$key = [System.Console]::ReadKey($true)
switch ($key.Key) {
'UpArrow' { if ($sel -gt 0) { $sel-- } }
'DownArrow' { if ($sel -lt ($menuItems.Count - 1)) { $sel++ } }
'Enter' {
if ($sel -eq ($menuItems.Count - 1)) {
Write-Host ""
$manual = Read-Host " Enter asset tag (e.g. WJRP9999) or blank to abort"
if ($manual) {
$manual = $manual.Trim().ToUpper()
Set-Content -LiteralPath $OutFile -Value $manual -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Saved asset tag: $manual"
Start-Sleep -Seconds 1
exit 0
} else {
exit 1
}
} else {
$pick = $bays[$sel].asset_tag
Set-Content -LiteralPath $OutFile -Value $pick -NoNewline -Encoding ascii
Write-Host ""
Write-Host " Selected: $pick"
Start-Sleep -Seconds 1
exit 0
}
}
'Escape' { exit 1 }
}
}

View File

@@ -110,7 +110,8 @@ exit /b 0
:flash_done :flash_done
echo BIOS update complete. echo BIOS update complete.
set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% -^> %TARGETVER%" set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% to %TARGETVER%"
echo flash_done %SYSMODEL% %BIOSVER% to %TARGETVER%> X:\bios-fired.flag
exit /b 0 exit /b 0
:staged :staged
@@ -121,7 +122,8 @@ echo It will flash during POST after the
echo post-imaging reboot. echo post-imaging reboot.
echo ======================================== echo ========================================
echo. echo.
set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% -^> %TARGETVER% (flashes on reboot)" set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% to %TARGETVER% (flashes on reboot)"
echo staged %SYSMODEL% %BIOSVER% to %TARGETVER%> X:\bios-fired.flag
exit /b 0 exit /b 0
:compare_versions :compare_versions

View File

@@ -0,0 +1,181 @@
# Fetch-StagingPayload.ps1 - post-boot bulk staging fetch (first-logon).
#
# WHY THIS EXISTS
# WinPE used to stage the whole shopfloor-setup tree + preinstall bundle to
# the target disk DURING the WinPE phase. But WinPE maps the enrollment share
# (Y:) early, then idles for many minutes while the full Windows image applies.
# Samba's `deadtime` drops idle sessions, so by the time WinPE reached the
# copies the Y: mount was dead and most copies failed (symptom: a bay with
# only site-config.json staged, then nothing). Doing the bulk copy here - at
# first logon, in full Windows, on a FRESH share mount with no prior idle -
# sidesteps that entirely.
#
# WHEN IT RUNS
# The unattend FirstLogonCommands runs this BEFORE the PowerShell 7 MSI install
# (which needs C:\PreInstall\installers\powershell7\) and before
# Run-ShopfloorSetup.ps1 (which needs C:\Enrollment\shopfloor-setup\). So this
# must populate both trees before those steps fire.
#
# WHAT IT FETCHES (generic bulk - Phase 1)
# \\<server>\enrollment\shopfloor-setup\Run-ShopfloorSetup.ps1 -> C:\Enrollment\
# \\<server>\enrollment\shopfloor-setup\{backup_lockdown.bat,Shopfloor,common,
# _ntlars-backups,gea-shopfloor-<pctype>} -> C:\Enrollment\shopfloor-setup\
# \\<server>\enrollment\pre-install\{preinstall.json,installers,udc-backups}
# -> C:\PreInstall\
# (Heavy per-type payloads - CMM/Keyence/WaxTrace - are still staged in WinPE
# for now; Phase 2 moves those here too.)
#
# LOGGING
# Verbose transcript + a per-item table to C:\Logs\Fetch\. Every robocopy logs
# its exit code, file/dir counts, byte total, and elapsed time, so a failed
# fetch is fully diagnosable (unlike the old opaque WinPE staging).
#
# Always exits 0 - a fetch failure must not abort the FirstLogonCommands chain;
# the log carries the truth and Run-ShopfloorSetup surfaces missing pieces.
$ErrorActionPreference = 'Continue'
# --- Logging setup ---
$logDir = 'C:\Logs\Fetch'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$stamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$logFile = Join-Path $logDir "fetch-staging-$stamp.log"
try { Start-Transcript -Path $logFile -Append -Force | Out-Null } catch {}
function Log {
param([string]$Message, [string]$Level = 'INFO')
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Write-Host "[$ts] [$Level] $Message"
}
Log "================================================================"
Log "=== Fetch-StagingPayload start (PID $PID) ==="
Log "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
Log "Host: $env:COMPUTERNAME"
Log "================================================================"
# --- Resolve the share source + creds (written by startnet to fetch-source.txt;
# falls back to the historical defaults if absent) ---
$shareUnc = '\\172.16.9.1\enrollment'
$shareUser = 'pxe-upload'
$sharePass = 'pxe'
$srcFile = 'C:\Enrollment\fetch-source.txt'
if (Test-Path -LiteralPath $srcFile) {
# Format: line1=UNC, line2=user, line3=pass
$lines = @(Get-Content -LiteralPath $srcFile -ErrorAction SilentlyContinue)
if ($lines.Count -ge 1 -and $lines[0].Trim()) { $shareUnc = $lines[0].Trim() }
if ($lines.Count -ge 2 -and $lines[1].Trim()) { $shareUser = $lines[1].Trim() }
if ($lines.Count -ge 3 -and $lines[2].Trim()) { $sharePass = $lines[2].Trim() }
Log "fetch-source.txt: UNC=$shareUnc user=$shareUser"
} else {
Log "fetch-source.txt absent - using defaults: UNC=$shareUnc user=$shareUser"
}
# --- pc-type (drives which gea-shopfloor-<type> dir to fetch) ---
$pcType = ''
if (Test-Path -LiteralPath 'C:\Enrollment\pc-type.txt') {
$pcType = (Get-Content -LiteralPath 'C:\Enrollment\pc-type.txt' -First 1 -EA 0).Trim()
}
Log "PC type: $(if ($pcType) { $pcType } else { '(none)' })"
# --- Status push (best-effort) ---
$pxeStatusLib = 'C:\Enrollment\shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1'
# (lib not fetched yet on first run; ignore if absent)
if (Test-Path $pxeStatusLib) { try { . $pxeStatusLib; Send-PxeStatus -Stage 'Fetch-StagingPayload: starting' -StageIndex 1 -StageTotal 8 } catch {} }
# --- Mount the share fresh (use Z:; retry to ride out a brief blip) ---
$drive = 'Z:'
function Mount-Share {
# Pre-clear any stale Z: mapping. Wrap in cmd.exe (output to nul INSIDE cmd)
# so net.exe's "network connection could not be found" stderr - emitted when
# Z: is not mapped (the normal first-attempt case) - never reaches PowerShell
# as a NativeCommandError. PS 2>$null does not reliably suppress that.
cmd /c "net use $drive /delete /y >nul 2>&1"
$r = & net use $drive $shareUnc /user:$shareUser $sharePass /persistent:no 2>&1
return ($LASTEXITCODE -eq 0)
}
$mounted = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
Log "Mounting $shareUnc as $drive (attempt $attempt/5)..."
if (Mount-Share) { $mounted = $true; Log "Mounted OK"; break }
Log "Mount failed (exit $LASTEXITCODE) - waiting 10s" 'WARN'
Start-Sleep -Seconds 10
}
if (-not $mounted) {
Log "Could not mount $shareUnc after 5 attempts - ABORTING fetch. Bay will be under-provisioned; re-run this script once the share is reachable." 'ERROR'
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Fetch helper: robocopy one item, log exit + counts + timing ---
$results = @()
function Fetch-Item {
param(
[string]$Label,
[string]$SrcDir, # under $drive
[string]$DstDir,
[string[]]$Files, # named files for a flat copy; empty = whole-dir /E
[switch]$Recurse # /E whole directory
)
$src = Join-Path $drive $SrcDir
if (-not (Test-Path -LiteralPath $src)) {
Log "[SKIP] $Label - source not on share: $src" 'WARN'
$script:results += [pscustomobject]@{ Item=$Label; Exit='n/a'; Result='SOURCE-MISSING' }
return
}
if (-not (Test-Path -LiteralPath $DstDir)) { New-Item -ItemType Directory -Path $DstDir -Force | Out-Null }
$args = @($src, $DstDir)
if ($Recurse) { $args += '/E' } else { $args += $Files }
$args += @('/R:2','/W:3','/NFL','/NDL','/NP')
$sw = [System.Diagnostics.Stopwatch]::StartNew()
Log "[COPY] $Label : robocopy $src -> $DstDir $(if ($Recurse){'/E'}else{$Files -join ','})"
$out = & robocopy @args 2>&1
$rc = $LASTEXITCODE
$sw.Stop()
# robocopy 0-7 = success, 8+ = failure
$ok = ($rc -lt 8)
# pull the summary counts robocopy prints
$summary = ($out | Select-String -Pattern 'Files :|Dirs :|Bytes :' ) -join ' | '
Log "[$(if($ok){'OK'}else{'FAIL'})] $Label exit=$rc time=$([math]::Round($sw.Elapsed.TotalSeconds,1))s $summary"
$script:results += [pscustomobject]@{ Item=$Label; Exit=$rc; Result=$(if($ok){'OK'}else{'FAIL'}) }
}
# --- Generic bulk fetch ---
$ENR = 'C:\Enrollment'
$SFD = 'C:\Enrollment\shopfloor-setup'
$PIN = 'C:\PreInstall'
Fetch-Item -Label 'Run-ShopfloorSetup.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Run-ShopfloorSetup.ps1')
# Verify-And-Heal-Staging runs as its own unattend step (right after this Fetch,
# before the production-network switch) to re-pull anything that did not arrive -
# including the heavy CMM payload Fetch does not carry. Pull the small script
# itself here so it is on disk for that step.
Fetch-Item -Label 'Verify-And-Heal-Staging.ps1' -SrcDir 'shopfloor-setup' -DstDir $ENR -Files @('Verify-And-Heal-Staging.ps1')
Fetch-Item -Label 'backup_lockdown.bat' -SrcDir 'shopfloor-setup' -DstDir $SFD -Files @('backup_lockdown.bat')
Fetch-Item -Label 'Shopfloor baseline' -SrcDir 'shopfloor-setup\Shopfloor' -DstDir (Join-Path $SFD 'Shopfloor') -Recurse
Fetch-Item -Label 'common' -SrcDir 'shopfloor-setup\common' -DstDir (Join-Path $SFD 'common') -Recurse
Fetch-Item -Label '_ntlars-backups' -SrcDir 'shopfloor-setup\_ntlars-backups' -DstDir (Join-Path $SFD '_ntlars-backups') -Recurse
if ($pcType) {
Fetch-Item -Label "type:$pcType" -SrcDir "shopfloor-setup\$pcType" -DstDir (Join-Path $SFD $pcType) -Recurse
}
# preinstall bundle
Fetch-Item -Label 'preinstall.json' -SrcDir 'pre-install' -DstDir $PIN -Files @('preinstall.json')
Fetch-Item -Label 'preinstall installers' -SrcDir 'pre-install\installers' -DstDir (Join-Path $PIN 'installers') -Recurse
Fetch-Item -Label 'udc-backups' -SrcDir 'pre-install\udc-backups' -DstDir (Join-Path $PIN 'udc-backups') -Recurse
# --- Unmount ---
cmd /c "net use $drive /delete /y >nul 2>&1"
# --- Summary table ---
Log "================================================================"
Log "FETCH SUMMARY:"
foreach ($r in $results) { Log (" {0,-28} exit={1,-4} {2}" -f $r.Item, $r.Exit, $r.Result) }
$failed = @($results | Where-Object { $_.Result -eq 'FAIL' })
if ($failed.Count -gt 0) {
Log "$($failed.Count) item(s) FAILED: $(( $failed | ForEach-Object { $_.Item }) -join ', ')" 'ERROR'
} else {
Log "All fetched items OK." 'INFO'
}
Log "=== Fetch-StagingPayload complete ==="
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -27,7 +27,7 @@ Write-Host "================================================================"
Write-Host "" Write-Host ""
# Imaging-progress reporter. Posts coarse stage updates to the PXE webapp # Imaging-progress reporter. Posts coarse stage updates to the PXE webapp
# at http://10.9.100.1:9009/imaging/status so the operator can watch # at http://172.16.9.1:9009/imaging/status so the operator can watch
# progress in a browser. Best-effort: failures never block imaging. # progress in a browser. Best-effort: failures never block imaging.
$pxeStatusLib = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1' $pxeStatusLib = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Send-PxeStatus.ps1'
if (Test-Path $pxeStatusLib) { if (Test-Path $pxeStatusLib) {
@@ -50,6 +50,35 @@ Report-Stage -Stage 'Run-ShopfloorSetup: starting' -Index 2
# Cancel any pending reboot so it doesn't interrupt setup # Cancel any pending reboot so it doesn't interrupt setup
cmd /c "shutdown /a 2>nul" *>$null cmd /c "shutdown /a 2>nul" *>$null
# Self-resume: register this script as a RunOnce so a vendor-installer-
# forced reboot mid-flight (FormTracePak Setup.exe, eDNC MSI, etc) auto-
# resumes the chain after the next SupportUser auto-login. RunOnce is
# single-shot - if we complete normally we remove this key at end of
# script. If we're killed mid-flight by a forced reboot, the key
# survives and fires after reboot.
#
# Idempotent design throughout this script: every step checks detection
# before installing, so a forced-reboot re-entry just skips the already-
# done work and continues from where it left off.
#
# Also top up AutoLogonCount so the SupportUser autologon budget
# (LogonCount=7 from unattend XML) survives extra unplanned reboots.
$selfResumeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce'
$selfResumeName = 'ResumeRunShopfloorSetup'
$selfResumeCmd = 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '"'
try {
Set-ItemProperty -Path $selfResumeKey -Name $selfResumeName -Value $selfResumeCmd -Type String -Force -ErrorAction Stop
Write-Host "Self-resume RunOnce registered: will re-fire $PSCommandPath if interrupted"
} catch {
Write-Warning "Failed to register self-resume RunOnce: $_"
}
try {
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -Name 'AutoLogonCount' -Value 10 -Type DWord -Force -ErrorAction Stop
Write-Host "AutoLogonCount topped up to 10 (vendor-forced reboot resilience)"
} catch {
Write-Warning "Failed to top up AutoLogonCount: $_"
}
# Wired NIC state handling moved to sync_intune (Monitor-IntuneProgress.ps1). # Wired NIC state handling moved to sync_intune (Monitor-IntuneProgress.ps1).
# Previously this script prompted the tech to unplug the PXE cable and # Previously this script prompted the tech to unplug the PXE cable and
# then re-enabled wired adapters interactively - that blocked the whole # then re-enabled wired adapters interactively - that blocked the whole
@@ -102,6 +131,12 @@ $skipInBaseline = @(
'06-OrganizeDesktop.ps1', '06-OrganizeDesktop.ps1',
'07-TaskbarLayout.ps1', '07-TaskbarLayout.ps1',
'08-EdgeDefaultBrowser.ps1', '08-EdgeDefaultBrowser.ps1',
# Machine number flow: split into two scripts registered as scheduled
# tasks by Register-CheckMachineNumberTask.ps1. Prompt runs as the
# logged-in user (GUI), Apply runs as SYSTEM (privileged writes).
# Neither should run in baseline pass.
'Prompt-MachineNumber.ps1',
'Apply-MachineNumber.ps1',
'Check-MachineNumber.ps1', 'Check-MachineNumber.ps1',
'Configure-PC.ps1' 'Configure-PC.ps1'
) )
@@ -299,63 +334,10 @@ if (Test-Path -LiteralPath $monitorScript) {
# These run on every logon regardless of PC type, mounting the SFLD share # These run on every logon regardless of PC type, mounting the SFLD share
# for version-pinned app enforcement. Initial install already handled by # for version-pinned app enforcement. Initial install already handled by
# preinstall flow; enforcers only kick in when detection fails. # preinstall flow; enforcers only kick in when detection fails.
# --- Re-enable wired NICs once lockdown completes (Phase 6) --- # Wired-disable / re-enable dance retired after PXE LAN renumber to
# migrate-to-wifi.ps1 disables wired NICs so the PPKG runs over WiFi. # 172.16.9.0/24. GE Report IP filters Get-NetIPAddress on StartsWith("10.")
# Keep them disabled through the entire Intune sync + DSC + lockdown # so PXE LAN addresses are no longer caught - wired NIC can stay up
# chain so nothing interrupts the WiFi-based enrollment. Only re-enable # through the whole imaging chain without leaking to the GE webhook.
# after lockdown lands (Autologon_Remediation.log confirms ShopFloor
# autologon set). Monitor-IntuneProgress runs as Limited and can't call
# Enable-NetAdapter (needs admin). This SYSTEM task fires at logon,
# polls for lockdown completion, re-enables wired NICs, and self-deletes.
$reEnableTask = 'GE Re-enable Wired NICs'
try {
$script = @'
# Poll for the GE Report-IP Proactive Remediation log file. Its appearance
# means the Report IP script has fired with WiFi-only IPs (because we
# disabled wired post-PPKG) - which is the exact moment we want to bring
# wired back up so Monitor-IntuneProgress can push idx=7 with the
# DeviceId / QR code before the Intune-triggered LAPS-prompt reboot lands.
# Extension is .LOG (not .txt) observed in field; match any extension.
$ip = Get-ChildItem 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $ip) { exit 0 }
# Vendor-agnostic wired-NIC re-enable. NetAdapter "Name" varies wildly
# ("Ethernet", "Ethernet 2", "Network", per-vendor names like "Realtek
# Gaming GbE", "Intel(R) Ethernet Connection (10) I219-V") so filtering
# by Name is unreliable. Filter by PhysicalMediaType instead, with a
# keyword-negative guard for drivers that mis-report PhysicalMediaType.
# Captures Realtek, Intel, Broadcom, Marvell, Aquantia, etc.
Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object {
$_.HardwareInterface -eq $true -and
$_.PhysicalMediaType -ne 'Native 802.11' -and
$_.PhysicalMediaType -ne 'Wireless WAN' -and
$_.PhysicalMediaType -ne 'BlueTooth' -and
$_.InterfaceDescription -notmatch '(?i)Wi-?Fi|Wireless|WLAN|802\.11|Bluetooth'
} |
Enable-NetAdapter -Confirm:$false -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName 'GE Re-enable Wired NICs' -Confirm:$false -ErrorAction SilentlyContinue
'@
$scriptPath = 'C:\Program Files\GE\ReEnableNIC.ps1'
if (-not (Test-Path 'C:\Program Files\GE')) {
New-Item -Path 'C:\Program Files\GE' -ItemType Directory -Force | Out-Null
}
Set-Content -Path $scriptPath -Value $script -Force
$reEnableAction = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
$reEnableTrigger = New-ScheduledTaskTrigger -AtLogOn
$reEnableTrigger.Repetition = (New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes 5)).Repetition
$reEnablePrincipal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$reEnableSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName $reEnableTask -Action $reEnableAction -Trigger $reEnableTrigger `
-Principal $reEnablePrincipal -Settings $reEnableSettings -Force -ErrorAction Stop | Out-Null
Write-Host "Registered '$reEnableTask' task (waits for SFLD creds, then re-enables wired NICs)."
} catch {
Write-Warning "Failed to register NIC re-enable task: $_"
}
$commonSetupDir = Join-Path $setupDir 'common' $commonSetupDir = Join-Path $setupDir 'common'
@@ -413,6 +395,26 @@ if ($noEnforceTypes -contains $pcType) {
Write-Host "Register-MapSfldShare.ps1 not found (optional) - skipping" Write-Host "Register-MapSfldShare.ps1 not found (optional) - skipping"
} }
# --- Check Machine Number logon prompt ---
# Auto-register the "Check Machine Number" scheduled task. Bays imaged with
# the 9999 placeholder will prompt the first ShopFloor end-user logon to
# enter the real machine number; on success Update-MachineNumber.ps1 pulls
# the per-machine NTLARS .reg + UDC settings JSON + UDC data backup from
# SFLD and the task self-unregisters. Self-disables once the number is
# real, so safe to always register here.
# Skipped for self-contained types (Display) that have no machine number.
$registerCheckMN = Join-Path $setupDir 'Shopfloor\Register-CheckMachineNumberTask.ps1'
if ($noEnforceTypes -contains $pcType) {
Write-Host ""
Write-Host "=== Skipping Check Machine Number task ($pcType has no machine number) ==="
} elseif (Test-Path -LiteralPath $registerCheckMN) {
Write-Host ""
Write-Host "=== Registering Check Machine Number logon task ==="
try { & $registerCheckMN } catch { Write-Warning "Check-MachineNumber registration failed: $_" }
} else {
Write-Host "Register-CheckMachineNumberTask.ps1 not found (optional) - skipping"
}
# --- Run enrollment (PPKG install) --- # --- Run enrollment (PPKG install) ---
# Enrollment is the LAST thing we do. Install-ProvisioningPackage triggers # Enrollment is the LAST thing we do. Install-ProvisioningPackage triggers
# an immediate reboot -- everything after this call is unlikely to execute. # an immediate reboot -- everything after this call is unlikely to execute.
@@ -474,31 +476,19 @@ if (Test-Path -LiteralPath $enrollScript) {
Write-Host "=== Running enrollment (PPKG install) ===" Write-Host "=== Running enrollment (PPKG install) ==="
Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel" Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel"
Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which" Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which"
Write-Host " runs a 60s settle (giving MDM time to push baseline" Write-Host " runs a 120s settle (giving MDM time to push baseline"
Write-Host " policy) and then performs a clean reboot." Write-Host " policy) and then performs a clean reboot."
try { Stop-Transcript | Out-Null } catch {} try { Stop-Transcript | Out-Null } catch {}
& $enrollScript & $enrollScript
# idx=6 push happens BEFORE wired disable so the dashboard captures
# the handoff stage. Disable-WiredNics comes right after - kills wired
# before PostPpkg settle's Schedule #3 hammer hits Intune endpoints,
# before the PPKG-driven reboot, and before IME starts firing the
# Report IP script. Goal: GE's Report IP webhook only ever sees the
# corp-WiFi IP, never PXE LAN (10.9.100.x). Monitor-IntuneProgress
# re-enables wired once C:\Logs\GE_Report_IP_Address*.txt shows up
# (proof of clean Report IP fire) and then pushes idx=7.
Write-Host "" Write-Host ""
Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 6 Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 6
$disableWiredScript = Join-Path $PSScriptRoot 'shopfloor-setup\Shopfloor\lib\Disable-WiredNics.ps1'
if (Test-Path -LiteralPath $disableWiredScript) {
try { & $disableWiredScript } catch { Write-Warning "Disable-WiredNics threw: $_" }
} else {
Write-Warning "Disable-WiredNics.ps1 not found at $disableWiredScript - wired stays up (Report IP leak risk)"
}
Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ===" Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ==="
cmd /c "shutdown /a 2>nul" | Out-Null cmd /c "shutdown /a 2>nul" | Out-Null
# Made it past all the reboot-prone vendor installers. Clear the
# self-resume RunOnce so a normal completion + reboot does not re-fire
# this script post-PPKG (PPKG install owns the reboot chain from here).
try { Remove-ItemProperty -Path $selfResumeKey -Name $selfResumeName -ErrorAction SilentlyContinue } catch {}
$monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1' $monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
if (Test-Path -LiteralPath $monitor) { if (Test-Path -LiteralPath $monitor) {
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitor -PostPpkg & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $monitor -PostPpkg
@@ -512,6 +502,7 @@ if (Test-Path -LiteralPath $enrollScript) {
Write-Host "================================================================" Write-Host "================================================================"
Write-Host "=== Run-ShopfloorSetup.ps1 complete $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" Write-Host "=== Run-ShopfloorSetup.ps1 complete $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ==="
Write-Host "================================================================" Write-Host "================================================================"
try { Remove-ItemProperty -Path $selfResumeKey -Name $selfResumeName -ErrorAction SilentlyContinue } catch {}
try { Stop-Transcript | Out-Null } catch {} try { Stop-Transcript | Out-Null } catch {}
Write-Host "Rebooting in 10 seconds..." Write-Host "Rebooting in 10 seconds..."
shutdown /r /t 10 shutdown /r /t 10

View File

@@ -165,8 +165,12 @@ if (Test-Path -LiteralPath $machineNumFile) {
# before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true) # before UDC_Setup.exe runs means the installer's File.Copy (overwrite:true)
# would overwrite it IF the share were reachable, but since it isn't, our # would overwrite it IF the share were reachable, but since it isn't, our
# pre-staged file survives and UDC launches with correct settings. # pre-staged file survives and UDC launches with correct settings.
# UDC payload (settings backups + webserver settings) lives only in the
# collections per-pc-type dir - UDC is the "C" of "collections". On nocoll
# bays the dir doesn't exist; Test-Path skips silently.
$udcCollDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'gea-shopfloor-collections'
if ($machineNum -and $machineNum -ne '9999') { if ($machineNum -and $machineNum -ne '9999') {
$udcBackupDir = 'C:\Enrollment\shopfloor-setup\Standard\udc-backups' $udcBackupDir = Join-Path $udcCollDir 'udc-backups'
$udcBackup = Join-Path $udcBackupDir "udc_settings_$machineNum.json" $udcBackup = Join-Path $udcBackupDir "udc_settings_$machineNum.json"
$udcTarget = 'C:\ProgramData\UDC\udc_settings.json' $udcTarget = 'C:\ProgramData\UDC\udc_settings.json'
if (Test-Path -LiteralPath $udcBackup) { if (Test-Path -LiteralPath $udcBackup) {
@@ -176,11 +180,11 @@ if ($machineNum -and $machineNum -ne '9999') {
Copy-Item -Path $udcBackup -Destination $udcTarget -Force Copy-Item -Path $udcBackup -Destination $udcTarget -Force
Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget" Write-PreInstallLog "Pre-staged UDC settings from $udcBackup -> $udcTarget"
} else { } else {
Write-PreInstallLog "No UDC settings backup for machine $machineNum in $udcBackupDir" Write-PreInstallLog "No UDC settings backup for machine $machineNum at $udcBackup (skipping - normal for nocoll bays)"
} }
} }
$udcWebSrc = 'C:\Enrollment\shopfloor-setup\Standard\udc_webserver_settings.json' $udcWebSrc = Join-Path $udcCollDir 'udc_webserver_settings.json'
$udcWebDst = 'C:\ProgramData\UDC\udc_webserver_settings.json' $udcWebDst = 'C:\ProgramData\UDC\udc_webserver_settings.json'
if (Test-Path -LiteralPath $udcWebSrc) { if (Test-Path -LiteralPath $udcWebSrc) {
if (-not (Test-Path 'C:\ProgramData\UDC')) { if (-not (Test-Path 'C:\ProgramData\UDC')) {
@@ -189,7 +193,7 @@ if (Test-Path -LiteralPath $udcWebSrc) {
Copy-Item -Path $udcWebSrc -Destination $udcWebDst -Force Copy-Item -Path $udcWebSrc -Destination $udcWebDst -Force
Write-PreInstallLog "Pre-staged UDC webserver settings from $udcWebSrc -> $udcWebDst" Write-PreInstallLog "Pre-staged UDC webserver settings from $udcWebSrc -> $udcWebDst"
} else { } else {
Write-PreInstallLog "No UDC webserver settings file at $udcWebSrc" "WARN" Write-PreInstallLog "No UDC webserver settings file at $udcWebSrc (skipping - normal for nocoll bays)"
} }
# --- Suppress Windows Defender Firewall "Allow access" prompts globally for # --- Suppress Windows Defender Firewall "Allow access" prompts globally for
@@ -317,7 +321,8 @@ foreach ($app in $config.Applications) {
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
) )
$myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase) $myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)
foreach ($n in @($pcType, $pcProfileKey) | Where-Object { $_ }) { foreach ($n in @($pcType, $pcProfileKey) | Where-Object { $_ }) {
@@ -326,8 +331,19 @@ foreach ($app in $config.Applications) {
if ($g -icontains $n) { foreach ($x in $g) { [void]$myNames.Add($x) } } if ($g -icontains $n) { foreach ($x in $g) { [void]$myNames.Add($x) } }
} }
} }
# PCTypesStrict=true bypasses the alias-expansion matcher and requires
# the actual pcType (or composite pcProfileKey) to literally equal one
# of the allowedTypes entries. Used by UDC because the alias graph
# transitively connects gea-shopfloor-collections <-> nocollections via
# the legacy 'Standard' group, which would otherwise cause UDC to install
# on nocoll bays even with PCTypes=['gea-shopfloor-collections'].
$matchesType = ($allowedTypes -contains '*') $matchesType = ($allowedTypes -contains '*')
if (-not $matchesType) { if (-not $matchesType) {
if ($app.PCTypesStrict) {
foreach ($t in $allowedTypes) {
if (($pcType -ieq $t) -or ($pcProfileKey -ieq $t)) { $matchesType = $true; break }
}
} else {
foreach ($t in $allowedTypes) { foreach ($t in $allowedTypes) {
if ($myNames.Contains($t)) { $matchesType = $true; break } if ($myNames.Contains($t)) { $matchesType = $true; break }
foreach ($g in $aliasGroups) { foreach ($g in $aliasGroups) {
@@ -338,6 +354,7 @@ foreach ($app in $config.Applications) {
} }
} }
} }
}
if (-not $matchesType) { if (-not $matchesType) {
Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping" Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
$skipped++ $skipped++

View File

@@ -132,8 +132,12 @@ function Invoke-DesktopSweep {
Name = @( Name = @(
'^UDC', '^UDC',
'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS', 'eDNC', '\bDNC\b', 'DncMain', 'GE DNC', 'NTLARS',
'Host\s*Explorer', 'ShopFloor', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250', 'Host\s*Explorer', 'TN3270', 'TN5250', 'HE\s*3270', 'HE\s*5250',
'OpenText', # OpenText / 'WJ Shopfloor' / 'ShopFloor' shortcuts left on
# the desktop intentionally. The actual filename varies by
# OpenText profile (e.g. 'WJ Shopfloor OpenText.lnk') so the
# taskbar pin path mismatch silently skipped these. Leaving
# them at the public desktop top level instead.
'Defect[_\s-]?Tracker', 'Defect[_\s-]?Tracker',
'MarkZebra', 'Zebra', 'MarkZebra', 'Zebra',
'PC-?DMIS', 'PC-?DMIS',

View File

@@ -213,7 +213,7 @@ if ($null -ne $cfgTabs -and $cfgTabs.Count -gt 0) {
$plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications' $plantApps = Resolve-StartupUrl -BaseName 'Plant Apps' -Fallback 'https://mes-wjefferson.apps.lr.geaerospace.net/run/?app_name=Plant%20Applications'
if ($plantApps) { $startupTabs += $plantApps } if ($plantApps) { $startupTabs += $plantApps }
$shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'http://tsgwp00524.logon.ds.ge.com/' $shopFloorHome = Resolve-StartupUrl -BaseName 'WJ Shop Floor Homepage' -Fallback 'https://tsgwp00525.wjs.geaerospace.net'
if ($shopFloorHome) { $startupTabs += $shopFloorHome } if ($shopFloorHome) { $startupTabs += $shopFloorHome }
$dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/' $dashboard = Resolve-StartupUrl -BaseName 'Shopfloor Dashboard' -Fallback 'https://tsgwp00525.wjs.geaerospace.net/shopdb/shopfloor-dashboard/'

View File

@@ -0,0 +1,115 @@
# Apply-MachineNumber.ps1 - SYSTEM-context worker for the two-task machine
# number flow. Triggered on-demand by Prompt-MachineNumber.ps1 via
# `schtasks /run /tn "WT-Apply-MachineNumber"`. Reads the requested number
# from a file the GUI script wrote, invokes Update-MachineNumber as SYSTEM
# (full HKLM + ProgramData access), writes a result JSON for the GUI to
# display, then cleans up.
#
# Why SYSTEM:
# The eDNC reg key (HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\
# General\MachineNo) and UDC settings JSON live in HKLM + ProgramData
# respectively - both require admin to write. The OLD design granted
# BUILTIN\Users SetValue + Modify via 02-MachineNumberACLs.ps1, but that
# was fragile (timing race with eDNC install, ACL silently failed on
# some bays) AND a security hole (any user could mess with the machine
# identity). Two-task design: GUI gathers input as logged-in user, SYSTEM
# does the actual write.
#
# Files:
# C:\Logs\SFLD\machine-number-request.txt - input, single line, new number
# C:\Logs\SFLD\machine-number-result.json - output, status fields for GUI
# C:\Logs\SFLD\Apply-MachineNumber.log - transcript
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Apply-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
function Write-Result {
param([hashtable]$Body)
$Body | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $resultFile -Encoding ascii -Force
}
Write-Host "Apply-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
try {
if (-not (Test-Path -LiteralPath $requestFile)) {
Write-Warning "No request file at $requestFile - nothing to apply."
Write-Result @{ Status = 'NoRequest'; Errors = @("request file missing: $requestFile") }
exit 0
}
$newNumber = (Get-Content -LiteralPath $requestFile -First 1 -ErrorAction Stop).Trim()
Write-Host "Requested new machine number: $newNumber"
if ($newNumber -notmatch '^\d+$') {
Write-Warning "Request is not digits-only: '$newNumber'"
Write-Result @{ Status = 'BadInput'; Requested = $newNumber; Errors = @("Not digits only: '$newNumber'") }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
exit 1
}
# Dot-source the shared helper. Update-MachineNumber.ps1 now has
# -ErrorAction Stop on the writes so failures actually throw.
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
$mnResult = Update-MachineNumber -NewNumber $newNumber -Site $site
$resultBody = @{
Status = if ($mnResult.Errors.Count -eq 0) { 'OK' } else { 'PartialErrors' }
Requested = $newNumber
Site = $site
UdcUpdated = [bool]$mnResult.UdcUpdated
EdncUpdated = [bool]$mnResult.EdncUpdated
OldUdc = $mnResult.OldUdc
OldEdnc = $mnResult.OldEdnc
UdcSettingsRestored = [bool]$mnResult.UdcSettingsRestored
UdcRestored = [bool]$mnResult.UdcRestored
MTConnectUpdated = $mnResult.MTConnectUpdated
MachineNumberTxtUpdated = [bool]$mnResult.MachineNumberTxtUpdated
Errors = $mnResult.Errors
AppliedAt = (Get-Date -Format 'o')
AppliedAs = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
}
Write-Result -Body $resultBody
Write-Host "Update-MachineNumber result:"
Write-Host " UdcUpdated = $($mnResult.UdcUpdated)"
Write-Host " EdncUpdated = $($mnResult.EdncUpdated)"
Write-Host " Errors = $($mnResult.Errors.Count)"
if ($mnResult.Errors) { $mnResult.Errors | ForEach-Object { Write-Host " FAILED: $_" } }
Remove-Item -LiteralPath $requestFile -Force -ErrorAction SilentlyContinue
# On clean success, also unregister the Prompt logon task. Prompt itself
# tries to self-unregister but it runs as a Limited user (BUILTIN\Users)
# and silently fails on Unregister-ScheduledTask (no delete right on a
# SYSTEM-registered task). We're running as SYSTEM here, so we can.
# Idempotent if Prompt already unregistered itself somehow.
if ($mnResult.Errors.Count -eq 0 -and $mnResult.EdncUpdated) {
try {
if (Get-ScheduledTask -TaskName 'Prompt Machine Number' -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName 'Prompt Machine Number' -Confirm:$false -ErrorAction Stop
Write-Host "Unregistered 'Prompt Machine Number' task (SYSTEM cleanup)."
}
} catch {
Write-Host "Could not unregister 'Prompt Machine Number': $_"
}
}
Write-Host "Apply-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
} catch {
Write-Warning "Apply threw: $_"
Write-Result @{ Status = 'Exception'; Errors = @("$_") }
} finally {
try { Stop-Transcript | Out-Null } catch {}
}

View File

@@ -93,13 +93,24 @@ if ($mnResult.EdncUpdated) { $results += "eDNC updated to $new" }
foreach ($err in $mnResult.Errors) { $results += $err -replace '^', 'FAILED: ' } foreach ($err in $mnResult.Errors) { $results += $err -replace '^', 'FAILED: ' }
# --- Show result --- # --- Show result ---
$summary = ($results -join "`n") + "`n`nTo apply eDNC changes, restart any running DncMain.exe." $summary = ($results -join "`n") + "`n`nTo apply eDNC changes, restart any running DncMain.exe.`n`nFull log: C:\Logs\SFLD\Check-MachineNumber.log"
# Force the MessageBox to topmost + take focus so it isn't hidden behind
# other windows. Without this, the result dialog can render off-screen or
# behind the FormTracePak / DNC windows and the tech misses it.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
[System.Windows.Forms.MessageBox]::Show( [System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary, $summary,
"Machine Number Updated", "Machine Number Updated",
[System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Information [System.Windows.Forms.MessageBoxIcon]::Information
) | Out-Null ) | Out-Null
$tmpForm.Close()
# --- Unregister task on success --- # --- Unregister task on success ---
Write-Host "Results: $($results -join '; ')" Write-Host "Results: $($results -join '; ')"

View File

@@ -285,8 +285,13 @@ if ($null -ne $cfgItems -and $cfgItems.Count -gt 0) {
) )
} }
# Machine-number logon task is item 6 # Machine-number logon tasks (item 6 toggle controls both)
$machineNumTaskName = 'Check Machine Number' # 2026-05-24: split into user-context Prompt + SYSTEM-context Apply.
# 'Check Machine Number' is the legacy single-task name kept for
# backward-detection on bays imaged before the split.
$machineNumPromptTask = 'Prompt Machine Number'
$machineNumApplyTask = 'Apply Machine Number'
$machineNumLegacyTask = 'Check Machine Number'
# ============================================================================ # ============================================================================
# Interactive UI # Interactive UI
@@ -354,8 +359,12 @@ foreach ($item in $items) {
Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail" Write-Host " $($item.Num). $on $($item.Label) - $($item.Detail)$avail"
} }
# Item 6: machine number logon prompt # Item 6: machine number logon prompt. "ON" if EITHER the new Prompt task OR
$machineNumTaskExists = [bool](Get-ScheduledTask -TaskName $machineNumTaskName -ErrorAction SilentlyContinue) # the legacy Check Machine Number task is registered.
$machineNumTaskExists = [bool](
(Get-ScheduledTask -TaskName $machineNumPromptTask -ErrorAction SilentlyContinue) -or
(Get-ScheduledTask -TaskName $machineNumLegacyTask -ErrorAction SilentlyContinue)
)
$mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' } $mnOn = if ($machineNumTaskExists) { '[ON]' } else { '[ ]' }
Write-Host " 6. $mnOn Prompt standard user for machine number if 9999" Write-Host " 6. $mnOn Prompt standard user for machine number if 9999"
@@ -419,62 +428,47 @@ if ($selection) {
# Process item 6: machine number logon task # Process item 6: machine number logon task
if ($selected -contains 6) { if ($selected -contains 6) {
if ($machineNumTaskExists) { if ($machineNumTaskExists) {
# Toggle OFF # Toggle OFF - remove Prompt + Apply (new design) AND the legacy
# Check Machine Number task name (in case this bay was imaged
# before the split and never re-imaged).
$removed = @()
foreach ($t in @($machineNumPromptTask, $machineNumApplyTask, $machineNumLegacyTask)) {
try { try {
Unregister-ScheduledTask -TaskName $machineNumTaskName -Confirm:$false -ErrorAction Stop if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Write-Host " Machine number logon prompt: REMOVED" -ForegroundColor Yellow Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
$removed += $t
}
} catch { Write-Warning " Failed to remove '$t': $_" }
}
if ($removed) {
Write-Host " Machine number logon prompt: REMOVED ($($removed -join ', '))" -ForegroundColor Yellow
}
$machineNumTaskExists = $false $machineNumTaskExists = $false
} catch { Write-Warning " Failed to remove task: $_" }
} else { } else {
# Toggle ON - register logon task # Toggle ON - register logon task
# The task needs to run as the logged-in user (for GUI), but # Defer task registration to the shared registrar so this code
# writing to HKLM + ProgramData requires the ACLs we pre-grant # path always matches the imaging-time path. Registrar installs
# during imaging (see task 7 / ACL pre-grant script). # BOTH the user-context "Prompt Machine Number" task and the
# SYSTEM-context "Apply Machine Number" task, sets the SDDL on
# Apply so Limited users can schtasks /run it, and cleans up
# any legacy "Check Machine Number" task name.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$checkScript = Join-Path $scriptDir 'Check-MachineNumber.ps1' $registrar = Join-Path $scriptDir 'Register-CheckMachineNumberTask.ps1'
if (-not (Test-Path -LiteralPath $registrar)) {
if (-not (Test-Path -LiteralPath $checkScript)) { $registrar = 'C:\Enrollment\shopfloor-setup\Shopfloor\Register-CheckMachineNumberTask.ps1'
# Fallback: check enrollment staging dir
$checkScript = 'C:\Enrollment\shopfloor-setup\Shopfloor\Check-MachineNumber.ps1'
} }
if (Test-Path -LiteralPath $registrar) {
if (Test-Path -LiteralPath $checkScript) {
try { try {
$action = New-ScheduledTaskAction ` & $registrar
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$checkScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Run as the logged-in user (needs GUI for InputBox), NOT
# SYSTEM (SYSTEM can't show UI to the user's desktop).
$principal = New-ScheduledTaskPrincipal `
-GroupId 'S-1-5-32-545' `
-RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask `
-TaskName $machineNumTaskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Force `
-ErrorAction Stop | Out-Null
Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green Write-Host " Machine number logon prompt: ENABLED" -ForegroundColor Green
Write-Host " (will auto-disable after machine number is set)" -ForegroundColor DarkGray Write-Host " (Prompt user-task + Apply SYSTEM-task registered;" -ForegroundColor DarkGray
Write-Host " will auto-disable after machine number is set)" -ForegroundColor DarkGray
$machineNumTaskExists = $true $machineNumTaskExists = $true
} catch { } catch {
Write-Warning " Failed to register task: $_" Write-Warning " Register-CheckMachineNumberTask failed: $_"
} }
} else { } else {
Write-Warning " Check-MachineNumber.ps1 not found at $checkScript" Write-Warning " Register-CheckMachineNumberTask.ps1 not found at $registrar"
} }
} }
} }

View File

@@ -0,0 +1,200 @@
# Prompt-MachineNumber.ps1 - User-context GUI script for the two-task
# machine number flow. Triggered AtLogOn for any BUILTIN\Users member.
#
# Flow:
# 1. Read current UDC + eDNC values (read-only - no privileges needed).
# 2. If neither is 9999, unregister self and exit (this PC is set up).
# 3. Show InputBox for new machine number.
# 4. Write number to C:\Logs\SFLD\machine-number-request.txt.
# 5. Trigger the SYSTEM-context Apply-MachineNumber task via
# schtasks /run. SYSTEM has full HKLM + ProgramData access so the
# actual write happens with proper privileges - the prompted user
# never needs HKLM write rights (security improvement over the old
# 02-MachineNumberACLs.ps1 ACL-grant hack).
# 6. Poll for C:\Logs\SFLD\machine-number-result.json (30s timeout).
# 7. Show result MessageBox. Unregister self on success.
#
# Why this script doesn't do the writes itself: GUI is required (InputBox),
# but GUI requires user-context (SYSTEM can't render to user desktop on
# modern Windows). The user-context dialog gathers input; the SYSTEM task
# does privileged writes.
# --- Transcript ---
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path -LiteralPath $logDir)) {
try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP }
}
$transcript = Join-Path $logDir 'Prompt-MachineNumber.log'
try { Start-Transcript -Path $transcript -Append -Force | Out-Null } catch {}
Write-Host "Prompt-MachineNumber.ps1 starting $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Write-Host "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)"
. "$PSScriptRoot\lib\Get-PCProfile.ps1"
. "$PSScriptRoot\lib\Update-MachineNumber.ps1"
Add-Type -AssemblyName Microsoft.VisualBasic
Add-Type -AssemblyName System.Windows.Forms
$taskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$requestFile = Join-Path $logDir 'machine-number-request.txt'
$resultFile = Join-Path $logDir 'machine-number-result.json'
$site = if ($siteConfig) { $siteConfig.siteName } else { 'West Jefferson' }
# --- Read current values (read-only, no perms needed) ---
$currentMN = Get-CurrentMachineNumber
$currentUdc = $currentMN.Udc
$currentEdnc = $currentMN.Ednc
Write-Host "UDC machine number: $(if ($currentUdc) { $currentUdc } else { '(not found)' })"
Write-Host "eDNC machine number: $(if ($currentEdnc) { $currentEdnc } else { '(not found)' })"
if ($currentUdc -ne '9999' -and $currentEdnc -ne '9999') {
Write-Host "Machine number is set (not 9999). Unregistering Prompt task and exiting."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
Write-Host "Placeholder 9999 detected - showing prompt."
# --- Show prompt ---
$promptLines = @()
$promptLines += "The machine number on this PC is still set to the"
$promptLines += "placeholder value (9999). Please enter the correct"
$promptLines += "machine number for this workstation."
$promptLines += ""
if ($currentUdc) { $promptLines += "Current UDC: $currentUdc" }
if ($currentEdnc) { $promptLines += "Current eDNC: $currentEdnc" }
$promptLines += ""
$promptLines += "Enter the new Machine Number:"
$prompt = $promptLines -join "`n"
$new = [Microsoft.VisualBasic.Interaction]::InputBox($prompt, "Set Machine Number", "")
if ([string]::IsNullOrWhiteSpace($new)) {
Write-Host "User cancelled. Will prompt again next logon."
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
$new = $new.Trim()
if ($new -notmatch '^\d+$') {
Write-Host "Invalid input: '$new' (not digits only). Showing error and re-prompting next logon."
[System.Windows.Forms.MessageBox]::Show(
"Machine number must be digits only.`n`nYou entered: '$new'`n`nThe prompt will appear again at next logon.",
"Invalid Machine Number",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 0
}
# --- Hand off to SYSTEM task ---
# Clean any stale request / result files first so we read fresh ones.
Remove-Item -LiteralPath $requestFile, $resultFile -Force -ErrorAction SilentlyContinue
try {
Set-Content -LiteralPath $requestFile -Value $new -Encoding ascii -Force -ErrorAction Stop
} catch {
[System.Windows.Forms.MessageBox]::Show(
"Could not write request file at $requestFile`n`n$_`n`nThe prompt will appear again at next logon.",
"Machine Number Request Failed",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Error
) | Out-Null
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
Write-Host "Wrote $requestFile with '$new'. Triggering SYSTEM apply task..."
& schtasks.exe /run /tn $applyTaskName 2>&1 | ForEach-Object { Write-Host " schtasks: $_" }
# --- Wait for result ---
$deadline = (Get-Date).AddSeconds(60)
$result = $null
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $resultFile) {
try {
$result = Get-Content -LiteralPath $resultFile -Raw -ErrorAction Stop | ConvertFrom-Json
break
} catch { Start-Sleep -Milliseconds 200 }
}
Start-Sleep -Milliseconds 500
}
# Make the result MessageBox topmost so it shows above the FormTracePak /
# DNC windows and isn't missed.
$tmpForm = New-Object System.Windows.Forms.Form
$tmpForm.TopMost = $true
$tmpForm.WindowState = 'Minimized'
$tmpForm.ShowInTaskbar = $false
$tmpForm.Opacity = 0
$tmpForm.Show()
if (-not $result) {
Write-Host "Timed out waiting for SYSTEM apply task to produce result file ($resultFile)."
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
"Timed out waiting for the SYSTEM update task to complete.`n`nCheck:`n C:\Logs\SFLD\Apply-MachineNumber.log`n C:\Logs\SFLD\Prompt-MachineNumber.log`n`nThe prompt will appear again at next logon.",
"Machine Number Update Timed Out",
[System.Windows.Forms.MessageBoxButtons]::OK,
[System.Windows.Forms.MessageBoxIcon]::Warning
) | Out-Null
$tmpForm.Close()
try { Stop-Transcript | Out-Null } catch {}
exit 1
}
# Build summary from result JSON
$lines = @()
$lines += "Requested: $($result.Requested)"
$lines += ""
if ($result.UdcUpdated) { $lines += "UDC updated to $($result.Requested)" } else { $lines += "UDC: not updated (UDC may not be installed)" }
if ($result.EdncUpdated) { $lines += "eDNC updated to $($result.Requested)" } else { $lines += "eDNC: not updated" }
if ($result.UdcSettingsRestored) { $lines += "UDC settings restored from SFLD" }
if ($result.UdcRestored) { $lines += "UDC live data restored from SFLD" }
if ($result.MachineNumberTxtUpdated) { $lines += "machine-number.txt updated" }
if ($result.MTConnectUpdated -and $result.MTConnectUpdated.Count -gt 0) {
$lines += ""
$lines += "MTConnect Devices.xml updates:"
$result.MTConnectUpdated | ForEach-Object { $lines += " - $_" }
}
if ($result.Errors -and $result.Errors.Count -gt 0) {
$lines += ""
$lines += "FAILURES:"
$result.Errors | ForEach-Object { $lines += " - $_" }
}
$lines += ""
$lines += "Status: $($result.Status)"
$lines += "Logs: C:\Logs\SFLD\Apply-MachineNumber.log"
$lines += " C:\Logs\SFLD\Prompt-MachineNumber.log"
$lines += ""
$lines += "To apply eDNC changes, restart any running DncMain.exe."
$summary = $lines -join "`n"
$icon = if ($result.Status -eq 'OK') { [System.Windows.Forms.MessageBoxIcon]::Information } else { [System.Windows.Forms.MessageBoxIcon]::Warning }
[System.Windows.Forms.MessageBox]::Show(
$tmpForm,
$summary,
"Machine Number Update Result",
[System.Windows.Forms.MessageBoxButtons]::OK,
$icon
) | Out-Null
$tmpForm.Close()
# Clean up result file for the next round.
Remove-Item -LiteralPath $resultFile -Force -ErrorAction SilentlyContinue
# Only unregister the Prompt task on full success (no errors AND eDNC
# updated to the requested value). If anything failed, leave it registered
# for next logon retry.
if ($result.Status -eq 'OK' -and $result.EdncUpdated) {
Write-Host "All updates succeeded. Unregistering Prompt task."
try { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue } catch {}
} else {
Write-Host "Some updates failed or skipped. Prompt task stays registered for next logon retry."
}
Write-Host "Prompt-MachineNumber.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
try { Stop-Transcript | Out-Null } catch {}
exit 0

View File

@@ -0,0 +1,149 @@
# Register-CheckMachineNumberTask.ps1 - Register the two-task machine
# number flow at imaging time:
#
# 1. "Prompt Machine Number" - AtLogOn, BUILTIN\Users, Limited.
# Shows InputBox + writes new number to a request file, then triggers
# the SYSTEM task via schtasks /run.
#
# 2. "Apply Machine Number" - on-demand only (no trigger), SYSTEM,
# RunLevel Highest. Reads the request file, calls Update-MachineNumber
# with full HKLM + ProgramData access, writes a result JSON, removes
# the request file. No GUI - the Prompt task polls the result file
# and displays the dialog.
#
# Replaces the old single-task design that ran as the logged-in user with
# pre-granted BUILTIN\Users HKLM ACLs (02-MachineNumberACLs.ps1). That
# approach was fragile (timing race with eDNC install, silent ACL skip)
# and a security hole (any user could write to the machine-identity reg
# key). With SYSTEM doing the actual writes, no ACL grants needed.
#
# Idempotent: safe to re-run. Existing tasks are overwritten.
#
# File kept named Register-CheckMachineNumberTask.ps1 (rather than
# Register-MachineNumberTasks.ps1) so Run-ShopfloorSetup's existing
# discovery doesn't need editing.
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
$logFile = Join-Path $logDir 'register-checkmn.log'
function Write-RegLog {
param([string]$Message)
$line = '[{0}] [INFO] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
Write-Host $line
}
Write-RegLog '=== Register-CheckMachineNumberTask start ==='
$promptTaskName = 'Prompt Machine Number'
$applyTaskName = 'Apply Machine Number'
$oldTaskName = 'Check Machine Number' # legacy, removed below
# Clean up the legacy single-task name from prior imaging cycles.
try {
if (Get-ScheduledTask -TaskName $oldTaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $oldTaskName -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered legacy task '$oldTaskName'"
}
} catch { Write-RegLog "Could not unregister legacy '$oldTaskName': $_" }
# Only arm the tasks if the bay was imaged with the 9999 placeholder. If
# the tech entered a real machine number during PXE imaging it's already
# in C:\Enrollment\machine-number.txt; no prompt needed on first logon.
$mnFile = 'C:\Enrollment\machine-number.txt'
$mnAtImaging = '9999'
if (Test-Path -LiteralPath $mnFile) {
$raw = (Get-Content -LiteralPath $mnFile -First 1 -ErrorAction SilentlyContinue)
if ($raw) { $mnAtImaging = $raw.Trim() }
}
Write-RegLog "Imaging-time machine-number.txt = '$mnAtImaging'"
if ($mnAtImaging -ne '9999') {
Write-RegLog "Machine number is real ('$mnAtImaging' != 9999). Not registering tasks."
foreach ($t in @($promptTaskName, $applyTaskName)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction Stop
Write-RegLog "Unregistered stale task '$t'"
}
} catch {}
}
Write-RegLog '=== Register-CheckMachineNumberTask end (no-op) ==='
exit 0
}
# Resolve script paths. Prefer the staged shopfloor-setup tree on C:
# (where Run-ShopfloorSetup ran from); fall back to the same dir as this
# Register script if invoked standalone.
function Resolve-Script {
param([string]$LeafName)
$p = Join-Path $PSScriptRoot $LeafName
if (Test-Path -LiteralPath $p) { return $p }
$p = "C:\Enrollment\shopfloor-setup\Shopfloor\$LeafName"
if (Test-Path -LiteralPath $p) { return $p }
return $null
}
$promptScript = Resolve-Script 'Prompt-MachineNumber.ps1'
$applyScript = Resolve-Script 'Apply-MachineNumber.ps1'
if (-not $promptScript) { Write-RegLog "Prompt-MachineNumber.ps1 not found - cannot register"; exit 1 }
if (-not $applyScript) { Write-RegLog "Apply-MachineNumber.ps1 not found - cannot register"; exit 1 }
Write-RegLog "Prompt script: $promptScript"
Write-RegLog "Apply script: $applyScript"
# --- Prompt task (user-context, GUI) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Normal -File `"$promptScript`""
$trigger = New-ScheduledTaskTrigger -AtLogOn
# Group SID S-1-5-32-545 = BUILTIN\Users (catches ShopFloor + support/admin
# users that log in interactively). RunLevel Limited - no elevation; the
# actual writes happen in the SYSTEM Apply task below.
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-545' -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
Register-ScheduledTask -TaskName $promptTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$promptTaskName' (AtLogOn, BUILTIN\Users, Limited)"
} catch {
Write-RegLog "FAILED to register '$promptTaskName': $_"
exit 1
}
# --- Apply task (SYSTEM, on-demand) ---
try {
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$applyScript`""
# No trigger - the Prompt task starts this via schtasks /run /tn.
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 10)
Register-ScheduledTask -TaskName $applyTaskName -Action $action -Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Write-RegLog "Registered scheduled task '$applyTaskName' (on-demand, SYSTEM, Highest)"
# Default SDDL on a SYSTEM-owned task only grants Admins + SYSTEM
# FullAccess - BUILTIN\Users can't see or run it via schtasks /run.
# Add an ACE granting BUILTIN\Users GenericRead + GenericExecute so the
# user-context Prompt task can trigger this Apply task on demand. They
# still can't modify/delete it - only read+execute.
try {
$svc = New-Object -ComObject Schedule.Service
$svc.Connect()
$taskObj = $svc.GetFolder('\').GetTask($applyTaskName)
# GenericRead = 0x80000000 (GR), GenericExecute = 0x20000000 (GX)
# BU = BUILTIN\Users
$newSd = 'O:BAG:BAD:(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)'
# SetSecurityDescriptor flag 0 = default, persists DACL change.
$taskObj.SetSecurityDescriptor($newSd, 0)
Write-RegLog "Granted BUILTIN\Users GR+GX on '$applyTaskName' (so Limited users can schtasks /run)"
} catch {
Write-RegLog "FAILED to set task SDDL on '$applyTaskName': $_ (Limited users may not be able to trigger Apply)"
}
} catch {
Write-RegLog "FAILED to register '$applyTaskName': $_"
exit 1
}
Write-RegLog '=== Register-CheckMachineNumberTask end ==='
exit 0

View File

@@ -1,45 +0,0 @@
# Disable-WiredNics.ps1
# Disables every Up wired (MediaType 802.3) NIC and records their names to
# C:\Enrollment\disabled-wired-nics.txt so Monitor-IntuneProgress can
# re-enable them once Report IP has run on WiFi-only.
#
# Reason: GE's Intune Proactive-Remediation "Report IP" script enumerates
# Get-NetIPAddress and POSTs every IP it finds to a GE webhook. When a
# shopfloor bay is still cabled to the air-gapped PXE LAN (10.9.100.0/24),
# the webhook sees 10.9.100.x as one of the device's IPs and tags the bay
# "not on corp net". A dynamic group / assignment-filter at GE then excludes
# the bay from receiving the SFLD ConfigurationProfile (Function + SasToken
# OMA-URI) -> Phase 2 "Device Configuration" never closes.
#
# Killing the wired NIC after stage 2 reports + before AAD-join makes the
# bay's first Report IP fire see corp-WiFi IP only. The bay is tagged
# clean, dynamic group eligibility flips, SFLD policy delivers normally.
# Monitor-IntuneProgress re-enables the NIC once Report IP's log file
# appears at C:\Logs\GE_Report_IP_Address*.txt.
$ErrorActionPreference = 'Continue'
$stateFile = 'C:\Enrollment\disabled-wired-nics.txt'
try {
$wired = Get-NetAdapter -ErrorAction Stop |
Where-Object {
$_.Status -eq 'Up' -and
$_.MediaType -eq '802.3' -and
$_.HardwareInterface -eq $true
}
if (-not $wired) {
Write-Host "Disable-WiredNics: no Up wired NICs found - nothing to disable."
return
}
$names = $wired | ForEach-Object { $_.Name }
$names | Out-File -FilePath $stateFile -Encoding ASCII -Force
Write-Host ("Disable-WiredNics: persisted {0} NIC name(s) -> {1}" -f $names.Count, $stateFile)
foreach ($n in $names) { Write-Host " - $n" }
$wired | Disable-NetAdapter -Confirm:$false -ErrorAction Continue
Write-Host "Disable-WiredNics: NICs disabled. Re-enable triggered by Monitor when GE_Report_IP_Address log appears."
} catch {
Write-Warning "Disable-WiredNics: failed: $_"
}

View File

@@ -66,6 +66,15 @@ if (Test-Path -LiteralPath $subtypeFile) {
$pcSubtype = (Get-Content -LiteralPath $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim() $pcSubtype = (Get-Content -LiteralPath $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
} }
# Display sub-type fallback: if pc-subtype.txt is absent (post-rename-reorg
# default) but display-type.txt exists, use it as the subtype. Lets the
# Display-Lobby / Display-Dashboard / gea-shopfloor-display-{lobby,dashboard}
# profile keys resolve correctly for Display PCs.
$displayTypeFile = 'C:\Enrollment\display-type.txt'
if (-not $pcSubtype -and ($pcType -ieq 'gea-shopfloor-display' -or $pcType -ieq 'Display') -and (Test-Path -LiteralPath $displayTypeFile)) {
$pcSubtype = (Get-Content -LiteralPath $displayTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
# Build the profile key: "Standard-Machine", "CMM", "Display-Lobby", etc. # Build the profile key: "Standard-Machine", "CMM", "Display-Lobby", etc.
$profileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType } $profileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
@@ -82,6 +91,8 @@ $pcProfileAliasGroups = @(
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Display-Lobby', 'gea-shopfloor-display-Lobby', 'gea-shopfloor-display-lobby'),
@('Display-Dashboard', 'gea-shopfloor-display-Dashboard', 'gea-shopfloor-display-dashboard'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat')
) )

View File

@@ -80,11 +80,11 @@ param(
# The persistent @logon sync_intune task takes over after reboot. # The persistent @logon sync_intune task takes over after reboot.
[switch]$PostPpkg, [switch]$PostPpkg,
# -PostPpkgSettleSec: how long to wait before the clean reboot when # -PostPpkgSettleSec: how long to wait before the clean reboot when
# in -PostPpkg mode. 60s empirically gives MDM enough time to push # in -PostPpkg mode. 120s empirically gives MDM enough time to push
# the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs # the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs
# see sync_intune resume after reboot, the readiness signals are # see sync_intune resume after reboot, the readiness signals are
# already meaningful instead of "policy still pulling". # already meaningful instead of "policy still pulling".
[int]$PostPpkgSettleSec = 60 [int]$PostPpkgSettleSec = 120
) )
# ============================================================================ # ============================================================================
@@ -186,7 +186,10 @@ $script:cache = @{
EnrollmentId = $null EnrollmentId = $null
DeviceId = $null DeviceId = $null
DeviceIdReported = $false DeviceIdReported = $false
SfldPolicyPushed = $false
CredsReadyPushed = $false
LockdownCompletePushed = $false LockdownCompletePushed = $false
ReportIpForced = $false
InternetAccessDeleted = $false InternetAccessDeleted = $false
} }
@@ -213,7 +216,12 @@ function Get-Phase1 {
# on-screen QR works but the dashboard QR did not. # on-screen QR works but the dashboard QR did not.
if (-not $script:cache.AzureAdJoined -or -not $script:cache.DeviceId) { if (-not $script:cache.AzureAdJoined -or -not $script:cache.DeviceId) {
try { try {
$dsreg = dsregcmd /status 2>&1 # dsregcmd on Win11 emits ANSI escape codes (\x1B[7m...\x1B[0m)
# around field names when its output is treated as a terminal.
# Captured output then contains those codes between e.g.
# "DeviceId" and ":", breaking a tight regex like
# 'DeviceId\s*:\s*<value>'. Strip ANSI sequences before matching.
$dsreg = (dsregcmd /status 2>&1 | Out-String) -replace '\x1B\[[0-9;]*[A-Za-z]', ''
if (-not $script:cache.AzureAdJoined -and $dsreg -match 'AzureAdJoined\s*:\s*YES') { if (-not $script:cache.AzureAdJoined -and $dsreg -match 'AzureAdJoined\s*:\s*YES') {
$script:cache.AzureAdJoined = $true $script:cache.AzureAdJoined = $true
} }
@@ -223,61 +231,12 @@ function Get-Phase1 {
} catch {} } catch {}
} }
# Report IP log presence drives two independent actions that USED to be # idx=7 push happens later in Get-Phase1 when Intune-registration
# bundled inside the DeviceId-push gate. Splitting them so re-enable # essentials are all green (see WiFi-swap block). The legacy
# fires even if DeviceId hasn't been captured yet (e.g. AAD join lag, # wired-NIC re-enable + reportIpLog-gated idx=7 retry was retired
# dsregcmd parse miss): # after the PXE LAN renumber to 172.16.9.0/24 - PXE LAN addresses
# # no longer pass GE Report IP's StartsWith("10.") filter, so the
# 1. Re-enable wired NICs as soon as the log lands + state file exists. # wired-disable / re-enable dance is unnecessary.
# 2. Push idx=7 once DeviceId is captured AND the log exists.
$reportIpLog = Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue |
Select-Object -First 1
$nicListFile = 'C:\Enrollment\disabled-wired-nics.txt'
$justReEnabled = $false
if ($reportIpLog -and (Test-Path $nicListFile)) {
try {
$nicNames = Get-Content $nicListFile -ErrorAction Stop
foreach ($n in $nicNames) {
if ([string]::IsNullOrWhiteSpace($n)) { continue }
try { Enable-NetAdapter -Name $n -Confirm:$false -ErrorAction Stop }
catch { Write-Warning "Enable-NetAdapter '$n' failed: $_" }
}
# Wait for DHCP renewal + route table update + reachability to
# PXE server. 1 second wasn't enough in field testing - the
# subsequent idx=7 push fired into the void before the wired
# NIC was carrying traffic.
Start-Sleep -Seconds 5
Remove-Item $nicListFile -Force -ErrorAction SilentlyContinue
$justReEnabled = $true
} catch {
Write-Warning "Re-enable wired NICs failed: $_"
}
}
# Push DeviceId / idx=7 once, when both DeviceId is captured and the
# Report IP log has landed (dashboard QR renders from DeviceId).
# Retry up to 6x with backoff because the imminent LAPS-prompt reboot
# gives us only seconds and the wired NIC may still be settling.
if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported -and $reportIpLog) {
Ensure-SendPxeStatus
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
$attempts = if ($justReEnabled) { 6 } else { 1 }
for ($i = 0; $i -lt $attempts; $i++) {
$err = $null
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
$script:cache.DeviceIdReported = $true
break
} catch { $err = $_ }
if ($i -lt $attempts - 1) { Start-Sleep -Seconds 2 }
}
if (-not $script:cache.DeviceIdReported -and $err) {
Write-Warning "idx=7 push failed after $attempts attempts: $err"
}
}
}
# Lockdown-applied auto-completion. Fleet-wide reality: bays use a LOCAL # Lockdown-applied auto-completion. Fleet-wide reality: bays use a LOCAL
# ShopFloor account, so AzureAdPrt stays NO and user-scoped Intune policies # ShopFloor account, so AzureAdPrt stays NO and user-scoped Intune policies
@@ -358,35 +317,99 @@ function Get-Phase1 {
} catch {} } catch {}
# Once Intune registration is fully landed (AAD-joined + Intune-enrolled # Once Intune registration is fully landed (AAD-joined + Intune-enrolled
# + EnterpriseMgmt task present + baseline policies arrived), three # + EnterpriseMgmt task present + baseline policies arrived):
# things must happen together: # - Push idx=7 to PXE dashboard with the DeviceId / QR.
# 1. Delete INTERNETACCESS WiFi profile (gets bay off 172.16.x) # The INTERNETACCESS -> AESFMA WiFi swap uses a VERIFY-BEFORE-DELETE
# 2. Connect AESFMA (gets bay onto corp 10.x via EAP-TLS - cert is # pattern so the bay never ends up with no path:
# already in LocalMachine\My thanks to Intune SCEP) # 1. Phase 1 essentials must be COMPLETE (Intune registration done).
# 3. Push idx=7 to the PXE dashboard with the captured DeviceId so # 2. Attempt netsh wlan connect AESFMA while INTERNETACCESS still up.
# the dashboard card shows the QR for the Intune device id. # 3. Wait ~8s, parse netsh wlan show interfaces for SSID=AESFMA +
# All three fire in one shot per Monitor lifetime via cache flags. # State=connected.
# 4. ONLY after operationally connected to AESFMA, delete INTERNETACCESS.
# 5. If connect fails (cert not provisioned yet, etc), keep
# INTERNETACCESS, retry next tick.
$phase1Essential = ($script:cache.AzureAdJoined -and $phase1Essential = ($script:cache.AzureAdJoined -and
$script:cache.IntuneEnrolled -and $script:cache.IntuneEnrolled -and
$script:cache.EmTaskExists -and $script:cache.EmTaskExists -and
$policiesBaselineReady) $policiesBaselineReady)
if ($phase1Essential -and -not $script:cache.InternetAccessDeleted) { if ($phase1Essential -and -not $script:cache.InternetAccessDeleted) {
try { # Helper: split netsh wlan show interfaces output into one block
Write-Host "Intune registration complete - deleting INTERNETACCESS profile + reconnecting to AESFMA..." # per adapter (delimited by lines starting with "Name :"), then
$delOut = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String # check whether any block contains SSID=AESFMA AND State=connected
Write-Host $delOut # in either order.
Start-Sleep -Seconds 2 function Test-AESFMAConnected {
$conOut = netsh wlan connect name="AESFMA" ssid="AESFMA" 2>&1 | Out-String $out = netsh wlan show interfaces 2>$null | Out-String
Write-Host $conOut if (-not $out) { return $false }
$blocks = ($out -split '(?ms)(?=^\s*Name\s*:\s*)')
foreach ($b in $blocks) {
if (($b -match 'SSID\s*:\s*AESFMA\b') -and ($b -match 'State\s*:\s*connected\b')) {
return $true
}
}
return $false
}
if (Test-AESFMAConnected) {
# Already connected (either via WLAN auto-join, prior tick's
# attempt, or an operator manual connect). Clean up
# INTERNETACCESS, force a Report IP push from the AESFMA-attached
# corp address, and stop trying.
Write-Host "AESFMA connected - cleaning up INTERNETACCESS..."
$null = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String
$script:cache.InternetAccessDeleted = $true $script:cache.InternetAccessDeleted = $true
# Force the GE Report IP exe to post the new (AESFMA corp) IP
# to the Tines webhook immediately - default trigger is on
# DHCP event + slow interval, this skips the wait.
if (-not $script:cache.ReportIpForced) {
$rip = 'C:\ProgramData\ReportIP\GE_ReportIP_3_v1.EXE'
if (Test-Path $rip) {
try {
Start-Process -FilePath $rip -ArgumentList '/ForceUpdate=True','/S' -WindowStyle Hidden -ErrorAction Stop
Write-Host "Forced GE Report IP push (corp-AESFMA IP)."
$script:cache.ReportIpForced = $true
} catch { } catch {
Write-Warning "WiFi swap (INTERNETACCESS -> AESFMA) failed: $_" Write-Warning "Force GE Report IP failed: $_"
} }
} }
if ($phase1Essential -and $script:cache.DeviceId -and -not $script:cache.DeviceIdReported) { }
} else {
# Not connected. Try without pre-gating on a cert chain check -
# the X509Chain.Build can return a partial chain (e.g. missing
# intermediate) which made the strict root-thumbprint match
# false even when EAP-TLS would actually succeed. Let netsh
# itself be the source of truth via the connect attempt.
# Rate-limit: at most one attempt every 30 seconds to avoid
# spam when AESFMA isn't actually reachable.
$now = Get-Date
if (-not $script:cache.AesfmaNextAttempt -or $now -ge $script:cache.AesfmaNextAttempt) {
try {
Write-Host "Attempting AESFMA connect (INTERNETACCESS stays up as fallback)..."
$null = netsh wlan connect name="AESFMA" ssid="AESFMA" 2>&1 | Out-String
Start-Sleep -Seconds 15
if (Test-AESFMAConnected) {
Write-Host "AESFMA connected. Deleting INTERNETACCESS profile..."
$null = netsh wlan delete profile name="INTERNETACCESS" 2>&1 | Out-String
$script:cache.InternetAccessDeleted = $true
} else {
Write-Host "AESFMA connect not yet operational - will retry in 30s."
$script:cache.AesfmaNextAttempt = $now.AddSeconds(30)
}
} catch {
Write-Warning "AESFMA connect/swap attempt failed: $_"
$script:cache.AesfmaNextAttempt = $now.AddSeconds(30)
}
}
}
}
# idx=7 push fires AS SOON AS DeviceId is captured. We want the QR
# to render on the PXE dashboard BEFORE the Intune-driven LAPS-prompt
# reboot lands (~1 min after GE Report IP posts its log). Phase 1
# essentials, SCEP cert delivery, and AESFMA connection all take
# longer than DeviceId capture, so don't gate on any of those.
if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) { if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try { try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune registration complete' ` Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
-StageIndex 7 -StageTotal 8 ` -StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop -IntuneDeviceId $script:cache.DeviceId -ErrorAction Stop
$script:cache.DeviceIdReported = $true $script:cache.DeviceIdReported = $true
@@ -863,21 +886,13 @@ function Format-Snapshot {
# not just "arriving". Stops the category prompt firing pre-first-reboot # not just "arriving". Stops the category prompt firing pre-first-reboot
# when only ~4 subkeys are present (we tested this empirically; clicking # when only ~4 subkeys are present (we tested this empirically; clicking
# "assign category" at 4 subkeys = imaging stalls + re-image required). # "assign category" at 4 subkeys = imaging stalls + re-image required).
# Report IP log presence is part of Phase 1 completion. Without that log
# we know GE's Proactive-Remediation script hasn't fired on WiFi-only
# yet, which means the SFLD ConfigurationProfile assignment filter still
# sees a leaked 10.9.100.x IP and Phase 2 won't unblock. Don't call
# registration "done" until Report IP has cleared.
$reportIpDone = [bool](Get-ChildItem -Path 'C:\Logs\GE_Report_IP_Address*' -ErrorAction SilentlyContinue | Select-Object -First 1)
$p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and $p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and
$Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady -and $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady)
$reportIpDone)
$p1Status = Get-PhaseStatus @( $p1Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false }, @{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false },
@{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false }, @{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false },
@{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false }, @{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false },
@{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false }, @{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false }
@{ Ok = $reportIpDone; Failed = $false }
) )
# Phase 6 / Lockdown (shared by both flows, rendered last). # Phase 6 / Lockdown (shared by both flows, rendered last).
@@ -1235,6 +1250,35 @@ try {
while ($true) { while ($true) {
$snap = Get-Snapshot $snap = Get-Snapshot
# Push sub-stage transitions to PXE dashboard so the operator sees
# whether the bay is waiting on category assignment, or has
# progressed past it. idx stays 7 across all three; the stage
# string drives the friendly label in imaging.html.
if (-not $script:cache.SfldPolicyPushed -and
$snap.Phase2.SfldRoot -and $snap.Phase2.FunctionOk -and $snap.Phase2.SasTokenOk) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Phase 2 SFLD policy delivered (device configuration)' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction SilentlyContinue
$script:cache.SfldPolicyPushed = $true
} catch {}
}
}
if (-not $script:cache.CredsReadyPushed -and
$snap.Phase4.CredsPopulated -and
$snap.Phase3.InstallComplete -and
$snap.Phase2.SfldRoot -and $snap.Phase2.FunctionOk -and $snap.Phase2.SasTokenOk) {
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Phases 1-4 complete - ready for lockdown (ARTS request)' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId -ErrorAction SilentlyContinue
$script:cache.CredsReadyPushed = $true
} catch {}
}
}
# Retry QR code every cycle until it actually renders. dsregcmd # Retry QR code every cycle until it actually renders. dsregcmd
# may report AzureAdJoined=YES before DeviceId is populated, so # may report AzureAdJoined=YES before DeviceId is populated, so
# a single-shot refresh misses the window. # a single-shot refresh misses the window.
@@ -1338,8 +1382,16 @@ try {
$nextRetrigger = $lastSync.AddMinutes($currentInterval) $nextRetrigger = $lastSync.AddMinutes($currentInterval)
} }
# Tight poll while DeviceId still missing - it may take a few
# minutes after PPKG for dsregcmd to return a DeviceId, and we
# need to catch it ASAP to push idx=7 before the LAPS reboot.
# Once captured + reported, fall back to the normal cadence.
if (-not $script:cache.DeviceIdReported) {
Start-Sleep -Seconds 5
} else {
Start-Sleep -Seconds $PollSecs Start-Sleep -Seconds $PollSecs
} }
}
} }
catch { catch {
# Any unhandled exception in the main loop lands here. Write the error # Any unhandled exception in the main loop lands here. Write the error

View File

@@ -19,7 +19,7 @@ function Send-PxeStatus {
# Only available post-AAD-join; pass it from Monitor-IntuneProgress # Only available post-AAD-join; pass it from Monitor-IntuneProgress
# once captured. The dashboard renders a QR of this value. # once captured. The dashboard renders a QR of this value.
[string]$IntuneDeviceId = '', [string]$IntuneDeviceId = '',
[string]$PxeServer = '10.9.100.1', [string]$PxeServer = '172.16.9.1',
[int]$Port = 9009, [int]$Port = 9009,
[int]$TimeoutSec = 5 [int]$TimeoutSec = 5
) )

View File

@@ -0,0 +1,44 @@
# Set-OpenTextAutoStart.ps1 - place WJ Shopfloor.lnk in the All Users
# Startup folder so HostExplorer's "WJ Shopfloor" session launches at
# every login. Idempotent: re-running is a no-op when the .lnk already
# exists at the same path.
#
# Used by per-pc-type 09-Setup scripts for shopfloor types whose only
# business app is OpenText (common, waxtrace, genspect, heattreat).
# collections + nocollections do NOT auto-start OpenText - their techs
# pick which apps via Configure-PC.ps1.
#
# Source .lnk is created by the OpenText preinstall (Setup-OpenText.ps1)
# on the public desktop. If the .lnk is missing, log a warning and exit
# 0 - imaging chain still continues; auto-start can be re-attempted on a
# subsequent login by re-running this script.
$ErrorActionPreference = 'Continue'
$startupDir = 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp'
$publicDesktop = 'C:\Users\Public\Desktop'
$candidates = @(
Join-Path $publicDesktop 'WJ Shopfloor.lnk'
Join-Path (Join-Path $publicDesktop 'Shopfloor Tools') 'WJ Shopfloor.lnk'
)
$src = $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1
if (-not $src) {
Write-Warning "WJ Shopfloor.lnk not found on public desktop - OpenText auto-start NOT configured."
Write-Warning " Searched: $($candidates -join ' ; ')"
Write-Warning " Setup-OpenText.ps1 should create it during preinstall - check OpenText install state."
return
}
if (-not (Test-Path -LiteralPath $startupDir)) {
New-Item -Path $startupDir -ItemType Directory -Force | Out-Null
}
$dst = Join-Path $startupDir 'WJ Shopfloor.lnk'
try {
Copy-Item -LiteralPath $src -Destination $dst -Force
Write-Host "OpenText auto-start enabled: $dst (source: $src)"
} catch {
Write-Warning "Failed to copy WJ Shopfloor.lnk to startup: $_"
}

View File

@@ -244,11 +244,16 @@ function Update-MachineNumber {
Start-Sleep -Seconds 1 Start-Sleep -Seconds 1
# --- Update UDC settings JSON --- # --- Update UDC settings JSON ---
# -ErrorAction Stop on the WRITE so PermissionDenied / IO errors become
# terminating and actually hit the catch block. Without this, the cmdlet
# writes a non-terminating error (visible in transcript) but flow
# continues + $out.UdcUpdated is set to $true, leading the dialog to
# report "UDC updated" when the file write actually failed.
if (Test-Path $script:UdcSettingsPath) { if (Test-Path $script:UdcSettingsPath) {
try { try {
$json = Get-Content $script:UdcSettingsPath -Raw | ConvertFrom-Json $json = Get-Content $script:UdcSettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json
$json.GeneralSettings.MachineNumber = $NewNumber $json.GeneralSettings.MachineNumber = $NewNumber
$json | ConvertTo-Json -Depth 99 | Set-Content -Path $script:UdcSettingsPath -Encoding UTF8 $json | ConvertTo-Json -Depth 99 | Set-Content -Path $script:UdcSettingsPath -Encoding UTF8 -ErrorAction Stop
$out.UdcUpdated = $true $out.UdcUpdated = $true
} catch { } catch {
$out.Errors += "UDC update failed: $_" $out.Errors += "UDC update failed: $_"
@@ -256,9 +261,15 @@ function Update-MachineNumber {
} }
# --- Update eDNC registry --- # --- Update eDNC registry ---
# Same -ErrorAction Stop reasoning as above. Set-ItemProperty's
# PermissionDenied is non-terminating by default; without -ErrorAction
# Stop, the catch block never fires and $out.EdncUpdated=$true gets set
# despite the write failing. This is the bug that made the 13:35:39
# tech run on FGY07FZ3 report "eDNC updated to 3005 / All updates
# succeeded" while the actual reg value stayed at 9999.
if (Test-Path $script:EdncRegPath) { if (Test-Path $script:EdncRegPath) {
try { try {
Set-ItemProperty -Path $script:EdncRegPath -Name MachineNo -Value $NewNumber -Type String -Force Set-ItemProperty -Path $script:EdncRegPath -Name MachineNo -Value $NewNumber -Type String -Force -ErrorAction Stop
$out.EdncUpdated = $true $out.EdncUpdated = $true
} catch { } catch {
$out.Errors += "eDNC update failed: $_" $out.Errors += "eDNC update failed: $_"

View File

@@ -86,6 +86,39 @@ switch ($stage) {
break break
} }
# Defensive: top up AutoLogonCount so SupportUser keeps auto-logging
# in across any vendor-installer-forced reboots during this stage.
# The unattend XML sets LogonCount=7 at install; typical imaging burns
# through several reboots (Office, Oracle, FormTracePak forced reboot,
# Run-ShopfloorSetup explicit reboot, stage advances) and the unplanned
# FormTracePak reboot can push the counter past 0 - clearing
# AutoAdminLogon and leaving the bay parked at the login screen with
# the dispatcher unable to fire. Set the counter to 10 every time this
# stage runs so the budget is restored. When sync-intune finishes the
# whole pipeline, AutoAdminLogon is left to decrement to 0 naturally;
# by then lockdown's own Autologon.exe has taken over for ShopFloor.
try {
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' `
-Name 'AutoLogonCount' -Value 10 -Type DWord -ErrorAction Stop
Write-Host "Topped up AutoLogonCount to 10 for SupportUser autologon resilience."
} catch {
Write-Warning "Failed to top up AutoLogonCount: $_"
}
# Defensive: re-register RunOnce BEFORE calling Run-ShopfloorSetup.
# Setup chains we don't control (FormTracePak Setup.exe, eDNC MSI,
# any vendor installer that forces an immediate reboot) can cut
# the script off mid-flight. Without this, the dispatcher never
# returns from & $script and the post-call Register-NextRun never
# fires, leaving the next boot with no RunOnce + a stalled image.
# With this defensive register the next boot re-fires the same
# dispatcher, which re-reads the still-'shopfloor-setup' stage
# file, re-runs Run-ShopfloorSetup (every step is idempotent +
# detects already-installed state), and converges. Once
# Run-ShopfloorSetup returns normally we re-register again below
# before advancing to the next stage - cheap, idempotent.
Register-NextRun
# -FromDispatcher bypasses the stage-file gate at the top of # -FromDispatcher bypasses the stage-file gate at the top of
# Run-ShopfloorSetup (which would otherwise see the stage file # Run-ShopfloorSetup (which would otherwise see the stage file
# and exit immediately thinking it should defer to us). # and exit immediately thinking it should defer to us).

View File

@@ -0,0 +1,27 @@
@echo off
REM ==========================================================================
REM Verify-And-Heal-Staging.bat - check every imaging payload arrived on this PC
REM and re-pull whatever is missing from the enrollment share.
REM
REM Usage (run on the PC):
REM Verify-And-Heal-Staging.bat verify + heal anything missing
REM Verify-And-Heal-Staging.bat /verifyonly report only, do not pull
REM ==========================================================================
setlocal EnableDelayedExpansion
net session >nul 2>&1
if %errorlevel% neq 0 (
echo Requesting administrator elevation...
powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'"
exit /b
)
set "PS=%~dp0Verify-And-Heal-Staging.ps1"
set "ARGS="
if /I "%~1"=="/verifyonly" set "ARGS=-VerifyOnly"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" %ARGS%
echo.
echo Exit code: %errorlevel% (0=all present/healed, 1=still missing)
pause
endlocal

View File

@@ -0,0 +1,150 @@
<#
Verify-And-Heal-Staging.ps1
Post-boot check that every payload the imaging flow is supposed to stage onto a
shopfloor PC actually arrived - and re-pull (heal) anything missing from the
enrollment share. Runs in full Windows (reliable network), so it is immune to the
WinPE samba-idle-drop that loses copies during the WIM apply.
Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND
the heavy per-type payload that Fetch-StagingPayload does NOT pull today: the CMM
bundle (C:\CMM-Install) and the selected bay's backup set
(C:\CMM-Install\backups\<cmmid>). That is the one that silently goes missing when
WinPE staging runs out of time before reboot.
Designed to be:
- run manually on a problem PC (Verify-And-Heal-Staging.bat), or
- called from the pre-install phase before 00-PreInstall-MachineApps so a bay is
never left under-provisioned.
Idempotent. Uses robocopy per item, which compares size + timestamp on every
file, so it re-pulls anything MISSING or PARTIAL (e.g. a truncated MSI that
"exists" but is incomplete and would fail to install) and skips files already
complete. Heals use /R:3 /W:5 (resilient), not the WinPE fail-fast /R:1 /W:1.
Share + creds: read from C:\Enrollment\fetch-source.txt (line1=UNC, line2=user,
line3=pass) - same file Fetch-StagingPayload uses - else the defaults below.
Run as administrator. Exit 0 = everything present or healed; 1 = something still
missing after heal attempts (read the table).
#>
param(
[string]$ShareUnc,
[string]$ShareUser,
[string]$SharePass,
[switch]$VerifyOnly # report only, do not heal
)
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$logDir = 'C:\Logs\Fetch'
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $logDir "verify-heal-$ts.log"
function Log($m,$lvl='INFO'){ $line="[$(Get-Date -Format 'HH:mm:ss')] [$lvl] $m"; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue }
# --- share + creds (mirror Fetch-StagingPayload) ---
$defUnc='\\172.16.9.1\enrollment'; $defUser='pxe-upload'; $defPass='pxe'
$srcFile='C:\Enrollment\fetch-source.txt'
if ((-not $ShareUnc) -and (Test-Path -LiteralPath $srcFile)) {
$l=@(Get-Content -LiteralPath $srcFile -EA SilentlyContinue)
if ($l.Count -ge 1 -and $l[0].Trim()) { $ShareUnc=$l[0].Trim() }
if ($l.Count -ge 2 -and $l[1].Trim()) { $ShareUser=$l[1].Trim() }
if ($l.Count -ge 3 -and $l[2].Trim()) { $SharePass=$l[2].Trim() }
}
if (-not $ShareUnc) { $ShareUnc=$defUnc }
if (-not $ShareUser) { $ShareUser=$defUser }
if (-not $SharePass) { $SharePass=$defPass }
# --- identity ---
function ReadTxt($p){ if (Test-Path -LiteralPath $p) { (Get-Content -LiteralPath $p -First 1 -EA 0).Trim() } else { '' } }
$pcType = ReadTxt 'C:\Enrollment\pc-type.txt'
$cmmid = ReadTxt 'C:\Enrollment\cmm\cmmid.txt'
Log "=== Verify-And-Heal-Staging ==="
Log "share=$ShareUnc user=$ShareUser pcType=$(if($pcType){$pcType}else{'(none)'}) cmmid=$(if($cmmid){$cmmid}else{'(none)'}) verifyOnly=$VerifyOnly"
# --- expected payload manifest -------------------------------------------------
# Each: Label, Src (under share), Dst, Mode (File|Dir), Verify (path that must
# exist to count as present), Optional (missing-and-no-source is not a failure),
# Files (for Mode=File), Xd (robocopy /XD dirs to exclude on heal).
$items = New-Object System.Collections.Generic.List[object]
function Add-Item($Label,$Src,$Dst,$Mode,$Verify,$Files=$null,$Optional=$false,$Xd=$null){
$items.Add([pscustomobject]@{Label=$Label;Src=$Src;Dst=$Dst;Mode=$Mode;Verify=$Verify;Files=$Files;Optional=$Optional;Xd=$Xd})
}
$ENR='C:\Enrollment'; $SFD='C:\Enrollment\shopfloor-setup'; $PIN='C:\PreInstall'
Add-Item 'Run-ShopfloorSetup.ps1' 'shopfloor-setup' $ENR 'File' (Join-Path $ENR 'Run-ShopfloorSetup.ps1') @('Run-ShopfloorSetup.ps1')
Add-Item 'Shopfloor baseline' 'shopfloor-setup\Shopfloor' (Join-Path $SFD 'Shopfloor') 'Dir' (Join-Path $SFD 'Shopfloor')
Add-Item 'common' 'shopfloor-setup\common' (Join-Path $SFD 'common') 'Dir' (Join-Path $SFD 'common')
Add-Item '_ntlars-backups' 'shopfloor-setup\_ntlars-backups' (Join-Path $SFD '_ntlars-backups') 'Dir' (Join-Path $SFD '_ntlars-backups') $null $true
if ($pcType) {
Add-Item "type:$pcType" "shopfloor-setup\$pcType" (Join-Path $SFD $pcType) 'Dir' (Join-Path $SFD $pcType)
}
Add-Item 'preinstall.json' 'pre-install' $PIN 'File' (Join-Path $PIN 'preinstall.json') @('preinstall.json')
Add-Item 'preinstall installers' 'pre-install\installers' (Join-Path $PIN 'installers') 'Dir' (Join-Path $PIN 'installers')
Add-Item 'udc-backups' 'pre-install\udc-backups' (Join-Path $PIN 'udc-backups') 'Dir' (Join-Path $PIN 'udc-backups') $null $true
# --- heavy CMM payload (the gap) ---
if ($pcType -eq 'gea-shopfloor-cmm') {
Add-Item 'CMM bundle' 'installers-post\cmm' 'C:\CMM-Install' 'Dir' 'C:\CMM-Install\cmm-manifest.json' $null $false 'backups'
if ($cmmid) {
Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true
}
}
# --- robocopy-based verify/heal -----------------------------------------------
# Presence alone is NOT trusted: a partially transferred file (e.g. a truncated
# MSI) exists but is incomplete and breaks install. Instead robocopy runs per
# item and compares size + timestamp on EVERY file, re-pulling any that are
# missing OR differ (partial/truncated) and skipping ones already complete (a
# cheap metadata scan). So it scans all files, not just checks a folder is
# non-empty. VerifyOnly adds /L (list-only): it reports what WOULD be re-pulled
# without changing anything.
$drive='Z:'; $mounted=$false
function Mount-Share { cmd /c "net use $drive /delete /y >nul 2>&1"; & net use $drive $ShareUnc /user:$ShareUser $SharePass /persistent:no 2>&1 | Out-Null; return ($LASTEXITCODE -eq 0) }
$report = New-Object System.Collections.Generic.List[object]
for ($a=1; $a -le 5 -and -not $mounted; $a++){ if (Mount-Share){$mounted=$true;Log "Mounted $ShareUnc as $drive"} else {Log "mount attempt $a/5 failed - 10s" 'WARN'; Start-Sleep 10} }
if (-not $mounted) {
Log "Could not mount $ShareUnc after 5 attempts - cannot verify/heal. Bay may be under-provisioned; re-run once the share is reachable." 'ERROR'
foreach ($it in $items) { $report.Add([pscustomobject]@{Item=$it.Label;Status='NO-MOUNT'}) }
} else {
foreach ($it in $items) {
$src = Join-Path $drive $it.Src
if (-not (Test-Path -LiteralPath $src)) {
$report.Add([pscustomobject]@{Item=$it.Label;Status=$(if($it.Optional){'ABSENT(opt)'}else{'NO-SOURCE'})})
Log "[$($it.Label)] source not on share ($src)$(if($it.Optional){' - optional'})" $(if($it.Optional){'INFO'}else{'WARN'})
continue
}
if (-not (Test-Path -LiteralPath $it.Dst)) { New-Item -ItemType Directory -Path $it.Dst -Force | Out-Null }
$args=@($src,$it.Dst)
if ($it.Mode -eq 'Dir') { $args+='/E' } else { $args+=$it.Files }
if ($it.Xd) { $args+=@('/XD',(Join-Path $src $it.Xd)) }
$args+=@('/R:3','/W:5','/NFL','/NDL','/NP')
if ($VerifyOnly) { $args+='/L' } # list-only: detect missing/partial, change nothing
$out = & robocopy @args 2>&1
$rc = $LASTEXITCODE
# robocopy exit bits: 1=copied, 2=extra, 4=mismatch, 8+=failure (<8 success).
$copied = (($rc -band 1) -ne 0) -or (($rc -band 4) -ne 0)
$files = ($out | Select-String -Pattern '^\s*Files :' | Select-Object -First 1)
if ($rc -ge 8) { $status='HEAL-FAIL' }
elseif (-not $copied) { $status='COMPLETE' } # in sync, nothing to do
elseif ($VerifyOnly) { $status='INCOMPLETE' } # would re-pull (missing/partial)
else { $status='HEALED' } # actually re-pulled missing/partial
$report.Add([pscustomobject]@{Item=$it.Label;Status=$status})
Log "[$($it.Label)] robocopy rc=$rc -> $status $(("$files").Trim())"
}
cmd /c "net use $drive /delete /y >nul 2>&1"
}
# --- report --------------------------------------------------------------------
Log '================ STAGING VERIFY/HEAL REPORT ================'
foreach ($r in $report) { Log (" {0,-26} {1}" -f $r.Item, $r.Status) }
$bad = @($report | Where-Object { $_.Status -in @('NO-SOURCE','HEAL-FAIL','NO-MOUNT','INCOMPLETE') })
if ($bad.Count -gt 0) {
Log "RESULT: $($bad.Count) item(s) need attention: $(($bad|ForEach-Object{$_.Item+'='+$_.Status}) -join ', ')" 'ERROR'
Log "Log: $log"
exit 1
} else {
Log 'RESULT: all required payloads complete (or healed).'
Log "Log: $log"
exit 0
}

View File

@@ -76,6 +76,40 @@ $pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction Silentl
$pcSubType = if (Test-Path $pcSubTypeFile) { $pcSubType = if (Test-Path $pcSubTypeFile) {
(Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim() (Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
} else { '' } } else { '' }
# Backfill pc-subtype.txt on Keyence PCs imaged before 2026-05 (startnet.cmd
# didn't write pc-subtype.txt for Keyence then). Without a subtype, the share
# manifest's per-model PCTypes gate falls back to installing the default model
# (VR-6000) on top of VR-3000 / VR-5000 boxes. Detect the installed model from
# its uninstall ProductCode and persist the subtype so subsequent GE-Enforce
# cycles + the share manifest gate route correctly.
if ($pcType -ieq 'keyence' -and -not $pcSubType) {
$keyenceProducts = @(
@{ Subtype = 'vr3000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{9CC9A062-2A93-4D3B-AECA-F70C691A46F2}' },
@{ Subtype = 'vr5000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{AF7E8B93-DBEB-4DB1-91CB-4DA592D8E222}' },
@{ Subtype = 'vr6000'; Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{058E7194-BDF8-4FA2-9D69-978BB0F25214}' }
)
foreach ($p in $keyenceProducts) {
if (Test-Path -LiteralPath $p.Path) {
$pcSubType = $p.Subtype
try {
$enrollDir = Split-Path -Parent $pcSubTypeFile
if (-not (Test-Path -LiteralPath $enrollDir)) {
New-Item -Path $enrollDir -ItemType Directory -Force | Out-Null
}
Set-Content -LiteralPath $pcSubTypeFile -Value $pcSubType -Encoding ascii -Force
Write-EnforceLog "Backfilled pc-subtype.txt = $pcSubType from installed product code"
} catch {
Write-EnforceLog "pc-subtype.txt backfill write failed: $_" 'WARN'
}
break
}
}
if (-not $pcSubType) {
Write-EnforceLog "Keyence PC with no pc-subtype.txt and no recognized VR product installed - skipping model-gated apps until imaging populates subtype" 'WARN'
}
}
Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })" Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })"
# --- site-config --- # --- site-config ---

View File

@@ -38,6 +38,12 @@ $ErrorActionPreference = 'Continue'
# logged; manifests tagged with a newer MINOR are fine. # logged; manifests tagged with a newer MINOR are fine.
# #
# Changelog: # Changelog:
# 2.6 - added _CmmVersion filter. Entry tagged _CmmVersion only applies when
# it equals C:\Enrollment\cmm\version.txt (the bay's resolved PC-DMIS
# version, written at imaging from cmm-bay-config.csv). Untagged entries
# always pass; missing/empty version file is a no-op (legacy install-all
# + non-CMM scopes unaffected). Lifted out of 09-Setup-CMM so the gate
# lives in one place both the imaging and enforce paths share.
# 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest # 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest
# entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI # entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI
# successfully but the wrapper process never exits (waits on a # successfully but the wrapper process never exits (waits on a
@@ -58,7 +64,7 @@ $ErrorActionPreference = 'Continue'
# 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types, # 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
# Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter # Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
$LIB_MANIFEST_MAJOR = 2 $LIB_MANIFEST_MAJOR = 2
$LIB_MANIFEST_MINOR = 5 $LIB_MANIFEST_MINOR = 6
$logDir = Split-Path -Parent $LogFile $logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) { if (-not (Test-Path $logDir)) {
@@ -354,9 +360,18 @@ function Invoke-InstallerAction {
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile } return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
} }
'PS1' { 'PS1' {
$scriptPath = Join-Path $InstallerRoot ($App.Script) # Accept either Script or Installer as the relative path, and never
# feed a null into Join-Path/Test-Path (that throws a cryptic
# 'LiteralPath is null'). Log the resolved value so a bad/empty
# entry is obvious in the log instead of crashing the entry.
$rel = if ($App.Script) { $App.Script } elseif ($App.Installer) { $App.Installer } else { $null }
if ([string]::IsNullOrWhiteSpace([string]$rel)) {
Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$scriptPath = Join-Path $InstallerRoot $rel
if (-not (Test-Path -LiteralPath $scriptPath)) { if (-not (Test-Path -LiteralPath $scriptPath)) {
Write-InstallLog " PS1 not found: $scriptPath" 'ERROR' Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null } return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
$psi.FileName = 'powershell.exe' $psi.FileName = 'powershell.exe'
@@ -455,7 +470,8 @@ $script:_pcTypeAliasGroups = @(
@('WaxAndTrace', 'gea-shopfloor-waxtrace'), @('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'), @('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'), @('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat') @('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
) )
# Returns every alias set (each itself a string array) that contains $name. # Returns every alias set (each itself a string array) that contains $name.
@@ -519,29 +535,35 @@ function Test-HostnameMatches {
} }
# Machine-number filter. Stable identifier tied to the bay; survives PC # Machine-number filter. Stable identifier tied to the bay; survives PC
# replacement at the same machine. Source of truth = the value the tech # replacement at the same machine.
# 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 # Source of truth = the eDNC/DNC registry MachineNo. That is what the
# (covers PCs that pre-date this filter being introduced). # reassignment flow (Set-MachineNumber -> Update-MachineNumber) actually
# rewrites when a bay is re-numbered (e.g. 9999 placeholder -> 7501). The
# imaging-time C:\Enrollment\machine-number.txt is written ONCE by startnet.cmd
# at the PXE menu and is NOT updated on reassignment, so it goes stale. Read
# the registry FIRST so TargetMachineNumbers gating follows reassignment; fall
# back to the txt only when the registry has no value (covers non-DNC PCs or a
# bay where eDNC has not populated MachineNo yet).
$script:_cachedMachineNumber = $null $script:_cachedMachineNumber = $null
function Get-CurrentMachineNumber { function Get-CurrentMachineNumber {
if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber } 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 @( foreach ($r in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) { )) {
if (Test-Path $r) { if (Test-Path $r) {
$p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue $p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue
if ($p.MachineNo) { $script:_cachedMachineNumber = [string]$p.MachineNo; return $script:_cachedMachineNumber } if ($p.MachineNo) {
$v = ([string]$p.MachineNo).Trim()
if ($v) { $script:_cachedMachineNumber = $v; return $script:_cachedMachineNumber }
}
}
}
foreach ($p in @('C:\Enrollment\machine-number.txt')) {
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 }
} }
} }
$script:_cachedMachineNumber = '' $script:_cachedMachineNumber = ''
@@ -559,6 +581,42 @@ function Test-MachineNumberMatches {
return $false return $false
} }
# CMM PC-DMIS version filter. The bay's PC-DMIS version (2016/2019/2026) is
# resolved at imaging by resolve-cmm-bay-config.ps1 from cmm-bay-config.csv (the
# single bay -> version map) and persisted to C:\Enrollment\cmm\version.txt. An
# entry tagged _CmmVersion applies only when it equals that file; untagged
# entries (CLM, goCMM, Protect Viewer, DODA, the PDF converter) always pass.
# When the file is absent/empty - a bay imaged before the picker, or any
# non-CMM PC running a different scope - the filter is a no-op so every tagged
# entry passes. That preserves the legacy "install all versions" behavior for
# pre-picker bays and leaves non-CMM scopes untouched.
#
# This is the SINGLE place the version gate lives. Both the imaging path
# (09-Setup-CMM) and the runtime path (GE-Enforce) call this lib, so the gate
# cannot apply in one path and not the other. The 2016-installed-on-a-2019-bay
# bug was exactly that drift: the imaging path filtered by _CmmVersion but the
# enforce path did not, so enforce reinstalled every version it did not detect.
$script:_cachedCmmVersion = $null
$script:_cmmVersionRead = $false
function Get-CurrentCmmVersion {
if ($script:_cmmVersionRead) { return $script:_cachedCmmVersion }
$script:_cmmVersionRead = $true
$f = 'C:\Enrollment\cmm\version.txt'
if (Test-Path -LiteralPath $f) {
$v = (Get-Content -LiteralPath $f -First 1 -ErrorAction SilentlyContinue)
if ($v) { $script:_cachedCmmVersion = $v.Trim() }
}
return $script:_cachedCmmVersion
}
function Test-CmmVersionMatches {
param($App)
if (-not $App._CmmVersion) { return $true } # untagged entry always applies
$myVer = Get-CurrentCmmVersion
if (-not $myVer) { return $true } # no resolved version -> legacy install-all
return ([string]$App._CmmVersion -ieq $myVer)
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main loop # Main loop
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -574,6 +632,11 @@ foreach ($app in $config.Applications) {
Write-InstallLog "==> $($app.Name)" Write-InstallLog "==> $($app.Name)"
# Per-entry guard: a single entry that throws must NOT abort the whole
# scope (and silently skip every later entry + the status write). Catch,
# log, count as failed, move on.
try {
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) { 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" Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++ $pcFiltered++
@@ -593,6 +656,13 @@ foreach ($app in $config.Applications) {
continue continue
} }
if (-not (Test-CmmVersionMatches -App $app)) {
$myVer = Get-CurrentCmmVersion
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
$pcFiltered++
continue
}
if (Test-AppInstalled -App $app) { if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping' Write-InstallLog ' Already installed at expected version - skipping'
$skipped++ $skipped++
@@ -680,6 +750,11 @@ foreach ($app in $config.Applications) {
$failed++ $failed++
} }
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
}
} }
Write-InstallLog '============================================' Write-InstallLog '============================================'

View File

@@ -0,0 +1,127 @@
# Deploy-ShopfloorStartLayout.ps1
#
# Local-DSC port of the Intune SFLD desktop/Start-menu deployment. Creates the
# Public Desktop weblinks (.url) + app/folder shortcuts (.lnk) AND pins them to
# the Windows 11 Start menu - using the exact same mechanism Simple-Install.ps1
# uses: shortcuts in the All-Users Start Menu, a ConfigureStartPins JSON policy
# in the registry, and a StartMenuExperienceHost reset so it applies on next
# logon. Nothing here needs Intune/MDM - it is all file + registry-policy.
#
# Designed to run from the GE-Enforce manifest engine as a Type=PS1 entry
# (DetectionMethod=Always, or Hash on the pins.json). Idempotent.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File Deploy-ShopfloorStartLayout.ps1
# -AssetsDir <globalassets dir on the share>
#
# AssetsDir holds the prebuilt .url/.lnk (the "globalassets" folder). The pin
# list + order below mirrors device-config.yaml StartMenuPins; entries with a
# Target are created on the fly (app/folder pins), the rest are copied from
# AssetsDir.
param(
[string]$AssetsDir = (Join-Path $PSScriptRoot 'globalassets'),
[string]$DesktopDir = 'C:\Users\Public\Desktop',
[switch]$NoShellRestart
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
New-Item -ItemType Directory -Path $logDir -Force -EA SilentlyContinue | Out-Null
$log = Join-Path $logDir ('start-layout-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log($m){ "$([DateTime]::Now.ToString('s')) $m" | Tee-Object -FilePath $log -Append | Out-Null }
# Ordered pin set - mirrors device-config.yaml StartMenuPins. Name = the file
# in the All-Users Start Menu (and AssetsDir for prebuilt ones). Target set =>
# create the shortcut; Target empty => copy the prebuilt file from AssetsDir.
$Pins = @(
@{ Name = 'Shopfloor Dashboard.url' }
@{ Name = 'PN & SN Label Printing.url' }
@{ Name = 'WJ Shop Floor Homepage.url' }
@{ Name = 'WJ Web Reports.url' }
@{ Name = 'Blueprint PDF Viewer.url' }
@{ Name = 'Central CSF Web Reports.url' }
@{ Name = 'Plant Apps.url' }
@{ Name = 'Safety Good Catch Form.url' }
@{ Name = 'WJ IT Help Desk.url' }
@{ Name = 'OneIDM.url' }
@{ Name = 'M365 Webmail.url' }
@{ Name = 'HR Central.url' }
@{ Name = 'Defect_Tracker.lnk' }
@{ Name = 'Calculator.lnk' }
@{ Name = 'Notepad.lnk' }
@{ Name = 'eDNC.lnk'; Target = 'C:\Program Files\eDNC\eDNC.exe' }
@{ Name = 'NTLARS.lnk'; Target = 'C:\Program Files (x86)\NTLARS\NTLARS.exe' }
@{ Name = 'Shopfloor Tools.lnk'; Target = 'C:\Users\Public\Desktop\Shopfloor Tools' }
)
$startMenuDir = Join-Path $env:ALLUSERSPROFILE 'Microsoft\Windows\Start Menu\Programs'
function New-UrlShortcut([string]$Path,[string]$Url){
@('[InternetShortcut]', "URL=$Url") | Set-Content -LiteralPath $Path -Encoding ASCII
}
function New-LnkShortcut([string]$Path,[string]$Target,[string]$Args,[string]$Icon){
$sh = New-Object -ComObject WScript.Shell
$sc = $sh.CreateShortcut($Path)
$sc.TargetPath = $Target
if ($Args) { $sc.Arguments = $Args }
# working dir: parent of target for files, the folder itself for folder pins
$sc.WorkingDirectory = if (Test-Path -LiteralPath $Target -PathType Container) { $Target } else { Split-Path -Parent $Target }
if ($Icon) { $sc.IconLocation = $Icon }
$sc.Save()
}
Log "=== Deploy shopfloor start layout (assets: $AssetsDir) ==="
New-Item -ItemType Directory -Path $startMenuDir -Force -EA SilentlyContinue | Out-Null
New-Item -ItemType Directory -Path $DesktopDir -Force -EA SilentlyContinue | Out-Null
$pinnedList = @()
foreach ($pin in $Pins) {
$leaf = $pin.Name
$dst = Join-Path $startMenuDir $leaf
try {
if ($pin.Target) {
# create app/folder shortcut from Target
New-LnkShortcut -Path $dst -Target $pin.Target -Args $pin.Arguments -Icon $pin.IconLocation
Log "created (target) $leaf -> $($pin.Target)"
} else {
# copy prebuilt asset (.url/.lnk) from globalassets
$src = Join-Path $AssetsDir $leaf
if (-not (Test-Path -LiteralPath $src)) { Log "MISSING asset, skipping pin: $src"; continue }
Copy-Item -LiteralPath $src -Destination $dst -Force
# also drop on the Public Desktop
Copy-Item -LiteralPath $src -Destination (Join-Path $DesktopDir $leaf) -Force
Log "copied $leaf (start menu + desktop)"
}
$pinnedList += @{ desktopAppLink = "%ALLUSERSPROFILE%\Microsoft\Windows\Start Menu\Programs\$leaf" }
} catch {
Log "ERROR pin ${leaf}: $($_.Exception.Message)"
}
}
# ConfigureStartPins JSON -> HKLM policy (same shape Simple-Install.ps1 writes)
$jsonDir = 'C:\ProgramData\SFLD\StartMenu'
New-Item -ItemType Directory -Path $jsonDir -Force -EA SilentlyContinue | Out-Null
$jsonPath = Join-Path $jsonDir 'pins.json'
([ordered]@{ applyOnce = $false; pinnedList = $pinnedList } | ConvertTo-Json -Depth 6) |
Set-Content -LiteralPath $jsonPath -Encoding UTF8
$reg = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Explorer'
if (-not (Test-Path $reg)) { New-Item -Path $reg -Force | Out-Null }
New-ItemProperty -Path $reg -Name 'ConfigureStartPins' -PropertyType String `
-Value (Get-Content -LiteralPath $jsonPath -Raw -Encoding UTF8) -Force | Out-Null
Log "ConfigureStartPins policy written ($($pinnedList.Count) pins) -> $reg"
# Apply now: clear each real user's cached start layout + restart the shell.
if (-not $NoShellRestart) {
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue |
Where-Object { $_.Name -notin @('Public','Default','Default User','All Users') } |
ForEach-Object {
$sb = Join-Path $_.FullName 'AppData\Local\Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\LocalState\start2.bin'
if (Test-Path -LiteralPath $sb) { Remove-Item -LiteralPath $sb -Force -EA SilentlyContinue; Log "cleared start2.bin: $($_.Name)" }
}
Get-Process -Name 'StartMenuExperienceHost' -EA SilentlyContinue | Stop-Process -Force -EA SilentlyContinue
Log 'StartMenuExperienceHost restarted (pins apply on next shell load)'
}
Log '=== done ==='
exit 0

View File

@@ -7,7 +7,7 @@
{ "name": "Adobe Acrobat Reader DC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{AC76BA86-7AD7-1033-7B44-AC0F074E4100}", "name": "DisplayVersion", "value": "25.001.20531" } }, { "name": "Adobe Acrobat Reader DC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{AC76BA86-7AD7-1033-7B44-AC0F074E4100}", "name": "DisplayVersion", "value": "25.001.20531" } },
{ "name": "WJF Defect Tracker", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{CC1B4D32-1606-4A3F-8F24-31312F723D5C}", "name": "DisplayVersion", "value": "01.00.0102" } }, { "name": "WJF Defect Tracker", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{CC1B4D32-1606-4A3F-8F24-31312F723D5C}", "name": "DisplayVersion", "value": "01.00.0102" } },
{ "name": "3OF9 barcode font", "verify": { "method": "File", "path": "C:\\Windows\\Fonts\\3OF9.ttf" } }, { "name": "3OF9 barcode font", "verify": { "method": "File", "path": "C:\\Windows\\Fonts\\3OF9.ttf" } },
{ "name": "Edge IE-Mode site list", "verify": { "method": "Hash", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "value": "16F2A6E45EFA19ED7B1C54B264D6B33597678D3A5303255BC7CEB7E8510C60FC" } } { "name": "Edge IE-Mode site list", "verify": { "method": "Hash", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "value": "E13073B2D89E120560AF638F08519E94CC1DC880FFEF5D6A4C7011430E21E4EA" } }
], ],
"fmsResolver": [ "fmsResolver": [
{ "name": "FMS hosts pin", "verify": { "method": "FileGrep", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "10\\.233\\.112\\.158\\s+wjfms3\\.ae\\.ge\\.com" } } { "name": "FMS hosts pin", "verify": { "method": "FileGrep", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "10\\.233\\.112\\.158\\s+wjfms3\\.ae\\.ge\\.com" } }

View File

@@ -119,8 +119,20 @@ elseif (-not (Test-Path $libSource)) {
Write-CMMLog "Shared library not found at $libSource" "ERROR" Write-CMMLog "Shared library not found at $libSource" "ERROR"
} }
else { else {
Write-CMMLog "Running Install-FromManifest against $stagingRoot" $pcType = ''
& $libSource -ManifestPath $stagingMani -InstallerRoot $stagingRoot -LogFile $logFile $pcSubType = ''
if (Test-Path 'C:\Enrollment\pc-type.txt') { $pcType = (Get-Content 'C:\Enrollment\pc-type.txt' -First 1 -EA 0).Trim() }
if (Test-Path 'C:\Enrollment\pc-subtype.txt') { $pcSubType = (Get-Content 'C:\Enrollment\pc-subtype.txt' -First 1 -EA 0).Trim() }
# PC-DMIS version gating (drop entries whose _CmmVersion does not match the
# bay's C:\Enrollment\cmm\version.txt) is now owned by the shared lib
# Install-FromManifest.ps1 (>= 2.6). Both this imaging path and the runtime
# GE-Enforce path call that lib, so the gate is applied identically in one
# place and the two paths cannot drift. We pass the full manifest unfiltered
# and let the lib filter per entry. The bug this prevents: enforce lacked
# the gate and reinstalled the wrong PC-DMIS version on an already-imaged bay.
Write-CMMLog "Running Install-FromManifest against $stagingRoot (PCType=$pcType, PCSubType=$pcSubType)"
& $libSource -ManifestPath $stagingMani -InstallerRoot $stagingRoot -LogFile $logFile -PCType $pcType -PCSubType $pcSubType
$rc = $LASTEXITCODE $rc = $LASTEXITCODE
Write-CMMLog "Install-FromManifest returned $rc" Write-CMMLog "Install-FromManifest returned $rc"
} }
@@ -131,15 +143,15 @@ else {
# PC-DMIS writes settings, probe configs, and measurement data to its own # PC-DMIS writes settings, probe configs, and measurement data to its own
# install directory at runtime. Without Modify permission for BUILTIN\Users, # install directory at runtime. Without Modify permission for BUILTIN\Users,
# non-admin accounts get a UAC elevation prompt on every launch. Granting # non-admin accounts get a UAC elevation prompt on every launch. Granting
# the ACL here is the Hexagon-documented approach for non-admin deployment # the ACL here is the Hexagon-documented approach for non-admin deployment.
# and avoids the need for a first-run-as-admin (which hits a license dialog # Step 2.6 below handles the required first-run-as-admin initialization.
# and can't be automated silently).
$pcdmisDirs = @( $pcdmisDirs = @(
'C:\Program Files\Hexagon\PC-DMIS 2016.0 64-bit', 'C:\Program Files\Hexagon\PC-DMIS 2016.0 64-bit',
'C:\Program Files\Hexagon\PC-DMIS 2019 R2 64-bit', 'C:\Program Files\Hexagon\PC-DMIS 2019 R2 64-bit',
'C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit',
'C:\ProgramData\Hexagon', 'C:\ProgramData\Hexagon',
'C:\Program Files (x86)\General Electric\goCMM', 'C:\Program Files (x86)\General Electric\goCMM',
'C:\Program Files\DODA' 'C:\Apps\DODA'
) )
foreach ($dir in $pcdmisDirs) { foreach ($dir in $pcdmisDirs) {
if (-not (Test-Path -LiteralPath $dir)) { if (-not (Test-Path -LiteralPath $dir)) {
@@ -164,18 +176,191 @@ foreach ($dir in $pcdmisDirs) {
} }
# ============================================================================ # ============================================================================
# Step 3: Clean up the bootstrap staging dir # Step 2.6: First-run-as-admin for each installed PC-DMIS version
# ============================================================================ # ============================================================================
# ~2 GB reclaimed. From here on, GE-Enforce takes over from the tsgwp00525 # PC-DMIS performs one-time initialization on first launch (COM registration,
# share for ongoing updates. # config file creation, internal setup). This must happen with admin rights
if (Test-Path $stagingRoot) { # before the PPKG locks the machine down. Launch each installed version,
Write-CMMLog "Deleting bootstrap staging at $stagingRoot" # wait for it to initialize, then kill it.
$pcdmisExes = @(
'C:\Program Files\Hexagon\PC-DMIS 2016.0 64-bit\PCDLRN.exe',
'C:\Program Files\Hexagon\PC-DMIS 2019 R2 64-bit\PCDLRN.exe',
'C:\Program Files\Hexagon\PC-DMIS 2026.1 64-bit\PCDLRN.exe'
)
foreach ($exe in $pcdmisExes) {
if (-not (Test-Path -LiteralPath $exe)) { continue }
$ver = Split-Path (Split-Path $exe -Parent) -Leaf
Write-CMMLog "First-run init: launching $ver"
try {
$proc = Start-Process -FilePath $exe -PassThru -ErrorAction Stop
$initTimeout = 45
Write-CMMLog " PID $($proc.Id) started, waiting ${initTimeout}s for initialization..."
Start-Sleep -Seconds $initTimeout
if (-not $proc.HasExited) {
$proc.Kill()
$proc.WaitForExit(10000)
Write-CMMLog " Killed after ${initTimeout}s (first-run init complete)"
} else {
Write-CMMLog " Exited on its own (exit $($proc.ExitCode))"
}
} catch {
Write-CMMLog " First-run launch failed: $_" 'WARN'
}
}
# ============================================================================
# Step 2.7: Seed goCMM registry path values + grant Users write on the key
# ============================================================================
# goCMM (.NET x86 WPF app) stores its config in the registry at
# HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM (32-bit MSI / 32-bit
# process, so install seed and runtime reads both land in the WOW6432Node
# view). A capture from a working CMM4 bay shows the two values that matter:
# Shared Data Directory = C:\geaofi\ (constant)
# Selected Part Group = \\tsgwp00525.wjs.geaerospace.net\SHARED\CMM\CMM4\Spool (per-bay UNC)
# i.e. the PER-BAY program path is "Selected Part Group" (a UNC to the
# tsgwp00525 SHARED share), and "Shared Data Directory" is the constant local
# C:\geaofi\. Both live in HKLM, so a non-admin shopfloor user cannot set
# them (nor save a part-group switch) without elevation. So in admin context
# we: seed both values, and grant BUILTIN\Users write on the key so runtime
# switches succeed without UAC. Mirrors Step 2.5 (install-dir ACL grant).
$goCmmKey = 'HKLM:\SOFTWARE\WOW6432Node\General Electric\goCMM'
# Constant local data dir on every bay.
$goCmmDataDir = 'C:\geaofi\'
# Host that S: maps to. Selected Part Group is stored as a UNC to this host's
# SHARED share. Kept in one place so a domain/host change is a one-line edit.
$partGroupShareRoot = '\\tsgwp00525.wjs.geaerospace.net\SHARED'
# Per-bay part group, resolved by resolve-cmm-bay-config.ps1 into
# C:\Enrollment\cmm\partgroup.txt as a friendly S:\... path. Convert the S:
# drive prefix to the UNC share root. Get-Content + Trim keeps internal spaces
# (e.g. CMM8 "Venture CMM8"); the value is passed as a single -Value arg,
# never through a command line, so the space cannot split the path.
$partGroup = ''
$pgFile = 'C:\Enrollment\cmm\partgroup.txt'
if (Test-Path -LiteralPath $pgFile) {
$raw = (Get-Content -LiteralPath $pgFile -First 1 -EA 0).Trim()
if ($raw) {
# ^S:\ -> \\host\SHARED\ (case-insensitive). Leave non-S: values as-is.
$partGroup = $raw -replace '(?i)^S:\\', "$partGroupShareRoot\"
}
}
if (-not (Test-Path $goCmmKey)) {
Write-CMMLog "goCMM key absent ($goCmmKey) - goCMM not installed or install failed; creating key so the seed/ACL still land" 'WARN'
try { New-Item -Path $goCmmKey -Force | Out-Null } catch { Write-CMMLog "Could not create $goCmmKey : $_" 'WARN' }
}
# Shared Data Directory (constant)
try {
New-ItemProperty -Path $goCmmKey -Name 'Shared Data Directory' -Value $goCmmDataDir -PropertyType String -Force | Out-Null
Write-CMMLog "Set goCMM 'Shared Data Directory' = $goCmmDataDir"
} catch {
Write-CMMLog "Failed to set goCMM 'Shared Data Directory': $_" 'WARN'
}
# Selected Part Group (per-bay UNC)
if ($partGroup) {
try {
New-ItemProperty -Path $goCmmKey -Name 'Selected Part Group' -Value $partGroup -PropertyType String -Force | Out-Null
Write-CMMLog "Set goCMM 'Selected Part Group' = $partGroup"
} catch {
Write-CMMLog "Failed to set goCMM 'Selected Part Group': $_" 'WARN'
}
} else {
Write-CMMLog "No partgroup.txt (bay not in bay-config, or manual CMM ID) - leaving 'Selected Part Group' unset" 'WARN'
}
# Grant BUILTIN\Users ReadKey+WriteKey (WriteKey = SetValue + CreateSubKey).
# Registry ACEs use ContainerInherit only (no leaf objects in the registry).
if (Test-Path $goCmmKey) {
try {
$racl = Get-Acl -Path $goCmmKey
$rrule = New-Object System.Security.AccessControl.RegistryAccessRule(
'BUILTIN\Users',
'ReadKey,WriteKey',
'ContainerInherit',
'None',
'Allow'
)
$racl.AddAccessRule($rrule)
Set-Acl -Path $goCmmKey -AclObject $racl -ErrorAction Stop
Write-CMMLog "Granted BUILTIN\Users write on $goCmmKey"
} catch {
Write-CMMLog "Failed to set ACL on $goCmmKey : $_" 'WARN'
}
}
# ============================================================================
# Step 2.8: Restore this bay's PC-DMIS + goCMM settings from its backup
# ============================================================================
# Runs AFTER app install + first-run-init (so a restored config is not clobbered
# by PC-DMIS's first-launch defaults) and BEFORE the Step 3 cleanup (the backup
# set lives under $stagingRoot\backups\<cmmid>, deleted by cleanup). Restore-CMM
# self-gates: it skips DODA bays and bays with no staged backup, and restores
# ONLY the config-version PC-DMIS zip. Same-bay restore -> no CommPort clobber.
# Best-effort: Restore-CMM always exits 0, so imaging never fails on a restore.
$restoreScript = Join-Path $PSScriptRoot 'scripts\Restore-CMM.ps1'
if (Test-Path -LiteralPath $restoreScript) {
Write-CMMLog "Running per-bay settings restore (Restore-CMM.ps1)"
try {
& $restoreScript -BackupRoot (Join-Path $stagingRoot 'backups') *>&1 | ForEach-Object { Write-CMMLog " $_" }
Write-CMMLog "Restore-CMM returned $LASTEXITCODE"
} catch {
Write-CMMLog "Restore-CMM threw (non-fatal): $_" 'WARN'
}
} else {
Write-CMMLog "Restore-CMM.ps1 not found at $restoreScript - skipping settings restore" 'WARN'
}
# ============================================================================
# Step 3: Conditional cleanup of the bootstrap staging dir
# ============================================================================
# Only delete C:\CMM-Install when EVERY manifest entry detected as installed.
# A vendor installer that forces an unplanned mid-install reboot would
# otherwise leave us with no recovery path on the self-resumed re-run
# (Run-ShopfloorSetup's new RunOnce would fire, but Step 2 would log
# "$stagingRoot does not exist" and bail). Leaving the staging dir in
# place until the manifest fully converges means a re-fire just re-runs
# the partial installs and completes.
$allDetected = $true
if (Test-Path $stagingMani) {
try {
$cfg = Get-Content $stagingMani -Raw | ConvertFrom-Json
foreach ($app in $cfg.Applications) {
if (-not $app.DetectionMethod -or -not $app.DetectionPath) { continue }
# Honor PCTypes filter when checking detection.
if ($app.PCTypes -and $app.PCTypes.Count -gt 0) {
$myNames = @($pcType)
if ($pcSubType) { $myNames += "$pcType-$pcSubType" }
$match = $false
foreach ($t in $app.PCTypes) { if ($myNames -contains $t) { $match = $true; break } }
if (-not $match) { continue } # not applicable to this PC, skip detection
}
if (-not (Test-Path $app.DetectionPath)) { $allDetected = $false; Write-CMMLog "Not installed: $($app.Name)"; break }
if ($app.DetectionName) {
$val = (Get-ItemProperty -Path $app.DetectionPath -Name $app.DetectionName -EA 0).$($app.DetectionName)
if (-not $val) { $allDetected = $false; Write-CMMLog "Not installed (no value): $($app.Name)"; break }
if ($app.DetectionValue -and $val -ne $app.DetectionValue) { $allDetected = $false; Write-CMMLog "Wrong version: $($app.Name) got $val expected $($app.DetectionValue)"; break }
}
}
} catch {
Write-CMMLog "Could not parse manifest for cleanup-gate check: $_" 'WARN'
$allDetected = $false
}
}
if ($allDetected -and (Test-Path $stagingRoot)) {
Write-CMMLog "All manifest entries installed. Deleting bootstrap staging at $stagingRoot"
try { try {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop
Write-CMMLog "Bootstrap cleanup complete" Write-CMMLog "Bootstrap cleanup complete"
} catch { } catch {
Write-CMMLog "Failed to delete $stagingRoot : $_" "WARN" Write-CMMLog "Failed to delete $stagingRoot : $_" "WARN"
} }
} elseif (Test-Path $stagingRoot) {
Write-CMMLog "Bootstrap staging retained at $stagingRoot (not all entries installed yet - will retry on next self-resumed run)"
} }
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) { if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {

View File

@@ -0,0 +1,48 @@
# Install-DODA.ps1 - Extract DODA zip to C:\Apps\DODA\.
#
# Called by Install-FromManifest as a Type=PS1 entry. The zip is staged
# alongside this script in C:\CMM-Install\ by startnet.cmd.
$ErrorActionPreference = 'Continue'
$installDir = 'C:\Apps\DODA'
$zipPattern = 'doda_build*.zip'
$stagingRoot = Split-Path $PSScriptRoot -ErrorAction SilentlyContinue
if (-not $stagingRoot) { $stagingRoot = 'C:\CMM-Install' }
$zip = Get-ChildItem -Path $stagingRoot -Filter $zipPattern -File -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $zip) {
Write-Host "DODA zip not found in $stagingRoot (pattern: $zipPattern)"
exit 1
}
if (-not (Test-Path $installDir)) {
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
}
Write-Host "Extracting $($zip.Name) to $installDir..."
try {
Expand-Archive -LiteralPath $zip.FullName -DestinationPath $installDir -Force -ErrorAction Stop
Write-Host "DODA extracted to $installDir"
} catch {
Write-Host "ERROR: Extract failed - $_"
exit 1
}
# MergeFiles.exe (cmm-utilities toolchain) reads C:\Apps\DODA\PreProcess\ as
# its working dir. The DODA zip extracts flat without it, so create it here -
# a missing PreProcess dir is the known cause of MergeFiles.GetDoDAFolder
# throwing DirectoryNotFoundException (see cmm-utilities dotNET event.txt).
$preProcess = Join-Path $installDir 'PreProcess'
if (-not (Test-Path $preProcess)) {
New-Item -Path $preProcess -ItemType Directory -Force | Out-Null
Write-Host "Created $preProcess"
}
if (Test-Path (Join-Path $installDir 'DovetailAnalysis.exe')) {
Write-Host "DovetailAnalysis.exe verified present"
exit 0
} else {
Write-Host "ERROR: DovetailAnalysis.exe not found after extract"
exit 1
}

View File

@@ -0,0 +1,141 @@
<#
Install-PCDMISPDFConverter.ps1
Installs the PC-DMIS PDF converter (Amyuni Document Converter 500, printer name
"PC-DMIS 50 Converter").
Why this exists: our CMM image installs PC-DMIS from a patched STANDALONE MSI,
bypassing Hexagon's Burn bundle. The Amyuni PDF converter is NOT a custom action
in the main MSI (INSTALLPDFCONVERTER is a bundle property the MSI never reads).
The bundle would have run the Amyuni install as a separate chained step - which
we skip. The MSI does lay the installer down on disk at:
C:\Program Files\Hexagon\PC-DMIS <ver> 64-bit\PDFDriverInstallFiles\BatFileInstallPDF50.zip
but nothing ever executes it. This script does.
The zip ships InstallPDF50.exe + the Amyuni driver (amyuni.inf, acfpdf*.dll,
cdintf*.dll, atpdf500.cat) + InstallPDF50.bat. We do NOT run the .bat (it ends in
`pause` and hangs under /qn) - we parse its InstallPDF50.exe invocation (printer
name + Wilcox licensee + license code) and run that directly from the extracted
folder so the sibling DLLs resolve.
The converter is ONE system printer shared by every PC-DMIS version, so we install
from the first PDFDriverInstallFiles we find and stop once the printer exists.
Idempotent: if the "PC-DMIS 50 Converter" printer already exists, exits 0 without
reinstalling. Run as administrator / SYSTEM (driver install needs it).
Exit: 0 = printer present (installed or already there), 1 = failed.
#>
param(
[string]$PrinterName = 'PC-DMIS 50 Converter',
[string]$OutDir = 'C:\Logs\CMM'
)
$ErrorActionPreference = 'Continue'
New-Item -ItemType Directory -Path $OutDir -Force -ErrorAction SilentlyContinue | Out-Null
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$log = Join-Path $OutDir "pdfconverter-$ts.log"
function Log($m){ $line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $m"; Write-Host $line; $line | Out-File -FilePath $log -Append -Encoding ascii }
function Test-ConverterPresent {
# Get-Printer is the authoritative check. Fall back to the printer-name key
# in the registry for old hosts where the Printing cmdlets are absent.
try { if (Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue) { return $true } } catch {}
$k = 'HKLM:\SYSTEM\CurrentControlSet\Control\Print\Printers\' + $PrinterName
return (Test-Path $k)
}
Log "==== PC-DMIS PDF converter install on $env:COMPUTERNAME ===="
if (Test-ConverterPresent) {
Log "Printer '$PrinterName' already present - nothing to do."
exit 0
}
# Find the Amyuni installer the MSI laid down. PC-DMIS 2016 (vendor Wai) and
# 2019/2026 (vendor Hexagon) all install under Program Files\Hexagon, but scan
# both Hexagon and Wai trees to be safe.
$zips = @()
foreach ($root in @("$env:ProgramFiles\Hexagon","$env:ProgramFiles\Wai","${env:ProgramFiles(x86)}\Hexagon","${env:ProgramFiles(x86)}\Wai")) {
if (-not (Test-Path $root)) { continue }
$zips += Get-ChildItem -Path $root -Recurse -Filter 'BatFileInstallPDF50.zip' -ErrorAction SilentlyContinue
}
$zips = $zips | Sort-Object FullName -Unique
if (-not $zips) {
Log "ERROR: no BatFileInstallPDF50.zip found under any PC-DMIS install dir."
Log " PC-DMIS may not be installed yet, or PDFDriverInstallFiles is missing."
exit 1
}
Log ("Found {0} Amyuni installer zip(s):" -f $zips.Count)
$zips | ForEach-Object { Log " $($_.FullName)" }
Add-Type -AssemblyName System.IO.Compression.FileSystem
foreach ($zip in $zips) {
$stage = Join-Path $env:TEMP ("amyuni-pdf-" + $ts + "-" + [Guid]::NewGuid().ToString('N').Substring(0,6))
try {
New-Item -ItemType Directory -Path $stage -Force | Out-Null
[System.IO.Compression.ZipFile]::ExtractToDirectory($zip.FullName, $stage)
Log "Extracted $($zip.Name) -> $stage"
$exe = Join-Path $stage 'InstallPDF50.exe'
$bat = Join-Path $stage 'InstallPDF50.bat'
if (-not (Test-Path $exe)) { Log " no InstallPDF50.exe in zip - skipping"; continue }
# Parse InstallPDF50.bat for the exact InstallPDF50.exe arguments (printer
# name + Wilcox licensee + license code). License code can differ per
# version, so read it rather than hardcode. Strip the leading exe token.
$args = $null
if (Test-Path $bat) {
$cmd = (Get-Content $bat | Where-Object { $_ -match 'InstallPDF50\.exe' } | Select-Object -First 1)
if ($cmd) { $args = ($cmd -replace '(?i)^\s*[^"]*InstallPDF50\.exe\s*','').Trim() }
}
if (-not $args) {
# Fallback to the known-good invocation if the bat is missing/odd.
$args = '"' + $PrinterName + '" -n "Wilcox Associates, Inc."'
Log " WARN: could not parse args from bat - using fallback (no license code): $args"
} else {
Log " parsed install args from bat"
}
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $exe
$psi.Arguments = $args
$psi.WorkingDirectory = $stage # sibling DLLs (cdintf64.dll, acfpdf*, amyuni.inf, atpdf500.cat) must resolve
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
Log " running: InstallPDF50.exe $args (cwd=$stage)"
$proc = [System.Diagnostics.Process]::Start($psi)
# InstallPDF50.exe creates the printer in a few seconds but then hangs
# (does not self-exit, same as Hexagon's Burn bundle). Poll for the
# printer instead of blocking; once it appears, kill the hung exe and
# move on. Hard cap at 90s as a backstop for a genuinely stuck install.
$deadline = (Get-Date).AddSeconds(90)
$appeared = $false
while ((Get-Date) -lt $deadline) {
if ($proc.HasExited) { Log " InstallPDF50.exe exited on its own (code $($proc.ExitCode))"; break }
if (Test-ConverterPresent) { $appeared = $true; break }
Start-Sleep -Seconds 2
}
if (-not $proc.HasExited) {
Log (" printer {0} - killing InstallPDF50.exe (it does not self-exit)" -f $(if ($appeared) { 'present' } else { 'NOT present after 90s' }))
try { $proc.Kill() } catch {}
}
Start-Sleep -Seconds 2
if (Test-ConverterPresent) {
Log "SUCCESS: printer '$PrinterName' is now present."
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
exit 0
}
Log " printer not present after this attempt - trying next zip if any."
} catch {
Log " ERROR during install from $($zip.Name): $($_.Exception.Message)"
} finally {
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
}
}
if (Test-ConverterPresent) { Log "Printer '$PrinterName' present."; exit 0 }
Log "ERROR: printer '$PrinterName' still not present after all attempts."
exit 1

View File

@@ -0,0 +1,13 @@
cmm_id,pcdmis_version,doda,part_group
CMM1,2019,no,S:\CMM\CMM1\HPTCMM1
CMM2,2019,no,S:\CMM\CMM2\HPT
CMM3,2019,no,S:\CMM\CMM3\VENTURE_CMM3
CMM4,2016,no,S:\CMM\CMM4\Spool
CMM5,2019,no,S:\CMM\CMM5\BLISKCMM5
CMM6,2019,no,S:\CMM\CMM6\BLISKCMM6
CMM7,2019,no,S:\CMM\CMM7\VENTURE_CMM7
CMM8,2019,no,S:\CMM\CMM8\Venture CMM8
CMM9,2019,no,S:\CMM\CMM9\BLISKCMM9
CMM10,2016,no,S:\CMM\CMM10\Spool
CMM11,2026,no,S:\CMM\CMM11\Spool
CMM12,2026,no,S:\CMM\CMM12\Spool
1 cmm_id pcdmis_version doda part_group
2 CMM1 2019 no S:\CMM\CMM1\HPTCMM1
3 CMM2 2019 no S:\CMM\CMM2\HPT
4 CMM3 2019 no S:\CMM\CMM3\VENTURE_CMM3
5 CMM4 2016 no S:\CMM\CMM4\Spool
6 CMM5 2019 no S:\CMM\CMM5\BLISKCMM5
7 CMM6 2019 no S:\CMM\CMM6\BLISKCMM6
8 CMM7 2019 no S:\CMM\CMM7\VENTURE_CMM7
9 CMM8 2019 no S:\CMM\CMM8\Venture CMM8
10 CMM9 2019 no S:\CMM\CMM9\BLISKCMM9
11 CMM10 2016 no S:\CMM\CMM10\Spool
12 CMM11 2026 no S:\CMM\CMM11\Spool
13 CMM12 2026 no S:\CMM\CMM12\Spool

View File

@@ -1,31 +1,44 @@
{ {
"Version": "2.0", "Version": "2.0",
"_comment": "CMM machine-app manifest, imaging-time only. Consumed by 09-Setup-CMM.ps1 reading from C:\\CMM-Install\\. Ongoing enforcement is handled separately by GE-Enforce reading cmm/manifest.json from the tsgwp00525 share. Option 3 (patched-MSI) install strategy: we bypass Hexagon's Burn bundle entirely for PC-DMIS 2016 and 2019 R2. The main PC-DMIS MSIs have been patched via COM SQL UPDATE (msibuild-style) to force the Condition column to '0' for two custom actions: ProcessLicensingFromBundle (which would otherwise spin for ~13 minutes trying to activate against licensing.wilcoxassoc.com with empty credentials) and IsLicenseDateValid (which would fail the install with 'no valid license'). With both CAs disabled, the MSI installs cleanly with no license present; PCDLRN.exe installs and loads at runtime and the tech activates a real license via clmadmin.exe after imaging. VS 2010/2012 x64 runtime prereqs are handled by the shared preinstall.json VC++ x64 entries (which run before this manifest). CLM Tools 1.5/1.7 chained MSIs from the original bundles are intentionally SKIPPED; CLM 1.8.73 standalone provides the admin + runtime interfaces. Protect Viewer is kept because it's useful alongside PC-DMIS 2019 R2.", "_comment": "CMM machine-app manifest, imaging-time only. Consumed by 09-Setup-CMM.ps1 reading from C:\\CMM-Install\\. 09-Setup reads C:\\Enrollment\\cmm\\version.txt (written by resolve-cmm-bay-config.ps1) and filters PC-DMIS entries by the _CmmVersion tag before passing to Install-FromManifest. Entries without _CmmVersion are always installed (CLM, goCMM, DODA). Ongoing enforcement is handled separately by GE-Enforce reading cmm/manifest.json from the tsgwp00525 share. Patched-MSI install strategy: we bypass Hexagon's Burn bundle entirely. The main PC-DMIS MSIs have been patched via COM SQL UPDATE to force the Condition column to '0' for licensing custom actions (ProcessLicensingFromBundle, IsLicenseDateValid, IsLicenseExpired, IsLmsLicenseServerConnectionError, IsLmsLicenseError). With CAs disabled, the MSI installs cleanly with no license present; the tech activates a real license via clmadmin.exe after imaging.",
"Applications": [ "Applications": [
{ {
"_comment": "PC-DMIS 2016 main MSI (PATCHED). ProcessLicensingFromBundle + IsLicenseDateValid custom actions have been pre-disabled by SQL UPDATE of InstallExecuteSequence.Condition to '0'. Install args: INSTALLFOLDER/APPLICATIONFOLDER paths have embedded double quotes to survive the runner's command-line concatenation when the path contains spaces. USINGWPFINSTALLER=1 mirrors the Burn bundle default and ensures HandleLicenseChoice CA (seq 783) stays skipped. HEIP=0 disables Hexagon telemetry. INSTALLPDFCONVERTER=0 skips the Nitro PDF converter. The patched MSI has a HashMismatch signature, which is expected and accepted by Windows Installer in /qn mode.",
"Name": "PC-DMIS 2016", "Name": "PC-DMIS 2016",
"_CmmVersion": "2016",
"Installer": "pcdmis2016-main-patched.msi", "Installer": "pcdmis2016-main-patched.msi",
"Type": "MSI", "Type": "MSI",
"InstallArgs": "/qn /norestart ALLUSERS=1 MSIFASTINSTALL=7 INSTALLFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\" APPLICATIONFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\" USINGWPFINSTALLER=1 HEIP=0 INSTALLPDFCONVERTER=0 REBOOT=ReallySuppress LICENSETYPE=LMSEntitlement", "InstallArgs": "/qn /norestart ALLUSERS=1 MSIFASTINSTALL=7 INSTALLFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\" APPLICATIONFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2016.0 64-bit\" USINGWPFINSTALLER=1 HEIP=0 INSTALLPDFCONVERTER=0 REBOOT=ReallySuppress LICENSETYPE=LMSEntitlement",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5389B196-81F0-44A9-A073-4C1D72041F09}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{5389B196-81F0-44A9-A073-4C1D72041F09}"
"DetectionName": "DisplayVersion",
"DetectionValue": "11.0.1179.0"
}, },
{ {
"_comment": "PC-DMIS 2019 R2 main MSI (PATCHED). Same patch strategy as 2016. Adds INSTALLOFFLINEHELP=0 (saves ~1.5 GB) and INSTALLUNIVERSALUPDATER=0 (disables Hexagon's auto-updater which we do not want on air-gapped shopfloor machines). Protect Viewer is a separate MSI installed next.",
"Name": "PC-DMIS 2019 R2", "Name": "PC-DMIS 2019 R2",
"_CmmVersion": "2019",
"Installer": "pcdmis2019-main-patched.msi", "Installer": "pcdmis2019-main-patched.msi",
"Type": "MSI", "Type": "MSI",
"InstallArgs": "/qn /norestart ALLUSERS=1 MSIFASTINSTALL=7 INSTALLFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\" APPLICATIONFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\" USINGWPFINSTALLER=1 HEIP=0 INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 INSTALLUNIVERSALUPDATER=0 REBOOT=ReallySuppress LICENSETYPE=LMSEntitlement", "InstallArgs": "/qn /norestart ALLUSERS=1 MSIFASTINSTALL=7 INSTALLFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\" APPLICATIONFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2019 R2 64-bit\" USINGWPFINSTALLER=1 HEIP=0 INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 INSTALLUNIVERSALUPDATER=0 REBOOT=ReallySuppress LICENSETYPE=LMSEntitlement",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{49DBE7F9-228A-4E66-8BB5-DB5A446DCAE7}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{49DBE7F9-228A-4E66-8BB5-DB5A446DCAE7}"
"DetectionName": "DisplayVersion",
"DetectionValue": "14.2.728.0"
}, },
{ {
"_comment": "Protect Viewer - companion tool bundled with PC-DMIS 2019 R2. Separate MSI with no license check of its own. Dark-extracted from the 2019 R2 Burn bundle and shipped as-is.", "Name": "PC-DMIS 2026.1",
"_CmmVersion": "2026",
"Installer": "pcdmis2026-main-patched.msi",
"Type": "MSI",
"InstallArgs": "/qn /norestart ALLUSERS=1 MSIFASTINSTALL=7 INSTALLFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2026.1 64-bit\" APPLICATIONFOLDER=\"C:\\Program Files\\Hexagon\\PC-DMIS 2026.1 64-bit\" USINGWPFINSTALLER=1 HEIP=0 INSTALLPDFCONVERTER=0 INSTALLOFFLINEHELP=0 INSTALLUNIVERSALUPDATER=0 REBOOT=ReallySuppress LICENSETYPE=LMSEntitlement",
"DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{81BACE1B-FB08-4DCF-8100-79911AD3EC1E}"
},
{
"_comment": "PC-DMIS PDF converter (Amyuni Document Converter 500; system printer 'PC-DMIS 50 Converter'). NOT installed by the PC-DMIS MSI: INSTALLPDFCONVERTER is a Burn-bundle property the standalone MSI never reads (0 of 153 custom actions reference it), and our patched-MSI strategy bypasses the bundle that would have chained the Amyuni install. The MSI only lays the installer down at <installdir>\\PDFDriverInstallFiles\\BatFileInstallPDF50.zip; this entry runs it (InstallPDF50.exe directly - the shipped .bat ends in `pause` and hangs under /qn). MUST stay AFTER the PC-DMIS entries so the files exist on disk. One shared printer covers every installed version; script is idempotent and scans Program Files\\Hexagon (and Wai). MarkerFile keeps it one-shot at imaging.",
"Name": "PC-DMIS PDF Converter",
"Type": "PS1",
"Script": "Install-PCDMISPDFConverter.ps1",
"DetectionMethod": "MarkerFile",
"DetectionPath": "C:\\Logs\\CMM\\pdfconverter.installed"
},
{
"_comment": "Protect Viewer - companion tool. Install for all versions.",
"Name": "Protect Viewer", "Name": "Protect Viewer",
"Installer": "ProtectViewer.msi", "Installer": "ProtectViewer.msi",
"Type": "MSI", "Type": "MSI",
@@ -34,28 +47,33 @@
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7DE6B8AF-F580-4CDE-845F-FBE46C1FCF69}" "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{7DE6B8AF-F580-4CDE-845F-FBE46C1FCF69}"
}, },
{ {
"_comment": "CLM 1.8.73 standalone bundle - provides clmadmin.exe and the runtime licensing libraries that both PC-DMIS 2016 and 2019 R2 use. Unlike the PC-DMIS bundles, CLM's WiX Burn bundle has no install-time license check (it IS the license tool), so we run it via its original EXE with no patches. The tech uses clmadmin.exe to activate a real license post-imaging, which unlocks both PC-DMIS versions.", "_comment": "CLM 1.8.73 standalone - provides clmadmin.exe for license activation. Install for all versions.",
"Name": "CLM 1.8.73", "Name": "CLM 1.8.73",
"Installer": "CLM_1.8.73.0_x64.exe", "Installer": "CLM_1.8.73.0_x64.exe",
"Type": "EXE", "Type": "EXE",
"InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\CLM.log\"", "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\CLM.log\"",
"LogFile": "C:\\Logs\\CMM\\CLM.log", "LogFile": "C:\\Logs\\CMM\\CLM.log",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{a55fecde-0776-474e-a5b3-d57ea93d6a9f}", "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. No license check. Unpatched bundle EXE runs as-is.", "_comment": "goCMM - Hexagon CMM job launcher. Install for all versions.",
"Name": "goCMM", "Name": "goCMM",
"Installer": "goCMM_1.1.6718.31289.exe", "Installer": "goCMM_1.1.6718.31289.exe",
"Type": "EXE", "Type": "EXE",
"InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\goCMM.log\"", "InstallArgs": "/quiet /norestart /log \"C:\\Logs\\CMM\\goCMM.log\"",
"LogFile": "C:\\Logs\\CMM\\goCMM.log", "LogFile": "C:\\Logs\\CMM\\goCMM.log",
"DetectionMethod": "Registry", "DetectionMethod": "Registry",
"DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{94f02b85-bbca-422e-9b8b-0c16a769eced}", "DetectionPath": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{94f02b85-bbca-422e-9b8b-0c16a769eced}"
"DetectionName": "DisplayVersion", },
"DetectionValue": "1.1.6710.18601" {
"_comment": "DODA - Dovetail Digital Analysis. Deployed as a flat file extract to C:\\Apps\\DODA\\. Only installed when doda.txt=yes (pc-subtype.txt=doda gates this via PCTypes).",
"Name": "DODA",
"PCTypes": ["cmm-doda"],
"Type": "PS1",
"Script": "Install-DODA.ps1",
"DetectionMethod": "File",
"DetectionPath": "C:\\Apps\\DODA\\DovetailAnalysis.exe"
} }
] ]
} }

View File

@@ -0,0 +1,65 @@
# resolve-cmm-bay-config.ps1 - Resolve CMM bay config from cmm-bay-config.csv.
#
# Called by startnet.cmd after the bay picker. Reads the CSV from the PXE
# enrollment share, looks up the selected CMM ID, and writes:
# W:\Enrollment\cmm\version.txt (e.g. "2019")
# W:\Enrollment\cmm\doda.txt (e.g. "yes" or "no")
#
# 09-Setup-CMM.ps1 reads these at install time to gate which PC-DMIS
# version gets installed and whether DODA is deployed.
param(
[Parameter(Mandatory=$true)][string]$ConfigPath,
[Parameter(Mandatory=$true)][string]$CmmId,
[Parameter(Mandatory=$true)][string]$OutDir
)
$ErrorActionPreference = 'Continue'
if (-not (Test-Path -LiteralPath $ConfigPath)) {
Write-Host "ERROR: CSV not found at $ConfigPath"
exit 1
}
try {
$bays = Import-Csv -LiteralPath $ConfigPath
} catch {
Write-Host "ERROR: Failed to parse $ConfigPath - $_"
exit 1
}
$match = $bays | Where-Object { $_.cmm_id -ieq $CmmId }
if (-not $match) {
Write-Host "WARNING: $CmmId not found in bay-config. No version/doda resolution."
exit 0
}
if (-not (Test-Path $OutDir)) {
New-Item -Path $OutDir -ItemType Directory -Force | Out-Null
}
$version = $match.pcdmis_version.Trim()
$doda = $match.doda.Trim().ToLower()
# part_group is the goCMM "Selected Part Group" path. It may legitimately
# contain spaces (e.g. CMM8 "Venture CMM8"); Trim() strips only leading/
# trailing whitespace, never internal spaces. Stored in the friendly S:\
# form; 09-Setup-CMM converts it to the tsgwp00525 UNC at apply time.
$partGroup = ''
if ($match.PSObject.Properties['part_group'] -and $match.part_group) {
$partGroup = $match.part_group.Trim()
}
[System.IO.File]::WriteAllText((Join-Path $OutDir 'version.txt'), $version)
[System.IO.File]::WriteAllText((Join-Path $OutDir 'doda.txt'), $doda)
# cmmid.txt: the resolved CMM id, so 09-Setup-CMM can locate this bay's staged
# backup set (installers-post\cmm\backups\<cmm_id>\) for restore-by-machine-#.
[System.IO.File]::WriteAllText((Join-Path $OutDir 'cmmid.txt'), $CmmId)
if ($partGroup) {
[System.IO.File]::WriteAllText((Join-Path $OutDir 'partgroup.txt'), $partGroup)
}
Write-Host "Resolved $CmmId -> PC-DMIS $version, DODA=$doda, PartGroup=$(if ($partGroup) { $partGroup } else { '(none)' })"
Write-Host " version.txt -> $OutDir\version.txt"
Write-Host " doda.txt -> $OutDir\doda.txt"
if ($partGroup) { Write-Host " partgroup.txt -> $OutDir\partgroup.txt" }
exit 0

View File

@@ -0,0 +1,19 @@
@echo off
REM Backup-CMM.bat - ONE backup for a whole CMM bay (goCMM + PC-DMIS, all versions).
REM
REM Run AS ADMINISTRATOR on the live CMM. Do NOT run on DODA bays.
REM
REM Backup-CMM.bat -CmmId CMM3 (names the folder by CMM #)
REM Backup-CMM.bat (uses the computer name)
REM
REM Output: C:\Logs\CMM\cmm-backup\<CmmId>\ (gocmm + pcdmis zips + index)
REM Copy that whole folder to the PXE staging area for restore-by-machine-#.
setlocal
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Backup-CMM.ps1" %*
echo.
echo ---------------------------------------------------------------
echo Backup folder is under C:\Logs\CMM\cmm-backup\
echo ---------------------------------------------------------------
pause

View File

@@ -0,0 +1,82 @@
<#
Backup-CMM.ps1
ONE backup for a whole CMM bay - runs both:
- Backup-goCMMSettings.ps1 (HKLM goCMM key + C:\geaofi, minus LocalProgramCopies/logs)
- Backup-PCDMISSettings.ps1 (PC-DMIS registry + data/probe/cal + interfac.dll,
every installed version; Homepage state excluded)
All zips land together in one per-CMM folder so they can be staged on PXE and
restored by CMM machine-# at imaging.
Run as ADMINISTRATOR on the live CMM. Do NOT run on DODA bays (handled separately).
Output (default): S:\2 WJ Scans Record Retention\backup\cmm\<CmmId>\
gocmm_backup_<PC>_<ts>.zip
pcdmis_backup_<PC>_<ver>_<ts>.zip (one per PC-DMIS version)
cmm-backup-index.json
If S: is not mapped/reachable, falls back to C:\Logs\CMM\cmm-backup\<CmmId>\.
Params:
-CmmId <id> e.g. CMM3 - names the folder + index. If omitted, the script
PROMPTS for it.
-OutDir <p> base output folder (default the S: record-retention path above)
#>
param(
[string]$CmmId,
[string]$OutDir = 'S:\2 WJ Scans Record Retention\backup\cmm'
)
$ErrorActionPreference = 'Continue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
# Prompt for the CMM number if not passed. It names the backup folder, so we
# need a real value - loop until non-empty.
if (-not $CmmId) {
while (-not $CmmId) {
$CmmId = (Read-Host "Enter the CMM number for this bay (e.g. CMM3)").Trim()
if (-not $CmmId) { Write-Host " CMM number is required." -ForegroundColor Yellow }
}
}
# Default OutDir is on S: (record retention). If S: is not mapped or that base
# path is unreachable, fall back to a local folder so the backup still runs -
# the operator can copy it up to S: afterward.
$fallback = 'C:\Logs\CMM\cmm-backup'
$baseRoot = Split-Path -Qualifier $OutDir -ErrorAction SilentlyContinue
if ($baseRoot -and -not (Test-Path "$baseRoot\")) {
Write-Host "WARNING: $baseRoot is not reachable (S: not mapped?). Falling back to $fallback" -ForegroundColor Yellow
$OutDir = $fallback
}
$dest = Join-Path $OutDir $CmmId
New-Item -ItemType Directory -Path $dest -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $dest "cmm-backup-$ts.log"
function Log($m){ Write-Host $m; $m | Out-File -FilePath $log -Append }
Log "================ CMM backup: $CmmId on $env:COMPUTERNAME at $(Get-Date) ================"
foreach ($s in 'Backup-goCMMSettings.ps1','Backup-PCDMISSettings.ps1') {
$p = Join-Path $here $s
if (-not (Test-Path $p)) { Log "MISSING sibling script: $p - skipping"; continue }
Log "---- running $s ----"
try { & $p -OutDir $dest *>&1 | ForEach-Object { Log " $_" } }
catch { Log " ERROR in $s : $($_.Exception.Message)" }
}
# index of what we captured
$zips = Get-ChildItem $dest -Filter '*.zip' -File -ErrorAction SilentlyContinue
[pscustomobject]@{
CmmId = $CmmId
Computer = $env:COMPUTERNAME
Timestamp = (Get-Date -Format o)
goCMM = @($zips | Where-Object { $_.Name -like 'gocmm_backup_*' } | Select-Object -Expand Name)
PCDMIS = @($zips | Where-Object { $_.Name -like 'pcdmis_backup_*' } | Select-Object -Expand Name)
} | ConvertTo-Json | Out-File (Join-Path $dest 'cmm-backup-index.json') -Encoding ascii
Log "================ DONE ================"
Log "Folder: $dest"
$zips | ForEach-Object { Log (" {0} ({1} KB)" -f $_.Name, [math]::Round($_.Length/1KB)) }
Write-Host ""
Write-Host "CMM backup for $CmmId complete:" -ForegroundColor Green
Write-Host " $dest"
$zips | ForEach-Object { Write-Host " $($_.Name)" }

View File

@@ -0,0 +1,19 @@
@echo off
REM Backup-PCDMISSettings.bat - capture PC-DMIS settings / probes / calibration
REM for every installed version (2016 / 2019 / 2026), headless.
REM
REM Run on a LIVE bay AS ADMINISTRATOR (reg export of HKLM + read of install dir).
REM
REM Backup-PCDMISSettings.bat (all detected versions)
REM Backup-PCDMISSettings.bat -Version 2026.1 (one version)
REM
REM Output: C:\Logs\CMM\pcdmis-backup\pcdmis_backup_<PC>_<ver>_<ts>.zip
setlocal
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Backup-PCDMISSettings.ps1" %*
echo.
echo ---------------------------------------------------------------
echo Backups under C:\Logs\CMM\pcdmis-backup\ (one zip per version)
echo ---------------------------------------------------------------
pause

View File

@@ -0,0 +1,180 @@
<#
Backup-PCDMISSettings.ps1
Manual PC-DMIS settings/probe/calibration backup - replicates what the Settings
Editor captures (registry + data/probe files), but headless and scriptable,
because SettingsEditor.exe /b only works through the GUI and fails non-interactively.
Works across PC-DMIS 2016 / 2019 / 2026 (auto-detects version + vendor hive:
'Hexagon' on 2019/2026, 'Wai' on older 2016 builds).
Captures, per installed version, into one zip:
- registry: HKLM + HKCU <vendor>\PC-DMIS\<ver> (settings, probe search paths)
- install-dir probe/cal master files: PROBE.DAT, usrprobe.dat, comp.dat,
tool.dat, *.prb (top level + Configuration\)
- the per-version data folders under ProgramData, Public\Documents, and
per-user AppData (Roaming + Local)
Run as administrator on a LIVE bay. One zip per installed version.
Output: C:\Logs\CMM\pcdmis-backup\pcdmis_backup_<PC>_<ver>_<ts>.zip
Params:
-Version <x> back up only this version (e.g. 2026.1). Default: every detected.
-OutDir <p> output folder (default C:\Logs\CMM\pcdmis-backup)
#>
param(
[string]$Version,
[string]$OutDir = 'C:\Logs\CMM\pcdmis-backup'
)
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
New-Item -ItemType Directory -Path $OutDir -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $OutDir "pcdmis-backup-$ts.log"
function Log($m){ Write-Host $m; $m | Out-File -FilePath $log -Append }
# --- discover installed PC-DMIS versions: vendor (Hexagon/Wai), version, install dir ---
function Get-PCDMISInstalls {
$found = @()
$roots = @(
@{ Path='HKLM:\SOFTWARE\WOW6432Node\Hexagon\PC-DMIS'; Vendor='Hexagon' },
@{ Path='HKLM:\SOFTWARE\Hexagon\PC-DMIS'; Vendor='Hexagon' },
@{ Path='HKLM:\SOFTWARE\WOW6432Node\Wai\PC-DMIS'; Vendor='Wai' },
@{ Path='HKLM:\SOFTWARE\Wai\PC-DMIS'; Vendor='Wai' }
)
foreach ($r in $roots) {
if (-not (Test-Path $r.Path)) { continue }
Get-ChildItem $r.Path -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match '^\d' } | ForEach-Object {
$p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
$ver = $_.PSChildName
$instDir = $p.Directory; if (-not $instDir) { $instDir = $p.InstallDir }
# Fallback: the registry Directory value is often blank - find the install dir on disk
if (-not $instDir) {
foreach ($pf in "$env:ProgramFiles\$($r.Vendor)","${env:ProgramFiles(x86)}\$($r.Vendor)") {
if (-not (Test-Path $pf)) { continue }
$cand = Get-ChildItem $pf -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "PC-DMIS*$ver*" -and (Test-Path (Join-Path $_.FullName 'PCDLRN.exe')) } | Select-Object -First 1
if ($cand) { $instDir = $cand.FullName; break }
}
}
$found += [pscustomobject]@{ Vendor=$r.Vendor; Version=$ver; HiveRoot=$r.Path; InstallDir=$instDir }
}
}
$found
}
function Backup-OneVersion($inst) {
$ver = $inst.Version; $vendor = $inst.Vendor
Log "==== Backing up PC-DMIS $vendor $ver ===="
$stage = Join-Path $env:TEMP "pcd-bk-$ver-$ts"
New-Item -ItemType Directory -Path $stage,"$stage\registry","$stage\install","$stage\ProgramData","$stage\PublicDocs","$stage\AppData" -Force | Out-Null
# registry: HKLM + HKCU under both Hexagon and Wai (export whichever exists)
foreach ($hk in @('HKLM','HKCU')) {
foreach ($v in @('Hexagon','Wai')) {
$regPath = "$hk\SOFTWARE\$(if($hk -eq 'HKLM'){'WOW6432Node\'} )$v\PC-DMIS\$ver"
$regPathNative = "$hk\SOFTWARE\$v\PC-DMIS\$ver"
foreach ($rp in @($regPath,$regPathNative)) {
$test = $rp -replace '^HKLM','HKLM:' -replace '^HKCU','HKCU:'
if (Test-Path $test) {
$f = "$stage\registry\$hk-$v-$ver.reg"
reg export $rp "$f" /y 2>&1 | Out-Null
if (Test-Path $f) { Log " reg export $rp" }
}
}
}
}
# install-dir master probe/cal files
if ($inst.InstallDir -and (Test-Path $inst.InstallDir)) {
# interfac.dll = the active controller's interface DLL, renamed to interfac.dll
# by PC-DMIS per the machine's controller. Machine-specific - capture it.
foreach ($pat in 'PROBE.DAT','usrprobe.dat','comp.dat','compens.dat','tool.dat','machine.dat','interfac.dll') {
Get-ChildItem $inst.InstallDir -Filter $pat -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName "$stage\install\" -Force }
}
Get-ChildItem $inst.InstallDir -Filter '*.prb' -Recurse -ErrorAction SilentlyContinue | ForEach-Object {
$rel = $_.FullName.Substring($inst.InstallDir.TrimEnd('\').Length).TrimStart('\')
$dst = Join-Path "$stage\install" $rel; New-Item -ItemType Directory -Path (Split-Path $dst) -Force -EA SilentlyContinue | Out-Null
Copy-Item $_.FullName $dst -Force
}
Log " copied install-dir probe/cal files"
}
# --- Identify the controller: which DLL became interfac.dll ---
# PC-DMIS makes the active controller's interface DLL into interfac.dll. If it
# was COPIED, an identical sibling .dll still exists -> hash match names it. If
# it was RENAMED (no copy), no sibling matches - so we also read the PE version
# resource's OriginalFilename, which survives a rename and names the source.
$controllerInfo = $null
$ifPath = if ($inst.InstallDir) { Join-Path $inst.InstallDir 'interfac.dll' } else { $null }
if ($ifPath -and (Test-Path $ifPath)) {
try {
$ifItem = Get-Item $ifPath
$ifHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $ifPath).Hash
$vi = $ifItem.VersionInfo
# size pre-filter so we don't hash every DLL in the install dir
$match = Get-ChildItem $inst.InstallDir -Filter '*.dll' -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'interfac.dll' -and $_.Length -eq $ifItem.Length } |
Where-Object { try { (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash -eq $ifHash } catch { $false } } |
Select-Object -First 1
$controllerInfo = [pscustomobject]@{
InterfacSha256 = $ifHash
MatchedSourceDll = if ($match) { $match.Name } else { $null } # null = renamed, no copy
OriginalFilename = $vi.OriginalFilename
FileDescription = $vi.FileDescription
ProductName = $vi.ProductName
FileVersion = $vi.FileVersion
}
Log (" controller: interfac.dll source=" + $(if ($match) { $match.Name } else { '(renamed, no copy)' }) +
" origName=$($vi.OriginalFilename) desc=$($vi.FileDescription)")
} catch { Log " WARN: controller identification failed: $($_.Exception.Message)" }
} else { Log " (no interfac.dll in install dir - controller not identified)" }
# per-version data folders
$dataMap = @(
@{ Src="$env:ProgramData\$vendor\PC-DMIS\$ver"; Dst="$stage\ProgramData" },
@{ Src="$env:PUBLIC\Documents\$vendor\PC-DMIS\$ver"; Dst="$stage\PublicDocs" },
@{ Src="$env:APPDATA\$vendor\PC-DMIS\$ver"; Dst="$stage\AppData\Roaming" },
@{ Src="$env:LOCALAPPDATA\$vendor\PC-DMIS\$ver"; Dst="$stage\AppData\Local" }
)
foreach ($d in $dataMap) {
if (Test-Path $d.Src) {
New-Item -ItemType Directory -Path $d.Dst -Force | Out-Null
# Exclude bay/path-specific Homepage state. Recent + Favorites store
# absolute routine paths (S:\..., C:\geaofi\LocalProgramCopies\...).
# Restoring them onto another bay makes PC-DMIS null-ref on launch
# (RecentExecutedItem.LoadRealNode) trying to resolve missing paths.
# Exclude the whole Homepage start-screen state (Recent, Favorites,
# DetailsView) - it stores absolute routine paths (S:\..., C:\geaofi\...)
# and PC-DMIS null-refs on launch resolving them (LoadRealNode). Rebuilt
# on use. Also skip regenerable caches/logs.
robocopy $d.Src $d.Dst /E /XD Cache Caches Temp logs Logs Homepage /R:1 /W:1 /NFL /NDL /NJH /NJS | Out-Null
Log " copied $($d.Src)"
}
}
# manifest
[pscustomobject]@{
Computer=$env:COMPUTERNAME; Timestamp=(Get-Date -Format o)
Vendor=$vendor; Version=$ver; InstallDir=$inst.InstallDir
ControllerInterface=$controllerInfo
} | ConvertTo-Json -Depth 4 | Out-File "$stage\manifest.json" -Encoding ascii
# zip
$zip = Join-Path $OutDir "pcdmis_backup_${env:COMPUTERNAME}_${ver}_$ts.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory($stage,$zip)
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Log "==== DONE: $zip ===="
return $zip
}
Log "PC-DMIS backup on $env:COMPUTERNAME at $(Get-Date)"
$installs = Get-PCDMISInstalls | Where-Object { $_.Version -match '^\d' } | Sort-Object Version -Unique
if ($Version) { $installs = $installs | Where-Object { $_.Version -eq $Version } }
if (-not $installs) { Log "No PC-DMIS installs detected (Hexagon/Wai). Nothing to back up."; return }
$made = @()
foreach ($inst in $installs) { $made += (Backup-OneVersion $inst) }
Write-Host ""
Write-Host "PC-DMIS backups written:" -ForegroundColor Green
$made | ForEach-Object { Write-Host " $_" }

View File

@@ -0,0 +1,19 @@
@echo off
REM Backup-goCMMSettings.bat - capture this bay's goCMM settings.
REM
REM Run on a LIVE legacy goCMM device, AS ADMINISTRATOR (reg export of HKLM +
REM read of C:\geaofi needs admin). Captures the registry pointers + the whole
REM Shared Data Directory (ApplicationSettings.xml = all 7 Settings tabs) into
REM one zip under C:\Logs\CMM\gocmm-backup\.
REM
REM Backup-goCMMSettings.bat (default output dir)
REM Backup-goCMMSettings.bat -OutDir D:\path
setlocal
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Backup-goCMMSettings.ps1" %*
echo.
echo ---------------------------------------------------------------
echo Backup zip is under C:\Logs\CMM\gocmm-backup\ (gocmm_backup_*.zip)
echo ---------------------------------------------------------------
pause

View File

@@ -0,0 +1,91 @@
<#
Backup-goCMMSettings.ps1
Capture a goCMM bay's COMPLETE settings into one zip. Run on a LIVE legacy
goCMM device. Mirrors Backup-FormtracepakSettings.ps1.
goCMM stores settings in two places:
1. Registry pointers: HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM
Shared Data Directory, Selected Part Group, Installation Directory
2. The Shared Data Directory (default C:\geaofi\) holds ApplicationSettings.xml
= the real content of ALL 7 Settings tabs: PC-DMIS, Quindos, Modus,
Machine Definition, User Input, Notifications, Part Groups.
This zip captures both so a re-imaged bay can be restored to identical settings.
Output: C:\Logs\CMM\gocmm-backup\gocmm_backup_<PC>_<timestamp>.zip
Run as administrator (reg export of HKLM + read of the data dir).
#>
param(
[string]$OutDir = 'C:\Logs\CMM\gocmm-backup'
)
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
New-Item -ItemType Directory -Path $OutDir -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $OutDir "gocmm-backup-$ts.log"
function Log($m){ Write-Host $m; $m | Out-File -FilePath $log -Append }
$stage = Join-Path $env:TEMP "gocmm-bk-$ts"
New-Item -ItemType Directory -Path $stage, "$stage\registry", "$stage\geaofi" -Force | Out-Null
Log "==== goCMM backup on $env:COMPUTERNAME at $(Get-Date) ===="
# --- Read the 3 registry pointers (32-bit / WOW6432Node view, as the 32-bit app sees them) ---
$sharedDir = 'C:\geaofi'; $partGroup = ''; $installDir = ''
try {
$base32 = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine','Registry32')
$k = $base32.OpenSubKey('SOFTWARE\General Electric\goCMM', $false)
if ($k) {
$sharedDir = ([string]$k.GetValue('Shared Data Directory','C:\geaofi')).TrimEnd('\')
$partGroup = [string]$k.GetValue('Selected Part Group','')
$installDir = [string]$k.GetValue('Installation Directory','')
$k.Close()
Log "Shared Data Directory = $sharedDir"
Log "Selected Part Group = $partGroup"
Log "Installation Directory= $installDir"
} else { Log "WARN: goCMM registry key not found - defaulting Shared Data Directory to $sharedDir" }
} catch { Log "WARN: reading goCMM registry failed: $($_.Exception.Message)" }
# --- Export the registry key ---
reg export 'HKLM\SOFTWARE\WOW6432Node\General Electric\goCMM' "$stage\registry\goCMM.reg" /y 2>&1 | Out-Null
if (Test-Path "$stage\registry\goCMM.reg") { Log "Exported registry key" } else { Log "WARN: registry export produced no file" }
# --- Copy the Shared Data Directory (skip transient LocalProgramCopies + logs) ---
if (Test-Path $sharedDir) {
robocopy $sharedDir "$stage\geaofi" /E /XD LocalProgramCopies logs /R:1 /W:1 /NFL /NDL /NJH /NJS | Out-Null
Log "Copied $sharedDir (excluded LocalProgramCopies, logs)"
if (Test-Path "$stage\geaofi\ApplicationSettings.xml") {
Log "ApplicationSettings.xml captured (PC-DMIS + all Settings tabs)"
} else {
Log "WARN: ApplicationSettings.xml NOT found under $sharedDir - settings tabs may be unconfigured"
}
} else {
Log "WARN: Shared Data Directory $sharedDir does not exist - only the registry key will be in this backup"
}
# --- Manifest ---
$ver = ''
if ($installDir) {
$exe = Join-Path ($installDir.TrimEnd('\')) 'goCMM.exe'
if (Test-Path $exe) { $ver = (Get-Item $exe).VersionInfo.FileVersion }
}
[pscustomobject]@{
Computer = $env:COMPUTERNAME
Timestamp = (Get-Date -Format o)
SharedDataDirectory = $sharedDir
SelectedPartGroup = $partGroup
InstallationDirectory = $installDir
goCMMVersion = $ver
} | ConvertTo-Json | Out-File "$stage\manifest.json" -Encoding ascii
# --- Zip ---
$zip = Join-Path $OutDir "gocmm_backup_${env:COMPUTERNAME}_$ts.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory($stage, $zip)
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
Log "==== DONE: $zip ===="
Write-Host ""
Write-Host "goCMM backup written:" -ForegroundColor Green
Write-Host " $zip"

View File

@@ -0,0 +1,23 @@
@echo off
REM Clear-PCDMISRecent.bat - fix the PC-DMIS startup crash (NullReferenceException
REM in RecentExecutedItem.LoadRealNode) caused by a stale Homepage "Recent
REM Executed Files" list pointing at routine paths that don't resolve on this bay.
REM
REM Deletes the Recent (and Favorites) Homepage state for every user + PC-DMIS
REM version; PC-DMIS rebuilds an empty list on next launch.
REM
REM Run AS ADMINISTRATOR.
REM
REM Clear-PCDMISRecent.bat (all users, Recent + Favorites)
REM Clear-PCDMISRecent.bat -RecentOnly (keep Favorites)
REM Clear-PCDMISRecent.bat -User ShopFloor
setlocal
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Clear-PCDMISRecent.ps1" %*
echo.
echo ---------------------------------------------------------------
echo Done. Relaunch PC-DMIS - the recent-list crash should be gone.
echo Log: C:\Logs\CMM\pcdmis-clearrecent-*.log
echo ---------------------------------------------------------------
pause

View File

@@ -0,0 +1,75 @@
<#
Clear-PCDMISRecent.ps1
Fixes the PC-DMIS startup crash:
System.NullReferenceException
at Adapter.Service.Recent.Models.RecentExecutedItem.LoadRealNode()
at Adapter.Service.Recent.Models.RecentExecutedFiles.LoadAllWhenReady()
Cause: the Homepage "Recent Executed Files" list contains absolute routine
paths (S:\..., C:\geaofi\LocalProgramCopies\...). When a path does not resolve
in the running user's context (drive not visible, cache not yet rebuilt, file
gone), PC-DMIS dereferences a null while async-loading the list and crashes on
launch. A list carried over from another bay (e.g. a settings restore) is the
common trigger.
Fix: delete the Recent (and Favorites) Homepage state for every user profile
and every PC-DMIS version. PC-DMIS rebuilds an empty list on next start.
Run as ADMINISTRATOR (to reach all user profiles).
Output: C:\Logs\CMM\pcdmis-clearrecent-<PC>-<ts>.log
Params:
-RecentOnly only clear the Recent list, keep Favorites
-User <name> only this user's profile (default: all profiles)
#>
param(
[switch]$RecentOnly,
[string]$User
)
$ErrorActionPreference = 'Continue'
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
$dir = 'C:\Logs\CMM'
New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue | Out-Null
$log = Join-Path $dir "pcdmis-clearrecent-$env:COMPUTERNAME-$ts.log"
function Log($m){ Write-Host $m; $m | Out-File -FilePath $log -Append }
Log "==== Clear PC-DMIS Recent/Favorites on $env:COMPUTERNAME at $(Get-Date) ===="
# which user profiles
$userDirs = @()
if ($User) {
if (Test-Path "C:\Users\$User") { $userDirs += "C:\Users\$User" } else { Log "User profile C:\Users\$User not found" }
} else {
$userDirs = (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
}
$targets = @('Homepage\Recent\RecentExecutedFiles.xml')
if (-not $RecentOnly) { $targets += 'Homepage\Favorites\Favorites.xml' }
$deleted = 0; $scanned = 0
foreach ($u in $userDirs) {
foreach ($vendor in 'Hexagon','WAI') {
$base = Join-Path $u "AppData\Local\$vendor\PC-DMIS"
if (-not (Test-Path $base)) { continue }
foreach ($vdir in (Get-ChildItem $base -Directory -ErrorAction SilentlyContinue)) {
$scanned++
foreach ($rel in $targets) {
$f = Join-Path $vdir.FullName $rel
if (Test-Path $f) {
try { Remove-Item $f -Force -ErrorAction Stop; $deleted++
Log " DELETED $f"
} catch { Log " FAILED $f : $($_.Exception.Message)" }
}
}
}
}
}
Log ""
Log "Scanned $scanned PC-DMIS version folder(s) across $($userDirs.Count) profile(s); deleted $deleted file(s)."
Log "PC-DMIS will rebuild an empty recent list on next launch."
Write-Host ""
if ($deleted -gt 0) { Write-Host "Cleared $deleted stale Homepage file(s). Relaunch PC-DMIS - the crash should be gone." -ForegroundColor Green }
else { Write-Host "Nothing to clear (no RecentExecutedFiles.xml found). If it still crashes, the cause is elsewhere - grab the event log." -ForegroundColor Yellow }
Log "Log: $log"

View File

@@ -0,0 +1,21 @@
@echo off
REM Export-PCDMISCrashEvents.bat - pull the Windows event-log entries that
REM explain the PC-DMIS crash (the full .NET exception + stack, which the
REM crash minidump does NOT contain).
REM
REM Run AS ADMINISTRATOR on the bay that crashed, soon after the crash.
REM
REM Export-PCDMISCrashEvents.bat (last 72 hours)
REM Export-PCDMISCrashEvents.bat -Hours 12 (narrower window)
REM
REM Output: C:\Logs\CMM\pcdmis-crash-events-<PC>-<ts>.txt
REM Send that file back - the first .NET Runtime (ID 1026) event is the answer.
setlocal
set "HERE=%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%HERE%Export-PCDMISCrashEvents.ps1" %*
echo.
echo ---------------------------------------------------------------
echo Wrote C:\Logs\CMM\pcdmis-crash-events-*.txt - send it back.
echo ---------------------------------------------------------------
pause

Some files were not shown because too many files have changed in this diff Show More