Files
shopdb-flask/scripts/site_imports/assetcollector/Collect-ShopDB.ps1
benjamin.baker 4a1b97362b Add site asset CSV import with fuzzy reconciliation
Sites seed inventory from the Asset Collector script or spreadsheet exports, but lookup names drift between sources (Dell vs Dell Inc.), which would otherwise create duplicate vendors/models/types on every import. The importer now normalizes and fuzzy-matches near-miss references onto existing lookup rows within an operator-set similarity threshold, auto-applying the match while reporting it for review so nothing merges silently. Operating systems and asset rows are never reconciled. Adds the dedicated Import Site Assets page with script/template download and a multi-file dry-run/commit workflow.
2026-08-24 14:29:02 -04:00

543 lines
22 KiB
PowerShell

#Requires -Version 5.1
[CmdletBinding()]
param(
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
# below. See the CONFIG block for the easy place to set this permanently.
[Parameter(Mandatory = $false)]
[string]$OutputRoot,
# Optional network share to mirror the store into after a run. Overrides the
# in-script CONFIG block below.
[Parameter(Mandatory = $false)]
[string]$PublishRoot,
# Business identifier for this PC (workstation number). Defaults to hostname.
[Parameter(Mandatory = $false)]
[string]$AssetNumber,
# Value written into assets.assettypeid for the PC (resolved by name).
[Parameter(Mandatory = $false)]
[string]$AssetType = 'Computer',
# Value written into computers.computertypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$ComputerType,
# Value written into machines.machinetypeid (resolved by name).
[Parameter(Mandatory = $false)]
[string]$MachineType,
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
# locations are searched.
[Parameter(Mandatory = $false)]
[string]$RegPath,
[Parameter(Mandatory = $false)]
[string]$JsonPath,
# Skip switches for partial runs.
[switch]$SkipMachine,
[switch]$SkipApps
)
<#
.SYNOPSIS
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
importable CSVs so a site can build its inventory PC by PC.
.DESCRIPTION
Accumulating store under OutputRoot:
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
computers.csv, machines.csv
Rows are merged field-by-field on each table's natural key (a later run fills
gaps and refreshes values without wiping data an earlier run captured).
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
computer_<assetnumber>.json (interfaces, installed apps)
machine_<machinenumber>.json (PC<->Machine link, connection detail)
Read-only against the machine. Local reads may run elevated; publishing to a
share runs in the current user context only.
.NOTES
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
#>
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# =============================================================================
# CONFIG - SET YOUR CSV DESTINATIONS HERE
# -----------------------------------------------------------------------------
# Two-stage flow so you can collect locally (possibly elevated) and then append
# the captured rows to a CSV store that lives on a network share:
#
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
# and merged. Collection can run elevated here.
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
# that you later point 'flask csv import --dir' at. Leave
# blank ('') to keep everything local. When set, the merged
# store is copied here at the end of the run. Network
# writes must happen as a STANDARD user - do NOT run the
# publish step elevated.
#
# Example network path: '\\fileserver\shopdb$\intake'
#
# Command-line -OutputRoot / -PublishRoot still override these values.
# =============================================================================
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
$NetworkStoreRoot = '\\wi001dataaege.wlm.geaerospace.net\shared\TEAMSYS\.sfscripts\shopdb'
# Resolve effective destinations (command-line params win over CONFIG above).
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
# --- Store definitions (header order + natural key per importable table) -------
$script:Stores = [ordered]@{
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
}
# --- Shared helpers -----------------------------------------------------------
function ConvertTo-CsvField {
param([AllowNull()]$Value)
if ($null -eq $Value) { return '' }
$text = [string]$Value
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
return $text
}
function Get-Prop {
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
if ($null -eq $Object) { return $null }
$prop = $Object.PSObject.Properties[$Name]
if ($null -eq $prop) { return $null }
return $prop.Value
}
function Get-NonEmpty {
param([AllowNull()]$Value)
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
return ([string]$Value).Trim()
}
return $null
}
function Get-FirstNonEmpty {
param([object[]]$Values)
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
return $null
}
function Format-ShopDbDate {
param([AllowNull()]$Value)
if ($null -eq $Value) { return $null }
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
}
function Write-Utf8NoBom {
param([string]$Path, [string]$Content)
$encoding = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
}
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
# values are kept when the incoming cell is empty, so a partial run never blanks
# out data an earlier run captured.
function Merge-CsvStore {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string[]]$Header,
[Parameter(Mandatory = $true)][string]$Key,
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
)
$index = [ordered]@{}
if (Test-Path -LiteralPath $Path) {
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
if (-not $keyVal) { continue }
$index[$keyVal.ToLowerInvariant()] = $existing
}
}
$changed = 0
foreach ($row in $IncomingRows) {
if ($null -eq $row) { continue }
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
if (-not $keyVal) { continue }
$lc = $keyVal.ToLowerInvariant()
if ($index.Contains($lc)) {
$target = $index[$lc]
foreach ($col in $Header) {
$incoming = Get-NonEmpty (Get-Prop $row $col)
if ($incoming) {
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
}
}
} else {
$fresh = [ordered]@{}
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
$index[$lc] = [pscustomobject]$fresh
}
$changed++
}
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add(($Header -join ','))
foreach ($stored in $index.Values) {
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
$lines.Add(($cells -join ','))
}
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
return $changed
}
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
function Resolve-ControllerVendor {
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
if ($ct -eq 'SIEMENS') { return 'Siemens' }
if ($ct -eq 'OKUMA') { return 'Okuma' }
return $null
}
function Read-RegExport {
param([Parameter(Mandatory = $true)][string]$Path)
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
}
$data = @{}
$currentKey = $null
foreach ($raw in $lines) {
$line = $raw.Trim()
if ([string]::IsNullOrWhiteSpace($line)) { continue }
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
if ($line -match '^\s*;') { continue }
if ($line -match '^\[(?<key>[^\]]+)\]$') {
$currentKey = $Matches['key']
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
continue
}
if (-not $currentKey) { continue }
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
$name = $Matches['name']
$rhs = $Matches['rhs'].Trim()
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
else { $val = $rhs }
$data[$currentKey][$name] = $val
}
}
return $data
}
function Get-RegExportValue {
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
foreach ($key in $Reg.Keys) {
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
}
}
return $null
}
function ConvertFrom-RegExport {
param([hashtable]$Reg)
[ordered]@{
source = 'reg'
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
}
}
function Get-RegistryValue {
param([string]$Path, [string]$Name)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
}
function ConvertFrom-LiveRegistry {
param([string]$Base)
if (-not (Test-Path -LiteralPath $Base)) { return $null }
$general = Join-Path $Base 'General'
$efocas = Join-Path $Base 'eFocas'
$ppdcs = Join-Path $Base 'PPDCS'
[ordered]@{
source = 'registry'
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
dnctype = 'eDNC'
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
}
}
function ConvertFrom-UdcJson {
param([string]$Path)
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
$general = Get-Prop $obj 'GeneralSettings'
$ethernet = Get-Prop $obj 'EthernetSettings'
$serial = Get-Prop $obj 'SerialSettings'
[ordered]@{
source = 'json'
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
site = Get-NonEmpty (Get-Prop $general 'Site')
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
ncif = $null
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
}
}
function Find-FirstFile {
param([string[]]$Roots, [string]$Filter)
foreach ($root in $Roots) {
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($hit) { return $hit.FullName }
}
return $null
}
function Resolve-DncSource {
param([string]$RegPath, [string]$JsonPath)
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $JsonPath
}
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
}
foreach ($base in @(
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
$fromReg = ConvertFrom-LiveRegistry -Base $base
if ($fromReg -and $fromReg.machinenumber) {
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
return $fromReg
}
}
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
if ($localReg) {
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
}
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
if ($localJson) {
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
return ConvertFrom-UdcJson -Path $localJson
}
return $null
}
# --- PC collection (read-only) ------------------------------------------------
function Get-PcFacts {
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
$interfaces = @()
try {
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
$interfaces += [ordered]@{
interfacealias = [string]$cfg.InterfaceAlias
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
macaddress = [string](Get-Prop $adapter 'MacAddress')
}
}
} catch {}
$installedApps = @()
if (-not $SkipApps) {
foreach ($keyPath in @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
if (-not $displayName) { continue }
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
$installedApps += [ordered]@{
appname = $displayName
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
}
}
}
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
}
[ordered]@{
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
interfaces = $interfaces
installedapps = $installedApps
}
}
# --- Main ---------------------------------------------------------------------
Write-Host ''
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
$sidecarDir = Join-Path $OutputRoot 'sidecars'
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
$pc = Get-PcFacts
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
# Row sets keyed to the shared stores.
$vendorRows = @()
$modelRows = @()
$osRows = @()
$assetRows = @()
$computerRows = @()
$machineRows = @()
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
$assetRows += [pscustomobject]@{
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null
notes = 'PC collected ' + $collectedAt
}
$computerRows += [pscustomobject]@{
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
}
# Machine (best-effort).
$machine = $null
if (-not $SkipMachine) {
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
}
if ($machine -and $machine.machinenumber) {
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
$noteBits = @("DNC=$($machine.dnctype)")
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
$noteBits += "transport=$transport"
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
$assetRows += [pscustomobject]@{
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
locationid = $null; businessunitid = $null; notes = $machineNote
}
$machineRows += [pscustomobject]@{
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
controllervendorid = $controllerVendor; controllermodelid = $null
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
}
$link = [ordered]@{
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
site = $machine.site; source = $machine.source; collectedat = $collectedAt
connection = [ordered]@{
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
comport = $machine.comport; baudrate = $machine.baudrate
}
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
} elseif (-not $SkipMachine) {
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
}
# --- Upsert into the shared store --------------------------------------------
$rowsByStore = @{
'vendors.csv' = $vendorRows
'models.csv' = $modelRows
'operatingsystems.csv' = $osRows
'assets.csv' = $assetRows
'computers.csv' = $computerRows
'machines.csv' = $machineRows
}
Write-Host ''
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
foreach ($fileName in $script:Stores.Keys) {
$spec = $script:Stores[$fileName]
$rows = @($rowsByStore[$fileName])
if ($rows.Count -eq 0) { continue }
$path = Join-Path $OutputRoot $fileName
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
}
# PC phase-2 sidecar.
$pcSidecar = [ordered]@{
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
interfaces = $pc.interfaces; installedapps = $pc.installedapps
}
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
# --- Publish (standard-user network mirror, optional) -------------------------
if ($PublishRoot) {
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host ''
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
}
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
Copy-Item -Path (Join-Path $OutputRoot '*') -Destination $PublishRoot -Recurse -Force
Write-Host ''
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
}
Write-Host ''
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
Write-Host ''