From edc8777a1f0a0e3c9973689b6e843f2a3853cdac Mon Sep 17 00:00:00 2001 From: Tim Aronsson Date: Wed, 8 Apr 2026 00:04:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20relative=20paths=20ro?= =?UTF-8?q?bustet=20install,=20docs=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 123 +++++++++++++++++++++++++++++++++++++++---- bootstrap.ps1 | 67 +++++++++++++++++++---- preflight-backup.ps1 | 18 ++++++- restore-backup.ps1 | 85 ++++++++++++++++++++++++++++-- 4 files changed, 267 insertions(+), 26 deletions(-) diff --git a/TODO.md b/TODO.md index e81cf87..f60e198 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ > **Goal:** Enable quick Windows reinstallation every 2 months without manual reconfiguration > -> **Last Updated:** 2025-10-02 +> **Last Updated:** 2026-04-08 > > **Note:** Research tasks have been moved to [RESEARCH.md](RESEARCH.md) @@ -37,7 +37,7 @@ ### Implementation Tasks -- [ ] 🔴 🛠️ Download Sophia Script and add to repository +- [x] 🔴 🛠️ Download Sophia Script and add to repository (implemented as auto-download at runtime via Get-SophiaScript in bootstrap.ps1) - [x] 🔴 🛠️ Create customized `Sophia.ps1` preset file - [x] 🟡 🛠️ Document which Sophia options are enabled/disabled - [x] 🟡 🛠️ Analyze overlap between AutoUnattend.xml and Sophia Script @@ -56,7 +56,7 @@ - [x] 🔴 🛠️ Create/obtain base autounattend.xml file - [x] 🔴 🛠️ Add FirstLogonCommands to execute bootstrap.ps1 - [x] 🟡 🛠️ Configure execution policy bypass in FirstLogonCommands -- [ ] 🟡 🛠️ Add network wait logic before running bootstrap +- [x] 🟡 🛠️ Add network wait logic before running bootstrap (implemented in bootstrap.ps1 via Wait-ForNetwork) --- @@ -82,7 +82,7 @@ - [ ] 🔴 🛠️ Add local mode flag (run from repo dir, not C:\Setup) - [ ] 🔴 🛠️ Add system restore point creation before changes - [ ] 🔴 🛠️ Add bloat removal path for existing installs (Appx + features) -- [ ] 🔴 🛠️ Auto-download Sophia Script when missing (version-pinned) +- [x] 🔴 🛠️ Auto-download Sophia Script when missing (version-pinned) — implemented via Get-SophiaScript in bootstrap.ps1 - [ ] 🟡 🛠️ Add safety prompt for destructive steps (with -Force override) - [ ] 🟡 🛠️ Add local-mode logging path (same format as C:\Setup) - [ ] 🟢 🛠️ Add local-mode desktop shortcut/summary (optional) @@ -115,7 +115,7 @@ - [ ] 🟡 🛠️ Create `config/registry.json` structure and schema - [ ] 🟡 🛠️ Create `config/features.json` for Windows features toggles - [ ] 🟡 🛠️ Create `config/settings.json` for miscellaneous OS settings -- [ ] 🟢 🛠️ Create PowerShell scripts to apply each config file type +- [x] 🟢 🛠️ Create PowerShell scripts to apply each config file type - [ ] 🟢 🛠️ Integrate config file application into bootstrap.ps1 --- @@ -127,7 +127,7 @@ ### Implementation Tasks - [x] 🔴 🛠️ Create `build-iso.ps1` script for automated ISO generation -- [ ] 🔴 🛠️ Add oscdimg.exe downloader (from Windows ADK) +- [ ] 🔴 🛠️ Add oscdimg.exe downloader (from Windows ADK) — Find-OscdImg has download logic but no default URL - [x] 🔴 🛠️ Implement ISO extraction logic - [x] 🔴 🛠️ Implement $OEM$ folder structure creation - [x] 🔴 🛠️ Implement file injection (autounattend.xml, bootstrap.ps1, apps.json, Sophia.ps1) @@ -185,7 +185,7 @@ - [x] 🔴 📝 Add Security Warning section to README - [ ] 🔴 📝 Document how to use build-iso.ps1 (prerequisites, usage, outputs) - [x] 🟡 📝 Document how to customize Sophia.ps1 preset -- [ ] 🟡 📝 Create docs/ISO-GENERATION.md with detailed ISO creation guide +- [x] 🟡 📝 Create docs/ISO-GENERATION.md with detailed ISO creation guide - [ ] 🟡 📝 Document $OEM$ folder structure - [ ] 🟡 📝 Document bootstrap.ps1 log format and location - [ ] 🟡 📝 Document manual re-run process (desktop shortcut) @@ -294,6 +294,93 @@ --- +# Phase 4: Infrastructure Improvements + +> Structural improvements to enable better testing, modularity, and robustness. These gate all later work. + +## Component: Module Refactor (bootstrap.ps1) + +> Refactor the ~1300-line monolithic bootstrap.ps1 into reusable PowerShell modules. + +### Implementation Tasks + +- [ ] 🔴 🛠️ Create `modules/DeclarativeWindows.psm1` root module with common utilities +- [ ] 🔴 🛠️ Extract `Invoke-WinGetInstall` into `modules/WinGet.psm1` (idempotent package install) +- [ ] 🔴 🛠️ Extract `Invoke-SophiaSetup` into `modules/Sophia.psm1` (OS tweaks via Sophia Script) +- [ ] 🔴 🛠️ Extract `Set-DesktopShortcuts` into `modules/Shortcuts.psm1` (lnk creation) +- [ ] 🔴 🛠️ Extract backup/restore logic into `modules/Backup.psm1` +- [ ] 🔴 🛠️ Extract state management into `modules/State.psm1` (Initialize-State, Save-State, Should-RunStep) +- [ ] 🔴 🛠️ Extract registry/application into `modules/Registry.psm1` (PostInstallTweaks, debloat) +- [ ] 🔴 🛠️ Refactor bootstrap.ps1 to import modules and orchestrate (target: <400 lines) +- [ ] 🟡 🛠️ Add module manifest `modules/DeclarativeWindows.psd1` +- [ ] 🟡 🛠️ Add `-WhatIf` support using PowerShell's `SupportsShouldProcess` +- [ ] 🟢 🛠️ Add `--version` flag to bootstrap.ps1 that prints git commit hash + +--- + +## Component: Functional Tests + +> Expand Pester tests beyond static string checks to actual behavioral testing with mocking. + +### Testing Tasks + +- [ ] 🔴 ✅ Add Pester tests for `Find-OscdImg` (mock ADK registry/path, test download fallback) +- [ ] 🔴 ✅ Add Pester tests for `Validate-StagedIsoLayout` with mocked file tree +- [ ] 🔴 ✅ Add Pester tests for `Get-UnattendSetupFileReferences` (parse sample XML) +- [ ] 🔴 ✅ Add Pester tests for bootstrap.ps1 idempotency (run twice, verify state) +- [ ] 🔴 ✅ Add Pester tests for DryRun mode (verify no system changes) +- [ ] 🔴 ✅ Add Pester tests for state management (Initialize-State, Should-RunStep) +- [ ] 🟡 ✅ Add Pester tests for `Invoke-WinGetInstall` with mocked `winget list` +- [ ] 🟡 ✅ Add Pester tests for `Set-DesktopShortcuts` with mocked WScript.Shell +- [ ] 🟢 ✅ Convert existing static tests in BuildIso.Tests.ps1 to functional equivalents + +--- + +## Component: Config File Completion + +> Expand stub config files and their apply scripts. + +### Implementation Tasks + +- [ ] 🟡 🛠️ Populate `config/registry.json` with 10+ commonly-requested registry tweaks +- [ ] 🟡 🛠️ Populate `config/features.json` with Windows features (WSL, SSH, Hyper-V, etc.) +- [ ] 🟡 🛠️ Populate `config/settings.json` with miscellaneous OS settings +- [ ] 🟡 🛠️ Enhance `apply-registry.ps1` to read and apply `config/registry.json` +- [ ] 🟡 🛠️ Create `apply-features.ps1` to enable/disable Windows features from `config/features.json` +- [ ] 🟡 🛠️ Create `apply-settings.ps1` to apply miscellaneous settings from `config/settings.json` +- [ ] 🟢 🛠️ Add JSON schema for each config file type with validation in apply scripts + +--- + +## Component: oscdimg.exe Auto-Downloader + +> Complete the auto-downloader in build-iso.ps1 so ADK installation is not required. + +### Implementation Tasks + +- [ ] 🔴 🛠️ Find reliable source URL for oscdimg.exe (ADK install media or known mirror) +- [ ] 🔴 🛠️ Implement auto-download in `Find-OscdImg` with SHA256 verification +- [ ] 🔴 🛠️ Cache downloaded oscdimg.exe to `$env:LOCALAPPDATA\declarative-windows\tools` +- [ ] 🟡 🛠️ Add `Find-OscdImg` tests with mocked downloads + +--- + +## Component: JSON Schema Validation + +> Fail fast on invalid config files before any system changes. + +### Implementation Tasks + +- [ ] 🟡 🛠️ Download/add WinGet packages.schema.json to `schemas/` +- [ ] 🟡 🛠️ Download Microsoft Unattend.xsd to `schemas/` +- [ ] 🟡 🛠️ Add `Test-AppsJsonSchema` function to validate apps.json against schema +- [ ] 🟡 🛠️ Add `Test-UnattendXmlSchema` function to validate autounattend.xml against XSD +- [ ] 🟡 🛠️ Call schema validation in bootstrap.ps1 before any install operations +- [ ] 🟡 🛠️ Call schema validation in build-iso.ps1 before ISO build +- [ ] 🟢 🛠️ Add Pester tests for schema validation (valid file passes, invalid fails with descriptive error) + +--- + # Backlog / Future Ideas > **Research:** See [RESEARCH.md - Backlog](RESEARCH.md#backlog--future-research) for future investigation tasks @@ -317,9 +404,27 @@ --- +## Implementation Dependency Order + +``` +Phase 4: Module Refactor ──▶ Functional Tests + │ │ + ▼ ▼ +Config Completion ◀────────── (parallel) + │ + ▼ +oscdimg Auto-Downloader ──┬──▶ JSON Schema Validation + │ │ + ▼ ▼ + (Phase 2 tasks unlock after Phase 4) +``` + +--- + **Notes:** -- Prioritize Phase 1 MVP tasks to get a working prototype +- Phase 1 MVP tasks remain the primary goal — don't deprioritize them for infrastructure work +- Phase 4 infrastructure gates Phase 2/3 — do these first to unlock later work - Test frequently in VM to avoid breaking personal system - Backup current system before testing destructive changes -- Keep security in mind - never commit passwords/tokens to Git +- Keep security in mind — never commit passwords/tokens to Git diff --git a/bootstrap.ps1 b/bootstrap.ps1 index d58fa05..3eb7ed6 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -498,18 +498,64 @@ function Invoke-WingetManifestInstall { Write-Log "$ManifestLabel changed since last WinGet run" -Level INFO } - $tempAppsJson = Join-Path $env:TEMP "apps-missing-$(Get-Random).json" - Write-FilteredAppsJson -AppsData $appsData -PackageIds $missingPackages -OutputPath $tempAppsJson - Write-Log "Installing $($missingPackages.Count) missing packages from $ManifestLabel" -Level INFO - $null = winget import $tempAppsJson --accept-package-agreements --accept-source-agreements 2>&1 - $stillMissing = foreach ($packageId in $missingPackages) { - if (-not (Test-WingetPackageInstalled -PackageId $packageId)) { - $packageId + $failedPackages = [System.Collections.Generic.List[string]]::new() + + foreach ($packageId in $missingPackages) { + $installed = $false + $scopesToTry = @('user', 'machine') + + foreach ($scope in $scopesToTry) { + Write-Log " Installing $packageId (scope: $scope)..." -Level INFO + + $stdoutFile = Join-Path $env:TEMP "winget-stdout-$packageId-$(Get-Random).log" + $stderrFile = Join-Path $env:TEMP "winget-stderr-$packageId-$(Get-Random).log" + + $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 + } + } + } + + # 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 } + + # Check if installed + if (Test-WingetPackageInstalled -PackageId $packageId) { + Write-Log " Successfully installed $packageId (scope: $scope)" -Level SUCCESS + $installed = $true + break + } + + if ($scope -eq 'user') { + Write-Log " User scope failed for $packageId, trying machine scope..." -Level WARNING + } + } + + if (-not $installed) { + $failedPackages.Add($packageId) + Write-Log "WARNING: $packageId failed to install (both user and machine scopes failed)" -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 @@ -530,12 +576,13 @@ function Invoke-WingetManifestInstall { return $false } catch { - Write-Log "ERROR during WinGet import from ${ManifestLabel}: $($_.Exception.Message)" -Level ERROR - Add-SummaryItem -Step $SummaryStep -Status "FAIL" -Message "WinGet import failed" - Set-StepState -StepId $StepId -Status "failed" -Message "WinGet import failed" + 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 } finally { + # Legacy single-file cleanup (kept for safety if old path is referenced) if ($tempAppsJson -and (Test-Path $tempAppsJson)) { Remove-Item -Path $tempAppsJson -Force -ErrorAction SilentlyContinue } diff --git a/preflight-backup.ps1 b/preflight-backup.ps1 index 55f8c01..bec03bd 100644 --- a/preflight-backup.ps1 +++ b/preflight-backup.ps1 @@ -128,6 +128,20 @@ function Normalize-RuleId { return $normalized.Trim('-').ToLowerInvariant() } +function Get-RelativePath { + param( + [Parameter(Mandatory)] + [string]$Path, + [Parameter(Mandatory)] + [string]$BasePath + ) + $pathUri = New-Object System.Uri($Path) + $baseUri = New-Object System.Uri($BasePath) + $relativeUri = $baseUri.MakeRelativeUri($pathUri) + $relativePath = [System.Uri]::UnescapeDataString($relativeUri.ToString()) + return $relativePath -replace '/', '\' +} + function Get-BackupRules { param([object]$Config) @@ -339,7 +353,7 @@ foreach ($rule in $rules) { restorePath = $rule.restorePath kind = $rule.kind tags = $rule.tags - backupPath = $ruleDestination + backupPath = Get-RelativePath -Path $ruleDestination -BasePath $sessionRoot success = $copyResult.Success message = $copyResult.Message }) @@ -371,7 +385,7 @@ foreach ($repoFile in $repoFiles) { $entry = [ordered]@{ relativePath = $repoFile.relativePath source = $repoFile.source - backupPath = $destination + backupPath = Get-RelativePath -Path $destination -BasePath $sessionRoot } if ($VerifyHashes -and (Test-Path $destination)) { diff --git a/restore-backup.ps1 b/restore-backup.ps1 index 39f95cc..2bd693b 100644 --- a/restore-backup.ps1 +++ b/restore-backup.ps1 @@ -47,18 +47,65 @@ function Find-BackupManifest { function Resolve-RestoreTargetPath { param( [string]$Path, - [string]$ProfileRoot + [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('\\') + $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 } @@ -115,6 +162,7 @@ if (-not $ManifestPath) { } $resolvedManifestPath = (Resolve-Path $ManifestPath).Path +$manifestDir = Split-Path $resolvedManifestPath -Parent $manifest = Get-Content -Path $resolvedManifestPath -Raw | ConvertFrom-Json if (-not $DestinationProfileRoot) { @@ -123,6 +171,22 @@ if (-not $DestinationProfileRoot) { $restoreReport = New-Object System.Collections.Generic.List[object] +# Check if backup.json is in the repo files list +$backupJsonEntry = $manifest.repoFiles | Where-Object { $_.relativePath -eq "config\backup.json" } +if ($backupJsonEntry) { + $restoreBackupJson = $false + if ($PSCmdlet.ShouldProcess("config\backup.json", "Prompt for restore")) { + $response = Read-Host "Restore config\backup.json? This file contains paths from your OLD machine. It is recommended to customize from backup.template.json on the new machine instead. [Y] Restore, [N] Skip (default: N)" + $restoreBackupJson = $response -eq 'Y' + } + + if (-not $restoreBackupJson) { + Write-Host "Skipping config\backup.json restore — customize from backup.template.json on the new machine" + # Remove from list so it's not processed in the loop below + $manifest.repoFiles = [array]($manifest.repoFiles | Where-Object { $_.relativePath -ne "config\backup.json" }) + } +} + foreach ($repoFile in $manifest.repoFiles) { $repoTargetRoot = [Environment]::ExpandEnvironmentVariables($manifest.repo.restorePath) $destination = Join-Path $repoTargetRoot $repoFile.relativePath @@ -140,12 +204,22 @@ foreach ($repoFile in $manifest.repoFiles) { } if ($PSCmdlet.ShouldProcess($destination, "Restore repo file")) { - Copy-Item -Path $repoFile.backupPath -Destination $destination -Force:($Mode -eq "Overwrite") + $sourcePath = Join-Path $manifestDir $repoFile.backupPath + Copy-Item -Path $sourcePath -Destination $destination -Force:($Mode -eq "Overwrite") } $restoreReport.Add([pscustomobject]@{ type = "repoFile"; path = $destination; status = "restored" }) } +# 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 + } +} + foreach ($rule in $manifest.rules) { if (-not $rule.success) { continue @@ -155,8 +229,9 @@ foreach ($rule in $manifest.rules) { continue } - $targetPath = Resolve-RestoreTargetPath -Path $rule.restorePath -ProfileRoot $DestinationProfileRoot - $success = Copy-Tree -Source $rule.backupPath -Destination $targetPath -RobocopyMode $Mode + $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" path = $targetPath