diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..34ea9e1 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,8 @@ +# Context + +## Glossary + +- **Bootstrap run**: One execution of the setup orchestrator after Windows is installed or when the user re-runs setup later. +- **Staged setup payload**: The setup assets bundled into install media so they are available on the installed machine before user configuration starts. +- **Backup manifest**: The pre-reinstall record that describes what was backed up and where it should be restored. +- **Canonical repo**: The repo copy used as the long-term source of configuration after setup completes. diff --git a/apply-registry.ps1 b/apply-registry.ps1 index a2fbd9a..b5432ee 100644 --- a/apply-registry.ps1 +++ b/apply-registry.ps1 @@ -11,96 +11,14 @@ param( $ErrorActionPreference = "Stop" -function Normalize-RegistryPath { - param([string]$Path) - - if ($Path -like "Registry::*") { - return $Path - } - - if ($Path -match '^(HKLM|HKCU|HKCR|HKU|HKCC)') { - return "Registry::$Path" - } - - return $Path -} - -function Convert-RegistryType { - param([string]$Type) - - switch ($Type.ToUpperInvariant()) { - "DWORD" { return "DWord" } - "STRING" { return "String" } - default { throw "Unsupported registry value type: $Type" } - } +$ModuleRoot = Join-Path $PSScriptRoot "modules" +$DeclarativeConfigModule = Join-Path $ModuleRoot "DeclarativeConfig.ps1" +if (Test-Path $DeclarativeConfigModule) { + . $DeclarativeConfigModule } -$config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json -$entries = $config.entries - -if (-not $entries) { - return [pscustomobject]@{ - Applied = 0 - Skipped = 0 - Failed = 0 - } +if (Get-Command Invoke-DeclarativeConfig -ErrorAction SilentlyContinue) { + return Invoke-DeclarativeConfig -Kind Registry -ConfigPath $ConfigPath -DryRun:$DryRun } -$applied = 0 -$skipped = 0 -$failed = 0 - -foreach ($entry in $entries) { - try { - if (-not $entry.path -or -not $entry.name -or -not $entry.type) { - throw "Registry entry missing required fields (path, name, type)" - } - - $registryPath = Normalize-RegistryPath -Path $entry.path - $valueType = Convert-RegistryType -Type $entry.type - $desiredValue = $entry.value - - if ($valueType -eq "DWord") { - $desiredValue = [int]$desiredValue - } - - if (-not (Test-Path -LiteralPath $registryPath)) { - if ($DryRun) { - $skipped++ - continue - } - - New-Item -Path $registryPath -Force | Out-Null - } - - $currentValue = $null - try { - $currentValue = (Get-ItemProperty -LiteralPath $registryPath -Name $entry.name -ErrorAction SilentlyContinue).$($entry.name) - } - catch { - $currentValue = $null - } - - if ($null -ne $currentValue -and $currentValue -eq $desiredValue) { - $skipped++ - continue - } - - if ($DryRun) { - $skipped++ - continue - } - - Set-ItemProperty -LiteralPath $registryPath -Name $entry.name -Value $desiredValue -Type $valueType -Force - $applied++ - } - catch { - $failed++ - } -} - -return [pscustomobject]@{ - Applied = $applied - Skipped = $skipped - Failed = $failed -} +throw "DeclarativeConfig module not found at $DeclarativeConfigModule" diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 3eb7ed6..382e2b2 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -30,6 +30,7 @@ $OptionalWingetMarker = Join-Path $SetupPath "optional-winget.completed" $RegistryConfig = Join-Path $SetupPath "config\registry.json" $RegistryScript = Join-Path $SetupPath "apply-registry.ps1" $StateFile = Join-Path $SetupPath "state.json" +$ProgressFile = Join-Path $SetupPath "progress.json" $CanonicalRepoPath = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "declarative-windows" $CanonicalBootstrap = Join-Path $CanonicalRepoPath "bootstrap.ps1" $SophiaDir = Join-Path $SetupPath "Sophia-Script" @@ -44,6 +45,98 @@ $SummaryItems = [System.Collections.Generic.List[object]]::new() $FailedItems = [System.Collections.Generic.List[object]]::new() $script:BackupManifestPath = $null $script:BackupManifest = $null +$script:ProgressState = [ordered]@{ + phase = "Starting" + status = "Initializing setup" + currentPackage = "" + packageIndex = 0 + packageTotal = 0 + mode = "admin" + lastUpdated = $null +} + +function Update-SetupProgress { + param( + [string]$Phase, + [string]$Status, + [string]$CurrentPackage, + [Nullable[int]]$PackageIndex, + [Nullable[int]]$PackageTotal, + [string]$Mode, + [switch]$ResetPackage + ) + + if ($PSBoundParameters.ContainsKey('Phase')) { + $script:ProgressState.phase = $Phase + } + + if ($PSBoundParameters.ContainsKey('Status')) { + $script:ProgressState.status = $Status + } + + if ($ResetPackage) { + $script:ProgressState.currentPackage = "" + $script:ProgressState.packageIndex = 0 + $script:ProgressState.packageTotal = 0 + } + + if ($PSBoundParameters.ContainsKey('CurrentPackage')) { + $script:ProgressState.currentPackage = $CurrentPackage + } + + if ($PSBoundParameters.ContainsKey('PackageIndex') -and $null -ne $PackageIndex) { + $script:ProgressState.packageIndex = $PackageIndex + } + + if ($PSBoundParameters.ContainsKey('PackageTotal') -and $null -ne $PackageTotal) { + $script:ProgressState.packageTotal = $PackageTotal + } + + if ($PSBoundParameters.ContainsKey('Mode')) { + $script:ProgressState.mode = $Mode + } + + $script:ProgressState.lastUpdated = (Get-Date).ToString('o') + [pscustomobject]$script:ProgressState | ConvertTo-Json -Depth 4 | Set-Content -Path $ProgressFile -Encoding UTF8 -Force +} + +function Update-WingetProgressFromLine { + param( + [string]$Line, + [string]$Phase, + [string]$Mode + ) + + if ($Line -match '^\((\d+)/(\d+)\)\s+Found .* \[(.+?)\]') { + $packageIndex = [int]$Matches[1] + $packageTotal = [int]$Matches[2] + $packageId = $Matches[3] + $lastPackage = $script:ProgressState.currentPackage + + Update-SetupProgress -Phase $Phase -Status ("Installing package {0} of {1}" -f $packageIndex, $packageTotal) -CurrentPackage $packageId -PackageIndex $packageIndex -PackageTotal $packageTotal -Mode $Mode + + if ($lastPackage -ne $packageId) { + Write-Log ("Installing [{0}/{1}]: {2} ({3})" -f $packageIndex, $packageTotal, $packageId, $Mode) -Level INFO + } + + return + } + + if ($Line -match 'Starting package install') { + $currentPackage = $script:ProgressState.currentPackage + if ($currentPackage) { + Update-SetupProgress -Phase $Phase -Status "Running installer" -CurrentPackage $currentPackage -Mode $Mode + } + return + } + + if ($Line -match 'Successfully installed') { + $currentPackage = $script:ProgressState.currentPackage + if ($currentPackage) { + Update-SetupProgress -Phase $Phase -Status "Installed successfully" -CurrentPackage $currentPackage -Mode $Mode + } + } +} function Write-Log { param( @@ -410,25 +503,229 @@ function Write-FilteredAppsJson { [string]$OutputPath ) - $filteredSources = foreach ($source in $AppsData.Sources) { - $filteredPackages = $source.Packages | Where-Object { - $PackageIds -contains $_.PackageIdentifier - } + $filteredSources = @( + foreach ($source in $AppsData.Sources) { + $filteredPackages = @($source.Packages | Where-Object { + $PackageIds -contains $_.PackageIdentifier + }) - if ($filteredPackages.Count -gt 0) { - [pscustomobject]@{ - Packages = $filteredPackages - SourceDetails = $source.SourceDetails + if ($filteredPackages.Count -gt 0) { + [pscustomobject]@{ + Packages = $filteredPackages + SourceDetails = $source.SourceDetails + } } } - } + ) $filteredData = [pscustomobject]@{ '$schema' = $AppsData.'$schema' + CreationDate = $AppsData.CreationDate Sources = $filteredSources + WinGetVersion = $AppsData.WinGetVersion + } + + $filteredData | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 -Force +} + +function Write-WingetOutput { + param( + [object[]]$Output, + [string]$Prefix = "WinGet:" + ) + + foreach ($line in @($Output)) { + $text = "$line" + $trimmed = $text.Trim() + + if (-not $trimmed) { + continue + } + + if ($trimmed -match '^[\|/\\-]+$') { + continue + } + + if ($trimmed -match '\d+(\.\d+)?\s*(KB|MB|GB)\s*/\s*\d+(\.\d+)?\s*(KB|MB|GB)') { + continue + } + + Write-Log "$Prefix $text" -Level INFO + } +} + +function Test-WingetRequiresUnelevatedRetry { + param([object[]]$Output) + + foreach ($line in @($Output)) { + if ("$line" -match "cannot be run from an administrator context|cannot be run as administrator|administrator context is not supported") { + return $true + } + } + + return $false +} + +function Invoke-WingetPackageInstall { + param( + [Parameter(Mandatory)] + [string]$PackageId, + + [switch]$Unelevated, + + [string]$Mode = 'admin', + + [int]$PackageIndex = 0, + + [int]$PackageTotal = 0, + + [int]$TimeoutSeconds = 14400 + ) + + if (-not $Unelevated) { + $output = New-Object System.Collections.Generic.List[string] + $arguments = @( + 'install', + '--id', $PackageId, + '--exact', + '--accept-package-agreements', + '--accept-source-agreements' + ) + + & winget @arguments 2>&1 | ForEach-Object { + $line = $_.ToString() + $output.Add($line) + Update-WingetProgressFromLine -Line $line -Phase 'Installing packages' -Mode $Mode + } + + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = @($output) + } + } + + $runnerPath = Join-Path $env:TEMP "winget-install-runner-$(Get-Random).ps1" + $resultPath = Join-Path $env:TEMP "winget-install-result-$(Get-Random).json" + $taskName = "WingetInstallUnelevated-$(Get-Random)" + + try { + $escapedPackageId = $PackageId.Replace("'", "''") + $escapedResultPath = $resultPath.Replace("'", "''") + $escapedProgressFile = $ProgressFile.Replace("'", "''") + $runnerContent = @" +`$Host.UI.RawUI.WindowTitle = 'WinGet User-Scope Retry' + +function Update-ProgressFile { + param( + [string]`$Phase, + [string]`$Status, + [string]`$CurrentPackage, + [int]`$PackageIndex = 0, + [int]`$PackageTotal = 0 + ) + + [pscustomobject]@{ + phase = `$Phase + status = `$Status + currentPackage = `$CurrentPackage + packageIndex = `$PackageIndex + packageTotal = `$PackageTotal + mode = 'user' + lastUpdated = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path '$escapedProgressFile' -Encoding UTF8 -Force +} + +Write-Host 'Starting user-scope WinGet retry...' -ForegroundColor Cyan +Write-Host 'This window will show package installs that cannot run as administrator.' -ForegroundColor Cyan + +`$output = New-Object System.Collections.Generic.List[string] +Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Installing package $PackageIndex of $PackageTotal' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal +winget install --id '$escapedPackageId' --exact --accept-package-agreements --accept-source-agreements 2>&1 | ForEach-Object { + `$line = `$_.ToString() + `$output.Add(`$line) + Write-Host `$line + + if (`$line -match 'Starting package install') { + Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Running installer' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal + } + elseif (`$line -match 'Successfully installed') { + Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Installed successfully' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal } +} + +`$exitCode = `$LASTEXITCODE +Write-Host "WinGet retry finished with exit code `$exitCode" -ForegroundColor Cyan +[pscustomobject]@{ + ExitCode = `$exitCode + Output = @(`$output) +} | ConvertTo-Json -Depth 5 | Set-Content -Path '$escapedResultPath' -Encoding UTF8 -Force +"@ - $filteredData | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Force + Set-Content -Path $runnerPath -Value $runnerContent -Encoding UTF8 -Force + + $taskUser = if ($env:USERDOMAIN) { "$($env:USERDOMAIN)\$($env:USERNAME)" } else { $env:USERNAME } + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$runnerPath`"" + $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $taskPrincipal = New-ScheduledTaskPrincipal -UserId $taskUser -LogonType Interactive -RunLevel Limited + + try { + $null = Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Force -ErrorAction Stop + } + catch { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Failed to create non-admin scheduled task", $_.Exception.Message) + } + } + + try { + Start-ScheduledTask -TaskName $taskName -ErrorAction Stop + } + catch { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Failed to start non-admin scheduled task", $_.Exception.Message) + } + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path $resultPath) { + break + } + + Start-Sleep -Seconds 2 + } + + if (-not (Test-Path $resultPath)) { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Timed out waiting for non-admin WinGet install to finish") + } + } + + $result = Get-Content -Path $resultPath -Raw | ConvertFrom-Json + $output = @() + if ($null -ne $result.Output) { + $output = @($result.Output) + } + + return [pscustomobject]@{ + ExitCode = [int]$result.ExitCode + Output = $output + } + } + finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + if (Test-Path $runnerPath) { + Remove-Item -Path $runnerPath -Force -ErrorAction SilentlyContinue + } + + if (Test-Path $resultPath) { + Remove-Item -Path $resultPath -Force -ErrorAction SilentlyContinue + } + } } function Invoke-WingetManifestInstall { @@ -453,6 +750,7 @@ function Invoke-WingetManifestInstall { ) $tempAppsJson = $null + $retryAppsJson = $null if (Test-Path $ManifestPath) { try { @@ -480,13 +778,33 @@ function Invoke-WingetManifestInstall { $markerHash = (Get-Content -Path $MarkerPath -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() } - $missingPackages = foreach ($packageId in $packageIds) { - if (-not (Test-WingetPackageInstalled -PackageId $packageId)) { - $packageId + $missingPackages = New-Object System.Collections.Generic.List[string] + $installedCount = 0 + $totalPackages = $packageIds.Count + + Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package 1 of {0}" -f $totalPackages) -CurrentPackage '' -PackageIndex 0 -PackageTotal $totalPackages -Mode 'admin' + + for ($index = 0; $index -lt $totalPackages; $index++) { + $packageId = $packageIds[$index] + $currentNumber = $index + 1 + + Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package {0} of {1}" -f $currentNumber, $totalPackages) -CurrentPackage $packageId -PackageIndex $currentNumber -PackageTotal $totalPackages -Mode 'admin' + + Write-Log "[$currentNumber/$totalPackages] Checking package: $packageId" -Level INFO + + if (Test-WingetPackageInstalled -PackageId $packageId) { + $installedCount++ + Write-Log "[$currentNumber/$totalPackages] Already installed: $packageId" -Level INFO + } + else { + $missingPackages.Add($packageId) + Write-Log "[$currentNumber/$totalPackages] Missing: $packageId" -Level INFO } } - if (-not $missingPackages) { + Write-Log ("Package scan complete for {0}: {1} missing, {2} already installed" -f $ManifestLabel, $missingPackages.Count, $installedCount) -Level INFO + + if ($missingPackages.Count -eq 0) { Write-Log "All packages from $ManifestLabel are already installed" -Level SUCCESS Set-Content -Path $MarkerPath -Value $appsHash -Force Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Already up to date ($MarkerPath)" @@ -498,81 +816,109 @@ function Invoke-WingetManifestInstall { Write-Log "$ManifestLabel changed since last WinGet run" -Level INFO } + Write-Log "Keep this window open. Some installers may open their own windows or ask for confirmation." -Level INFO Write-Log "Installing $($missingPackages.Count) missing packages from $ManifestLabel" -Level INFO + Update-SetupProgress -Phase 'Installing packages' -Status ("Installing 1 of {0}" -f $missingPackages.Count) -CurrentPackage '' -PackageIndex 0 -PackageTotal $missingPackages.Count -Mode 'admin' + $installedPackages = [System.Collections.Generic.List[string]]::new() + $unverifiedPackages = [System.Collections.Generic.List[string]]::new() $failedPackages = [System.Collections.Generic.List[string]]::new() + $packageNumber = 0 - foreach ($packageId in $missingPackages) { + foreach ($packageId in @($missingPackages)) { + $packageNumber++ $installed = $false - $scopesToTry = @('user', 'machine') + $verified = $false + $unverified = $false - foreach ($scope in $scopesToTry) { - Write-Log " Installing $packageId (scope: $scope)..." -Level INFO + Write-Log ("Installing [{0}/{1}]: {2} (admin)" -f $packageNumber, $missingPackages.Count, $packageId) -Level INFO + Update-SetupProgress -Phase 'Installing packages' -Status ("Installing package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'admin' - $stdoutFile = Join-Path $env:TEMP "winget-stdout-$packageId-$(Get-Random).log" - $stderrFile = Join-Path $env:TEMP "winget-stderr-$packageId-$(Get-Random).log" + $installResult = Invoke-WingetPackageInstall -PackageId $packageId -Mode 'admin' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + Write-WingetOutput -Output $installResult.Output -Prefix "WinGet:" - $proc = Start-Process -FilePath "winget" -ArgumentList "install", $packageId, "--accept-package-agreements", "--accept-source-agreements", "--scope", $scope -NoNewWindow -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile - $proc.WaitForExit() - - # Stream stdout in real-time - if (Test-Path $stdoutFile) { - Get-Content $stdoutFile | ForEach-Object { - Write-Log " $_" -Level INFO - } - } - - # Stream stderr as warnings - if (Test-Path $stderrFile) { - $stderrContent = Get-Content $stderrFile -Raw - if ($stderrContent -and $stderrContent.Trim()) { - Get-Content $stderrFile | ForEach-Object { - Write-Log " $_" -Level WARNING - } - } - } + if (Test-WingetPackageInstalled -PackageId $packageId) { + $installed = $true + $verified = $true + } + elseif (Test-WingetRequiresUnelevatedRetry -Output $installResult.Output) { + Write-Log "Retrying $packageId in a non-administrator session" -Level INFO + Write-Log "A second PowerShell window may appear for user-scope installers. Leave it open until it finishes." -Level INFO + Update-SetupProgress -Phase 'Retrying user-scope packages' -Status ("Retrying package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'user' - # Clean up temp output files - if (Test-Path $stdoutFile) { Remove-Item $stdoutFile -Force -ErrorAction SilentlyContinue } - if (Test-Path $stderrFile) { Remove-Item $stderrFile -Force -ErrorAction SilentlyContinue } + $retryResult = Invoke-WingetPackageInstall -PackageId $packageId -Unelevated -Mode 'user' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + Write-WingetOutput -Output $retryResult.Output -Prefix "WinGet (user):" - # Check if installed if (Test-WingetPackageInstalled -PackageId $packageId) { - Write-Log " Successfully installed $packageId (scope: $scope)" -Level SUCCESS $installed = $true - break + $verified = $true } - - if ($scope -eq 'user') { - Write-Log " User scope failed for $packageId, trying machine scope..." -Level WARNING + elseif ($retryResult.ExitCode -eq 0) { + $installed = $true + $unverified = $true } + else { + Write-Log "Non-admin WinGet install exited with code $($retryResult.ExitCode) for $packageId" -Level WARNING + } + } + elseif ($installResult.ExitCode -eq 0) { + $installed = $true + $unverified = $true + } + else { + Write-Log "WinGet install exited with code $($installResult.ExitCode) for $packageId" -Level WARNING + } + + if ($verified) { + $installedPackages.Add($packageId) + Write-Log "Successfully installed and verified $packageId" -Level SUCCESS + continue + } + + if ($unverified) { + $unverifiedPackages.Add($packageId) + Add-FailedItem -Category "$SummaryStep Verification" -Item $packageId -Reason "WinGet reported success but winget list did not verify the package" + Write-Log "WARNING: $packageId install reported success, but winget list did not verify it" -Level WARNING + continue } if (-not $installed) { $failedPackages.Add($packageId) - Write-Log "WARNING: $packageId failed to install (both user and machine scopes failed)" -Level WARNING + Write-Log "WARNING: $packageId failed to install" -Level WARNING } } - $stillMissing = $failedPackages - - foreach ($packageId in $stillMissing) { - Add-FailedItem -Category $SummaryStep -Item $packageId -Reason "Not installed after import from $ManifestLabel" - Write-Log "WARNING: $packageId still not installed after WinGet import from $ManifestLabel" -Level WARNING + foreach ($packageId in $failedPackages) { + Add-FailedItem -Category $SummaryStep -Item $packageId -Reason "Not installed after per-package install from $ManifestLabel" + Write-Log "WARNING: $packageId still not installed after WinGet install from $ManifestLabel" -Level WARNING } - if (-not $stillMissing) { - Write-Log "WinGet import from $ManifestLabel completed successfully" -Level SUCCESS + $failCount = @($failedPackages).Count + $unverifiedCount = @($unverifiedPackages).Count + + if ($failCount -eq 0) { + Update-SetupProgress -Phase 'Installing packages' -Status 'Completed' -CurrentPackage '' -PackageIndex $missingPackages.Count -PackageTotal $missingPackages.Count -Mode 'admin' + Write-Log "WinGet install from $ManifestLabel completed successfully" -Level SUCCESS Set-Content -Path $MarkerPath -Value $appsHash -Force - Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Installed $($missingPackages.Count) packages" - Set-StepState -StepId $StepId -Status "done" -Message "Installed $($missingPackages.Count) packages" + + if ($unverifiedCount -gt 0) { + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "Installed $($installedPackages.Count) package(s); $unverifiedCount verification warning(s)" + Set-StepState -StepId $StepId -Status "done" -Message "Installed with $unverifiedCount verification warning(s)" + } + else { + Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Installed $($installedPackages.Count) package(s)" + Set-StepState -StepId $StepId -Status "done" -Message "Installed $($installedPackages.Count) package(s)" + } + return $true } - $failCount = @($stillMissing).Count - Write-Log "WinGet import from $ManifestLabel finished; $failCount package(s) failed" -Level WARNING - Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "$failCount package(s) failed - see Failed Installs.txt" - Set-StepState -StepId $StepId -Status "failed" -Message "$failCount package(s) failed" + Update-SetupProgress -Phase 'Installing packages' -Status ("Completed with {0} failure(s)" -f $failCount) -CurrentPackage '' -PackageIndex ($missingPackages.Count - $failCount) -PackageTotal $missingPackages.Count -Mode 'admin' + Write-Log "WinGet install from $ManifestLabel finished; $failCount package(s) failed" -Level WARNING + + $warningSuffix = if ($unverifiedCount -gt 0) { "; $unverifiedCount verification warning(s)" } else { "" } + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "$failCount package(s) failed$warningSuffix - see Failed Installs.txt" + Set-StepState -StepId $StepId -Status "failed" -Message "$failCount package(s) failed$warningSuffix" return $false } catch { @@ -586,6 +932,10 @@ function Invoke-WingetManifestInstall { if ($tempAppsJson -and (Test-Path $tempAppsJson)) { Remove-Item -Path $tempAppsJson -Force -ErrorAction SilentlyContinue } + + if ($retryAppsJson -and (Test-Path $retryAppsJson)) { + Remove-Item -Path $retryAppsJson -Force -ErrorAction SilentlyContinue + } } } @@ -641,7 +991,21 @@ function Set-RegistryValueSafe { New-Item -Path $Path -Force | Out-Null } - Set-ItemProperty -Path $Path -Name $Name -Value $Value -Type $Type -Force + if ($Type -eq "DWord") { + $typedValue = [int]$Value + $propertyType = "DWord" + } + else { + $typedValue = [string]$Value + $propertyType = "String" + } + + if ($Name -eq "(Default)") { + Set-Item -Path $Path -Value $typedValue -Force + return + } + + New-ItemProperty -Path $Path -Name $Name -Value $typedValue -PropertyType $propertyType -Force | Out-Null } function Remove-ProvisionedAppIfPresent { @@ -879,11 +1243,21 @@ function Get-RunBootstrapTarget { return (Join-Path $SetupPath "bootstrap.ps1") } +$ModuleRoot = Join-Path $PSScriptRoot "modules" +foreach ($moduleName in @("BootstrapRun.ps1", "BackupManifest.ps1", "WinGetInstall.ps1")) { + $modulePath = Join-Path $ModuleRoot $moduleName + if (Test-Path $modulePath) { + . $modulePath + } +} + try { Write-Log "========================================" -Level INFO Write-Log "Windows Setup Bootstrap - Starting" -Level INFO Write-Log "========================================" -Level INFO + Update-SetupProgress -Phase 'Starting' -Status 'Bootstrapping setup' -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' + $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { Write-Log "ERROR: This script must be run as Administrator" -Level ERROR @@ -899,6 +1273,7 @@ try { $SetupState = Initialize-State -StatePath $StateFile -StepIds $StepIds Save-State -State $SetupState -StatePath $StateFile + Update-SetupProgress -Phase 'Preparing setup' -Status 'Administrator privileges verified' -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' if ($DryRun) { Write-Log "Dry run mode enabled; no system changes will be applied" -Level WARNING @@ -906,6 +1281,7 @@ try { if ($OptionalAppsOnly) { Write-Log "Optional apps only mode enabled; skipping core setup steps" -Level INFO + Update-SetupProgress -Phase 'Preparing setup' -Status 'Optional apps only mode enabled' -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' } $stepId = "winget" @@ -943,7 +1319,7 @@ try { if (-not $manifest) { Write-Log "No backup manifest found; canonical repo clone skipped" -Level WARNING Add-SummaryItem -Step "Repo" -Status "WARN" -Message "Backup manifest not found; using C:\Setup fallback" - Set-StepState -StepId $stepId -Status "failed" -Message "Backup manifest not found" + Set-StepState -StepId $stepId -Status "skipped" -Message "Backup manifest not found; using C:\Setup fallback" } elseif (Ensure-CanonicalRepo -Manifest $manifest) { $null = Restore-RepoFilesFromManifest -Manifest $manifest @@ -1214,7 +1590,9 @@ try { } elseif (-not (Test-Path $OptionalAppsJson)) { if ($OptionalAppsOnly) { - $null = Invoke-WingetManifestInstall -ManifestPath $OptionalAppsJson -StepId $stepId -SummaryStep "Optional Apps" -MarkerPath $OptionalWingetMarker -ManifestLabel "optional-apps.json" -MissingManifestMessage "optional-apps.json not found" + Write-Log "WARNING: optional-apps.json not found at $OptionalAppsJson" -Level WARNING + Add-SummaryItem -Step "Optional Apps" -Status "WARN" -Message "optional-apps.json not found" + Set-StepState -StepId $stepId -Status "done" -Message "optional-apps.json not found" } } else { @@ -1270,6 +1648,8 @@ try { Write-Log "========================================" -Level INFO Write-Log "Log file saved to: $LogFile" -Level INFO + Update-SetupProgress -Phase 'Completed' -Status 'Windows setup bootstrap completed' -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' + if ($PromptRestart) { $restartResponse = Read-Host "Restart now? (Y/N)" if ($restartResponse -match '^(y|yes)$') { @@ -1284,5 +1664,6 @@ try { catch { Write-Log "FATAL ERROR: $($_.Exception.Message)" -Level ERROR Write-Log "Stack Trace: $($_.ScriptStackTrace)" -Level ERROR + Update-SetupProgress -Phase 'Failed' -Status $_.Exception.Message -CurrentPackage '' -PackageIndex 0 -PackageTotal 0 -Mode 'admin' exit 1 } diff --git a/build-iso.ps1 b/build-iso.ps1 index f6f6073..01285bc 100644 --- a/build-iso.ps1 +++ b/build-iso.ps1 @@ -100,73 +100,10 @@ function Validate-SourceIsoHash { Write-Success "Source ISO checksum verified" } -function Get-UnattendSetupFileReferences { - param( - [Parameter(Mandatory)] - [string]$UnattendPath - ) - - $document = [xml](Get-Content -Path $UnattendPath -Raw) - $namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable) - [void]$namespaceManager.AddNamespace('u', 'urn:schemas-microsoft-com:unattend') - - $references = [System.Collections.Generic.List[string]]::new() - $commandNodes = $document.SelectNodes('//u:FirstLogonCommands/u:SynchronousCommand/u:CommandLine', $namespaceManager) - - foreach ($commandNode in $commandNodes) { - foreach ($match in [regex]::Matches($commandNode.InnerText, '(?i)\bC:\\Setup\\[^\s"'';]+')) { - $references.Add($match.Value) - } - } - - return $references | Sort-Object -Unique -} - -function Validate-StagedIsoLayout { - param( - [Parameter(Mandatory)] - [string]$WorkRoot, - - [Parameter(Mandatory)] - [string]$UnattendPath, - - [Parameter(Mandatory)] - [bool]$HasOptionalApps - ) - - $stagedSetupRoot = Join-Path $WorkRoot 'sources\$OEM$\$1\Setup' - $requiredStagedFiles = @( - (Join-Path $WorkRoot 'autounattend.xml'), - (Join-Path $stagedSetupRoot 'bootstrap.ps1'), - (Join-Path $stagedSetupRoot 'apps.json'), - (Join-Path $stagedSetupRoot 'Sophia-Preset.ps1'), - (Join-Path $stagedSetupRoot 'restore-backup.ps1'), - (Join-Path $stagedSetupRoot 'apply-registry.ps1'), - (Join-Path $stagedSetupRoot 'config\registry.json'), - (Join-Path $stagedSetupRoot 'config\backup.template.json') - ) - - if ($HasOptionalApps) { - $requiredStagedFiles += Join-Path $stagedSetupRoot 'optional-apps.json' - } - - foreach ($stagedFile in $requiredStagedFiles) { - if (-not (Test-Path $stagedFile -PathType Leaf)) { - throw "Staged ISO is missing required file: $stagedFile" - } - } - - # Proven build-time check: every C:\Setup reference in unattend must resolve to the staged $OEM$ payload. - foreach ($setupReference in (Get-UnattendSetupFileReferences -UnattendPath $UnattendPath)) { - $relativePath = $setupReference.Substring('C:\Setup\'.Length) - $stagedReference = Join-Path $stagedSetupRoot $relativePath - - if (-not (Test-Path $stagedReference -PathType Leaf)) { - throw "autounattend.xml references $setupReference, but staged ISO is missing $stagedReference" - } - } - - Write-Success 'Staged ISO layout validation passed' +$ModuleRoot = Join-Path $ScriptRoot "modules" +$StagedSetupPayloadModule = Join-Path $ModuleRoot "StagedSetupPayload.ps1" +if (Test-Path $StagedSetupPayloadModule) { + . $StagedSetupPayloadModule } # Function to find oscdimg.exe from Windows ADK @@ -273,6 +210,10 @@ try { "backup.template.json" = Join-Path $ScriptRoot "config\backup.template.json" } + $requiredDirectories = @{ + "modules" = Join-Path $ScriptRoot "modules" + } + $optionalFiles = @{ "optional-apps.json" = Join-Path $ScriptRoot "optional-apps.json" } @@ -287,6 +228,16 @@ try { } } + foreach ($directory in $requiredDirectories.GetEnumerator()) { + if (Test-Path $directory.Value -PathType Container) { + Write-Success "$($directory.Key) found" + } + else { + Write-ErrorMessage "$($directory.Key) not found at: $($directory.Value)" + throw "Missing required directory: $($directory.Key)" + } + } + foreach ($file in $optionalFiles.GetEnumerator()) { if (Test-Path $file.Value) { Write-Success "$($file.Key) found" @@ -378,6 +329,9 @@ try { Write-Success "$($file.Name) copied" } + Copy-Item -Path $requiredDirectories["modules"] -Destination (Join-Path $setupPath "modules") -Recurse -Force + Write-Success "modules folder copied" + if (Test-Path $optionalFiles["optional-apps.json"]) { Copy-Item -Path $optionalFiles["optional-apps.json"] -Destination $setupPath -Force Write-Success "optional-apps.json copied" diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 33ee89e..62ca233 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -124,6 +124,62 @@ Failed to download package --- +### Package Shows as Verification Warning + +**Symptoms:** + +```text +WinGet reported success but winget list did not verify the package +``` + +**What it means:** + +- The installer exited successfully. +- `winget list --id --exact` did not find the package afterward. +- This can happen when a package installs under a different display name, creates a per-user install, or registers after a delay. + +**Solutions:** + +1. **Check manually:** + ```powershell + winget list --id --exact + winget list | Select-String "" + ``` + +2. **Open the app once** if it uses a first-run registration step. + +3. **Only reinstall if it is truly missing:** + ```powershell + winget install --id --exact --accept-package-agreements --accept-source-agreements + ``` + +--- + +### Package Failed After Per-Package Install + +**Symptoms:** + +```text + still not installed after WinGet install +``` + +**Solutions:** + +1. **Review the reports:** + ```powershell + Get-Content C:\Setup\failed-installs.log + Get-Content "$env:USERPROFILE\Desktop\Failed Installs.txt" + ``` + +2. **Retry one package directly:** + ```powershell + winget install --id --exact --accept-package-agreements --accept-source-agreements + ``` + +3. **If WinGet says the installer cannot run as administrator,** re-run setup from the desktop shortcut or install that package from a non-admin PowerShell session. + +--- + ## Sophia Script Issues ### Sophia Script Not Found @@ -418,6 +474,36 @@ cannot be loaded because running scripts is disabled --- +### Backup Manifest Not Found + +**Symptoms:** + +```text +Backup manifest not found; using C:\Setup fallback +``` + +**What it means:** + +- Setup did not find `backup-manifest.json` on another drive. +- Bootstrap continues from the staged setup payload in `C:\Setup`. +- Repo clone and personal repo file restore are skipped until a manifest is available. + +**Solutions:** + +1. **If you made a preflight backup, attach that drive and run restore manually:** + ```powershell + powershell.exe -ExecutionPolicy Bypass -File C:\Setup\restore-backup.ps1 + ``` + +2. **If autodetection fails, pass the manifest path explicitly:** + ```powershell + powershell.exe -ExecutionPolicy Bypass -File C:\Setup\restore-backup.ps1 -ManifestPath "E:\declarative-windows-backup\\backup-manifest.json" + ``` + +3. **If you did not make a preflight backup,** clone the repo manually later and copy any personal config files back into place. + +--- + ## Network & Connectivity ### No Network During FirstLogonCommands diff --git a/modules/BackupManifest.ps1 b/modules/BackupManifest.ps1 new file mode 100644 index 0000000..a7c148b --- /dev/null +++ b/modules/BackupManifest.ps1 @@ -0,0 +1,170 @@ +function Find-BackupManifest { + $drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { + $_.Root -ne "$($env:SystemDrive)\" + } + + $candidates = foreach ($drive in $drives) { + $root = $drive.Root + $container = Join-Path $root "declarative-windows-backup" + if (-not (Test-Path $container)) { + continue + } + + Get-ChildItem -Path $container -Filter "backup-manifest.json" -Recurse -File -ErrorAction SilentlyContinue + } + + return ($candidates | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).FullName +} + +function Get-BackupManifestRoot { + param([object]$Manifest) + + if ($Manifest.backup -and $Manifest.backup.backupRoot) { + return [Environment]::ExpandEnvironmentVariables($Manifest.backup.backupRoot) + } + + return $null +} + +function Get-RestoreTargetMap { + param([object]$Manifest) + + $restoreTargetMap = @{} + if ($Manifest.restoreTargets) { + foreach ($prop in $Manifest.restoreTargets.PSObject.Properties) { + $restoreTargetMap[$prop.Name] = $prop.Value + } + } + + return $restoreTargetMap +} + +function Resolve-BackupSourcePath { + param( + [string]$Path, + + [string]$ManifestBackupRoot, + [string]$ActualBackupRoot + ) + + $expandedPath = [Environment]::ExpandEnvironmentVariables($Path) + if (-not [System.IO.Path]::IsPathRooted($expandedPath)) { + if ($ActualBackupRoot) { + return Join-Path $ActualBackupRoot $expandedPath + } + + return $expandedPath + } + + if (Test-Path $expandedPath) { + return $expandedPath + } + + if ($ManifestBackupRoot -and $ActualBackupRoot -and $expandedPath.StartsWith($ManifestBackupRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + $relativePath = $expandedPath.Substring($ManifestBackupRoot.Length).TrimStart('\') + $candidatePath = if ($relativePath) { + Join-Path $ActualBackupRoot $relativePath + } + else { + $ActualBackupRoot + } + + if (Test-Path $candidatePath) { + if (Get-Command Write-Info -ErrorAction SilentlyContinue) { + Write-Info "Using remapped backup path: $candidatePath" + } + return $candidatePath + } + } + + return $expandedPath +} + +function Resolve-RestoreTargetPath { + param( + [string]$Path, + [string]$ProfileRoot, + [string]$OriginalOsDrive, + [hashtable]$RestoreTargetMap + ) + + $expandedPath = [Environment]::ExpandEnvironmentVariables($Path) + + if ($ProfileRoot) { + $currentProfile = [Environment]::ExpandEnvironmentVariables("%USERPROFILE%") + if ($expandedPath.StartsWith($currentProfile, [System.StringComparison]::OrdinalIgnoreCase)) { + $relativePath = $expandedPath.Substring($currentProfile.Length).TrimStart('\') + return Join-Path $ProfileRoot $relativePath + } + } + + if ($OriginalOsDrive -and $env:SystemDrive -ne $OriginalOsDrive) { + $osDriveSlash = $OriginalOsDrive + "\" + if ($expandedPath.StartsWith($osDriveSlash, [System.StringComparison]::OrdinalIgnoreCase)) { + $currentOsDriveSlash = $env:SystemDrive + "\" + $relativePath = $expandedPath.Substring($OriginalOsDrive.Length) + $newPath = $currentOsDriveSlash + $relativePath.TrimStart('\') + + foreach ($key in $RestoreTargetMap.Keys) { + if ($newPath -and $RestoreTargetMap[$key]) { + $mapKeySlash = $key + "\" + $mapValueSlash = $RestoreTargetMap[$key] + "\" + if ($newPath.StartsWith($mapKeySlash, [System.StringComparison]::OrdinalIgnoreCase)) { + return $newPath.Replace($mapKeySlash, $mapValueSlash) + } + } + } + + return $newPath + } + } + + if ($RestoreTargetMap -and $RestoreTargetMap.Count -gt 0) { + foreach ($key in $RestoreTargetMap.Keys) { + if ($key -and $RestoreTargetMap[$key]) { + $keySlash = $key + "\" + if ($expandedPath.StartsWith($keySlash, [System.StringComparison]::OrdinalIgnoreCase)) { + $valueSlash = $RestoreTargetMap[$key] + "\" + return $expandedPath.Replace($keySlash, $valueSlash) + } + } + } + } + + if ($expandedPath -match '^[A-Za-z]:\\') { + if (Get-Command Write-Log -ErrorAction SilentlyContinue) { + Write-Log "Warning: Absolute path '$expandedPath' cannot be remapped - returning as-is" + } + else { + Write-Warning "Absolute path '$expandedPath' cannot be remapped - returning as-is" + } + } + + return $expandedPath +} + +function New-BackupManifest { + param( + [Parameter(Mandatory)][object]$Machine, + [Parameter(Mandatory)][object]$Repo, + [Parameter(Mandatory)][object]$Backup, + [Parameter(Mandatory)][object]$Config, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Rules, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$RepoFiles, + [Parameter(Mandatory)][object]$Exports, + [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Failures + ) + + return [ordered]@{ + manifestVersion = 1 + createdAt = (Get-Date).ToString("o") + machine = $Machine + repo = $Repo + backup = $Backup + config = $Config + rules = $Rules + repoFiles = $RepoFiles + exports = $Exports + failures = $Failures + } +} diff --git a/modules/BootstrapRun.ps1 b/modules/BootstrapRun.ps1 new file mode 100644 index 0000000..6e09c5d --- /dev/null +++ b/modules/BootstrapRun.ps1 @@ -0,0 +1,294 @@ +function Convert-StepsToHashtable { + param([object]$Steps) + + $stepsTable = [ordered]@{} + if ($Steps) { + foreach ($property in $Steps.PSObject.Properties) { + $stepsTable[$property.Name] = $property.Value + } + } + + return $stepsTable +} + +function Initialize-State { + param( + [string]$StatePath, + [string[]]$StepIds + ) + + $state = $null + if (Test-Path $StatePath) { + try { + $state = Get-Content -Path $StatePath -Raw | ConvertFrom-Json + } + catch { + $state = $null + } + } + + if (-not $state) { + $state = [pscustomobject]@{ + version = "1" + lastUpdated = (Get-Date).ToString("o") + steps = [ordered]@{} + } + } + + $state.steps = Convert-StepsToHashtable -Steps $state.steps + + foreach ($stepId in $StepIds) { + if (-not $state.steps.Contains($stepId)) { + $state.steps[$stepId] = [pscustomobject]@{ + status = "pending" + lastRun = $null + message = "" + } + } + } + + return $state +} + +function Save-State { + param( + [Parameter(Mandatory)] + [object]$State, + + [Parameter(Mandatory)] + [string]$StatePath + ) + + $State.lastUpdated = (Get-Date).ToString("o") + $State | ConvertTo-Json -Depth 6 | Set-Content -Path $StatePath -Force +} + +function Set-StepState { + param( + [Parameter(Mandatory)] + [string]$StepId, + + [Parameter(Mandatory)] + [string]$Status, + + [Parameter(Mandatory)] + [string]$Message + ) + + if (-not $SetupState.steps.Contains($StepId)) { + $SetupState.steps[$StepId] = [pscustomobject]@{ + status = "pending" + lastRun = $null + message = "" + } + } + + $SetupState.steps[$StepId].status = $Status + $SetupState.steps[$StepId].lastRun = (Get-Date).ToString("o") + $SetupState.steps[$StepId].message = $Message + Save-State -State $SetupState -StatePath $StateFile +} + +function Should-RunStep { + param([string]$StepId) + + if ($Force) { + return $true + } + + if (-not $SetupState) { + return $true + } + + if (-not $SetupState.steps.Contains($StepId)) { + return $true + } + + return $SetupState.steps[$StepId].status -ne "done" +} + +function Update-SetupProgress { + param( + [string]$Phase, + [string]$Status, + [string]$CurrentPackage, + [Nullable[int]]$PackageIndex, + [Nullable[int]]$PackageTotal, + [string]$Mode, + [switch]$ResetPackage + ) + + if ($PSBoundParameters.ContainsKey('Phase')) { + $script:ProgressState.phase = $Phase + } + + if ($PSBoundParameters.ContainsKey('Status')) { + $script:ProgressState.status = $Status + } + + if ($ResetPackage) { + $script:ProgressState.currentPackage = "" + $script:ProgressState.packageIndex = 0 + $script:ProgressState.packageTotal = 0 + } + + if ($PSBoundParameters.ContainsKey('CurrentPackage')) { + $script:ProgressState.currentPackage = $CurrentPackage + } + + if ($PSBoundParameters.ContainsKey('PackageIndex') -and $null -ne $PackageIndex) { + $script:ProgressState.packageIndex = $PackageIndex + } + + if ($PSBoundParameters.ContainsKey('PackageTotal') -and $null -ne $PackageTotal) { + $script:ProgressState.packageTotal = $PackageTotal + } + + if ($PSBoundParameters.ContainsKey('Mode')) { + $script:ProgressState.mode = $Mode + } + + $script:ProgressState.lastUpdated = (Get-Date).ToString('o') + [pscustomobject]$script:ProgressState | ConvertTo-Json -Depth 4 | Set-Content -Path $ProgressFile -Encoding UTF8 -Force +} + +function Add-SummaryItem { + param( + [Parameter(Mandatory)] + [string]$Step, + + [Parameter(Mandatory)] + [string]$Status, + + [Parameter(Mandatory)] + [string]$Message + ) + + $SummaryItems.Add([pscustomobject]@{ + Step = $Step + Status = $Status + Message = $Message + }) +} + +function Write-SummaryReport { + param([string]$DesktopPath) + + $summaryPath = Join-Path $DesktopPath "Setup Summary.txt" + $summaryLines = @( + "Declarative Windows Setup Summary", + "Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')", + "" + ) + + foreach ($item in $SummaryItems) { + $statusSymbol = switch ($item.Status) { + "OK" { "✓" } + "WARN" { "⚠" } + "FAIL" { "✗" } + default { $item.Status } + } + $summaryLines += "{0} {1}: {2}" -f $statusSymbol, $item.Step, $item.Message + } + + Set-Content -Path $summaryPath -Value $summaryLines -Force + return $summaryPath +} + +function Add-FailedItem { + param( + [Parameter(Mandatory)] + [string]$Category, + + [Parameter(Mandatory)] + [string]$Item, + + [string]$Reason = "" + ) + + $FailedItems.Add([pscustomobject]@{ + Category = $Category + Item = $Item + Reason = $Reason + }) +} + +function Write-FailedInstallsReport { + param([string]$DesktopPath) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $lines = @( + "Failed Installs - $timestamp", + "========================================" + ) + + if ($FailedItems.Count -eq 0) { + $lines += "" + $lines += "No failures recorded. Everything installed successfully." + } + else { + $categories = $FailedItems | Select-Object -ExpandProperty Category -Unique + foreach ($category in $categories) { + $lines += "" + $lines += "${category}:" + foreach ($entry in ($FailedItems | Where-Object { $_.Category -eq $category })) { + $detail = if ($entry.Reason) { " - $($entry.Reason)" } else { "" } + $lines += " - $($entry.Item)$detail" + } + } + $lines += "" + $lines += "========================================" + $lines += "Review the items above and install/apply them manually." + } + + $lines | Set-Content -Path $FailedInstallsLog -Force + + if ($DesktopPath) { + $desktopReport = Join-Path $DesktopPath "Failed Installs.txt" + $lines | Set-Content -Path $desktopReport -Force + return $desktopReport + } + + return $FailedInstallsLog +} + +function Invoke-BootstrapRunStep { + param( + [Parameter(Mandatory)] + [string]$StepId, + + [Parameter(Mandatory)] + [string]$StepName, + + [Parameter(Mandatory)] + [scriptblock]$Action, + + [scriptblock]$DryRunAction, + + [switch]$OptionalAppsOnlySkip + ) + + if ($OptionalAppsOnlySkip) { + Write-Log "Skipping $StepName (optional apps only mode)" -Level INFO + return $null + } + + if (-not (Should-RunStep -StepId $StepId)) { + Write-Log "Skipping $StepName (already completed)" -Level INFO + Add-SummaryItem -Step $StepName -Status "OK" -Message "Skipped (already completed)" + return $true + } + + if ($DryRun) { + if ($DryRunAction) { + return & $DryRunAction + } + + Write-Log "Dry run - skipping $StepName" -Level WARNING + Add-SummaryItem -Step $StepName -Status "WARN" -Message "Dry run: skipped" + Set-StepState -StepId $StepId -Status "pending" -Message "Dry run: skipped" + return $null + } + + return & $Action +} diff --git a/modules/DeclarativeConfig.ps1 b/modules/DeclarativeConfig.ps1 new file mode 100644 index 0000000..8d35f16 --- /dev/null +++ b/modules/DeclarativeConfig.ps1 @@ -0,0 +1,134 @@ +function Normalize-RegistryPath { + param([string]$Path) + + if ($Path -like "Registry::*") { + return $Path + } + + if ($Path -match '^(HKLM|HKCU|HKCR|HKU|HKCC)') { + return "Registry::$Path" + } + + return $Path +} + +function Convert-RegistryType { + param([string]$Type) + + switch ($Type.ToUpperInvariant()) { + "DWORD" { return "DWord" } + "STRING" { return "String" } + default { throw "Unsupported registry value type: $Type" } + } +} + +function Invoke-RegistryConfig { + param( + [Parameter(Mandatory)] + [string]$ConfigPath, + + [switch]$DryRun + ) + + $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json + $entries = $config.entries + + if (-not $entries) { + return [pscustomobject]@{ + Applied = 0 + Skipped = 0 + Failed = 0 + } + } + + $applied = 0 + $skipped = 0 + $failed = 0 + + foreach ($entry in $entries) { + try { + if (-not $entry.path -or -not $entry.name -or -not $entry.type) { + throw "Registry entry missing required fields (path, name, type)" + } + + $registryPath = Normalize-RegistryPath -Path $entry.path + $valueType = Convert-RegistryType -Type $entry.type + $desiredValue = $entry.value + + if ($valueType -eq "DWord") { + $desiredValue = [int]$desiredValue + } + else { + $desiredValue = [string]$desiredValue + } + + if (-not (Test-Path -LiteralPath $registryPath)) { + if ($DryRun) { + $skipped++ + continue + } + + New-Item -Path $registryPath -Force | Out-Null + } + + $valueName = if ($entry.name -eq "(Default)") { "" } else { $entry.name } + $currentValue = $null + $currentKind = $null + try { + $registryKey = Get-Item -LiteralPath $registryPath -ErrorAction Stop + $currentValue = $registryKey.GetValue($valueName, $null, "DoNotExpandEnvironmentNames") + if ($null -ne $currentValue) { + $currentKind = $registryKey.GetValueKind($valueName) + } + } + catch { + $currentValue = $null + $currentKind = $null + } + + if ($null -ne $currentValue -and $currentValue -eq $desiredValue -and "$currentKind" -eq $valueType) { + $skipped++ + continue + } + + if ($DryRun) { + $skipped++ + continue + } + + if ($entry.name -eq "(Default)") { + Set-Item -LiteralPath $registryPath -Value $desiredValue -Force + } + else { + New-ItemProperty -LiteralPath $registryPath -Name $entry.name -Value $desiredValue -PropertyType $valueType -Force | Out-Null + } + $applied++ + } + catch { + $failed++ + } + } + + return [pscustomobject]@{ + Applied = $applied + Skipped = $skipped + Failed = $failed + } +} + +function Invoke-DeclarativeConfig { + param( + [Parameter(Mandatory)] + [ValidateSet("Registry")] + [string]$Kind, + + [Parameter(Mandatory)] + [string]$ConfigPath, + + [switch]$DryRun + ) + + switch ($Kind) { + "Registry" { return Invoke-RegistryConfig -ConfigPath $ConfigPath -DryRun:$DryRun } + } +} diff --git a/modules/StagedSetupPayload.ps1 b/modules/StagedSetupPayload.ps1 new file mode 100644 index 0000000..e44db5d --- /dev/null +++ b/modules/StagedSetupPayload.ps1 @@ -0,0 +1,91 @@ +function Get-UnattendSetupFileReferences { + param( + [Parameter(Mandatory)] + [string]$UnattendPath + ) + + $document = [xml](Get-Content -Path $UnattendPath -Raw) + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($document.NameTable) + [void]$namespaceManager.AddNamespace('u', 'urn:schemas-microsoft-com:unattend') + + $references = [System.Collections.Generic.List[string]]::new() + $commandNodes = $document.SelectNodes('//u:FirstLogonCommands/u:SynchronousCommand/u:CommandLine', $namespaceManager) + + foreach ($commandNode in $commandNodes) { + foreach ($match in [regex]::Matches($commandNode.InnerText, '(?i)\bC:\\Setup\\[^\s"'';]+')) { + $references.Add($match.Value) + } + } + + return $references | Sort-Object -Unique +} + +function Get-StagedSetupRequiredFiles { + param( + [Parameter(Mandatory)] + [string]$WorkRoot, + + [Parameter(Mandatory)] + [bool]$HasOptionalApps + ) + + $stagedSetupRoot = Join-Path $WorkRoot 'sources\$OEM$\$1\Setup' + $requiredStagedFiles = @( + (Join-Path $WorkRoot 'autounattend.xml'), + (Join-Path $stagedSetupRoot 'bootstrap.ps1'), + (Join-Path $stagedSetupRoot 'apps.json'), + (Join-Path $stagedSetupRoot 'Sophia-Preset.ps1'), + (Join-Path $stagedSetupRoot 'restore-backup.ps1'), + (Join-Path $stagedSetupRoot 'apply-registry.ps1'), + (Join-Path $stagedSetupRoot 'modules\BootstrapRun.ps1'), + (Join-Path $stagedSetupRoot 'modules\WinGetInstall.ps1'), + (Join-Path $stagedSetupRoot 'modules\BackupManifest.ps1'), + (Join-Path $stagedSetupRoot 'modules\StagedSetupPayload.ps1'), + (Join-Path $stagedSetupRoot 'modules\DeclarativeConfig.ps1'), + (Join-Path $stagedSetupRoot 'config\registry.json'), + (Join-Path $stagedSetupRoot 'config\backup.template.json') + ) + + if ($HasOptionalApps) { + $requiredStagedFiles += Join-Path $stagedSetupRoot 'optional-apps.json' + } + + return $requiredStagedFiles +} + +function Validate-StagedIsoLayout { + param( + [Parameter(Mandatory)] + [string]$WorkRoot, + + [Parameter(Mandatory)] + [string]$UnattendPath, + + [Parameter(Mandatory)] + [bool]$HasOptionalApps + ) + + $stagedSetupRoot = Join-Path $WorkRoot 'sources\$OEM$\$1\Setup' + + foreach ($stagedFile in (Get-StagedSetupRequiredFiles -WorkRoot $WorkRoot -HasOptionalApps $HasOptionalApps)) { + if (-not (Test-Path $stagedFile -PathType Leaf)) { + throw "Staged ISO is missing required file: $stagedFile" + } + } + + foreach ($setupReference in (Get-UnattendSetupFileReferences -UnattendPath $UnattendPath)) { + $relativePath = $setupReference.Substring('C:\Setup\'.Length) + $stagedReference = Join-Path $stagedSetupRoot $relativePath + + if (-not (Test-Path $stagedReference -PathType Leaf)) { + throw "autounattend.xml references $setupReference, but staged ISO is missing $stagedReference" + } + } + + if (Get-Command Write-Success -ErrorAction SilentlyContinue) { + Write-Success 'Staged ISO layout validation passed' + } + else { + Write-Host 'Staged ISO layout validation passed' -ForegroundColor Green + } +} diff --git a/modules/WinGetInstall.ps1 b/modules/WinGetInstall.ps1 new file mode 100644 index 0000000..a8a0545 --- /dev/null +++ b/modules/WinGetInstall.ps1 @@ -0,0 +1,467 @@ +function Get-WingetPackageIdsFromJson { + param([string]$Path) + + $content = Get-Content -Path $Path -Raw + $data = $content | ConvertFrom-Json + $packageIds = @() + + foreach ($source in $data.Sources) { + foreach ($package in $source.Packages) { + if ($package.PackageIdentifier) { + $packageIds += $package.PackageIdentifier + } + } + } + + return ,@($packageIds | Sort-Object -Unique) +} + +function Test-WingetPackageInstalled { + param([string]$PackageId) + + $result = winget list --id $PackageId --exact 2>&1 + if ($LASTEXITCODE -ne 0) { + return $false + } + + return $result -match [regex]::Escape($PackageId) +} + +function Write-WingetOutput { + param( + [object[]]$Output, + [string]$Prefix = "WinGet:" + ) + + foreach ($line in @($Output)) { + $text = "$line" + $trimmed = $text.Trim() + + if (-not $trimmed) { + continue + } + + if ($trimmed -match '^[\|/\\-]+$') { + continue + } + + if ($trimmed -match '\d+(\.\d+)?\s*(KB|MB|GB)\s*/\s*\d+(\.\d+)?\s*(KB|MB|GB)') { + continue + } + + Write-Log "$Prefix $text" -Level INFO + } +} + +function Test-WingetRequiresUnelevatedRetry { + param([object[]]$Output) + + foreach ($line in @($Output)) { + if ("$line" -match "cannot be run from an administrator context|cannot be run as administrator|administrator context is not supported") { + return $true + } + } + + return $false +} + +function Update-WingetProgressFromLine { + param( + [string]$Line, + [string]$Phase, + [string]$Mode + ) + + if ($Line -match '^\((\d+)/(\d+)\)\s+Found .* \[(.+?)\]') { + $packageIndex = [int]$Matches[1] + $packageTotal = [int]$Matches[2] + $packageId = $Matches[3] + $lastPackage = $script:ProgressState.currentPackage + + Update-SetupProgress -Phase $Phase -Status ("Installing package {0} of {1}" -f $packageIndex, $packageTotal) -CurrentPackage $packageId -PackageIndex $packageIndex -PackageTotal $packageTotal -Mode $Mode + + if ($lastPackage -ne $packageId) { + Write-Log ("Installing [{0}/{1}]: {2} ({3})" -f $packageIndex, $packageTotal, $packageId, $Mode) -Level INFO + } + + return + } + + if ($Line -match 'Starting package install') { + $currentPackage = $script:ProgressState.currentPackage + if ($currentPackage) { + Update-SetupProgress -Phase $Phase -Status "Running installer" -CurrentPackage $currentPackage -Mode $Mode + } + return + } + + if ($Line -match 'Successfully installed') { + $currentPackage = $script:ProgressState.currentPackage + if ($currentPackage) { + Update-SetupProgress -Phase $Phase -Status "Installed successfully" -CurrentPackage $currentPackage -Mode $Mode + } + } +} + +function Invoke-WingetPackageInstall { + param( + [Parameter(Mandatory)] + [string]$PackageId, + + [switch]$Unelevated, + + [string]$Mode = 'admin', + + [int]$PackageIndex = 0, + + [int]$PackageTotal = 0, + + [int]$TimeoutSeconds = 14400 + ) + + if (-not $Unelevated) { + $output = New-Object System.Collections.Generic.List[string] + $arguments = @( + 'install', + '--id', $PackageId, + '--exact', + '--accept-package-agreements', + '--accept-source-agreements' + ) + + & winget @arguments 2>&1 | ForEach-Object { + $line = $_.ToString() + $output.Add($line) + Update-WingetProgressFromLine -Line $line -Phase 'Installing packages' -Mode $Mode + } + + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = @($output) + } + } + + $runnerPath = Join-Path $env:TEMP "winget-install-runner-$(Get-Random).ps1" + $resultPath = Join-Path $env:TEMP "winget-install-result-$(Get-Random).json" + $taskName = "WingetInstallUnelevated-$(Get-Random)" + + try { + $escapedPackageId = $PackageId.Replace("'", "''") + $escapedResultPath = $resultPath.Replace("'", "''") + $escapedProgressFile = $ProgressFile.Replace("'", "''") + $runnerContent = @" +`$Host.UI.RawUI.WindowTitle = 'WinGet User-Scope Retry' + +function Update-ProgressFile { + param( + [string]`$Phase, + [string]`$Status, + [string]`$CurrentPackage, + [int]`$PackageIndex = 0, + [int]`$PackageTotal = 0 + ) + + [pscustomobject]@{ + phase = `$Phase + status = `$Status + currentPackage = `$CurrentPackage + packageIndex = `$PackageIndex + packageTotal = `$PackageTotal + mode = 'user' + lastUpdated = (Get-Date).ToString('o') + } | ConvertTo-Json -Depth 4 | Set-Content -Path '$escapedProgressFile' -Encoding UTF8 -Force +} + +Write-Host 'Starting user-scope WinGet retry...' -ForegroundColor Cyan +Write-Host 'This window will show package installs that cannot run as administrator.' -ForegroundColor Cyan + +`$output = New-Object System.Collections.Generic.List[string] +Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Installing package $PackageIndex of $PackageTotal' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal +winget install --id '$escapedPackageId' --exact --accept-package-agreements --accept-source-agreements 2>&1 | ForEach-Object { + `$line = `$_.ToString() + `$output.Add(`$line) + Write-Host `$line + + if (`$line -match 'Starting package install') { + Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Running installer' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal + } + elseif (`$line -match 'Successfully installed') { + Update-ProgressFile -Phase 'Retrying user-scope packages' -Status 'Installed successfully' -CurrentPackage '$escapedPackageId' -PackageIndex $PackageIndex -PackageTotal $PackageTotal + } +} + +`$exitCode = `$LASTEXITCODE +Write-Host "WinGet retry finished with exit code `$exitCode" -ForegroundColor Cyan +[pscustomobject]@{ + ExitCode = `$exitCode + Output = @(`$output) +} | ConvertTo-Json -Depth 5 | Set-Content -Path '$escapedResultPath' -Encoding UTF8 -Force +"@ + + Set-Content -Path $runnerPath -Value $runnerContent -Encoding UTF8 -Force + + $taskUser = if ($env:USERDOMAIN) { "$($env:USERDOMAIN)\$($env:USERNAME)" } else { $env:USERNAME } + $taskAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$runnerPath`"" + $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) + $taskPrincipal = New-ScheduledTaskPrincipal -UserId $taskUser -LogonType Interactive -RunLevel Limited + + try { + $null = Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Force -ErrorAction Stop + } + catch { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Failed to create non-admin scheduled task", $_.Exception.Message) + } + } + + try { + Start-ScheduledTask -TaskName $taskName -ErrorAction Stop + } + catch { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Failed to start non-admin scheduled task", $_.Exception.Message) + } + } + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + if (Test-Path $resultPath) { + break + } + + Start-Sleep -Seconds 2 + } + + if (-not (Test-Path $resultPath)) { + return [pscustomobject]@{ + ExitCode = 1 + Output = @("Timed out waiting for non-admin WinGet install to finish") + } + } + + $result = Get-Content -Path $resultPath -Raw | ConvertFrom-Json + $output = @() + if ($null -ne $result.Output) { + $output = @($result.Output) + } + + return [pscustomobject]@{ + ExitCode = [int]$result.ExitCode + Output = $output + } + } + finally { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + + if (Test-Path $runnerPath) { + Remove-Item -Path $runnerPath -Force -ErrorAction SilentlyContinue + } + + if (Test-Path $resultPath) { + Remove-Item -Path $resultPath -Force -ErrorAction SilentlyContinue + } + } +} + +function Invoke-WingetManifestInstall { + param( + [Parameter(Mandatory)] + [string]$ManifestPath, + + [Parameter(Mandatory)] + [string]$StepId, + + [Parameter(Mandatory)] + [string]$SummaryStep, + + [Parameter(Mandatory)] + [string]$MarkerPath, + + [Parameter(Mandatory)] + [string]$ManifestLabel, + + [Parameter(Mandatory)] + [string]$MissingManifestMessage + ) + + if (-not (Test-Path $ManifestPath)) { + Write-Log "WARNING: $ManifestLabel not found at $ManifestPath - skipping application import" -Level WARNING + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message $MissingManifestMessage + Set-StepState -StepId $StepId -Status "done" -Message $MissingManifestMessage + return $false + } + + try { + Write-Log "Found $ManifestLabel at $ManifestPath" -Level INFO + + if (-not (Wait-ForNetwork)) { + Add-SummaryItem -Step $SummaryStep -Status "FAIL" -Message "Network unavailable; skipped install" + Set-StepState -StepId $StepId -Status "failed" -Message "Network unavailable" + return $false + } + + $packageIds = Get-WingetPackageIdsFromJson -Path $ManifestPath + if (-not $packageIds -or $packageIds.Count -eq 0) { + Write-Log "$ManifestLabel contains no packages to install" -Level WARNING + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "No packages found in $ManifestLabel" + Set-StepState -StepId $StepId -Status "done" -Message "No packages found" + return $true + } + + $appsHash = (Get-FileHash -Path $ManifestPath -Algorithm SHA256).Hash + $markerHash = $null + if (Test-Path $MarkerPath) { + $markerHash = (Get-Content -Path $MarkerPath -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() + } + + $missingPackages = New-Object System.Collections.Generic.List[string] + $installedCount = 0 + $totalPackages = $packageIds.Count + + Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package 1 of {0}" -f $totalPackages) -CurrentPackage '' -PackageIndex 0 -PackageTotal $totalPackages -Mode 'admin' + + for ($index = 0; $index -lt $totalPackages; $index++) { + $packageId = $packageIds[$index] + $currentNumber = $index + 1 + + Update-SetupProgress -Phase 'Scanning packages' -Status ("Checking package {0} of {1}" -f $currentNumber, $totalPackages) -CurrentPackage $packageId -PackageIndex $currentNumber -PackageTotal $totalPackages -Mode 'admin' + Write-Log "[$currentNumber/$totalPackages] Checking package: $packageId" -Level INFO + + if (Test-WingetPackageInstalled -PackageId $packageId) { + $installedCount++ + Write-Log "[$currentNumber/$totalPackages] Already installed: $packageId" -Level INFO + } + else { + $missingPackages.Add($packageId) + Write-Log "[$currentNumber/$totalPackages] Missing: $packageId" -Level INFO + } + } + + Write-Log ("Package scan complete for {0}: {1} missing, {2} already installed" -f $ManifestLabel, $missingPackages.Count, $installedCount) -Level INFO + + if ($missingPackages.Count -eq 0) { + Write-Log "All packages from $ManifestLabel are already installed" -Level SUCCESS + Set-Content -Path $MarkerPath -Value $appsHash -Force + Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Already up to date ($MarkerPath)" + Set-StepState -StepId $StepId -Status "done" -Message "Already up to date" + return $true + } + + if ($markerHash -and $markerHash -ne $appsHash) { + Write-Log "$ManifestLabel changed since last WinGet run" -Level INFO + } + + Write-Log "Keep this window open. Some installers may open their own windows or ask for confirmation." -Level INFO + Write-Log "Installing $($missingPackages.Count) missing packages from $ManifestLabel" -Level INFO + Update-SetupProgress -Phase 'Installing packages' -Status ("Installing 1 of {0}" -f $missingPackages.Count) -CurrentPackage '' -PackageIndex 0 -PackageTotal $missingPackages.Count -Mode 'admin' + + $installedPackages = [System.Collections.Generic.List[string]]::new() + $unverifiedPackages = [System.Collections.Generic.List[string]]::new() + $failedPackages = [System.Collections.Generic.List[string]]::new() + $packageNumber = 0 + + foreach ($packageId in @($missingPackages)) { + $packageNumber++ + $installed = $false + $verified = $false + $unverified = $false + + Write-Log ("Installing [{0}/{1}]: {2} (admin)" -f $packageNumber, $missingPackages.Count, $packageId) -Level INFO + Update-SetupProgress -Phase 'Installing packages' -Status ("Installing package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'admin' + + $installResult = Invoke-WingetPackageInstall -PackageId $packageId -Mode 'admin' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + Write-WingetOutput -Output $installResult.Output -Prefix "WinGet:" + + if (Test-WingetPackageInstalled -PackageId $packageId) { + $installed = $true + $verified = $true + } + elseif (Test-WingetRequiresUnelevatedRetry -Output $installResult.Output) { + Write-Log "Retrying $packageId in a non-administrator session" -Level INFO + Write-Log "A second PowerShell window may appear for user-scope installers. Leave it open until it finishes." -Level INFO + Update-SetupProgress -Phase 'Retrying user-scope packages' -Status ("Retrying package {0} of {1}" -f $packageNumber, $missingPackages.Count) -CurrentPackage $packageId -PackageIndex $packageNumber -PackageTotal $missingPackages.Count -Mode 'user' + + $retryResult = Invoke-WingetPackageInstall -PackageId $packageId -Unelevated -Mode 'user' -PackageIndex $packageNumber -PackageTotal $missingPackages.Count + Write-WingetOutput -Output $retryResult.Output -Prefix "WinGet (user):" + + if (Test-WingetPackageInstalled -PackageId $packageId) { + $installed = $true + $verified = $true + } + elseif ($retryResult.ExitCode -eq 0) { + $installed = $true + $unverified = $true + } + else { + Write-Log "Non-admin WinGet install exited with code $($retryResult.ExitCode) for $packageId" -Level WARNING + } + } + elseif ($installResult.ExitCode -eq 0) { + $installed = $true + $unverified = $true + } + else { + Write-Log "WinGet install exited with code $($installResult.ExitCode) for $packageId" -Level WARNING + } + + if ($verified) { + $installedPackages.Add($packageId) + Write-Log "Successfully installed and verified $packageId" -Level SUCCESS + continue + } + + if ($unverified) { + $unverifiedPackages.Add($packageId) + Add-FailedItem -Category "$SummaryStep Verification" -Item $packageId -Reason "WinGet reported success but winget list did not verify the package" + Write-Log "WARNING: $packageId install reported success, but winget list did not verify it" -Level WARNING + continue + } + + if (-not $installed) { + $failedPackages.Add($packageId) + Write-Log "WARNING: $packageId failed to install" -Level WARNING + } + } + + foreach ($packageId in $failedPackages) { + Add-FailedItem -Category $SummaryStep -Item $packageId -Reason "Not installed after per-package install from $ManifestLabel" + Write-Log "WARNING: $packageId still not installed after WinGet install from $ManifestLabel" -Level WARNING + } + + $failCount = @($failedPackages).Count + $unverifiedCount = @($unverifiedPackages).Count + + if ($failCount -eq 0) { + Update-SetupProgress -Phase 'Installing packages' -Status 'Completed' -CurrentPackage '' -PackageIndex $missingPackages.Count -PackageTotal $missingPackages.Count -Mode 'admin' + Write-Log "WinGet install from $ManifestLabel completed successfully" -Level SUCCESS + Set-Content -Path $MarkerPath -Value $appsHash -Force + + if ($unverifiedCount -gt 0) { + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "Installed $($installedPackages.Count) package(s); $unverifiedCount verification warning(s)" + Set-StepState -StepId $StepId -Status "done" -Message "Installed with $unverifiedCount verification warning(s)" + } + else { + Add-SummaryItem -Step $SummaryStep -Status "OK" -Message "Installed $($installedPackages.Count) package(s)" + Set-StepState -StepId $StepId -Status "done" -Message "Installed $($installedPackages.Count) package(s)" + } + + return $true + } + + Update-SetupProgress -Phase 'Installing packages' -Status ("Completed with {0} failure(s)" -f $failCount) -CurrentPackage '' -PackageIndex ($missingPackages.Count - $failCount) -PackageTotal $missingPackages.Count -Mode 'admin' + Write-Log "WinGet install from $ManifestLabel finished; $failCount package(s) failed" -Level WARNING + + $warningSuffix = if ($unverifiedCount -gt 0) { "; $unverifiedCount verification warning(s)" } else { "" } + Add-SummaryItem -Step $SummaryStep -Status "WARN" -Message "$failCount package(s) failed$warningSuffix - see Failed Installs.txt" + Set-StepState -StepId $StepId -Status "failed" -Message "$failCount package(s) failed$warningSuffix" + return $false + } + catch { + Write-Log "ERROR during WinGet install from ${ManifestLabel}: $($_.Exception.Message)" -Level ERROR + Add-SummaryItem -Step $SummaryStep -Status "FAIL" -Message "WinGet install failed" + Set-StepState -StepId $StepId -Status "failed" -Message "WinGet install failed" + return $false + } +} diff --git a/preflight-backup.ps1 b/preflight-backup.ps1 index bec03bd..29d34a9 100644 --- a/preflight-backup.ps1 +++ b/preflight-backup.ps1 @@ -23,6 +23,11 @@ $DefaultConfigPath = Join-Path $ScriptRoot "config\backup.json" $TemplateConfigPath = Join-Path $ScriptRoot "config\backup.template.json" $ManifestFileName = "backup-manifest.json" $BackupContainerName = "declarative-windows-backup" +$ModuleRoot = Join-Path $ScriptRoot "modules" +$BackupManifestModule = Join-Path $ModuleRoot "BackupManifest.ps1" +if (Test-Path $BackupManifestModule) { + . $BackupManifestModule +} function Write-Info { param([string]$Message) @@ -402,38 +407,35 @@ Write-Progress -Activity "Exporting WinGet inventory" -Status "Running winget ex $wingetExported = Export-WingetInventory -OutputPath $wingetExportPath Write-Progress -Activity "Exporting WinGet inventory" -Completed -$manifest = [ordered]@{ - manifestVersion = 1 - createdAt = (Get-Date).ToString("o") - machine = [ordered]@{ +$manifest = New-BackupManifest ` + -Machine ([ordered]@{ computerName = $env:COMPUTERNAME userProfile = $env:USERPROFILE osDrive = $env:SystemDrive - } - repo = [ordered]@{ + }) ` + -Repo ([ordered]@{ remoteUrl = $repoRemoteUrl name = "declarative-windows" restorePath = $canonicalRepoPath - } - backup = [ordered]@{ + }) ` + -Backup ([ordered]@{ destinationRoot = (Resolve-Path $DestinationRoot).Path backupRoot = $sessionRoot filesRoot = $filesRoot repoFilesRoot = $repoFilesRoot exportsRoot = $exportsRoot reportPath = (Join-Path $reportsRoot "backup-report.txt") - } - config = [ordered]@{ + }) ` + -Config ([ordered]@{ sourcePath = $effectiveConfigPath templateFallbackUsed = $effectiveConfigPath -eq (Resolve-Path $TemplateConfigPath).Path - } - rules = $manifestRules - repoFiles = $manifestRepoFiles - exports = [ordered]@{ + }) ` + -Rules @($manifestRules) ` + -RepoFiles @($manifestRepoFiles) ` + -Exports ([ordered]@{ wingetPath = if ($wingetExported) { $wingetExportPath } else { $null } - } - failures = $failedRules -} + }) ` + -Failures @($failedRules) $manifestJson = $manifest | ConvertTo-Json -Depth 8 if ($PSCmdlet.ShouldProcess($ManifestPath, "Write backup manifest")) { diff --git a/restore-backup.ps1 b/restore-backup.ps1 index 2bd693b..4a8271a 100644 --- a/restore-backup.ps1 +++ b/restore-backup.ps1 @@ -26,88 +26,7 @@ function Write-Success { Write-Host "[ OK ] $Message" -ForegroundColor Green } -function Find-BackupManifest { - $drives = Get-PSDrive -PSProvider FileSystem | Where-Object { - $_.Root -ne "$($env:SystemDrive)\" - } - - $candidates = foreach ($drive in $drives) { - $root = $drive.Root - $container = Join-Path $root "declarative-windows-backup" - if (-not (Test-Path $container)) { - continue - } - - Get-ChildItem -Path $container -Filter "backup-manifest.json" -Recurse -File -ErrorAction SilentlyContinue - } - - return ($candidates | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 1).FullName -} - -function Resolve-RestoreTargetPath { - param( - [string]$Path, - [string]$ProfileRoot, - [string]$OriginalOsDrive, - [hashtable]$RestoreTargetMap - ) - - $expandedPath = [Environment]::ExpandEnvironmentVariables($Path) - - # Profile remapping takes priority - if ($DestinationProfileRoot) { - $currentProfile = [Environment]::ExpandEnvironmentVariables("%USERPROFILE%") - if ($expandedPath.StartsWith($currentProfile, [System.StringComparison]::OrdinalIgnoreCase)) { - $relativePath = $expandedPath.Substring($currentProfile.Length).TrimStart('\') - return Join-Path $ProfileRoot $relativePath - } - } - - # OS drive remapping - if ($OriginalOsDrive -and $env:SystemDrive -ne $OriginalOsDrive) { - $osDriveSlash = $OriginalOsDrive + "\" - if ($expandedPath.StartsWith($osDriveSlash, [System.StringComparison]::OrdinalIgnoreCase)) { - $currentOsDriveSlash = $env:SystemDrive + "\" - $relativePath = $expandedPath.Substring($OriginalOsDrive.Length) - $newPath = $currentOsDriveSlash + $relativePath.TrimStart('\') - # Check restore target map for remapping - foreach ($key in $RestoreTargetMap.Keys) { - if ($newPath -and $RestoreTargetMap[$key]) { - $mapKeySlash = $key + "\" - $mapValueSlash = $RestoreTargetMap[$key] + "\" - if ($newPath.StartsWith($mapKeySlash, [System.StringComparison]::OrdinalIgnoreCase)) { - return $newPath.Replace($mapKeySlash, $mapValueSlash) - } - } - } - return $newPath - } - } - - # Check restore target map for other remappings - if ($RestoreTargetMap -and $RestoreTargetMap.Count -gt 0) { - foreach ($key in $RestoreTargetMap.Keys) { - if ($key -and $RestoreTargetMap[$key]) { - $keySlash = $key + "\" - if ($expandedPath.StartsWith($keySlash, [System.StringComparison]::OrdinalIgnoreCase)) { - $valueSlash = $RestoreTargetMap[$key] + "\" - return $expandedPath.Replace($keySlash, $valueSlash) - } - } - } - } - - # Warn if absolute path with drive letter cannot be remapped - if ($expandedPath -match '^[A-Za-z]:\\') { - if (Get-Command Write-Log -ErrorAction SilentlyContinue) { - Write-Log "Warning: Absolute path '$expandedPath' cannot be remapped - returning as-is" - } else { - Write-Warning "Absolute path '$expandedPath' cannot be remapped - returning as-is" - } - } - - return $expandedPath -} +$actualBackupRoot = $null function Copy-Tree { param( @@ -153,6 +72,12 @@ function Copy-Tree { return $LASTEXITCODE -lt 8 } +$ModuleRoot = Join-Path $PSScriptRoot "modules" +$BackupManifestModule = Join-Path $ModuleRoot "BackupManifest.ps1" +if (Test-Path $BackupManifestModule) { + . $BackupManifestModule +} + if (-not $ManifestPath) { $ManifestPath = Find-BackupManifest } @@ -162,8 +87,9 @@ if (-not $ManifestPath) { } $resolvedManifestPath = (Resolve-Path $ManifestPath).Path -$manifestDir = Split-Path $resolvedManifestPath -Parent $manifest = Get-Content -Path $resolvedManifestPath -Raw | ConvertFrom-Json +$actualBackupRoot = Split-Path -Parent $resolvedManifestPath +$manifestBackupRoot = Get-BackupManifestRoot -Manifest $manifest if (-not $DestinationProfileRoot) { $DestinationProfileRoot = $env:USERPROFILE @@ -198,14 +124,15 @@ foreach ($repoFile in $manifest.repoFiles) { } } + $repoFileSource = Resolve-BackupSourcePath -Path $repoFile.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot + if ((Test-Path $destination) -and $Mode -eq "SkipExisting") { $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "skipped" }) continue } if ($PSCmdlet.ShouldProcess($destination, "Restore repo file")) { - $sourcePath = Join-Path $manifestDir $repoFile.backupPath - Copy-Item -Path $sourcePath -Destination $destination -Force:($Mode -eq "Overwrite") + Copy-Item -Path $repoFileSource -Destination $destination -Force:($Mode -eq "Overwrite") } $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "restored" }) @@ -213,12 +140,7 @@ foreach ($repoFile in $manifest.repoFiles) { # Build restore target map from manifest $originalOsDrive = $manifest.machine.osDrive -$restoreTargetMap = @{} -if ($manifest.restoreTargets) { - foreach ($prop in $manifest.restoreTargets.PSObject.Properties) { - $restoreTargetMap[$prop.Name] = $prop.Value - } -} +$restoreTargetMap = Get-RestoreTargetMap -Manifest $manifest foreach ($rule in $manifest.rules) { if (-not $rule.success) { @@ -229,8 +151,8 @@ foreach ($rule in $manifest.rules) { continue } + $sourcePath = Resolve-BackupSourcePath -Path $rule.backupPath -ManifestBackupRoot $manifestBackupRoot -ActualBackupRoot $actualBackupRoot $targetPath = Resolve-RestoreTargetPath -Path $rule.restorePath -ProfileRoot $DestinationProfileRoot -OriginalOsDrive $originalOsDrive -RestoreTargetMap $restoreTargetMap - $sourcePath = Join-Path $manifestDir $rule.backupPath $success = Copy-Tree -Source $sourcePath -Destination $targetPath -RobocopyMode $Mode $restoreReport.Add([pscustomobject]@{ type = "content" diff --git a/tests/ArchitectureModules.Tests.ps1 b/tests/ArchitectureModules.Tests.ps1 new file mode 100644 index 0000000..cf8776e --- /dev/null +++ b/tests/ArchitectureModules.Tests.ps1 @@ -0,0 +1,82 @@ +Describe "architecture module checks" { + BeforeAll { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + $moduleRoot = Join-Path $repoRoot "modules" + $bootstrap = Get-Content (Join-Path $repoRoot "bootstrap.ps1") -Raw + $buildIso = Get-Content (Join-Path $repoRoot "build-iso.ps1") -Raw + $applyRegistry = Get-Content (Join-Path $repoRoot "apply-registry.ps1") -Raw + $modules = @{ + BootstrapRun = Get-Content (Join-Path $moduleRoot "BootstrapRun.ps1") -Raw + WinGetInstall = Get-Content (Join-Path $moduleRoot "WinGetInstall.ps1") -Raw + BackupManifest = Get-Content (Join-Path $moduleRoot "BackupManifest.ps1") -Raw + StagedSetupPayload = Get-Content (Join-Path $moduleRoot "StagedSetupPayload.ps1") -Raw + DeclarativeConfig = Get-Content (Join-Path $moduleRoot "DeclarativeConfig.ps1") -Raw + } + } + + It "has a deep BootstrapRun module for state progress and reports" { + $bootstrap | Should -Match "BootstrapRun\.ps1" + $modules.BootstrapRun | Should -Match "function Initialize-State" + $modules.BootstrapRun | Should -Match "function Update-SetupProgress" + $modules.BootstrapRun | Should -Match "function Invoke-BootstrapRunStep" + } + + It "has a deep WinGetInstall module for per-package install classification" { + $bootstrap | Should -Match "WinGetInstall\.ps1" + $modules.WinGetInstall | Should -Match "function Invoke-WingetManifestInstall" + $modules.WinGetInstall | Should -Match "Invoke-WingetPackageInstall" + $modules.WinGetInstall | Should -Match "WinGet reported success but winget list did not verify the package" + $modules.WinGetInstall | Should -Match "Retrying user-scope packages" + } + + It "returns an array for one-package WinGet manifests" { + . (Join-Path $moduleRoot "WinGetInstall.ps1") + + $manifestPath = Join-Path $TestDrive "apps.json" + @' +{ + "Sources": [ + { + "Packages": [ + { "PackageIdentifier": "Vendor.OneApp" } + ] + } + ] +} +'@ | Set-Content -Path $manifestPath -Encoding UTF8 + + $packageIds = Get-WingetPackageIdsFromJson -Path $manifestPath + + $packageIds.GetType().IsArray | Should -BeTrue + $packageIds.Count | Should -Be 1 + $packageIds[0] | Should -Be "Vendor.OneApp" + } + + It "has a shared BackupManifest module for source and target remapping" { + $modules.BackupManifest | Should -Match "function Find-BackupManifest" + $modules.BackupManifest | Should -Match "function Get-BackupManifestRoot" + $modules.BackupManifest | Should -Match "function Get-RestoreTargetMap" + $modules.BackupManifest | Should -Match "function New-BackupManifest" + } + + It "has a StagedSetupPayload module and requires it in ISO output" { + $buildIso | Should -Match "StagedSetupPayload\.ps1" + $modules.StagedSetupPayload | Should -Match "function Get-StagedSetupRequiredFiles" + $modules.StagedSetupPayload | Should -Match "modules\\BootstrapRun\.ps1" + $modules.StagedSetupPayload | Should -Match "modules\\DeclarativeConfig\.ps1" + } + + It "has a DeclarativeConfig interface for registry application" { + $applyRegistry | Should -Match "DeclarativeConfig\.ps1" + $applyRegistry | Should -Match "Invoke-DeclarativeConfig" + $modules.DeclarativeConfig | Should -Match "function Invoke-DeclarativeConfig" + $modules.DeclarativeConfig | Should -Match "ValidateSet\(\""Registry\""\)" + } + + It "preserves requested registry value kinds in declarative config" { + $modules.DeclarativeConfig | Should -Match "GetValueKind" + $modules.DeclarativeConfig | Should -Match "New-ItemProperty" + $modules.DeclarativeConfig | Should -Match '-PropertyType \$valueType' + $modules.DeclarativeConfig | Should -Not -Match 'Set-ItemProperty[^\r\n]+-Type' + } +} diff --git a/tests/BackupRestore.Tests.ps1 b/tests/BackupRestore.Tests.ps1 index 3607a25..fcfc041 100644 --- a/tests/BackupRestore.Tests.ps1 +++ b/tests/BackupRestore.Tests.ps1 @@ -3,10 +3,13 @@ Describe "backup and restore static checks" { $backupScriptPath = Resolve-Path (Join-Path $PSScriptRoot "..\preflight-backup.ps1") $restoreScriptPath = Resolve-Path (Join-Path $PSScriptRoot "..\restore-backup.ps1") $backupConfigPath = Resolve-Path (Join-Path $PSScriptRoot "..\config\backup.template.json") + $backupManifestModulePath = Resolve-Path (Join-Path $PSScriptRoot "..\modules\BackupManifest.ps1") $backupScriptContent = Get-Content $backupScriptPath -Raw $restoreScriptContent = Get-Content $restoreScriptPath -Raw $backupConfigContent = Get-Content $backupConfigPath -Raw + $backupManifestModuleContent = Get-Content $backupManifestModulePath -Raw + $restoreAndModuleContent = $restoreScriptContent + "`n" + $backupManifestModuleContent } It "falls back to the backup template config" { @@ -29,9 +32,15 @@ Describe "backup and restore static checks" { } It "supports restore manifest autodetection" { - $restoreScriptContent | Should -Match "Find-BackupManifest" - $restoreScriptContent | Should -Match "declarative-windows-backup" - $restoreScriptContent | Should -Match 'Sort-Object LastWriteTimeUtc -Descending' + $restoreAndModuleContent | Should -Match "Find-BackupManifest" + $restoreAndModuleContent | Should -Match "declarative-windows-backup" + $restoreAndModuleContent | Should -Match 'Sort-Object LastWriteTimeUtc -Descending' + } + + It "remaps backup paths when drive letter differs from manifest" { + $restoreScriptContent | Should -Match "Resolve-BackupSourcePath" + $restoreScriptContent | Should -Match "manifestBackupRoot" + $restoreScriptContent | Should -Match "actualBackupRoot" } It "defines known folders and extra paths in the template" { @@ -39,4 +48,49 @@ Describe "backup and restore static checks" { $backupConfigContent | Should -Match '"extraPaths"' $backupConfigContent | Should -Match '"repoPath"' } + + It "reads manifest backup root metadata before remapping restore paths" { + $restoreAndModuleContent | Should -Match 'manifest\.backup\.backupRoot' + $restoreAndModuleContent | Should -Match 'ExpandEnvironmentVariables' + $restoreAndModuleContent | Should -Match 'StartsWith\(\$ManifestBackupRoot' + } + + It "uses remapped source paths for both repo files and content rules" { + $restoreScriptContent | Should -Match 'repoFileSource = Resolve-BackupSourcePath' + $restoreScriptContent | Should -Match 'sourcePath = Resolve-BackupSourcePath' + $restoreAndModuleContent | Should -Match 'IsPathRooted' + $restoreAndModuleContent | Should -Match 'Join-Path \$ActualBackupRoot \$relativePath' + $restoreScriptContent | Should -Match 'Resolve-RestoreTargetPath -Path \$rule\.restorePath -ProfileRoot \$DestinationProfileRoot -OriginalOsDrive \$originalOsDrive -RestoreTargetMap \$restoreTargetMap' + } + + It "reports when a remapped backup path is used" { + $restoreAndModuleContent | Should -Match 'Using remapped backup path:' + $restoreAndModuleContent | Should -Match 'Write-Info' + } + + It "shares backup manifest implementation through a module" { + $backupScriptContent | Should -Match 'BackupManifest\.ps1' + $restoreScriptContent | Should -Match 'BackupManifest\.ps1' + $backupManifestModuleContent | Should -Match 'function New-BackupManifest' + $backupManifestModuleContent | Should -Match 'function Resolve-BackupSourcePath' + $backupManifestModuleContent | Should -Match 'function Resolve-RestoreTargetPath' + } + + It "allows successful backup manifests with empty optional collections" { + . $backupManifestModulePath + + $manifest = New-BackupManifest ` + -Machine ([ordered]@{ computerName = "test"; userProfile = "C:\Users\test"; osDrive = "C:" }) ` + -Repo ([ordered]@{ remoteUrl = $null; name = "declarative-windows"; restorePath = "C:\Users\test\Documents\declarative-windows" }) ` + -Backup ([ordered]@{ backupRoot = "E:\backup" }) ` + -Config ([ordered]@{ sourcePath = "config\backup.template.json"; templateFallbackUsed = $true }) ` + -Rules @() ` + -RepoFiles @() ` + -Exports ([ordered]@{ wingetPath = $null }) ` + -Failures @() + + $manifest.rules | Should -BeNullOrEmpty + $manifest.repoFiles | Should -BeNullOrEmpty + $manifest.failures | Should -BeNullOrEmpty + } } diff --git a/tests/Bootstrap.Tests.ps1 b/tests/Bootstrap.Tests.ps1 index 1034829..51d4db0 100644 --- a/tests/Bootstrap.Tests.ps1 +++ b/tests/Bootstrap.Tests.ps1 @@ -1,7 +1,10 @@ Describe "bootstrap.ps1 static checks" { BeforeAll { $scriptPath = Resolve-Path (Join-Path $PSScriptRoot "..\bootstrap.ps1") + $wingetModulePath = Resolve-Path (Join-Path $PSScriptRoot "..\modules\WinGetInstall.ps1") $scriptContent = Get-Content $scriptPath -Raw + $wingetModuleContent = Get-Content $wingetModulePath -Raw + $bootstrapAndWingetContent = $scriptContent + "`n" + $wingetModuleContent } It "tracks WinGet completion marker with hash" { @@ -90,17 +93,88 @@ Describe "bootstrap.ps1 static checks" { $scriptContent | Should -Match "failed-installs\.log" } - It "checks individual packages after WinGet import" { - $scriptContent | Should -Match "stillMissing" - $scriptContent | Should -Match "Not installed after import from" + It "installs packages individually instead of using bulk import as the primary path" { + $scriptContent | Should -Match "Invoke-WingetPackageInstall" + $scriptContent | Should -Match "winget install --id" + $scriptContent | Should -Not -Match "winget import --import-file" } It "keeps partial WinGet failures retryable" { - $scriptContent | Should -Match 'Set-StepState -StepId \$stepId -Status "failed" -Message "\$failCount package\(s\) failed"' + $scriptContent | Should -Match 'Set-StepState -StepId \$StepId -Status "failed" -Message "\$failCount package\(s\) failed\$warningSuffix"' } - It "chooses newest backup manifest across drives" { - $scriptContent | Should -Match 'Sort-Object LastWriteTimeUtc -Descending' - $scriptContent | Should -Match 'newestMatch' + It "writes progress.json during bootstrap" { + $scriptContent | Should -Match 'progress\.json' + $scriptContent | Should -Match 'Update-SetupProgress' + } + + It "logs useful WinGet output and detects elevation issues" { + $scriptContent | Should -Match 'Write-WingetOutput' + $scriptContent | Should -Match 'Test-WingetRequiresUnelevatedRetry' + $scriptContent | Should -Match 'cannot be run from an administrator context' + $scriptContent | Should -Match '\[\\\|/\\\\-\]\+' + } + + It "preserves CreationDate and WinGetVersion in filtered apps json" { + $scriptContent | Should -Match 'CreationDate' + $scriptContent | Should -Match 'WinGetVersion' + } + + It "treats missing backup manifest as warning not fatal for repo step" { + $scriptContent | Should -Match 'Backup manifest not found; using C:\\Setup fallback' + $scriptContent | Should -Match 'Set-StepState -StepId \$stepId -Status "skipped" -Message "Backup manifest not found; using C:\\Setup fallback"' + $scriptContent | Should -Not -Match 'Set-StepState -StepId \$stepId -Status "failed" -Message "Backup manifest not found"' + } + + It "reports missing optional-apps.json gracefully in OptionalAppsOnly mode" { + $scriptContent | Should -Match 'optional-apps\.json not found' + } + + It "tracks detailed progress state fields" { + $scriptContent | Should -Match 'phase = "Starting"' + $scriptContent | Should -Match 'status = "Initializing setup"' + $scriptContent | Should -Match 'currentPackage = ""' + $scriptContent | Should -Match 'packageIndex = 0' + $scriptContent | Should -Match 'packageTotal = 0' + $scriptContent | Should -Match 'mode = "admin"' + $scriptContent | Should -Match 'lastUpdated = \$null' + } + + It "retries user-scope packages through a limited scheduled task" { + $bootstrapAndWingetContent | Should -Match 'Register-ScheduledTask' + $bootstrapAndWingetContent | Should -Match 'New-ScheduledTaskPrincipal' + $bootstrapAndWingetContent | Should -Match 'RunLevel Limited' + $bootstrapAndWingetContent | Should -Match 'LogonType Interactive' + $bootstrapAndWingetContent | Should -Match 'WingetInstallUnelevated-' + $bootstrapAndWingetContent | Should -Match 'cannot be run from an administrator context' + $bootstrapAndWingetContent | Should -Not -Match 'schtasks\.exe /Create' + } + + It "updates progress during user-scope retry" { + $scriptContent | Should -Match "Retrying user-scope packages" + $scriptContent | Should -Match "mode = 'user'" + $scriptContent | Should -Match 'A second PowerShell window may appear for user-scope installers' + } + + It "parses package counters from WinGet output" { + $installLogPattern = [regex]::Escape('Installing [{0}/{1}]: {2} ({3})') + $progressStatusPattern = [regex]::Escape('Installing package {0} of {1}') + + $scriptContent | Should -Match $installLogPattern + $scriptContent | Should -Match $progressStatusPattern + } + + It "classifies successful but unverified packages as verification warnings" { + $scriptContent | Should -Match 'WinGet reported success but winget list did not verify the package' + $scriptContent | Should -Match '\$unverifiedPackages' + $scriptContent | Should -Match '\$unverifiedCount' + } + + It "sets registry values with the requested registry value kind" { + $scriptContent | Should -Match "function Set-RegistryValueSafe" + $scriptContent | Should -Match '\[int\]\$Value' + $scriptContent | Should -Match 'New-ItemProperty' + $scriptContent | Should -Match '-PropertyType \$propertyType' + $scriptContent | Should -Not -Match 'Set-ItemProperty[^\r\n]+-Type' } } diff --git a/tests/BuildIso.Tests.ps1 b/tests/BuildIso.Tests.ps1 index 556f3e3..e00a062 100644 --- a/tests/BuildIso.Tests.ps1 +++ b/tests/BuildIso.Tests.ps1 @@ -1,11 +1,14 @@ Describe "build-iso.ps1 static checks" { BeforeAll { $scriptPath = Resolve-Path (Join-Path $PSScriptRoot "..\build-iso.ps1") + $payloadModulePath = Resolve-Path (Join-Path $PSScriptRoot "..\modules\StagedSetupPayload.ps1") $scriptContent = Get-Content $scriptPath -Raw + $payloadModuleContent = Get-Content $payloadModulePath -Raw + $buildAndPayloadContent = $scriptContent + "`n" + $payloadModuleContent } It "uses $OEM$ $1 Setup path" { - ($scriptContent -like '*sources*`$OEM`$*`$1\Setup*') | Should -BeTrue + ($scriptContent -like '*sources*`$OEM`$*`$1\Setup*') | Should -Be $true } @@ -43,22 +46,28 @@ Describe "build-iso.ps1 static checks" { } It "validates the staged ISO layout before oscdimg" { - $scriptContent | Should -Match "Validate-StagedIsoLayout" + $buildAndPayloadContent | Should -Match "Validate-StagedIsoLayout" $scriptContent | Should -Match "Validating staged ISO layout" - $scriptContent | Should -Match "Staged ISO layout validation passed" + $buildAndPayloadContent | Should -Match "Staged ISO layout validation passed" + } + + It "copies modules into the staged setup payload" { + $scriptContent | Should -Match "modules" + $scriptContent | Should -Match ([regex]::Escape('Copy-Item -Path $requiredDirectories["modules"]')) + $scriptContent | Should -Match "modules folder copied" } It "checks unattend C:\\Setup references against the staged OEM payload" { $stagedPathPattern = [regex]::Escape('Join-Path $WorkRoot ''sources\$OEM$\$1\Setup''') - $scriptContent | Should -Match "Get-UnattendSetupFileReferences" - $scriptContent | Should -Match "C:\\Setup\\" - $scriptContent | Should -Match $stagedPathPattern - $scriptContent | Should -Match "autounattend\.xml references .* staged ISO is missing" + $buildAndPayloadContent | Should -Match "Get-UnattendSetupFileReferences" + $buildAndPayloadContent | Should -Match "C:\\Setup\\" + $buildAndPayloadContent | Should -Match $stagedPathPattern + $buildAndPayloadContent | Should -Match "autounattend\.xml references .* staged ISO is missing" } It "runs staged layout validation before building the ISO" { - $validationIndex = $scriptContent.IndexOf('Validate-StagedIsoLayout') + $validationIndex = $scriptContent.LastIndexOf('Validate-StagedIsoLayout') $buildIndex = $scriptContent.IndexOf('Write-Step "Building custom ISO with oscdimg"') $validationIndex | Should -BeGreaterThan -1 @@ -67,10 +76,11 @@ Describe "build-iso.ps1 static checks" { } It "passes source ISO into unattend validation" { - ($scriptContent -like '*-SourceISO $SourceISO*') | Should -BeTrue + $sourceIsoArgumentPattern = [regex]::Escape('-SourceISO $SourceISO') + $scriptContent | Should -Match $sourceIsoArgumentPattern } It "does not reference MountDir anymore" { - $scriptContent.Contains('$MountDir') | Should -BeFalse + $scriptContent.Contains('$MountDir') | Should -Be $false } } diff --git a/tests/Quality.Tests.ps1 b/tests/Quality.Tests.ps1 new file mode 100644 index 0000000..2ae5721 --- /dev/null +++ b/tests/Quality.Tests.ps1 @@ -0,0 +1,32 @@ +Describe "repository quality checks" { + BeforeAll { + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + $gitPathPattern = '([\\/]\.git([\\/]|$))' + } + + It "parses all PowerShell scripts" { + $scripts = Get-ChildItem -Path $repoRoot -Recurse -File -Filter "*.ps1" | + Where-Object { $_.FullName -notmatch $gitPathPattern } + + foreach ($script in $scripts) { + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseFile($script.FullName, [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty -Because "$($script.FullName) should parse" + } + } + + It "does not contain unresolved merge conflict markers" { + $files = Get-ChildItem -Path $repoRoot -Recurse -File | + Where-Object { + $_.FullName -notmatch $gitPathPattern -and + $_.Extension -in @(".ps1", ".md", ".json", ".xml", ".yml", ".yaml") + } + + $conflictMarkers = foreach ($file in $files) { + Select-String -LiteralPath $file.FullName -Pattern '^(<<<<<<<|\|\|\|\|\|\|\||=======|>>>>>>>)' + } + + $conflictMarkers | Should -BeNullOrEmpty + } +}