diff --git a/CLAUDE.md b/CLAUDE.md index 1968a57..08c887d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Configuration files and documentation are already in place. ## 🪟 Windows Version Support -**Supported:** Windows 11 (22H2 or later) ONLY +**Supported:** Windows 11 (24H2 or later) ONLY **Not Supported:** Windows 10 @@ -79,7 +79,7 @@ declarative-windows/ ### Workflow 1: Custom ISO Generation (Recommended) -**Goal:** Create a custom Windows ISO with all configs baked in - one command, fully automated +**Goal:** Create a custom Windows ISO with all configs baked in - one command, automated after manual disk selection ```bash # 1. Generate custom ISO (one command) @@ -87,8 +87,8 @@ declarative-windows/ # 2. Burn ISO to USB or boot in VM -# 3. Boot from ISO - Windows installs automatically with: -# - autounattend.xml configures Windows setup +# 3. Boot from ISO - choose the target disk/partition, then setup continues with: +# - autounattend.xml configures Windows setup after disk selection # - Files copied to C:\Setup via $OEM$ folder # - bootstrap.ps1 runs automatically after first login # - Desktop shortcut created for manual re-runs diff --git a/FAQ.md b/FAQ.md index fe6828b..6b66f46 100644 --- a/FAQ.md +++ b/FAQ.md @@ -19,7 +19,7 @@ Before reinstall, you can also run a declarative backup workflow that preserves ## What Windows versions are supported? -Windows 11 only (22H2 or later). Windows 10 is not supported. +Windows 11 only (24H2 or later). Windows 10 is not supported. ## Do I need to create a custom ISO? @@ -37,6 +37,8 @@ winget export -o apps.json Then edit the JSON to remove unwanted apps. +If you want some apps to stay optional after reinstall, create `optional-apps.json` with the same WinGet manifest format. Bootstrap prompts for it after first login and also creates `Install Optional Apps.lnk` for later use. + ## Where does the repo live after reinstall? The installer tries to clone the original repo remote into `%USERPROFILE%\Documents\declarative-windows`. diff --git a/README.md b/README.md index 10489b7..a8f7ecd 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ winget export -o apps.json --source winget For personal usage, keep `apps.json` out of git. The repo ships `apps-template.json`, and the backup workflow preserves your personal `apps.json` so it can be restored into the cloned repo after reinstall. +If you want a second-stage app list, create `optional-apps.json` alongside `apps.json`. `apps.json` installs automatically during bootstrap, while `optional-apps.json` is offered with a yes/no prompt after first login and can also be installed later from a desktop shortcut. + ### Backup Before Reinstall Create a personal backup config by copying `config\backup.template.json` to `config\backup.json`, then enable the known folders and extra paths you want to preserve. @@ -121,6 +123,13 @@ winget import apps.json --ignore-versions winget import apps.json --accept-package-agreements --accept-source-agreements ``` +Optional apps can use the same manifest format: + +```powershell +Copy-Item apps.json optional-apps.json +# Then edit optional-apps.json for apps you want to install later +``` + ### 4. Find Package IDs ```powershell @@ -137,7 +146,7 @@ If you want to test OS tweaks before automation: ```powershell # 1. Download Sophia Script for Windows 11 -Invoke-WebRequest -Uri "https://github.com/farag2/Sophia-Script-for-Windows/releases/latest/download/Sophia.Script.for.Windows.11.v6.9.1.zip" -OutFile "SophiaScript.zip" +Invoke-WebRequest -Uri "https://github.com/farag2/Sophia-Script-for-Windows/releases/latest/download/Sophia.Script.for.Windows.11.v7.1.4.zip" -OutFile "SophiaScript.zip" # 2. Extract the archive Expand-Archive -Path "SophiaScript.zip" -DestinationPath ".\SophiaScript" -Force @@ -167,7 +176,8 @@ declarative-windows/ ├── CLAUDE.md # Project guidance for Claude Code ├── brainstorm.md # Design discussions │ -├── apps.json # Your WinGet package list (create this) +├── apps.json # Auto-installed WinGet package list +├── optional-apps.json # Prompted/later WinGet package list (optional) ├── Sophia-Preset.ps1 # Custom Sophia Script configuration ├── autounattend.xml # Windows unattended install config ├── bootstrap.ps1 # Main orchestration script @@ -184,7 +194,7 @@ declarative-windows/ ## Windows Version Support -**Supported:** Windows 11 (22H2 or later) +**Supported:** Windows 11 (24H2 or later) **Not Supported:** Windows 10 diff --git a/SOPHIA-FUNCTIONS-REFERENCE.md b/SOPHIA-FUNCTIONS-REFERENCE.md index 90ae64b..43aa6a9 100644 --- a/SOPHIA-FUNCTIONS-REFERENCE.md +++ b/SOPHIA-FUNCTIONS-REFERENCE.md @@ -1,7 +1,9 @@ -# Sophia Script for Windows 11 - Complete Function Reference -**Version:** 6.9.1 +# Sophia Script for Windows 11 - Archived Function Reference +**Version:** 6.9.1 (archived snapshot) **Source:** https://raw.githubusercontent.com/farag2/Sophia-Script-for-Windows/master/src/Sophia_Script_for_Windows_11/Sophia.ps1 +> This file is an older local reference. The active preset targets Sophia Script `7.1.4` and Windows 11 `24H2+`, so verify behavior against the upstream 7.1.4 release before relying on this document. + --- ## Privacy & Telemetry diff --git a/Sophia-Preset.ps1 b/Sophia-Preset.ps1 index 3e3c9cf..91f1e3c 100644 --- a/Sophia-Preset.ps1 +++ b/Sophia-Preset.ps1 @@ -4,14 +4,14 @@ .DESCRIPTION This preset file contains customized Windows 11 tweaks and configurations. - Verified against Sophia Script v6.9.1 for Windows 11. + Verified against Sophia Script v7.1.4 for Windows 11. This preset handles ALL UI tweaks, registry customizations, and OS settings. - AutoUnattend.xml only handles installation-time tasks (disk setup, bloatware removal). + AutoUnattend.xml only handles installation-time tasks (manual disk selection flow, bloatware removal). .NOTES - Tested with: Sophia Script v6.9.1 - Windows 11: 22H2 or later + Tested with: Sophia Script v7.1.4 + Windows 11: 24H2 or later Reference: SOPHIA-FUNCTIONS-REFERENCE.md .LINK @@ -93,8 +93,8 @@ AppColorMode -Dark # Open File Explorer to "This PC" OpenFileExplorerTo -ThisPC -# Disable search highlights -SearchHighlights -Disable +# Hide search highlights +SearchHighlights -Hide # Hide widgets icon on taskbar TaskbarWidgets -Hide @@ -142,14 +142,14 @@ WindowsTips -Disable # Enable storage sense (auto cleanup) StorageSense -Enable -# Disable hibernation (saves disk space) -Hibernation -Disable +# Keep hibernation at the Windows default unless you explicitly want it off +# Hibernation -Disable -# Set power plan to high performance -PowerPlan -High +# Keep the default balanced power plan for main-machine use +# PowerPlan -High # Enable long path support (>260 characters) -Win32LongPathSupport -Enable +Win32LongPathsSupport -Enable # Don't let Windows manage default printer WindowsManageDefaultPrinter -Disable @@ -175,13 +175,13 @@ StickyShift -Disable # Disable autoplay for all media Autoplay -Disable -# Don't save zone information about files from Internet -SaveZoneInformation -Disable +# Keep zone information on downloaded files for SmartScreen/Attachment Manager +# SaveZoneInformation -Disable #endregion System #region Windows Features -# Install Windows Subsystem for Linux -Install-WSL +# Install Windows Subsystem for Linux when explicitly needed +# Install-WSL # Note: .NET 3.5 can be installed via WindowsCapabilities if needed # WindowsCapabilities -Install -Names "NetFx3~~~~" @@ -212,14 +212,14 @@ XboxGameTips -Disable #endregion Gaming #region Scheduled Tasks -# Disable Windows cleanup scheduled task -CleanupTask -Disable +# Delete Sophia Windows cleanup scheduled task if it exists +CleanupTask -Delete -# Disable SoftwareDistributionTask -SoftwareDistributionTask -Disable +# Delete Sophia SoftwareDistribution cleanup task if it exists +SoftwareDistributionTask -Delete -# Disable temp files cleanup task -TempTask -Disable +# Delete Sophia temp cleanup task if it exists +TempTask -Delete #endregion Scheduled Tasks #region Microsoft Defender & Security @@ -232,13 +232,11 @@ PUAppsDetection -Enable # Enable Microsoft Defender Sandbox DefenderSandbox -Enable -# Disable SmartScreen for apps and files -AppsSmartScreen -Disable +# Keep SmartScreen enabled for apps and downloaded files +# AppsSmartScreen -Disable -# Enable DNS-over-HTTPS (Cloudflare) -DNSoverHTTPS -Enable -PrimaryDNS 1.1.1.1 -SecondaryDNS 1.0.0.1 - -# Note: Change to Google DNS if preferred: -PrimaryDNS 8.8.8.8 -SecondaryDNS 8.8.4.4 +# Optional: enable DNS-over-HTTPS with a named provider if you want to force it +# DNSoverHTTPS -Cloudflare #endregion Microsoft Defender & Security #region Context Menu @@ -258,7 +256,7 @@ OpenWindowsTerminalContext -Show #endregion Context Menu <# - VERIFIED AGAINST: Sophia Script v6.9.1 + VERIFIED AGAINST: Sophia Script v7.1.4 REFERENCE: SOPHIA-FUNCTIONS-REFERENCE.md DIVISION OF RESPONSIBILITIES: @@ -268,8 +266,8 @@ OpenWindowsTerminalContext -Show WHAT THIS PRESET INCLUDES: 1. Privacy: Disabled telemetry, Bing, tips, suggested content, tailored experiences 2. UI: Dark mode, show file extensions/hidden files, left taskbar, minimal icons - 3. System: High performance, storage sense, disabled hibernation, long paths - 4. Security: Network protection, PUA detection, Defender sandbox, DNS-over-HTTPS + 3. System: storage sense, long paths, conservative main-machine defaults + 4. Security: Network protection, PUA detection, Defender sandbox 5. Debloat: Disabled Xbox features, autoplay, unnecessary scheduled tasks TO CUSTOMIZE: diff --git a/TODO.md b/TODO.md index a22a981..e81cf87 100644 --- a/TODO.md +++ b/TODO.md @@ -163,7 +163,7 @@ - [ ] 🟡 ✅ Test Sophia Script execution on fresh Windows - [ ] 🟡 ✅ Verify desktop summary log is created correctly - [ ] 🟡 ✅ Test "continue where left off" after simulated failure -- [ ] 🟢 ✅ Verify all scripts work with Windows 11 (22H2 or later) +- [ ] 🟢 ✅ Verify all scripts work with Windows 11 (24H2 or later) --- diff --git a/bootstrap.ps1 b/bootstrap.ps1 index 41e6490..d8e848c 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -13,17 +13,20 @@ param( [switch]$DryRun, [switch]$Force, - [switch]$PromptRestart + [switch]$PromptRestart, + [switch]$OptionalAppsOnly ) $ErrorActionPreference = "Continue" $SetupPath = "C:\Setup" $LogFile = Join-Path $SetupPath "install.log" $AppsJson = Join-Path $SetupPath "apps.json" +$OptionalAppsJson = Join-Path $SetupPath "optional-apps.json" $RestoreScript = Join-Path $SetupPath "restore-backup.ps1" $SophiaPreset = Join-Path $SetupPath "Sophia-Preset.ps1" $SophiaMarker = Join-Path $SetupPath "sophia.completed" $WingetMarker = Join-Path $SetupPath "winget.completed" +$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" @@ -35,7 +38,7 @@ $SophiaVersion = "7.1.4" $SophiaZipName = "Sophia.Script.for.Windows.11.v$SophiaVersion.zip" $SophiaDownloadUrl = "https://github.com/farag2/Sophia-Script-for-Windows/releases/download/$SophiaVersion/$SophiaZipName" $FailedInstallsLog = Join-Path $SetupPath "failed-installs.log" -$StepIds = @("winget", "repo", "sophia", "registry", "shortcut", "restoreShortcut", "summary") +$StepIds = @("winget", "repo", "sophia", "registry", "shortcut", "restoreShortcut", "optionalShortcut", "optionalWinget", "summary") $SetupState = $null $SummaryItems = [System.Collections.Generic.List[object]]::new() $FailedItems = [System.Collections.Generic.List[object]]::new() @@ -428,6 +431,150 @@ function Write-FilteredAppsJson { $filteredData | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Force } +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 + ) + + $tempAppsJson = $null + + if (Test-Path $ManifestPath) { + 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 + } + + $appsData = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json + $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 = foreach ($packageId in $packageIds) { + if (-not (Test-WingetPackageInstalled -PackageId $packageId)) { + $packageId + } + } + + if (-not $missingPackages) { + 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 + } + + $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 + } + } + + 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 + } + + if (-not $stillMissing) { + Write-Log "WinGet import 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" + 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" + 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" + return $false + } + finally { + if ($tempAppsJson -and (Test-Path $tempAppsJson)) { + Remove-Item -Path $tempAppsJson -Force -ErrorAction SilentlyContinue + } + } + } + + 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 +} + +function New-DesktopShortcut { + param( + [Parameter(Mandatory)] + [string]$ShortcutPath, + + [Parameter(Mandatory)] + [string]$TargetPath, + + [Parameter(Mandatory)] + [string]$Arguments, + + [Parameter(Mandatory)] + [string]$WorkingDirectory, + + [Parameter(Mandatory)] + [string]$Description + ) + + $shell = New-Object -ComObject WScript.Shell + $shortcut = $shell.CreateShortcut($ShortcutPath) + $shortcut.TargetPath = $TargetPath + $shortcut.Arguments = $Arguments + $shortcut.WorkingDirectory = $WorkingDirectory + $shortcut.Description = $Description + $shortcut.Save() +} + function Find-BackupManifest { $drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue | Where-Object { $_.Root -ne "$($env:SystemDrive)\" @@ -576,8 +723,15 @@ try { Write-Log "Dry run mode enabled; no system changes will be applied" -Level WARNING } + if ($OptionalAppsOnly) { + Write-Log "Optional apps only mode enabled; skipping core setup steps" -Level INFO + } + $stepId = "winget" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 1: Skipping WinGet core apps (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 1: Skipping WinGet (already completed)" -Level INFO Add-SummaryItem -Step "WinGet" -Status "OK" -Message "Skipped (already completed)" } @@ -586,102 +740,15 @@ try { Add-SummaryItem -Step "WinGet" -Status "WARN" -Message "Dry run: WinGet import skipped" Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: WinGet import skipped" } - elseif (Test-Path $AppsJson) { - $tempAppsJson = $null - - try { - Write-Log "Found apps.json at $AppsJson" -Level INFO - - if (-not (Wait-ForNetwork)) { - Add-SummaryItem -Step "WinGet" -Status "FAIL" -Message "Network unavailable; skipped app install" - Set-StepState -StepId $stepId -Status "failed" -Message "Network unavailable" - } - else { - $appsData = Get-Content -Path $AppsJson -Raw | ConvertFrom-Json - $packageIds = Get-WingetPackageIdsFromJson -Path $AppsJson - - if (-not $packageIds -or $packageIds.Count -eq 0) { - Write-Log "apps.json contains no packages to install" -Level WARNING - Add-SummaryItem -Step "WinGet" -Status "WARN" -Message "No packages found in apps.json" - Set-StepState -StepId $stepId -Status "done" -Message "No packages found" - } - else { - $appsHash = (Get-FileHash -Path $AppsJson -Algorithm SHA256).Hash - $markerHash = $null - if (Test-Path $WingetMarker) { - $markerHash = (Get-Content -Path $WingetMarker -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() - } - - $missingPackages = foreach ($packageId in $packageIds) { - if (-not (Test-WingetPackageInstalled -PackageId $packageId)) { - $packageId - } - } - - if (-not $missingPackages) { - Write-Log "All WinGet packages already installed" -Level SUCCESS - Set-Content -Path $WingetMarker -Value $appsHash -Force - Add-SummaryItem -Step "WinGet" -Status "OK" -Message "Already up to date ($WingetMarker)" - Set-StepState -StepId $stepId -Status "done" -Message "Already up to date" - } - else { - if ($markerHash -and $markerHash -ne $appsHash) { - Write-Log "apps.json 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" -Level INFO - $null = winget import $tempAppsJson --accept-package-agreements --accept-source-agreements 2>&1 - - # Check each package individually so we know exactly what failed - $stillMissing = foreach ($packageId in $missingPackages) { - if (-not (Test-WingetPackageInstalled -PackageId $packageId)) { - $packageId - } - } - - foreach ($packageId in $stillMissing) { - Add-FailedItem -Category "WinGet Packages" -Item $packageId -Reason "Not installed after import" - Write-Log "WARNING: $packageId still not installed after WinGet import" -Level WARNING - } - - if (-not $stillMissing) { - Write-Log "WinGet import completed successfully" -Level SUCCESS - Set-Content -Path $WingetMarker -Value $appsHash -Force - Add-SummaryItem -Step "WinGet" -Status "OK" -Message "Installed $($missingPackages.Count) packages" - Set-StepState -StepId $stepId -Status "done" -Message "Installed $($missingPackages.Count) packages" - } - else { - $failCount = @($stillMissing).Count - Write-Log "WinGet import finished; $failCount package(s) failed" -Level WARNING - Add-SummaryItem -Step "WinGet" -Status "WARN" -Message "$failCount package(s) failed - see Failed Installs.txt" - Set-StepState -StepId $stepId -Status "failed" -Message "$failCount package(s) failed" - } - } - } - } - } - catch { - Write-Log "ERROR during WinGet import: $($_.Exception.Message)" -Level ERROR - Add-SummaryItem -Step "WinGet" -Status "FAIL" -Message "WinGet import failed" - Set-StepState -StepId $stepId -Status "failed" -Message "WinGet import failed" - } - finally { - if ($tempAppsJson -and (Test-Path $tempAppsJson)) { - Remove-Item -Path $tempAppsJson -Force -ErrorAction SilentlyContinue - } - } - } else { - Write-Log "WARNING: apps.json not found at $AppsJson - skipping application import" -Level WARNING - Add-SummaryItem -Step "WinGet" -Status "WARN" -Message "apps.json not found" - Set-StepState -StepId $stepId -Status "done" -Message "apps.json not found" + $null = Invoke-WingetManifestInstall -ManifestPath $AppsJson -StepId $stepId -SummaryStep "WinGet" -MarkerPath $WingetMarker -ManifestLabel "apps.json" -MissingManifestMessage "apps.json not found" } $stepId = "repo" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 2: Skipping canonical repo restore (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 2: Skipping canonical repo restore (already completed)" -Level INFO Add-SummaryItem -Step "Repo" -Status "OK" -Message "Skipped (already completed)" } @@ -710,7 +777,10 @@ try { } $stepId = "sophia" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 3: Skipping Sophia (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 3: Skipping Sophia (already completed)" -Level INFO Add-SummaryItem -Step "Sophia" -Status "OK" -Message "Skipped (already completed)" } @@ -783,7 +853,10 @@ try { } $stepId = "registry" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 4: Skipping registry fallback (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 4: Skipping registry fallback (already completed)" -Level INFO Add-SummaryItem -Step "Registry" -Status "OK" -Message "Skipped (already completed)" } @@ -828,7 +901,10 @@ try { $desktopPath = [Environment]::GetFolderPath("Desktop") $stepId = "shortcut" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 5: Skipping desktop shortcut (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 5: Skipping desktop shortcut (already completed)" -Level INFO Add-SummaryItem -Step "Shortcut" -Status "OK" -Message "Skipped (already completed)" } @@ -840,14 +916,8 @@ try { else { try { $shortcutPath = Join-Path $desktopPath "Run Windows Setup.lnk" - $shell = New-Object -ComObject WScript.Shell - $shortcut = $shell.CreateShortcut($shortcutPath) $bootstrapTarget = Get-RunBootstrapTarget - $shortcut.TargetPath = "powershell.exe" - $shortcut.Arguments = "-ExecutionPolicy Bypass -File `"$bootstrapTarget`"" - $shortcut.WorkingDirectory = Split-Path -Path $bootstrapTarget -Parent - $shortcut.Description = "Re-run declarative Windows setup" - $shortcut.Save() + New-DesktopShortcut -ShortcutPath $shortcutPath -TargetPath "powershell.exe" -Arguments "-ExecutionPolicy Bypass -File `"$bootstrapTarget`"" -WorkingDirectory (Split-Path -Path $bootstrapTarget -Parent) -Description "Re-run declarative Windows setup" Add-SummaryItem -Step "Shortcut" -Status "OK" -Message "Run Windows Setup.lnk created" Set-StepState -StepId $stepId -Status "done" -Message "Shortcut created" @@ -860,7 +930,10 @@ try { } $stepId = "restoreShortcut" - if (-not (Should-RunStep -StepId $stepId)) { + if ($OptionalAppsOnly) { + Write-Log "Step 6: Skipping restore shortcut (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { Write-Log "Step 6: Skipping restore shortcut (already completed)" -Level INFO Add-SummaryItem -Step "Restore" -Status "OK" -Message "Skipped (already completed)" } @@ -877,13 +950,7 @@ try { else { try { $restoreShortcutPath = Join-Path $desktopPath "Restore My Files.lnk" - $restoreShell = New-Object -ComObject WScript.Shell - $restoreShortcut = $restoreShell.CreateShortcut($restoreShortcutPath) - $restoreShortcut.TargetPath = "powershell.exe" - $restoreShortcut.Arguments = "-ExecutionPolicy Bypass -File `"$RestoreScript`"" - $restoreShortcut.WorkingDirectory = $SetupPath - $restoreShortcut.Description = "Restore backed up files after Windows reinstall" - $restoreShortcut.Save() + New-DesktopShortcut -ShortcutPath $restoreShortcutPath -TargetPath "powershell.exe" -Arguments "-ExecutionPolicy Bypass -File `"$RestoreScript`"" -WorkingDirectory $SetupPath -Description "Restore backed up files after Windows reinstall" Add-SummaryItem -Step "Restore" -Status "OK" -Message "Restore My Files.lnk created" Set-StepState -StepId $stepId -Status "done" -Message "Restore shortcut created" @@ -895,12 +962,81 @@ try { } } + $stepId = "optionalShortcut" + if ($OptionalAppsOnly) { + Write-Log "Step 7: Skipping optional apps shortcut (optional apps only mode)" -Level INFO + } + elseif (-not (Should-RunStep -StepId $stepId)) { + Write-Log "Step 7: Skipping optional apps shortcut (already completed)" -Level INFO + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "OK" -Message "Skipped (already completed)" + } + elseif ($DryRun) { + Write-Log "Step 7: Dry run - skipping optional apps shortcut" -Level WARNING + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "WARN" -Message "Dry run: optional shortcut not created" + Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: optional shortcut not created" + } + elseif (-not (Test-Path $OptionalAppsJson)) { + Write-Log "optional-apps.json not found at $OptionalAppsJson - skipping optional apps shortcut" -Level INFO + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "WARN" -Message "optional-apps.json not found" + Set-StepState -StepId $stepId -Status "done" -Message "optional-apps.json not found" + } + else { + try { + $optionalShortcutPath = Join-Path $desktopPath "Install Optional Apps.lnk" + $bootstrapTarget = Get-RunBootstrapTarget + New-DesktopShortcut -ShortcutPath $optionalShortcutPath -TargetPath "powershell.exe" -Arguments "-ExecutionPolicy Bypass -File `"$bootstrapTarget`" -OptionalAppsOnly" -WorkingDirectory (Split-Path -Path $bootstrapTarget -Parent) -Description "Install optional declarative Windows apps later" + + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "OK" -Message "Install Optional Apps.lnk created" + Set-StepState -StepId $stepId -Status "done" -Message "Optional apps shortcut created" + } + catch { + Write-Log "WARNING: Failed to create optional apps shortcut: $($_.Exception.Message)" -Level WARNING + Add-SummaryItem -Step "Optional Apps Shortcut" -Status "WARN" -Message "Failed to create optional shortcut" + Set-StepState -StepId $stepId -Status "failed" -Message "Failed to create optional shortcut" + } + } + + $stepId = "optionalWinget" + if (-not (Should-RunStep -StepId $stepId) -and -not $OptionalAppsOnly) { + Write-Log "Step 8: Skipping optional apps (already completed)" -Level INFO + Add-SummaryItem -Step "Optional Apps" -Status "OK" -Message "Skipped (already completed)" + } + elseif ($DryRun) { + Write-Log "Step 8: Dry run - skipping optional apps import" -Level WARNING + Add-SummaryItem -Step "Optional Apps" -Status "WARN" -Message "Dry run: optional apps skipped" + Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: optional apps skipped" + } + 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" + } + } + else { + $installOptionalApps = $OptionalAppsOnly + + if (-not $installOptionalApps) { + $optionalAppsResponse = Read-Host "Install optional apps now? (Y/N)" + if ($optionalAppsResponse -match '^(y|yes)$') { + $installOptionalApps = $true + } + else { + Write-Log "Optional apps skipped by user" -Level INFO + Add-SummaryItem -Step "Optional Apps" -Status "WARN" -Message "Skipped by user - use Install Optional Apps.lnk later" + Set-StepState -StepId $stepId -Status "pending" -Message "Skipped by user" + } + } + + if ($installOptionalApps) { + $null = Invoke-WingetManifestInstall -ManifestPath $OptionalAppsJson -StepId $stepId -SummaryStep "Optional Apps" -MarkerPath $OptionalWingetMarker -ManifestLabel "optional-apps.json" -MissingManifestMessage "optional-apps.json not found" + } + } + $stepId = "summary" if (-not (Should-RunStep -StepId $stepId)) { - Write-Log "Step 7: Skipping summary report (already completed)" -Level INFO + Write-Log "Step 9: Skipping summary report (already completed)" -Level INFO } elseif ($DryRun) { - Write-Log "Step 7: Dry run - skipping summary report" -Level WARNING + Write-Log "Step 9: Dry run - skipping summary report" -Level WARNING Set-StepState -StepId $stepId -Status "pending" -Message "Dry run: summary skipped" } else { diff --git a/build-iso.ps1 b/build-iso.ps1 index 3d34e36..0537fa2 100644 --- a/build-iso.ps1 +++ b/build-iso.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION This script takes a source Windows 11 ISO and creates a customized version with: - autounattend.xml for unattended installation - - bootstrap.ps1, apps.json, and Sophia-Preset.ps1 copied via $OEM$ structure + - bootstrap.ps1, apps.json, optional-apps.json, and Sophia-Preset.ps1 copied via $OEM$ structure when present - Fully automated setup that runs on first login .PARAMETER SourceISO @@ -202,6 +202,10 @@ try { "backup.template.json" = Join-Path $ScriptRoot "config\backup.template.json" } + $optionalFiles = @{ + "optional-apps.json" = Join-Path $ScriptRoot "optional-apps.json" + } + foreach ($file in $requiredFiles.GetEnumerator()) { if (Test-Path $file.Value) { Write-Success "$($file.Key) found" @@ -212,6 +216,15 @@ try { } } + foreach ($file in $optionalFiles.GetEnumerator()) { + if (Test-Path $file.Value) { + Write-Success "$($file.Key) found" + } + else { + Write-Info "$($file.Key) not found - skipping optional apps payload" + } + } + # Validate source ISO checksum if provided Validate-SourceIsoHash -IsoPath $SourceISO -ExpectedHash $SourceIsoHash @@ -274,6 +287,11 @@ try { Write-Success "$($file.Name) copied" } + if (Test-Path $optionalFiles["optional-apps.json"]) { + Copy-Item -Path $optionalFiles["optional-apps.json"] -Destination $setupPath -Force + Write-Success "optional-apps.json copied" + } + # Copy config files Write-Step "Copying config files" $configSource = Join-Path $ScriptRoot "config" @@ -355,7 +373,7 @@ try { Write-Host "Next Steps:" -ForegroundColor Cyan Write-Host " 1. Burn the ISO to a USB drive (use Rufus or similar)" -ForegroundColor White Write-Host " 2. Boot from the USB" -ForegroundColor White - Write-Host " 3. Windows will install automatically with your configuration" -ForegroundColor White + Write-Host " 3. Choose the target disk in Windows Setup, then let post-install automation continue" -ForegroundColor White Write-Host " 4. bootstrap.ps1 will run on first login" -ForegroundColor White Write-Host "" diff --git a/docs/ISO-GENERATION.md b/docs/ISO-GENERATION.md index 5a69798..7fb77be 100644 --- a/docs/ISO-GENERATION.md +++ b/docs/ISO-GENERATION.md @@ -4,7 +4,7 @@ This guide explains how to build a custom Windows 11 ISO with declarative-window ## Prerequisites -- Windows 11 (22H2 or later) ISO +- Windows 11 (24H2 or later) ISO - Windows ADK **or** a direct `oscdimg.exe` download URL - Administrator privileges - At least 10GB of free disk space @@ -47,6 +47,7 @@ Custom-ISO:\ └── Setup\ ├── bootstrap.ps1 ├── apps.json + ├── optional-apps.json (optional) ├── Sophia-Preset.ps1 ├── restore-backup.ps1 ├── apply-registry.ps1 @@ -63,6 +64,7 @@ During installation, you still choose the target disk and partition in Windows S C:\Setup\ ├── bootstrap.ps1 ├── apps.json +├── optional-apps.json (optional) ├── Sophia-Preset.ps1 ├── apply-registry.ps1 ├── state.json @@ -75,6 +77,7 @@ A desktop shortcut is created for manual re-runs: - `Run Windows Setup.lnk` - `Restore My Files.lnk` +- `Install Optional Apps.lnk` (only when `optional-apps.json` exists) After first login, bootstrap attempts to clone the original repo remote into `%USERPROFILE%\Documents\declarative-windows`. `C:\Setup` remains the staging area and fallback location if cloning fails. @@ -83,7 +86,8 @@ After first login, bootstrap attempts to clone the original repo remote into `%U 1. Boot from the custom ISO. 2. Choose the destination disk and partition layout manually in Windows Setup. 3. Complete the normal Windows account setup flow. -4. Let `bootstrap.ps1` continue the app install and post-install configuration automatically after first login. +4. Let `bootstrap.ps1` continue the core app install and post-install configuration automatically after first login. +5. If `optional-apps.json` exists, answer the yes/no prompt to install optional apps now or use the desktop shortcut later. ## Logs and Resume State @@ -99,6 +103,12 @@ Use the desktop shortcut or run: powershell.exe -ExecutionPolicy Bypass -File C:\Setup\bootstrap.ps1 ``` +To install optional apps later without rerunning the whole flow: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File C:\Setup\bootstrap.ps1 -OptionalAppsOnly +``` + ## Backup And Restore Flow Before reinstall, run: diff --git a/preflight-backup.ps1 b/preflight-backup.ps1 index 21eb5bb..e966806 100644 --- a/preflight-backup.ps1 +++ b/preflight-backup.ps1 @@ -34,6 +34,24 @@ function Write-Success { Write-Host "[ OK ] $Message" -ForegroundColor Green } +function Write-BackupProgress { + param( + [string]$Activity, + [string]$Status, + [int]$Current, + [int]$Total + ) + + $percentComplete = if ($Total -gt 0) { + [int][Math]::Floor(($Current / $Total) * 100) + } + else { + 100 + } + + Write-Progress -Activity $Activity -Status $Status -PercentComplete $percentComplete +} + function Resolve-EffectiveConfigPath { param([string]$RequestedPath) @@ -197,11 +215,9 @@ function Copy-DirectoryWithRobocopy { "/R:1", "/W:1", "/XJ", - "/NFL", "/NDL", "/NJH", - "/NJS", - "/NP" + "/NJS" ) foreach ($pattern in $ExcludePatterns) { @@ -211,7 +227,12 @@ function Copy-DirectoryWithRobocopy { } } - $null = & robocopy @robocopyArgs + & robocopy @robocopyArgs 2>&1 | ForEach-Object { + $line = $_.ToString().Trim() + if ($line) { + Write-Host " $line" + } + } $exitCode = $LASTEXITCODE $success = $exitCode -lt 8 @@ -293,8 +314,13 @@ foreach ($path in @($sessionRoot, $filesRoot, $repoFilesRoot, $exportsRoot, $rep $manifestRules = New-Object System.Collections.Generic.List[object] $failedRules = New-Object System.Collections.Generic.List[object] +$totalRuleCount = @($rules).Count +$currentRuleIndex = 0 foreach ($rule in $rules) { + $currentRuleIndex++ + Write-BackupProgress -Activity "Backing up configured folders" -Status "[$currentRuleIndex/$totalRuleCount] $($rule.label)" -Current $currentRuleIndex -Total $totalRuleCount + if (-not (Test-Path $rule.source)) { if ($rule.required) { $failedRules.Add([pscustomobject]@{ id = $rule.id; message = "Required source path not found" }) @@ -322,8 +348,15 @@ foreach ($rule in $rules) { } } +Write-Progress -Activity "Backing up configured folders" -Completed + $manifestRepoFiles = New-Object System.Collections.Generic.List[object] +$totalRepoFileCount = @($repoFiles).Count +$currentRepoFileIndex = 0 foreach ($repoFile in $repoFiles) { + $currentRepoFileIndex++ + Write-BackupProgress -Activity "Backing up personal repo files" -Status "[$currentRepoFileIndex/$totalRepoFileCount] $($repoFile.relativePath)" -Current $currentRepoFileIndex -Total $totalRepoFileCount + $destination = Join-Path $repoFilesRoot $repoFile.relativePath $destinationParent = Split-Path -Path $destination -Parent if ($PSCmdlet.ShouldProcess($destinationParent, "Create repo file backup directory")) { @@ -347,8 +380,12 @@ foreach ($repoFile in $repoFiles) { $manifestRepoFiles.Add([pscustomobject]$entry) } +Write-Progress -Activity "Backing up personal repo files" -Completed + $wingetExportPath = Join-Path $exportsRoot "apps.json" +Write-Progress -Activity "Exporting WinGet inventory" -Status "Running winget export" -PercentComplete 0 $wingetExported = Export-WingetInventory -OutputPath $wingetExportPath +Write-Progress -Activity "Exporting WinGet inventory" -Completed $manifest = [ordered]@{ manifestVersion = 1 diff --git a/tests/Bootstrap.Tests.ps1 b/tests/Bootstrap.Tests.ps1 index 69f6734..9932a60 100644 --- a/tests/Bootstrap.Tests.ps1 +++ b/tests/Bootstrap.Tests.ps1 @@ -9,6 +9,12 @@ Describe "bootstrap.ps1 static checks" { $scriptContent | Should -Match "Get-FileHash" } + It "supports optional apps manifest and marker" { + $scriptContent | Should -Match "optional-apps\.json" + $scriptContent | Should -Match "optional-winget\.completed" + $scriptContent | Should -Match "OptionalAppsOnly" + } + It "tracks Sophia completion marker with hash" { $scriptContent | Should -Match "sophia\.completed" $scriptContent | Should -Match "Get-FileHash" @@ -48,6 +54,11 @@ Describe "bootstrap.ps1 static checks" { $scriptContent | Should -Match "restore-backup\.ps1" } + It "creates an optional apps shortcut and prompt" { + $scriptContent | Should -Match "Install Optional Apps\.lnk" + $scriptContent | Should -Match "Install optional apps now\? \(Y/N\)" + } + It "can fall back when repo clone fails" { $scriptContent | Should -Match "git" $scriptContent | Should -Match "continuing with C:\\Setup" @@ -70,7 +81,7 @@ Describe "bootstrap.ps1 static checks" { It "checks individual packages after WinGet import" { $scriptContent | Should -Match "stillMissing" - $scriptContent | Should -Match "Not installed after import" + $scriptContent | Should -Match "Not installed after import from" } It "keeps partial WinGet failures retryable" { diff --git a/tests/BuildIso.Tests.ps1 b/tests/BuildIso.Tests.ps1 index db6bbf9..50ad52e 100644 --- a/tests/BuildIso.Tests.ps1 +++ b/tests/BuildIso.Tests.ps1 @@ -32,6 +32,11 @@ Describe "build-iso.ps1 static checks" { $scriptContent | Should -Match "backup\.template\.json" } + It "supports optional apps payload when present" { + $scriptContent | Should -Match "optional-apps\.json" + $scriptContent | Should -Match "skipping optional apps payload" + } + It "does not reference MountDir anymore" { $scriptContent.Contains('$MountDir') | Should -BeFalse }