Finish improvements.

This commit is contained in:
Vasyl Palamarchuk
2026-09-09 11:52:19 -07:00
parent b2f4d6310e
commit 39c28f2d90
7 changed files with 271 additions and 46 deletions
+88 -46
View File
@@ -93,25 +93,23 @@ param(
[string]$Module = 'ECU',
[string]$VIN,
[string]$Notes,
[string]$Technician
[string]$Technician,
[switch]$PromptForRestart,
[switch]$AllowAutomaticRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$moduleRoot = Join-Path $PSScriptRoot 'Modules'
Import-Module (Join-Path $moduleRoot 'Configuration.psm1') -Force
Import-Module (Join-Path $moduleRoot 'Checkpoint.psm1') -Force
Import-Module (Join-Path $moduleRoot 'Download.psm1') -Force
Import-Module (Join-Path $moduleRoot 'Workspace.psm1') -Force
function Import-SetupConfiguration {
param([Parameter(Mandatory)][string]$Name)
$path = Join-Path $PSScriptRoot (Join-Path 'Config' $Name)
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required configuration file is missing: $path"
}
try {
return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Configuration file is invalid: $path. $($_.Exception.Message)"
}
return Import-WorkstationJsonConfiguration -BasePath $PSScriptRoot -Name $Name
}
$Configuration = @{
@@ -263,8 +261,7 @@ function Test-SetupPhaseCompleted {
if (-not $ResumeFromCheckpoint -or -not (Test-Path -LiteralPath $CheckpointFile -PathType Leaf)) {
return $false
}
$checkpoint = Load-ExecutionCheckpoint
return @($checkpoint.CompletedPhases | Where-Object { $_.Phase -eq $Phase -and $_.Status -eq 'Completed' }).Count -gt 0
return Test-WorkstationCheckpointPhase -Path $CheckpointFile -Phase $Phase
}
function Detect-PendingRestart {
@@ -279,6 +276,24 @@ function Complete-SetupPhase {
Save-ExecutionCheckpoint -Phase $Phase -Status 'Completed' -Details $Details
}
function Request-SetupRestart {
param([Parameter(Mandatory)][string]$Reason)
Save-ExecutionCheckpoint -Phase 'RestartRequired' -Status 'AwaitingRestart' -Details $Reason
if ($AllowAutomaticRestart) {
Write-Log "Restarting Windows automatically: $Reason" 'WARN'
Restart-Computer -Force
return
}
if ($PromptForRestart) {
$answer = Read-Host "$Reason Restart now? (Y/N)"
if ($answer -match '^(?i)y(?:es)?$') {
Restart-Computer -Force
return
}
}
Write-Log "Restart required before continuing. Resume with -ResumeFromCheckpoint after restarting." 'WARN'
}
function Invoke-Safe {
param([string]$Category, [string]$Name, [scriptblock]$Action)
try {
@@ -529,11 +544,7 @@ function Set-WindowsProfileConfiguration {
function ConvertTo-ProjectSegment {
param([Parameter(Mandatory)][string]$Value, [Parameter(Mandatory)][string]$Name)
$segment = $Value.Trim()
if ([string]::IsNullOrWhiteSpace($segment) -or $segment -match '[\\/:*?"<>|]' -or $segment -eq '.' -or $segment -eq '..') {
throw "$Name contains an invalid path segment: '$Value'."
}
return ($segment -replace '\s+', '_')
return ConvertTo-WorkstationProjectSegment -Value $Value -Name $Name
}
function New-AutomotiveProject {
@@ -726,13 +737,10 @@ function Test-DownloadUri {
[Parameter(Mandatory)][string]$Uri,
[switch]$AllowInsecureHttp
)
$parsedUri = [Uri]$Uri
if ($parsedUri.Scheme -eq 'https') { return }
if ($parsedUri.Scheme -eq 'http' -and $AllowInsecureHttp) {
Test-WorkstationDownloadUri -Uri $Uri -AllowInsecureHttp:$AllowInsecureHttp
if (([Uri]$Uri).Scheme -eq 'http' -and $AllowInsecureHttp) {
Write-Log "Using explicitly approved HTTP download: $Uri" 'WARN'
return
}
throw "Insecure download blocked: $Uri. Use HTTPS or explicitly approve this documented vendor exception."
}
function Test-DownloadedFile {
@@ -1046,6 +1054,13 @@ if ($EffectiveMode -eq 'Audit' -or $EffectiveMode -eq 'HealthCheck') {
return
}
if ($EffectiveMode -eq 'Inventory') {
Invoke-Safe 'Inventory' 'Export workstation inventory' { Export-WorkstationInventory } | Out-Null
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
if ($EnableTranscript) { Stop-Transcript | Out-Null }
return
}
if ($EffectiveMode -eq 'Plan') {
$windowsPlan = @(Get-WindowsConfigurationPlan)
$planFile = Join-Path $Paths.Logs ("{0}_Plan_{1}.csv" -f $Profile.Folder, $TimeStamp)
@@ -1084,6 +1099,18 @@ if ($EffectiveMode -eq 'UpdatePortable') {
$InstallOptionalApps = $false
}
if ($EffectiveMode -eq 'Repair') {
if (-not (Test-Path -LiteralPath $CheckpointFile -PathType Leaf)) {
throw "Repair requires an existing checkpoint: $CheckpointFile"
}
$ResumeFromCheckpoint = $true
$repairCheckpoint = Load-ExecutionCheckpoint
$ExecutionId = $repairCheckpoint.ExecutionId
$ExecutionState.StartTime = $repairCheckpoint.StartTime
$ExecutionState.CompletedPhases = @($repairCheckpoint.CompletedPhases)
Write-Log 'Repair mode will revalidate completed phases and retry failed or incomplete work.' 'INFO'
}
if (-not (Test-Administrator)) {
throw 'Run this script from Windows PowerShell or PowerShell as Administrator.'
}
@@ -1120,20 +1147,21 @@ if ($RestoreConfiguration) {
return
}
# 1. Create local structure.
$Paths.Values | ForEach-Object {
if (-not (Test-SetupPhaseCompleted -Phase 'LocalStructure')) {
# 1. Create local structure.
$Paths.Values | ForEach-Object {
$null = New-Item -ItemType Directory -Path $_ -Force
}
}
$Subfolders = @($Configuration.Folders.Local)
foreach ($relative in $Subfolders) {
$Subfolders = @($Configuration.Folders.Local)
foreach ($relative in $Subfolders) {
$path = Join-Path $LocalRoot $relative
$null = New-Item -ItemType Directory -Path $path -Force
}
Add-Result 'Folders' 'Local automotive structure' 'Success' $LocalRoot
Complete-SetupPhase -Phase 'LocalStructure' -Details $LocalRoot
}
Add-Result 'Folders' 'Local automotive structure' 'Success' $LocalRoot
Complete-SetupPhase -Phase 'LocalStructure' -Details $LocalRoot
Invoke-Safe 'Configuration' 'Workstation profile metadata' {
Invoke-Safe 'Configuration' 'Workstation profile metadata' {
@"
Profile: $WorkstationProfile
Focus: $($Profile.Focus)
@@ -1145,7 +1173,8 @@ Shared installers: $ProfileInstallerRoot
This Windows installation uses only C:, S: (AUTO_DATA), and P: (PORTABLE_APPS).
Do not assign drive letters to other Windows installations from this workstation.
"@ | Set-Content -LiteralPath (Join-Path $Paths.Config 'Workstation-Profile.txt') -Encoding UTF8
} | Out-Null
} | Out-Null
}
if ($BackupConfiguration) {
Invoke-Safe 'Recovery' 'Backup workstation configuration' {
@@ -1154,31 +1183,32 @@ if ($BackupConfiguration) {
} | Out-Null
}
# 2. Configure both shared partitions and expose them inside C:\Automotive.
# S: holds data shared by all three Windows installations.
$SharedDataFolders = @($Configuration.Folders.SharedData)
if (-not (Test-SetupPhaseCompleted -Phase 'SharedPartitions')) {
# 2. Configure both shared partitions and expose them inside C:\Automotive.
# S: holds data shared by all three Windows installations.
$SharedDataFolders = @($Configuration.Folders.SharedData)
# P: holds portable applications, shared tool configuration, installers, and manifests.
$SharedPortableFolders = @($Configuration.Folders.SharedPortable)
$SharedPortableFolders = @($Configuration.Folders.SharedPortable)
Invoke-Safe 'SharedPartition' 'Configure S: shared automotive data' {
Invoke-Safe 'SharedPartition' 'Configure S: shared automotive data' {
Initialize-SharedPartition -Drive 'S:' -ExpectedLabel $SharedDataLabel -Folders $SharedDataFolders
} | Out-Null
} | Out-Null
Invoke-Safe 'SharedPartition' 'Configure P: shared portable applications' {
Invoke-Safe 'SharedPartition' 'Configure P: shared portable applications' {
Initialize-SharedPartition -Drive 'P:' -ExpectedLabel $SharedPortableLabel -Folders $SharedPortableFolders
} | Out-Null
} | Out-Null
if ($CreateSharedLinks) {
if ($CreateSharedLinks) {
Invoke-Safe 'Link' 'Link C:\Automotive\SharedData to S:' {
New-DirectoryLink -Link (Join-Path $LocalRoot 'SharedData') -Target $SharedDataRoot
} | Out-Null
Invoke-Safe 'Link' 'Link C:\Automotive\SharedPortableApps to P:' {
New-DirectoryLink -Link (Join-Path $LocalRoot 'SharedPortableApps') -Target $SharedPortableRoot
} | Out-Null
}
}
if ((Test-Path -LiteralPath $SharedPortableRoot) -and (Test-Path -LiteralPath $SharedDataRoot)) {
if ((Test-Path -LiteralPath $SharedPortableRoot) -and (Test-Path -LiteralPath $SharedDataRoot)) {
Invoke-Safe 'AutomotiveTooling' 'Commercial automotive tool workspaces' {
foreach ($toolName in $Configuration.Shortcuts.CommercialAutomotiveTools) {
$portableToolRoot = Join-Path $SharedPortableRoot "Automotive\CommercialTools\$toolName"
@@ -1186,9 +1216,10 @@ if ((Test-Path -LiteralPath $SharedPortableRoot) -and (Test-Path -LiteralPath $S
$null = New-Item -ItemType Directory -Path $portableToolRoot, (Join-Path $portableToolRoot 'Config'), (Join-Path $portableToolRoot 'Shortcuts'), $dataToolRoot -Force
}
} | Out-Null
}
}
Complete-SetupPhase -Phase 'SharedPartitions' -Details "DataRoot=$SharedDataRoot; PortableRoot=$SharedPortableRoot"
Complete-SetupPhase -Phase 'SharedPartitions' -Details "DataRoot=$SharedDataRoot; PortableRoot=$SharedPortableRoot"
}
# 3. Portable applications are installed after WinGet so archive prerequisites are available.
@@ -1205,6 +1236,7 @@ $OptionalPackages = if ($catalogPackages.Count -gt 0) {
@($Configuration.OptionalApps.Packages)
}
if (-not (Test-SetupPhaseCompleted -Phase 'WinGetPackages')) {
if (-not $SkipWinget) {
Invoke-Safe 'WinGet' 'Refresh package sources' {
Update-WingetSources
@@ -1219,9 +1251,15 @@ if (-not $SkipWinget) {
}
}
if (Detect-PendingRestart) {
Request-SetupRestart -Reason 'WinGet reports that Windows needs to restart before setup can continue.'
if ($AllowAutomaticRestart -or $PromptForRestart) { return }
}
Complete-SetupPhase -Phase 'WinGetPackages' -Details "Skipped=$SkipWinget; Optional=$InstallOptionalApps"
}
# 5. Download, install/update, validate, and configure portable applications.
if (-not (Test-SetupPhaseCompleted -Phase 'PortableApplications')) {
if (-not $SkipDownloads) {
$PortableRoot = Get-PortableInstallRoot
$PortableDefinitions = [System.Collections.Generic.List[object]]::new()
@@ -1889,9 +1927,11 @@ according to the vendor instructions. Do not use a third-party mirror.
}
Complete-SetupPhase -Phase 'PortableApplications' -Details "Skipped=$SkipDownloads"
}
# 6. Convenience shortcuts.
if (-not (Test-SetupPhaseCompleted -Phase 'Shortcuts')) {
if (-not $SkipShortcuts) {
$desktopGroup = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Automotive Workstation'
$startGroup = Join-Path ([Environment]::GetFolderPath('Programs')) 'Automotive Workstation'
@@ -1945,6 +1985,7 @@ if (-not $SkipShortcuts) {
}
Complete-SetupPhase -Phase 'Shortcuts' -Details "Skipped=$SkipShortcuts"
}
# 7. Environment and safety optimizations.
Invoke-Safe 'Configuration' 'Local TEMP workspace' {
@@ -1982,6 +2023,7 @@ Invoke-Safe 'Report' 'Export workstation inventory' { Export-WorkstationInventor
$Results | Export-Csv -LiteralPath $SummaryFile -NoTypeInformation -Encoding UTF8
Invoke-Safe 'Report' 'Export setup report' { Export-SetupReport } | Out-Null
if (Detect-PendingRestart) {
Request-SetupRestart -Reason 'Setup completed but Windows reports a pending restart.'
Save-ExecutionCheckpoint -Phase 'Complete' -Status 'AwaitingRestart' -Details 'Windows reports a pending restart.'
} else {
Save-ExecutionCheckpoint -Phase 'Complete' -Status 'Completed' -Details 'Setup flow completed.'
+44
View File
@@ -0,0 +1,44 @@
# Deployment
## Build a release package
Run from an elevated Windows PowerShell session only when the target machine will be configured. Packaging itself does not require elevation.
```powershell
.\Deploy\Package-WorkstationSetup.ps1 -Version '2026.09.09' -IncludeTests
```
The package is written to `Releases\\Automotive-Workstation-Setup-<version>.zip` and includes:
- Main setup script
- JSON configuration files
- User and troubleshooting documentation
- Validation checklist
- SHA256 release manifests
- Tests when `-IncludeTests` is specified
Do not distribute the temporary staging directory. Verify the generated archive hash from the accompanying `.release.json` file before copying it to a deployment share.
## Rollout sequence
1. Verify the release archive SHA256.
2. Extract it to a versioned folder on the deployment share.
3. Boot the intended isolated Windows installation.
4. Confirm only the intended `C:`, `S:`, and `P:` volumes are mounted.
5. Run `-Mode Plan` and review the Windows/application plan.
6. Run `-Mode Audit` and save the generated reports.
7. Create a configuration backup.
8. Run `-Mode Apply` from an elevated Windows PowerShell window.
9. Restart when the checkpoint reports `AwaitingRestart`.
10. Resume with `-ResumeFromCheckpoint`.
11. Run `-Mode HealthCheck` and review the setup report.
12. Install licensed OEM suites and communication drivers separately through vendor installers.
## Production gates
- All Pester tests pass.
- The archive hash is verified.
- The selected profile matches the isolated Windows installation.
- OEM software licensing and driver compatibility are confirmed.
- Original ECU, EEPROM, and coding data have independent backups.
- Elevated Apply testing has completed on a clean test workstation before production rollout.
+57
View File
@@ -0,0 +1,57 @@
[CmdletBinding()]
param(
[string]$OutputRoot = (Join-Path (Split-Path $PSScriptRoot -Parent) 'Releases'),
[string]$Version = (Get-Date -Format 'yyyy.MM.dd'),
[switch]$IncludeTests
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$sourceRoot = Split-Path $PSScriptRoot -Parent
$packageName = "Automotive-Workstation-Setup-$Version"
$stagingRoot = Join-Path ([IO.Path]::GetTempPath()) $packageName
$packageRoot = Join-Path $stagingRoot 'Workstation-Setup'
Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packageRoot, $OutputRoot -Force | Out-Null
$files = @(
'Automotive-Workstation-Setup.ps1',
'COMPLETION-SUMMARY.md',
'USER-GUIDE.md',
'TROUBLESHOOTING.md',
'VALIDATION-CHECKLIST.md',
'Config'
)
if ($IncludeTests) { $files += 'Tests' }
foreach ($relativePath in $files) {
$sourcePath = Join-Path $sourceRoot $relativePath
if (-not (Test-Path -LiteralPath $sourcePath)) { throw "Release input is missing: $relativePath" }
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $packageRoot $relativePath) -Recurse -Force
}
$manifest = Get-ChildItem -LiteralPath $packageRoot -File -Recurse | ForEach-Object {
$hash = Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256
[pscustomobject]@{
Path = $_.FullName.Substring($packageRoot.Length).TrimStart('\\')
SizeBytes = $_.Length
SHA256 = $hash.Hash
}
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $packageRoot 'RELEASE-MANIFEST.json') -Encoding UTF8
$manifest | Export-Csv -LiteralPath (Join-Path $packageRoot 'RELEASE-MANIFEST.csv') -NoTypeInformation -Encoding UTF8
$archivePath = Join-Path $OutputRoot "$packageName.zip"
Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath $archivePath -Force
$archiveHash = Get-FileHash -LiteralPath $archivePath -Algorithm SHA256
[pscustomobject]@{
Package = $packageName
Archive = $archivePath
SHA256 = $archiveHash.Hash
IncludeTests = [bool]$IncludeTests
Created = Get-Date
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $OutputRoot "$packageName.release.json") -Encoding UTF8
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
Write-Output $archivePath
+14
View File
@@ -0,0 +1,14 @@
Set-StrictMode -Version Latest
function Test-WorkstationCheckpointPhase {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Phase
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
$state = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
return @($state.CompletedPhases | Where-Object { $_.Phase -eq $Phase -and $_.Status -eq 'Completed' }).Count -gt 0
}
Export-ModuleMember -Function Test-WorkstationCheckpointPhase
+21
View File
@@ -0,0 +1,21 @@
Set-StrictMode -Version Latest
function Import-WorkstationJsonConfiguration {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$BasePath,
[Parameter(Mandatory)][string]$Name
)
$path = Join-Path $BasePath (Join-Path 'Config' $Name)
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required configuration file is missing: $path"
}
try {
Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Configuration file is invalid: $path. $($_.Exception.Message)"
}
}
Export-ModuleMember -Function Import-WorkstationJsonConfiguration
+31
View File
@@ -0,0 +1,31 @@
Set-StrictMode -Version Latest
function Test-WorkstationDownloadUri {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Uri,
[switch]$AllowInsecureHttp
)
$parsedUri = [Uri]$Uri
if ($parsedUri.Scheme -eq 'https') { return }
if ($parsedUri.Scheme -eq 'http' -and $AllowInsecureHttp) { return }
throw "Insecure download blocked: $Uri"
}
function Test-WorkstationDownloadedFile {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][int64]$MinimumBytes,
[string]$ExpectedSHA256
)
$file = Get-Item -LiteralPath $Path -ErrorAction Stop
if ($file.Length -lt $MinimumBytes) { throw "Downloaded file is smaller than the required minimum: $Path" }
if (-not [string]::IsNullOrWhiteSpace($ExpectedSHA256)) {
$actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
if ($actual -ne $ExpectedSHA256.ToUpperInvariant()) { throw "SHA256 mismatch for $Path" }
}
return $file
}
Export-ModuleMember -Function Test-WorkstationDownloadUri, Test-WorkstationDownloadedFile
+16
View File
@@ -0,0 +1,16 @@
Set-StrictMode -Version Latest
function ConvertTo-WorkstationProjectSegment {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Value,
[Parameter(Mandatory)][string]$Name
)
$segment = $Value.Trim()
if ([string]::IsNullOrWhiteSpace($segment) -or $segment -match '[\\/:*?"<>|]' -or $segment -in @('.', '..')) {
throw "$Name contains an invalid path segment: '$Value'."
}
return ($segment -replace '\s+', '_')
}
Export-ModuleMember -Function ConvertTo-WorkstationProjectSegment