From 094bebb5f737fa38405b832258db45b2dd9efead Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:15:33 -0700 Subject: [PATCH 01/19] Init dev config --- src/windows-dev-config/dev-config.ps1 | 78 ++++++++ src/windows-dev-config/steps/_elevation.ps1 | 42 +++++ src/windows-dev-config/steps/_environment.ps1 | 13 ++ .../steps/_reboot-resume.ps1 | 46 +++++ src/windows-dev-config/steps/_registry.ps1 | 44 +++++ src/windows-dev-config/steps/_retry.ps1 | 32 ++++ src/windows-dev-config/steps/_step-runner.ps1 | 69 ++++++++ src/windows-dev-config/steps/copilot.ps1 | 99 +++++++++++ src/windows-dev-config/steps/edge.ps1 | 24 +++ src/windows-dev-config/steps/fonts.ps1 | 166 ++++++++++++++++++ src/windows-dev-config/steps/packages.ps1 | 67 +++++++ .../steps/powershell-profile.ps1 | 98 +++++++++++ .../steps/registry-explorer.ps1 | 34 ++++ .../steps/registry-system.ps1 | 26 +++ .../steps/registry-taskbar-search.ps1 | 34 ++++ src/windows-dev-config/steps/terminal.ps1 | 92 ++++++++++ src/windows-dev-config/steps/wsl.ps1 | 84 +++++++++ 17 files changed, 1048 insertions(+) create mode 100644 src/windows-dev-config/dev-config.ps1 create mode 100644 src/windows-dev-config/steps/_elevation.ps1 create mode 100644 src/windows-dev-config/steps/_environment.ps1 create mode 100644 src/windows-dev-config/steps/_reboot-resume.ps1 create mode 100644 src/windows-dev-config/steps/_registry.ps1 create mode 100644 src/windows-dev-config/steps/_retry.ps1 create mode 100644 src/windows-dev-config/steps/_step-runner.ps1 create mode 100644 src/windows-dev-config/steps/copilot.ps1 create mode 100644 src/windows-dev-config/steps/edge.ps1 create mode 100644 src/windows-dev-config/steps/fonts.ps1 create mode 100644 src/windows-dev-config/steps/packages.ps1 create mode 100644 src/windows-dev-config/steps/powershell-profile.ps1 create mode 100644 src/windows-dev-config/steps/registry-explorer.ps1 create mode 100644 src/windows-dev-config/steps/registry-system.ps1 create mode 100644 src/windows-dev-config/steps/registry-taskbar-search.ps1 create mode 100644 src/windows-dev-config/steps/terminal.ps1 create mode 100644 src/windows-dev-config/steps/wsl.ps1 diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 new file mode 100644 index 0000000..4725b9b --- /dev/null +++ b/src/windows-dev-config/dev-config.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + Calm OS developer workstation setup, in plain PowerShell. + +.DESCRIPTION + Configures apps, desktop/taskbar tweaks, the PowerShell profile, and WSL + Ubuntu. + Safe to re-run: each phase skips work that's already done. The WSL phase runs + last on purpose, so the one disruptive reboot it needs happens after everything + else is configured; it resumes automatically after you log back in. +#> + +[CmdletBinding()] +param( + [switch] $NoElevate +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Windows PowerShell 5.1 defaults to the ANSI code page; force UTF-8 so glyphs render correctly. +try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch { + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" +} + +$stepsDir = Join-Path $PSScriptRoot 'steps' +. (Join-Path $stepsDir '_step-runner.ps1') +. (Join-Path $stepsDir '_elevation.ps1') +. (Join-Path $stepsDir '_reboot-resume.ps1') +. (Join-Path $stepsDir '_registry.ps1') +. (Join-Path $stepsDir '_environment.ps1') +. (Join-Path $stepsDir '_retry.ps1') + +Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate + +# Whether this is a fresh start or the post-reboot resume, any leftover task is done with. +Clear-DevConfigResume + +# WSL is last on purpose -- see the file header. +$phases = @( + @{ File = 'packages.ps1'; Function = 'Invoke-PackagesPhase' } + @{ File = 'registry-system.ps1'; Function = 'Invoke-RegistrySystemPhase' } + @{ File = 'registry-explorer.ps1'; Function = 'Invoke-RegistryExplorerPhase' } + @{ File = 'registry-taskbar-search.ps1'; Function = 'Invoke-RegistryTaskbarSearchPhase' } + @{ File = 'edge.ps1'; Function = 'Invoke-EdgePhase' } + @{ File = 'fonts.ps1'; Function = 'Invoke-FontsPhase' } + @{ File = 'terminal.ps1'; Function = 'Invoke-TerminalPhase' } + @{ File = 'powershell-profile.ps1'; Function = 'Invoke-PowerShellProfilePhase' } + @{ File = 'copilot.ps1'; Function = 'Invoke-CopilotPhase' } + @{ File = 'wsl.ps1'; Function = 'Invoke-WslPhase' } +) + +foreach ($phase in $phases) { + $path = Join-Path $stepsDir $phase.File + if (-not (Test-Path -LiteralPath $path)) { + Write-Host "-- $($phase.File) not written yet, skipping" -ForegroundColor DarkGray + continue + } + + . $path + if ($phase.File -eq 'wsl.ps1') { + # The WSL phase needs the orchestrator's own path to register the reboot-resume task. + Invoke-WslPhase -OrchestratorPath $PSCommandPath + } else { + & $phase.Function + } + + if ($phase.File -eq 'packages.ps1') { + # Packages installed above (pwsh, dotnet, git, ...) won't resolve on PATH until this refreshes. + Update-DevConfigSessionPath + } +} + +Write-Host '' +Write-Host 'Calm OS setup complete.' -ForegroundColor Green diff --git a/src/windows-dev-config/steps/_elevation.ps1 b/src/windows-dev-config/steps/_elevation.ps1 new file mode 100644 index 0000000..8ed2bac --- /dev/null +++ b/src/windows-dev-config/steps/_elevation.ps1 @@ -0,0 +1,42 @@ +<# +.SYNOPSIS + Admin check plus a one-time elevation relaunch, so the whole flow needs only a single UAC prompt. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-DevConfigIsAdmin { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($id) + return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-DevConfigShellExe { + # Prefer pwsh if it's already on PATH; Windows PowerShell 5.1 is always present as a fallback. + if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } +} + +function Invoke-DevConfigElevate { + param( + [Parameter(Mandatory)] [string] $ScriptPath, + [switch] $NoElevate + ) + + if (Test-DevConfigIsAdmin) { + return + } + + if ($NoElevate) { + throw 'Not running as Administrator and -NoElevate was passed; re-launch from an elevated shell.' + } + + Write-Host 'This needs to run elevated once (a UAC prompt will appear)...' -ForegroundColor Yellow + + $shell = Get-DevConfigShellExe + $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') + Start-Process -FilePath $shell -ArgumentList $relaunchArgs -Verb RunAs -Wait + + # The elevated relaunch already did the work; nothing left for this process to do. + exit 0 +} diff --git a/src/windows-dev-config/steps/_environment.ps1 b/src/windows-dev-config/steps/_environment.ps1 new file mode 100644 index 0000000..767e1ee --- /dev/null +++ b/src/windows-dev-config/steps/_environment.ps1 @@ -0,0 +1,13 @@ +<# +.SYNOPSIS + Refreshes this process's PATH from the registry, so tools installed earlier in the same run become runnable. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Update-DevConfigSessionPath { + $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') + $env:Path = @($machinePath, $userPath) -join ';' +} diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 new file mode 100644 index 0000000..3e67f9c --- /dev/null +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Scheduled-task plumbing so the flow can resume elevated after the WSL-required reboot. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Script:DevConfigResumeTask = 'WindowsDevConfigResume' + +function Clear-DevConfigResume { + # Safe to call even when no task is registered. + Unregister-ScheduledTask -TaskName $Script:DevConfigResumeTask -Confirm:$false -ErrorAction SilentlyContinue +} + +function Suspend-DevConfigForReboot { + param( + [Parameter(Mandatory)] [string] $ScriptPath + ) + + $shell = Get-DevConfigShellExe + $logPath = Join-Path (Split-Path -Path $ScriptPath -Parent) 'resume-output.log' + + # Task Scheduler actions have no redirection of their own, and in-script '*>' misses + # unhandled thrown errors; wrap in cmd.exe for real process-level stdout/stderr capture. + $innerCommand = "`"$shell`" -NoProfile -ExecutionPolicy Bypass -File `"`"$ScriptPath`"`" -NoElevate > `"$logPath`" 2>&1" + $arguments = "/c `"$innerCommand`"" + $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $arguments + + # WindowsIdentity's Name gives DOMAIN\User (or MACHINE\User for local accounts), + # which is what the scheduled task's logon matching needs. + $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser + $principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Highest + + Clear-DevConfigResume + Register-ScheduledTask -TaskName $Script:DevConfigResumeTask -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null + + Write-Host 'Registered a one-time resume task; rebooting now to finish WSL setup after you log back in...' -ForegroundColor Yellow + Restart-Computer -Force + + # Restart-Computer -Force signals the reboot but returns immediately; sleep so this + # process doesn't fall through to code that assumes the reboot already happened. + Start-Sleep -Seconds 60 + exit 0 +} diff --git a/src/windows-dev-config/steps/_registry.ps1 b/src/windows-dev-config/steps/_registry.ps1 new file mode 100644 index 0000000..af577da --- /dev/null +++ b/src/windows-dev-config/steps/_registry.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Shared registry read/write helpers used by every registry-based phase. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Convert-DevConfigRegistryPath { + param( + [Parameter(Mandatory)] [string] $KeyPath + ) + # Source data uses paths with no drive colon (HKCU\...); the registry PS provider needs one (HKCU:\...). + return $KeyPath -replace '^(HKCU|HKLM|HKCR|HKU|HKCC)\\', '$1:\' +} + +function Test-DevConfigRegistryValue { + param( + [Parameter(Mandatory)] [string] $KeyPath, + [Parameter(Mandatory)] [string] $ValueName, + [Parameter(Mandatory)] $Value + ) + $psPath = Convert-DevConfigRegistryPath -KeyPath $KeyPath + $current = Get-ItemProperty -Path $psPath -Name $ValueName -ErrorAction SilentlyContinue + if (-not $current) { + return $false + } + $prop = $current.PSObject.Properties[$ValueName] + return ($prop) -and ($prop.Value -eq $Value) +} + +function Set-DevConfigRegistryValue { + param( + [Parameter(Mandatory)] [string] $KeyPath, + [Parameter(Mandatory)] [string] $ValueName, + [Parameter(Mandatory)] $Value, + [string] $Type = 'DWord' + ) + $psPath = Convert-DevConfigRegistryPath -KeyPath $KeyPath + if (-not (Test-Path -LiteralPath $psPath)) { + New-Item -Path $psPath -Force | Out-Null + } + New-ItemProperty -Path $psPath -Name $ValueName -Value $Value -PropertyType $Type -Force | Out-Null +} diff --git a/src/windows-dev-config/steps/_retry.ps1 b/src/windows-dev-config/steps/_retry.ps1 new file mode 100644 index 0000000..5187bc9 --- /dev/null +++ b/src/windows-dev-config/steps/_retry.ps1 @@ -0,0 +1,32 @@ +<# +.SYNOPSIS + Retries a script block with exponential backoff, for flaky network calls. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-DevConfigRetry { + param( + [Parameter(Mandatory)] [scriptblock] $ScriptBlock, + [string] $Name = 'operation', + [int] $MaxAttempts = 3, + [int] $InitialDelaySeconds = 5 + ) + $attempt = 0 + $delay = $InitialDelaySeconds + while ($true) { + $attempt++ + try { + & $ScriptBlock + return + } catch { + if ($attempt -ge $MaxAttempts) { + throw + } + Write-Warning "${Name}: attempt $attempt failed ($($_.Exception.Message)); retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay = $delay * 2 + } + } +} diff --git a/src/windows-dev-config/steps/_step-runner.ps1 b/src/windows-dev-config/steps/_step-runner.ps1 new file mode 100644 index 0000000..d9d5992 --- /dev/null +++ b/src/windows-dev-config/steps/_step-runner.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Runs a named list of steps; each step checks first, and only applies itself if needed. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function New-DevConfigStep { + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [scriptblock] $Check, + [Parameter(Mandatory)] [scriptblock] $Apply, + [string] $Description = '', + [object[]] $ArgumentList = @(), + [switch] $BestEffort + ) + # ArgumentList is passed positionally to Check/Apply at call time, not captured by closure. + [pscustomobject]@{ + Name = $Name + Description = $Description + Check = $Check + Apply = $Apply + ArgumentList = $ArgumentList + BestEffort = [bool]$BestEffort + } +} + +function Invoke-DevConfigSteps { + param( + [Parameter(Mandatory)] [object[]] $Steps + ) + foreach ($step in $Steps) { + Write-Host "==> $($step.Name)" -ForegroundColor Cyan + if ($step.Description) { + Write-Host " $($step.Description)" -ForegroundColor DarkGray + } + + # Splat (@) needs a plain variable, not a property-access expression. + $stepArgs = $step.ArgumentList + + $alreadyDone = $false + try { + $alreadyDone = [bool](& $step.Check @stepArgs) + } catch { + Write-Warning "$($step.Name): Check threw ($($_.Exception.Message)); applying anyway." + } + + if ($alreadyDone) { + Write-Host ' already OK' -ForegroundColor DarkGray + continue + } + + # BestEffort steps warn and move on instead of blocking the whole run (e.g. OS-blocked registry values). + try { + & $step.Apply @stepArgs + if (-not [bool](& $step.Check @stepArgs)) { + throw "ran, but the follow-up check still says it isn't done." + } + Write-Host ' done' -ForegroundColor Green + } catch { + if ($step.BestEffort) { + Write-Warning "$($step.Name): $($_.Exception.Message) (best-effort step, continuing)" + } else { + throw + } + } + } +} diff --git a/src/windows-dev-config/steps/copilot.ps1 b/src/windows-dev-config/steps/copilot.ps1 new file mode 100644 index 0000000..81fc643 --- /dev/null +++ b/src/windows-dev-config/steps/copilot.ps1 @@ -0,0 +1,99 @@ +<# +.SYNOPSIS + GitHub Copilot Windows Terminal profile, WinUI templates, and the win-dev-skills Copilot plugin. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Script:CopilotFragmentGuid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' + +function Get-DevConfigCopilotFragmentDir { + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig' +} + +function Test-DevConfigCopilotTerminalProfile { + $fragmentPath = Join-Path (Get-DevConfigCopilotFragmentDir) 'github-copilot.fragment.json' + return Test-Path -LiteralPath $fragmentPath +} + +function Set-DevConfigCopilotTerminalProfile { + $fragmentsDir = Get-DevConfigCopilotFragmentDir + New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null + + # Icon lives alongside the fragment file so its relative path resolves correctly. + $iconPath = Join-Path $fragmentsDir 'copilot.png' + Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing + + $fragment = @{ + profiles = @( + @{ + guid = $Script:CopilotFragmentGuid + name = 'GitHub Copilot' + commandline = 'pwsh.exe -NoExit -Command "copilot"' + icon = 'copilot.png' + startingDirectory = '%USERPROFILE%' + hidden = $false + tabTitle = 'Copilot' + } + ) + } + + $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' + $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 + + # Touch settings.json so Windows Terminal's hot-reload re-scans Fragments\*.json. + @( + "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", + "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | ForEach-Object { + try { (Get-Item -LiteralPath $_).LastWriteTime = Get-Date } catch {} + } + + Write-Host "GitHub Copilot profile fragment written to $fragmentFile" + Write-Host "Open Windows Terminal: the 'GitHub Copilot' profile is available in the dropdown." +} + +function Test-DevConfigWinUITemplatesInstalled { + return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) +} + +function Install-DevConfigWinUITemplates { + dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates +} + +function Test-DevConfigWinSkillsMarketplaceAdded { + return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') +} + +function Add-DevConfigWinSkillsMarketplace { + copilot plugin marketplace add microsoft/win-dev-skills +} + +function Test-DevConfigWinUIPluginInstalled { + return [bool](copilot plugin list 2>&1 | Select-String 'winui') +} + +function Install-DevConfigWinUIPlugin { + copilot plugin install winui@win-dev-skills +} + +function Invoke-CopilotPhase { + $steps = @( + New-DevConfigStep -Name 'GitHubCopilotProfile' -Description 'Add a GitHub Copilot profile to Windows Terminal' ` + -Check { Test-DevConfigCopilotTerminalProfile } ` + -Apply { Set-DevConfigCopilotTerminalProfile } + New-DevConfigStep -Name 'WinUITemplates' -Description 'Install WinUI dotnet-new templates' ` + -Check { Test-DevConfigWinUITemplatesInstalled } ` + -Apply { Install-DevConfigWinUITemplates } + New-DevConfigStep -Name 'WinSkillsMarketplace' -Description 'Add win-dev-skills to the Copilot plugin marketplace' ` + -Check { Test-DevConfigWinSkillsMarketplaceAdded } ` + -Apply { Add-DevConfigWinSkillsMarketplace } + New-DevConfigStep -Name 'WinUIPlugin' -Description 'Install the WinUI Copilot plugin from win-dev-skills' ` + -Check { Test-DevConfigWinUIPluginInstalled } ` + -Apply { Install-DevConfigWinUIPlugin } + ) + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/edge.ps1 b/src/windows-dev-config/steps/edge.ps1 new file mode 100644 index 0000000..5960321 --- /dev/null +++ b/src/windows-dev-config/steps/edge.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Microsoft Edge policy tweaks: blank new tab page, no first-run experience. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-EdgePhase { + $tweaks = @( + @{ Name = 'EdgeNewTab'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Edge'; ValueName = 'NewTabPageLocation'; Value = 'about:blank'; Type = 'String'; Description = 'Set Edge new tab to blank' } + @{ Name = 'EdgeOOBE'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Edge'; ValueName = 'HideFirstRunExperience'; Value = 1; Type = 'DWord'; Description = 'Disable Edge first-run experience' } + ) + + # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + $steps = foreach ($tweak in $tweaks) { + New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` + -Check { param($KeyPath, $ValueName, $Value, $Type) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -Apply { param($KeyPath, $ValueName, $Value, $Type) Set-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value -Type $Type } ` + -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value, $tweak.Type) + } + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/fonts.ps1 b/src/windows-dev-config/steps/fonts.ps1 new file mode 100644 index 0000000..2465c8b --- /dev/null +++ b/src/windows-dev-config/steps/fonts.ps1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + Downloads and installs Cascadia Code Nerd Fonts, and sets Cascadia Mono NF as the Windows Terminal default font. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Script:CascadiaFontVersion = '2407.24' +$Script:CascadiaWantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') +$Script:CascadiaZipSha256 = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' +$Script:CascadiaDefaultFontFace = 'Cascadia Mono NF' + +function Test-DevConfigCascadiaFontsInstalled { + $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + $regValues = @( + (Get-ItemProperty $regPath -ErrorAction SilentlyContinue).PSObject.Properties | + Where-Object Name -notin 'PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider' | + Select-Object -ExpandProperty Value + ) + $filesOk = -not ($Script:CascadiaWantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) + $regOk = -not ($Script:CascadiaWantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) + return ($filesOk -and $regOk) +} + +function Install-DevConfigCascadiaFonts { + $version = $Script:CascadiaFontVersion + $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$version/CascadiaCode-$version.zip" + $workDir = Join-Path $env:TEMP "CascadiaCode-$version" + $zipPath = Join-Path $workDir 'CascadiaCode.zip' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null + + Write-Host "Downloading $zipUrl ..." + $ProgressPreference = 'SilentlyContinue' + Invoke-DevConfigRetry -Name 'Cascadia fonts download' -ScriptBlock { + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing + } + + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash + if ($actualHash -ne $Script:CascadiaZipSha256) { + Remove-Item $zipPath -Force + throw "Hash mismatch for CascadiaCode-$version.zip: expected $($Script:CascadiaZipSha256), got $actualHash" + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.Drawing + + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + foreach ($name in $Script:CascadiaWantedFonts) { + $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 + if (-not $entry) { + Write-Warning "Not found in archive: $name" + continue + } + + $dest = Join-Path $fontsDir $name + Write-Host "Installing $name -> $dest" + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + + $pfc = New-Object System.Drawing.Text.PrivateFontCollection + try { + $pfc.AddFontFile($dest) + $family = $pfc.Families[0].Name + } finally { + $pfc.Dispose() + } + + $regName = "$family (TrueType)" + New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null + Write-Host " registered as '$regName'" + } + } finally { + $zip.Dispose() + } + + Remove-Item $zipPath -Force + Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." +} + +function Get-DevConfigTerminalSettingsPath { + # Packaged (MSIX) Terminal first, then the unpackaged/portable location. + $candidates = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) + return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +function Get-DevConfigTerminalSettingsRaw { + param( + [Parameter(Mandatory)] [string] $Path + ) + # Terminal's settings.json is JSONC; strip block and line comments before parsing. + $raw = Get-Content -LiteralPath $Path -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + return [regex]::Replace($clean, '(?m)^\s*//.*$', '') +} + +function Get-DevConfigTerminalDefaultFontFace { + param( + [Parameter(Mandatory)] [object] $Settings + ) + # Walk profiles.defaults.font.face defensively: any level may be absent, and strict + # mode throws on a direct dot-access to a missing property. + $profilesProp = $Settings.PSObject.Properties['profiles'] + if (-not $profilesProp) { return $null } + $defaultsProp = $profilesProp.Value.PSObject.Properties['defaults'] + if (-not $defaultsProp) { return $null } + $fontProp = $defaultsProp.Value.PSObject.Properties['font'] + if (-not $fontProp) { return $null } + $faceProp = $fontProp.Value.PSObject.Properties['face'] + if (-not $faceProp) { return $null } + return $faceProp.Value +} + +function Test-DevConfigCascadiaDefaultFont { + $path = Get-DevConfigTerminalSettingsPath + if (-not $path) { + return $true + } + $settings = Get-DevConfigTerminalSettingsRaw -Path $path | ConvertFrom-Json + return (Get-DevConfigTerminalDefaultFontFace -Settings $settings) -eq $Script:CascadiaDefaultFontFace +} + +function Set-DevConfigCascadiaDefaultFont { + $path = Get-DevConfigTerminalSettingsPath + if (-not $path) { + throw 'Windows Terminal settings.json not found.' + } + Write-Host "Using: $path" + Copy-Item -LiteralPath $path -Destination "$path.bak" -Force + + # Plain ConvertFrom-Json (not -AsHashtable, which needs PowerShell 6+) so this also runs on Windows PowerShell 5.1. + $json = Get-DevConfigTerminalSettingsRaw -Path $path | ConvertFrom-Json + if (-not $json.PSObject.Properties['profiles']) { $json | Add-Member -NotePropertyName profiles -NotePropertyValue ([pscustomobject]@{}) } + if (-not $json.profiles.PSObject.Properties['defaults']) { $json.profiles | Add-Member -NotePropertyName defaults -NotePropertyValue ([pscustomobject]@{}) } + if (-not $json.profiles.defaults.PSObject.Properties['font']) { $json.profiles.defaults | Add-Member -NotePropertyName font -NotePropertyValue ([pscustomobject]@{}) } + if ($json.profiles.defaults.font.PSObject.Properties['face']) { + $json.profiles.defaults.font.face = $Script:CascadiaDefaultFontFace + } else { + $json.profiles.defaults.font | Add-Member -NotePropertyName face -NotePropertyValue $Script:CascadiaDefaultFontFace + } + + $json | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $path -Encoding utf8 + Write-Host "Set Terminal default font to '$($Script:CascadiaDefaultFontFace)' (backup: $path.bak)" +} + +function Invoke-FontsPhase { + $steps = @( + New-DevConfigStep -Name 'CascadiaFonts' -Description 'Install Cascadia Code Nerd Fonts' ` + -Check { Test-DevConfigCascadiaFontsInstalled } ` + -Apply { Install-DevConfigCascadiaFonts } + New-DevConfigStep -Name 'CascadiaDefaultFont' -Description 'Set Cascadia Mono NF as the Windows Terminal default font' ` + -Check { Test-DevConfigCascadiaDefaultFont } ` + -Apply { Set-DevConfigCascadiaDefaultFont } + ) + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 new file mode 100644 index 0000000..a17c6b6 --- /dev/null +++ b/src/windows-dev-config/steps/packages.ps1 @@ -0,0 +1,67 @@ +<# +.SYNOPSIS + Installs the Calm OS package set via winget. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-DevConfigWingetPackageInstalled { + param( + [Parameter(Mandatory)] [string] $Id + ) + $listOutput = & winget list --id $Id --exact --source winget --accept-source-agreements 2>&1 | Out-String + if ($listOutput -match 'No installed package found') { + return $false + } + + # useLatest: true in the original -- an available upgrade means this step isn't satisfied yet. + $upgradeOutput = & winget list --id $Id --exact --upgrade-available --source winget --accept-source-agreements 2>&1 | Out-String + return $upgradeOutput -match 'No installed package found' +} + +function Install-DevConfigWingetPackage { + param( + [Parameter(Mandatory)] [string] $Id + ) + Invoke-DevConfigRetry -Name "winget install $Id" -ScriptBlock { + & winget install --id $Id --exact --source winget --silent --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { + throw "winget install $Id failed with exit code $LASTEXITCODE" + } + } +} + +function Invoke-PackagesPhase { + $packages = @( + @{ Name = 'Terminal'; Id = 'Microsoft.WindowsTerminal' } + @{ Name = 'PowerShell'; Id = 'Microsoft.PowerShell' } + @{ Name = 'Git'; Id = 'Git.Git' } + @{ Name = 'GitHubCLI'; Id = 'GitHub.cli' } + @{ Name = 'GitHubCopilot'; Id = 'GitHub.Copilot' } + @{ Name = 'VSCode'; Id = 'Microsoft.VisualStudioCode' } + @{ Name = 'DotnetSdk'; Id = 'Microsoft.DotNet.SDK.10' } + @{ Name = 'Python'; Id = 'Python.Python.3.14' } + @{ Name = 'UV'; Id = 'astral-sh.uv' } + @{ Name = 'NodeJS'; Id = 'OpenJS.NodeJS.LTS' } + @{ Name = 'nvmForNode'; Id = 'CoreyButler.NVMforWindows' } + @{ Name = 'Coreutils'; Id = 'Microsoft.Coreutils' } + @{ Name = 'OhMyPosh'; Id = 'JanDeDobbeleer.OhMyPosh' } + @{ Name = 'winappCli'; Id = 'Microsoft.WinAppCli' } + @{ Name = 'PowerToys'; Id = 'Microsoft.PowerToys' } + ) + + # ArgumentList binds each package's Id at call time instead of relying on closure capture. + $steps = foreach ($pkg in $packages) { + New-DevConfigStep -Name $pkg.Name -Description "winget install $($pkg.Id)" ` + -Check { param($Id) Test-DevConfigWingetPackageInstalled -Id $Id } ` + -Apply { param($Id) Install-DevConfigWingetPackage -Id $Id } ` + -ArgumentList @($pkg.Id) + } + + $steps += New-DevConfigStep -Name 'PowerToysAOT' -Description 'Turn off PowerToys always-on-top notifications' ` + -Check { Test-DevConfigRegistryValue -KeyPath 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys' -ValueName 'Enabled' -Value 0 } ` + -Apply { Set-DevConfigRegistryValue -KeyPath 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys' -ValueName 'Enabled' -Value 0 } + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/powershell-profile.ps1 b/src/windows-dev-config/steps/powershell-profile.ps1 new file mode 100644 index 0000000..bda9acd --- /dev/null +++ b/src/windows-dev-config/steps/powershell-profile.ps1 @@ -0,0 +1,98 @@ +<# +.SYNOPSIS + Adds the Oh My Posh init line to the PowerShell 7 profile. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Matches the oh-my-posh DSC resource's own detection: any non-commented line calling +# "oh-my-posh init" is treated as a valid, already-configured init, ours or user-customized. +$Script:OhMyPoshInitLineRegex = 'oh-my-posh(?:\.exe)?\s+init' + +$Script:OhMyPoshInitCommand = @' +$(if (Get-Command 'oh-my-posh' -ErrorAction SilentlyContinue) { + oh-my-posh init pwsh + # Set output encoding to UTF-8 + [Console]::OutputEncoding =[System.Text.Encoding]::UTF8 + # Set input encoding to UTF-8 (for reading user input with non-ASCII chars) + [Console]::InputEncoding =[System.Text.Encoding]::UTF8 +}) +'@ + +function Get-DevConfigPwshProfilePath { + $pwsh = Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue + if (-not $pwsh) { + return $null + } + # Ask pwsh itself for $PROFILE rather than hardcoding the path. + return & $pwsh.Source -NoProfile -Command '$PROFILE' +} + +function Test-DevConfigOhMyPoshInitLinePresent { + param( + [Parameter(Mandatory)] [string] $ProfilePath + ) + if (-not (Test-Path -LiteralPath $ProfilePath)) { + return $false + } + + # Scan from the end: the last non-comment matching line is what counts, matching the source resource. + $lines = @(Get-Content -LiteralPath $ProfilePath) + for ($i = $lines.Count - 1; $i -ge 0; $i--) { + if ($lines[$i].TrimStart().StartsWith('#')) { + continue + } + if ($lines[$i] -cmatch $Script:OhMyPoshInitLineRegex) { + return $true + } + } + return $false +} + +function Test-DevConfigOhMyPoshProfileConfigured { + $profilePath = Get-DevConfigPwshProfilePath + if (-not $profilePath) { + return $false + } + return Test-DevConfigOhMyPoshInitLinePresent -ProfilePath $profilePath +} + +function Set-DevConfigOhMyPoshProfile { + $profilePath = Get-DevConfigPwshProfilePath + if (-not $profilePath) { + throw 'pwsh.exe not found; install the PowerShell package first.' + } + + if (-not (Test-Path -LiteralPath $profilePath)) { + New-Item -ItemType Directory -Path (Split-Path -Parent $profilePath) -Force | Out-Null + New-Item -ItemType File -Path $profilePath -Force | Out-Null + } + + if (Test-DevConfigOhMyPoshInitLinePresent -ProfilePath $profilePath) { + return + } + + # Mirrors the resource's own shellCommand(): the whole block piped to Invoke-Expression. + $content = Get-Content -LiteralPath $profilePath -Raw + if (-not $content) { + $content = '' + } + if ($content -and -not $content.EndsWith("`n")) { + $content += "`n" + } + $content += "$Script:OhMyPoshInitCommand`n | Invoke-Expression`n" + + Set-Content -LiteralPath $profilePath -Value $content -NoNewline + Write-Host "Added Oh My Posh init to $profilePath" +} + +function Invoke-PowerShellProfilePhase { + $steps = @( + New-DevConfigStep -Name 'OhMyPoshProfile' -Description 'Add Oh My Posh init to the PowerShell 7 profile' ` + -Check { Test-DevConfigOhMyPoshProfileConfigured } ` + -Apply { Set-DevConfigOhMyPoshProfile } + ) + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/registry-explorer.ps1 b/src/windows-dev-config/steps/registry-explorer.ps1 new file mode 100644 index 0000000..eb39df1 --- /dev/null +++ b/src/windows-dev-config/steps/registry-explorer.ps1 @@ -0,0 +1,34 @@ +<# +.SYNOPSIS + File Explorer and Desktop registry tweaks. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-RegistryExplorerPhase { + $advanced = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + $explorer = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer' + + $tweaks = @( + @{ Name = 'ShowFileExtensions'; KeyPath = $advanced; ValueName = 'HideFileExt'; Value = 0; Description = 'Show file extensions in Explorer' } + @{ Name = 'ShowHiddenFiles'; KeyPath = $advanced; ValueName = 'Hidden'; Value = 1; Description = 'Show hidden files in Explorer' } + @{ Name = 'FullPathTitlebar'; KeyPath = $advanced; ValueName = 'FullPathAddress'; Value = 1; Description = 'Show full path in Explorer titlebar' } + @{ Name = 'OpenThisPC'; KeyPath = $advanced; ValueName = 'LaunchTo'; Value = 1; Description = 'Open File Explorer to This PC' } + @{ Name = 'FrequentFolders'; KeyPath = $advanced; ValueName = 'ShowFrequent'; Value = 0; Description = 'Disable frequent folders in Quick Access' } + @{ Name = 'FrequentFiles'; KeyPath = $explorer; ValueName = 'ShowRecent'; Value = 0; Description = 'Disable frequent files in Quick Access' } + @{ Name = 'RecommendedFiles'; KeyPath = $explorer; ValueName = 'ShowCloudFilesInQuickAccess'; Value = 0; Description = 'Disable recommended/cloud files in Quick Access' } + @{ Name = 'GitCodeFolders'; KeyPath = $advanced; ValueName = 'NavPaneShowVersionControl'; Value = 1; Description = 'Enable Git integration in File Explorer' } + @{ Name = 'TipsOff'; KeyPath = $advanced; ValueName = 'ShowSyncProviderNotifications'; Value = 0; Description = 'Disable sync provider notifications (tips)' } + ) + + # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + $steps = foreach ($tweak in $tweaks) { + New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` + -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -Apply { param($KeyPath, $ValueName, $Value) Set-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value) + } + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/registry-system.ps1 b/src/windows-dev-config/steps/registry-system.ps1 new file mode 100644 index 0000000..6ce45f0 --- /dev/null +++ b/src/windows-dev-config/steps/registry-system.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + System-level developer settings: Sudo, Developer Mode, long path support, Remote Desktop. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-RegistrySystemPhase { + $tweaks = @( + @{ Name = 'Sudo'; KeyPath = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo'; ValueName = 'Enabled'; Value = 3; Description = 'Enable Sudo in inline mode' } + @{ Name = 'DeveloperMode'; KeyPath = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'; ValueName = 'AllowDevelopmentWithoutDevLicense'; Value = 1; Description = 'Enable Developer Mode (sideload + dev features)' } + @{ Name = 'LongPaths'; KeyPath = 'HKLM\SYSTEM\CurrentControlSet\Control\FileSystem'; ValueName = 'LongPathsEnabled'; Value = 1; Description = 'Enable Win32 long path support' } + @{ Name = 'RemoteDesktop'; KeyPath = 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server'; ValueName = 'fDenyTSConnections'; Value = 0; Description = 'Enable Remote Desktop (firewall rule still needs separate enable)' } + ) + + # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + $steps = foreach ($tweak in $tweaks) { + New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` + -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -Apply { param($KeyPath, $ValueName, $Value) Set-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value) + } + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/registry-taskbar-search.ps1 b/src/windows-dev-config/steps/registry-taskbar-search.ps1 new file mode 100644 index 0000000..6592639 --- /dev/null +++ b/src/windows-dev-config/steps/registry-taskbar-search.ps1 @@ -0,0 +1,34 @@ +<# +.SYNOPSIS + Taskbar, Start, Search, notifications, and Widget service registry tweaks. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-RegistryTaskbarSearchPhase { + $advanced = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' + + $tweaks = @( + @{ Name = 'DoNotDisturb'; KeyPath = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings'; ValueName = 'NOC_GLOBAL_SETTING_TOASTS_ENABLED'; Value = 0; Description = 'Enable Do Not Disturb (disable all notifications)' } + # Windows 24H2+ blocks direct writes to TaskbarDa even for admins; WidgetServiceOff below covers the same intent. + @{ Name = 'TaskbarHideWidgets'; KeyPath = $advanced; ValueName = 'TaskbarDa'; Value = 0; Description = 'Hide Widgets button on the taskbar'; BestEffort = $true } + @{ Name = 'BluetoothOff'; KeyPath = 'HKCU\Control Panel\Bluetooth'; ValueName = 'Notification Area Icon'; Value = 0; Description = 'Hide Bluetooth icon in taskbar notification area' } + @{ Name = 'EndTask'; KeyPath = $advanced; ValueName = 'TaskbarEndTask'; Value = 1; Description = 'Enable "End Task" on right-click of taskbar icons' } + @{ Name = 'WebSearchOff'; KeyPath = 'HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer'; ValueName = 'DisableSearchBoxSuggestions'; Value = 1; Description = 'Disable web search in Start/Search' } + @{ Name = 'SearchHightlightOff'; KeyPath = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings'; ValueName = 'IsDynamicSearchBoxEnabled'; Value = 0; Description = 'Disable Show search highlights' } + @{ Name = 'StartRecommendations'; KeyPath = $advanced; ValueName = 'Start_IrisRecommendations'; Value = 0; Description = 'Disable Start menu recommendations' } + @{ Name = 'WidgetServiceOff'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Dsh'; ValueName = 'AllowNewsAndInterests'; Value = 0; Description = 'Disable Widget service' } + ) + + # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + $steps = foreach ($tweak in $tweaks) { + New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` + -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -Apply { param($KeyPath, $ValueName, $Value) Set-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` + -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value) ` + -BestEffort:($tweak.Contains('BestEffort') -and $tweak.BestEffort) + } + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/terminal.ps1 b/src/windows-dev-config/steps/terminal.ps1 new file mode 100644 index 0000000..fd4060e --- /dev/null +++ b/src/windows-dev-config/steps/terminal.ps1 @@ -0,0 +1,92 @@ +<# +.SYNOPSIS + Dark theme and Windows Terminal profile defaults. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-DevConfigDarkThemeSet { + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -ErrorAction SilentlyContinue + $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -ErrorAction SilentlyContinue + return ($apps -eq 0 -and $system -eq 0) +} + +function Set-DevConfigDarkTheme { + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + Set-ItemProperty -Path $regPath -Name 'AppsUseLightTheme' -Value 0 + Set-ItemProperty -Path $regPath -Name 'SystemUsesLightTheme' -Value 0 +} + +function Get-DevConfigTerminalSettingsPath { + # Packaged (MSIX) Terminal first, then the unpackaged/portable location. + $candidates = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) + return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +function Get-DevConfigTerminalSettings { + param( + [Parameter(Mandatory)] [string] $Path + ) + # Terminal's settings.json is JSONC; strip block and line comments before parsing. + $raw = Get-Content -LiteralPath $Path -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + return $clean | ConvertFrom-Json +} + +function Find-DevConfigPs7Profile { + param( + [Parameter(Mandatory)] [object] $Settings + ) + # Some built-in profiles (e.g. "Windows PowerShell") have no 'source' property at all; + # index into PSObject.Properties instead of dotting into it so strict mode doesn't throw. + return $Settings.profiles.list | Where-Object { + $sourceProp = $_.PSObject.Properties['source'] + (($sourceProp) -and ($sourceProp.Value -eq 'Windows.Terminal.PowershellCore')) -or ($_.name -eq 'PowerShell') + } | Select-Object -First 1 +} + +function Test-DevConfigPs7DefaultProfile { + $path = Get-DevConfigTerminalSettingsPath + if (-not $path) { + return $true + } + $settings = Get-DevConfigTerminalSettings -Path $path + $ps7 = Find-DevConfigPs7Profile -Settings $settings + if (-not $ps7) { + return $true + } + return ($settings.defaultProfile -eq $ps7.guid) +} + +function Set-DevConfigPs7DefaultProfile { + $path = Get-DevConfigTerminalSettingsPath + if (-not $path) { + return + } + $settings = Get-DevConfigTerminalSettings -Path $path + $ps7 = Find-DevConfigPs7Profile -Settings $settings + if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { + $settings.defaultProfile = $ps7.guid + $settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding UTF8 + } +} + +function Invoke-TerminalPhase { + $steps = @( + New-DevConfigStep -Name 'DarkTheme' -Description 'Force dark app/system theme' ` + -Check { Test-DevConfigDarkThemeSet } ` + -Apply { Set-DevConfigDarkTheme } + New-DevConfigStep -Name 'Ps7DefaultProfile' -Description 'Set PowerShell 7 as the default Windows Terminal profile' ` + -Check { Test-DevConfigPs7DefaultProfile } ` + -Apply { Set-DevConfigPs7DefaultProfile } + ) + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 new file mode 100644 index 0000000..97bc262 --- /dev/null +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Installs WSL platform components, reboots once if needed, then installs Ubuntu. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-DevConfigVmComputePresent { + # vmcompute (Hyper-V Host Compute Service) only registers once Virtual Machine Platform is active. + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" + return [bool]$svc +} + +function Install-DevConfigWslComponents { + Invoke-DevConfigRetry -Name 'wsl --install --no-distribution' -ScriptBlock { + Write-Host 'Running wsl --install --no-distribution...' + # No -NoNewWindow / -Redirect*: wsl's install bootstrap needs a real console to run against. + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '--no-distribution' -Wait -PassThru + if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { + Write-Host 'WSL components installed; a reboot is required to activate them.' + } elseif ($p.ExitCode -ne 0) { + throw "wsl --install --no-distribution failed with exit code $($p.ExitCode)" + } + } +} + +function Test-DevConfigUbuntuInstalled { + $env:WSL_UTF8 = '1' + $out = [System.IO.Path]::GetTempFileName() + $err = [System.IO.Path]::GetTempFileName() + try { + # Redirect wsl's output here: this is a query, not a bootstrap step, so no console is needed. + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list', '--quiet' ` + -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err + if ($p.ExitCode -ne 0) { + return $false + } + $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | + ForEach-Object { ($_ -replace "`0", '').Trim() } | + Where-Object { $_ }) + return $distros.Count -gt 0 + } finally { + Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue + } +} + +function Install-DevConfigUbuntu { + # Suppresses the "Welcome to WSL" first-run GUI. + $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + New-Item -Path $lxssPath -Force | Out-Null + Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force + + Invoke-DevConfigRetry -Name 'wsl --install -d Ubuntu' -ScriptBlock { + Write-Host 'Running wsl --install -d Ubuntu --no-launch...' + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '-d', 'Ubuntu', '--no-launch' -Wait -PassThru + if ($p.ExitCode -ne 0) { + throw "wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" + } + } +} + +function Invoke-WslPhase { + param( + [Parameter(Mandatory)] [string] $OrchestratorPath + ) + + $steps = @( + New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' ` + -Check { Test-DevConfigVmComputePresent } ` + -Apply { + Install-DevConfigWslComponents + if (-not (Test-DevConfigVmComputePresent)) { + # Never returns: registers the resume task, reboots, and exits this process. + Suspend-DevConfigForReboot -ScriptPath $OrchestratorPath + } + } + New-DevConfigStep -Name 'WslUbuntu' -Description 'Install the default Ubuntu distro' ` + -Check { Test-DevConfigUbuntuInstalled } ` + -Apply { Install-DevConfigUbuntu } + ) + + Invoke-DevConfigSteps -Steps $steps +} From 0fbb3cb3348f54b01245629c253a8461965143aa Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:20:49 -0700 Subject: [PATCH 02/19] Enhance resume experience --- .../steps/_reboot-resume.ps1 | 14 ++--- .../steps/_resume-wrapper.ps1 | 55 +++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 src/windows-dev-config/steps/_resume-wrapper.ps1 diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 index 3e67f9c..9c75ed8 100644 --- a/src/windows-dev-config/steps/_reboot-resume.ps1 +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -18,14 +18,12 @@ function Suspend-DevConfigForReboot { [Parameter(Mandatory)] [string] $ScriptPath ) - $shell = Get-DevConfigShellExe - $logPath = Join-Path (Split-Path -Path $ScriptPath -Parent) 'resume-output.log' - - # Task Scheduler actions have no redirection of their own, and in-script '*>' misses - # unhandled thrown errors; wrap in cmd.exe for real process-level stdout/stderr capture. - $innerCommand = "`"$shell`" -NoProfile -ExecutionPolicy Bypass -File `"`"$ScriptPath`"`" -NoElevate > `"$logPath`" 2>&1" - $arguments = "/c `"$innerCommand`"" - $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $arguments + $shell = Get-DevConfigShellExe + $wrapperPath = Join-Path $PSScriptRoot '_resume-wrapper.ps1' + + # The wrapper (not cmd.exe) handles output capture, so the resumed run stays visible on screen. + $arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$wrapperPath`" -ScriptPath `"$ScriptPath`"" + $action = New-ScheduledTaskAction -Execute $shell -Argument $arguments # WindowsIdentity's Name gives DOMAIN\User (or MACHINE\User for local accounts), # which is what the scheduled task's logon matching needs. diff --git a/src/windows-dev-config/steps/_resume-wrapper.ps1 b/src/windows-dev-config/steps/_resume-wrapper.ps1 new file mode 100644 index 0000000..ffea65e --- /dev/null +++ b/src/windows-dev-config/steps/_resume-wrapper.ps1 @@ -0,0 +1,55 @@ +<# +.SYNOPSIS + Post-reboot scheduled-task entry point: runs the orchestrator with output both shown live + on screen and mirrored to a log file, without masking the real exit code. +#> + +param( + [Parameter(Mandatory)] [string] $ScriptPath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot '_elevation.ps1') + +$logDir = Split-Path -Path $ScriptPath -Parent +$masterLog = Join-Path $logDir 'resume-output.log' +$innerOut = Join-Path $logDir 'resume-inner-stdout.log' +$innerErr = Join-Path $logDir 'resume-inner-stderr.log' +Remove-Item $masterLog, $innerOut, $innerErr -ErrorAction SilentlyContinue + +$shell = Get-DevConfigShellExe +$proc = Start-Process -FilePath $shell ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') ` + -RedirectStandardOutput $innerOut -RedirectStandardError $innerErr -NoNewWindow -PassThru + +# Tee: mirror new lines to the console (visible on screen) and one combined log file. +$shown = 0 +function Show-DevConfigResumeNewLines { + # @() forces array semantics; Get-Content returns a bare string for single-line files. + $lines = @(Get-Content -Path $innerOut -ErrorAction SilentlyContinue) + if ($lines.Count -gt $script:shown) { + $lines[$script:shown..($lines.Count - 1)] | ForEach-Object { + Write-Host $_ + Add-Content -Path $masterLog -Value $_ + } + $script:shown = $lines.Count + } +} + +while (-not $proc.HasExited) { + Show-DevConfigResumeNewLines + Start-Sleep -Milliseconds 300 +} +Show-DevConfigResumeNewLines + +# Errors are terminal, so showing them last matches when they actually happened. +if (Test-Path -LiteralPath $innerErr) { + Get-Content -Path $innerErr | ForEach-Object { + Write-Host $_ -ForegroundColor Red + Add-Content -Path $masterLog -Value $_ + } +} + +exit $proc.ExitCode From a4650bed0212c4c0621cde1cafe21b9ab5a5fc7b Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:39:00 -0700 Subject: [PATCH 03/19] Enhance resume experience --- src/windows-dev-config/dev-config.ps1 | 56 +++++-- src/windows-dev-config/steps/_console.ps1 | 33 +++++ .../steps/_reboot-resume.ps1 | 6 +- .../steps/_resume-wrapper.ps1 | 24 ++- src/windows-dev-config/steps/_step-runner.ps1 | 138 ++++++++++++++++-- src/windows-dev-config/steps/copilot.ps1 | 32 +++- src/windows-dev-config/steps/packages.ps1 | 54 ++++++- src/windows-dev-config/steps/wsl.ps1 | 6 +- 8 files changed, 305 insertions(+), 44 deletions(-) create mode 100644 src/windows-dev-config/steps/_console.ps1 diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index 4725b9b..0a49882 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -11,7 +11,8 @@ [CmdletBinding()] param( - [switch] $NoElevate + [switch] $NoElevate, + [switch] $Resumed ) $ErrorActionPreference = 'Stop' @@ -27,6 +28,7 @@ try { } $stepsDir = Join-Path $PSScriptRoot 'steps' +. (Join-Path $stepsDir '_console.ps1') . (Join-Path $stepsDir '_step-runner.ps1') . (Join-Path $stepsDir '_elevation.ps1') . (Join-Path $stepsDir '_reboot-resume.ps1') @@ -39,27 +41,47 @@ Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate # Whether this is a fresh start or the post-reboot resume, any leftover task is done with. Clear-DevConfigResume +$Script:DevConfigResumed = [bool]$Resumed +if ($Script:DevConfigResumed) { + # Brings back the tally from before the reboot so the final summary covers the whole run. + Restore-DevConfigTally -Path (Join-Path $PSScriptRoot 'devconfig-tally.json') +} +Write-Host '' +if ($Script:DevConfigResumed) { + Write-Host 'Welcome back. Resuming Calm OS setup after the reboot...' -ForegroundColor Cyan +} else { + Write-Host 'Calm OS setup -- 10 phases, one reboot along the way (expected, not an error)' -ForegroundColor Cyan +} + # WSL is last on purpose -- see the file header. $phases = @( - @{ File = 'packages.ps1'; Function = 'Invoke-PackagesPhase' } - @{ File = 'registry-system.ps1'; Function = 'Invoke-RegistrySystemPhase' } - @{ File = 'registry-explorer.ps1'; Function = 'Invoke-RegistryExplorerPhase' } - @{ File = 'registry-taskbar-search.ps1'; Function = 'Invoke-RegistryTaskbarSearchPhase' } - @{ File = 'edge.ps1'; Function = 'Invoke-EdgePhase' } - @{ File = 'fonts.ps1'; Function = 'Invoke-FontsPhase' } - @{ File = 'terminal.ps1'; Function = 'Invoke-TerminalPhase' } - @{ File = 'powershell-profile.ps1'; Function = 'Invoke-PowerShellProfilePhase' } - @{ File = 'copilot.ps1'; Function = 'Invoke-CopilotPhase' } - @{ File = 'wsl.ps1'; Function = 'Invoke-WslPhase' } + @{ File = 'packages.ps1'; Function = 'Invoke-PackagesPhase'; Title = 'Packages' } + @{ File = 'registry-system.ps1'; Function = 'Invoke-RegistrySystemPhase'; Title = 'System settings' } + @{ File = 'registry-explorer.ps1'; Function = 'Invoke-RegistryExplorerPhase'; Title = 'File Explorer tweaks' } + @{ File = 'registry-taskbar-search.ps1'; Function = 'Invoke-RegistryTaskbarSearchPhase'; Title = 'Taskbar, search & start tweaks' } + @{ File = 'edge.ps1'; Function = 'Invoke-EdgePhase'; Title = 'Microsoft Edge tweaks' } + @{ File = 'fonts.ps1'; Function = 'Invoke-FontsPhase'; Title = 'Fonts' } + @{ File = 'terminal.ps1'; Function = 'Invoke-TerminalPhase'; Title = 'Windows Terminal' } + @{ File = 'powershell-profile.ps1'; Function = 'Invoke-PowerShellProfilePhase'; Title = 'PowerShell profile' } + @{ File = 'copilot.ps1'; Function = 'Invoke-CopilotPhase'; Title = 'GitHub Copilot' } + @{ File = 'wsl.ps1'; Function = 'Invoke-WslPhase'; Title = 'WSL + Ubuntu' } ) +$phaseIndex = 0 foreach ($phase in $phases) { + $phaseIndex++ $path = Join-Path $stepsDir $phase.File if (-not (Test-Path -LiteralPath $path)) { Write-Host "-- $($phase.File) not written yet, skipping" -ForegroundColor DarkGray continue } + # Read by Invoke-DevConfigSteps to print this phase's header, without threading params through every phase file. + $Script:DevConfigPhaseIndex = $phaseIndex + $Script:DevConfigPhaseTotal = $phases.Count + $Script:DevConfigPhaseTitle = $phase.Title + $Script:DevConfigPhaseHeaderShown = $false + . $path if ($phase.File -eq 'wsl.ps1') { # The WSL phase needs the orchestrator's own path to register the reboot-resume task. @@ -74,5 +96,17 @@ foreach ($phase in $phases) { } } +Show-DevConfigSilentSkipSummary Write-Host '' Write-Host 'Calm OS setup complete.' -ForegroundColor Green +$tally = $Script:DevConfigTally +$summaryParts = @("$($tally.Done) changed", "$($tally.AlreadyOk) already up to date") +if ($tally.Warned -gt 0) { + $summaryParts += "$($tally.Warned) flagged" +} +Write-Host " $($summaryParts -join ', ')" -ForegroundColor DarkGray + +if (-not $Script:DevConfigResumed) { + # When resumed, the wrapper's own window owns the final pause instead (see _resume-wrapper.ps1). + Wait-DevConfigKeyPress +} diff --git a/src/windows-dev-config/steps/_console.ps1 b/src/windows-dev-config/steps/_console.ps1 new file mode 100644 index 0000000..ccc375e --- /dev/null +++ b/src/windows-dev-config/steps/_console.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS + Small shared console helper used at the very end of a run, so a window nobody is + watching doesn't just vanish the moment the last line prints. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Wait-DevConfigKeyPress { + param( + [string] $Message = 'Press any key to close this window...', + [int] $TimeoutSeconds = 900 + ) + + Write-Host '' + $minutes = [Math]::Round($TimeoutSeconds / 60) + Write-Host "$Message (closes on its own in $minutes minutes if you step away)" -ForegroundColor DarkGray + + # Polls instead of a blocking ReadKey so an unattended window still closes eventually. + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + try { + while ((Get-Date) -lt $deadline) { + if ([Console]::KeyAvailable) { + [void][Console]::ReadKey($true) + return + } + Start-Sleep -Milliseconds 200 + } + } catch { + # No real console attached (e.g. input redirected) -- nothing to wait on. + } +} diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 index 9c75ed8..c5820b4 100644 --- a/src/windows-dev-config/steps/_reboot-resume.ps1 +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -33,8 +33,12 @@ function Suspend-DevConfigForReboot { Clear-DevConfigResume Register-ScheduledTask -TaskName $Script:DevConfigResumeTask -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null + Save-DevConfigTally -Path (Join-Path (Split-Path -Path $ScriptPath -Parent) 'devconfig-tally.json') - Write-Host 'Registered a one-time resume task; rebooting now to finish WSL setup after you log back in...' -ForegroundColor Yellow + Write-Host '' + Write-Host 'WSL needs a restart to finish. Rebooting in 10s -- setup continues automatically' -ForegroundColor Yellow + Write-Host 'after you log back in. This is expected, not an error.' -ForegroundColor Yellow + Start-Sleep -Seconds 10 Restart-Computer -Force # Restart-Computer -Force signals the reboot but returns immediately; sleep so this diff --git a/src/windows-dev-config/steps/_resume-wrapper.ps1 b/src/windows-dev-config/steps/_resume-wrapper.ps1 index ffea65e..a130112 100644 --- a/src/windows-dev-config/steps/_resume-wrapper.ps1 +++ b/src/windows-dev-config/steps/_resume-wrapper.ps1 @@ -11,7 +11,17 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# Own console needs the same UTF-8 fix as dev-config.ps1, so relayed glyphs render correctly here too. +try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch { + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" +} + . (Join-Path $PSScriptRoot '_elevation.ps1') +. (Join-Path $PSScriptRoot '_console.ps1') $logDir = Split-Path -Path $ScriptPath -Parent $masterLog = Join-Path $logDir 'resume-output.log' @@ -21,18 +31,19 @@ Remove-Item $masterLog, $innerOut, $innerErr -ErrorAction SilentlyContinue $shell = Get-DevConfigShellExe $proc = Start-Process -FilePath $shell ` - -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') ` + -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate', '-Resumed') ` -RedirectStandardOutput $innerOut -RedirectStandardError $innerErr -NoNewWindow -PassThru # Tee: mirror new lines to the console (visible on screen) and one combined log file. $shown = 0 function Show-DevConfigResumeNewLines { # @() forces array semantics; Get-Content returns a bare string for single-line files. - $lines = @(Get-Content -Path $innerOut -ErrorAction SilentlyContinue) + # -Encoding UTF8 matches how the redirected child process actually writes its output. + $lines = @(Get-Content -Path $innerOut -Encoding UTF8 -ErrorAction SilentlyContinue) if ($lines.Count -gt $script:shown) { $lines[$script:shown..($lines.Count - 1)] | ForEach-Object { Write-Host $_ - Add-Content -Path $masterLog -Value $_ + Add-Content -Path $masterLog -Value $_ -Encoding UTF8 } $script:shown = $lines.Count } @@ -46,10 +57,13 @@ Show-DevConfigResumeNewLines # Errors are terminal, so showing them last matches when they actually happened. if (Test-Path -LiteralPath $innerErr) { - Get-Content -Path $innerErr | ForEach-Object { + Get-Content -Path $innerErr -Encoding UTF8 | ForEach-Object { Write-Host $_ -ForegroundColor Red - Add-Content -Path $masterLog -Value $_ + Add-Content -Path $masterLog -Value $_ -Encoding UTF8 } } +# This window is what's actually visible after the reboot, so it owns the "don't just vanish" pause. +Wait-DevConfigKeyPress + exit $proc.ExitCode diff --git a/src/windows-dev-config/steps/_step-runner.ps1 b/src/windows-dev-config/steps/_step-runner.ps1 index d9d5992..4c5033e 100644 --- a/src/windows-dev-config/steps/_step-runner.ps1 +++ b/src/windows-dev-config/steps/_step-runner.ps1 @@ -6,6 +6,94 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# [char] avoids embedding a literal multi-byte glyph in the source file, which Windows PowerShell +# 5.1 can misread without a BOM. +$Script:DevConfigCheckMark = [char]0x2713 + +# dev-config.ps1 sets these; defaults here cover the fresh-run case. +$Script:DevConfigResumed = $false +$Script:DevConfigTally = @{ Done = 0; AlreadyOk = 0; Warned = 0 } +# Names of steps ever flagged, so a permanently-blocked step doesn't count twice across the reboot. +$Script:DevConfigWarnedSteps = @() +$Script:DevConfigSilentSkips = 0 +$Script:DevConfigPhaseIndex = 0 +$Script:DevConfigPhaseTotal = 0 +$Script:DevConfigPhaseTitle = '' +$Script:DevConfigPhaseHeaderShown = $false + +function Write-DevConfigPhaseHeader { + param( + [Parameter(Mandatory)] [int] $Index, + [Parameter(Mandatory)] [int] $Total, + [Parameter(Mandatory)] [string] $Title + ) + Write-Host '' + Write-Host "Phase $Index/$Total -- $Title" -ForegroundColor Cyan +} + +# Guarded so a phase that does work before its step list even starts (e.g. Packages' WinGet bootstrap) +# can show the header up front without Invoke-DevConfigSteps printing it a second time afterwards. +function Show-DevConfigPhaseHeader { + if ($Script:DevConfigPhaseHeaderShown -or -not $Script:DevConfigPhaseTitle) { + return + } + Write-DevConfigPhaseHeader -Index $Script:DevConfigPhaseIndex -Total $Script:DevConfigPhaseTotal -Title $Script:DevConfigPhaseTitle + $Script:DevConfigPhaseHeaderShown = $true +} + +# Hands the tally across the reboot so the final summary covers the whole run, not just the resumed leg. +function Save-DevConfigTally { + param( + [Parameter(Mandatory)] [string] $Path + ) + try { + $state = [pscustomobject]@{ + Done = $Script:DevConfigTally.Done + AlreadyOk = $Script:DevConfigTally.AlreadyOk + WarnedSteps = ($Script:DevConfigWarnedSteps -join ',') + } + $state | ConvertTo-Json -Compress | Set-Content -LiteralPath $Path -Encoding UTF8 + } catch { + Write-Verbose "Could not save the tally before reboot: $($_.Exception.Message)" + } +} + +# Best-effort: a missing or unreadable file just means the summary covers only this leg. +function Restore-DevConfigTally { + param( + [Parameter(Mandatory)] [string] $Path + ) + if (-not (Test-Path -LiteralPath $Path)) { + return + } + try { + $saved = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json + $Script:DevConfigTally.Done += [int]$saved.Done + $Script:DevConfigTally.AlreadyOk += [int]$saved.AlreadyOk + if ($saved.WarnedSteps) { + foreach ($name in ($saved.WarnedSteps -split ',')) { + if ($Script:DevConfigWarnedSteps -notcontains $name) { + $Script:DevConfigWarnedSteps += $name + } + } + } + $Script:DevConfigTally.Warned = $Script:DevConfigWarnedSteps.Count + } catch { + Write-Verbose "Could not restore the pre-reboot tally: $($_.Exception.Message)" + } finally { + Remove-Item -LiteralPath $Path -ErrorAction SilentlyContinue + } +} + +# Flushes the running count of steps collapsed during resume, right before anything else prints. +function Show-DevConfigSilentSkipSummary { + if ($Script:DevConfigSilentSkips -gt 0) { + Write-Host '' + Write-Host "Re-checked $($Script:DevConfigSilentSkips) earlier steps -- all already OK." -ForegroundColor DarkGray + $Script:DevConfigSilentSkips = 0 + } +} + function New-DevConfigStep { param( [Parameter(Mandatory)] [string] $Name, @@ -30,37 +118,65 @@ function Invoke-DevConfigSteps { param( [Parameter(Mandatory)] [object[]] $Steps ) - foreach ($step in $Steps) { - Write-Host "==> $($step.Name)" -ForegroundColor Cyan - if ($step.Description) { - Write-Host " $($step.Description)" -ForegroundColor DarkGray - } - - # Splat (@) needs a plain variable, not a property-access expression. - $stepArgs = $step.ArgumentList + # Check first (cheap by design) so a fully-idle resumed phase can collapse before printing anything. + $checked = foreach ($step in $Steps) { $alreadyDone = $false try { + # Splat (@) needs a plain variable, not a property-access expression. + $stepArgs = $step.ArgumentList $alreadyDone = [bool](& $step.Check @stepArgs) } catch { Write-Warning "$($step.Name): Check threw ($($_.Exception.Message)); applying anyway." } - + # Tallied here (not in the print loop below) so a collapsed/silent-skipped phase still counts correctly. if ($alreadyDone) { - Write-Host ' already OK' -ForegroundColor DarkGray + $Script:DevConfigTally.AlreadyOk++ + } + [pscustomobject]@{ Step = $step; AlreadyDone = $alreadyDone } + } + + # After a reboot, collapse a fully no-op phase into a running count instead of repeating every step. + $allAlreadyOk = -not ($checked | Where-Object { -not $_.AlreadyDone }) + if ($Script:DevConfigResumed -and $allAlreadyOk) { + $Script:DevConfigSilentSkips += $checked.Count + return + } + + Show-DevConfigSilentSkipSummary + Show-DevConfigPhaseHeader + + foreach ($item in $checked) { + $step = $item.Step + $stepArgs = $step.ArgumentList + $label = $step.Name.PadRight(22) + + if ($item.AlreadyDone) { + Write-Host " $Script:DevConfigCheckMark $label already OK" -ForegroundColor DarkGray continue } + # Printed live, right before the (possibly slow) Apply runs, so the console never sits silent unexplained. + $what = if ($step.Description) { $step.Description } else { $step.Name } + Write-Host " -> $what..." -ForegroundColor DarkCyan + # BestEffort steps warn and move on instead of blocking the whole run (e.g. OS-blocked registry values). try { & $step.Apply @stepArgs if (-not [bool](& $step.Check @stepArgs)) { throw "ran, but the follow-up check still says it isn't done." } - Write-Host ' done' -ForegroundColor Green + $Script:DevConfigTally.Done++ + Write-Host " $Script:DevConfigCheckMark $label done" -ForegroundColor Green } catch { if ($step.BestEffort) { + # Dedup by name: a permanently-blocked step would otherwise flag again every leg, forever. + if ($Script:DevConfigWarnedSteps -notcontains $step.Name) { + $Script:DevConfigWarnedSteps += $step.Name + } + $Script:DevConfigTally.Warned = $Script:DevConfigWarnedSteps.Count Write-Warning "$($step.Name): $($_.Exception.Message) (best-effort step, continuing)" + Write-Host " ! $label flagged (see warning above)" -ForegroundColor Yellow } else { throw } diff --git a/src/windows-dev-config/steps/copilot.ps1 b/src/windows-dev-config/steps/copilot.ps1 index 81fc643..38e00a6 100644 --- a/src/windows-dev-config/steps/copilot.ps1 +++ b/src/windows-dev-config/steps/copilot.ps1 @@ -60,7 +60,11 @@ function Test-DevConfigWinUITemplatesInstalled { } function Install-DevConfigWinUITemplates { - dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + $output = dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Write-Host $output + throw "dotnet new install failed with exit code $LASTEXITCODE" + } } function Test-DevConfigWinSkillsMarketplaceAdded { @@ -68,7 +72,11 @@ function Test-DevConfigWinSkillsMarketplaceAdded { } function Add-DevConfigWinSkillsMarketplace { - copilot plugin marketplace add microsoft/win-dev-skills + $output = copilot plugin marketplace add microsoft/win-dev-skills 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Write-Host $output + throw "copilot plugin marketplace add failed with exit code $LASTEXITCODE" + } } function Test-DevConfigWinUIPluginInstalled { @@ -76,23 +84,33 @@ function Test-DevConfigWinUIPluginInstalled { } function Install-DevConfigWinUIPlugin { - copilot plugin install winui@win-dev-skills + $output = copilot plugin install winui@win-dev-skills 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + Write-Host $output + throw "copilot plugin install winui failed with exit code $LASTEXITCODE" + } } function Invoke-CopilotPhase { + # BestEffort throughout: these are bonus integrations layered on top of Calm OS, all network-dependent + # (GitHub asset CDN, NuGet.org, Copilot marketplace) -- a hiccup in any of them must not block Phase 10 (WSL + reboot). $steps = @( New-DevConfigStep -Name 'GitHubCopilotProfile' -Description 'Add a GitHub Copilot profile to Windows Terminal' ` -Check { Test-DevConfigCopilotTerminalProfile } ` - -Apply { Set-DevConfigCopilotTerminalProfile } + -Apply { Set-DevConfigCopilotTerminalProfile } ` + -BestEffort New-DevConfigStep -Name 'WinUITemplates' -Description 'Install WinUI dotnet-new templates' ` -Check { Test-DevConfigWinUITemplatesInstalled } ` - -Apply { Install-DevConfigWinUITemplates } + -Apply { Install-DevConfigWinUITemplates } ` + -BestEffort New-DevConfigStep -Name 'WinSkillsMarketplace' -Description 'Add win-dev-skills to the Copilot plugin marketplace' ` -Check { Test-DevConfigWinSkillsMarketplaceAdded } ` - -Apply { Add-DevConfigWinSkillsMarketplace } + -Apply { Add-DevConfigWinSkillsMarketplace } ` + -BestEffort New-DevConfigStep -Name 'WinUIPlugin' -Description 'Install the WinUI Copilot plugin from win-dev-skills' ` -Check { Test-DevConfigWinUIPluginInstalled } ` - -Apply { Install-DevConfigWinUIPlugin } + -Apply { Install-DevConfigWinUIPlugin } ` + -BestEffort ) Invoke-DevConfigSteps -Steps $steps diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index a17c6b6..40ea458 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -6,18 +6,52 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# Required: every check/install below is a Microsoft.WinGet.Client cmdlet, so the module has to load. +function Install-DevConfigWinGetModule { + if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) { + Write-Host ' Setting up the WinGet PowerShell module...' -ForegroundColor DarkCyan + Write-Host ' (First time only. This can take a few minutes.)' -ForegroundColor DarkGray + # A fresh machine can prompt to install the NuGet provider on first use; bootstrap it non-interactively first. + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force -ErrorAction Stop | Out-Null + } + Install-Module -Name Microsoft.WinGet.Client -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop | Out-Null + } + Import-Module -Name Microsoft.WinGet.Client -ErrorAction Stop +} + +# Best-effort: fixes the odd App Execution Alias glitches winget occasionally hits, before any real installs start. +# Get-WinGetVersion is a quick health check -- only pay for the slower repair when it says WinGet isn't responding. +function Repair-DevConfigWinget { + try { + $version = Get-WinGetVersion -ErrorAction Stop + Write-Host " WinGet $version looks healthy -- skipping repair." -ForegroundColor DarkGray + return + } catch { + Write-Host ' WinGet is not responding as expected -- repairing...' -ForegroundColor DarkCyan + Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray + } + + try { + Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null + Write-Host ' WinGet repair finished.' -ForegroundColor DarkGray + } catch { + Write-Warning "WinGet repair skipped: $($_.Exception.Message) (continuing anyway)" + } +} + function Test-DevConfigWingetPackageInstalled { param( [Parameter(Mandatory)] [string] $Id ) - $listOutput = & winget list --id $Id --exact --source winget --accept-source-agreements 2>&1 | Out-String - if ($listOutput -match 'No installed package found') { + # EqualsCaseInsensitive avoids ambiguous substring matches (e.g. an MSIX-correlated entry sharing the same Id text). + $pkg = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive + if (-not $pkg) { return $false } # useLatest: true in the original -- an available upgrade means this step isn't satisfied yet. - $upgradeOutput = & winget list --id $Id --exact --upgrade-available --source winget --accept-source-agreements 2>&1 | Out-String - return $upgradeOutput -match 'No installed package found' + return -not $pkg.IsUpdateAvailable } function Install-DevConfigWingetPackage { @@ -25,14 +59,20 @@ function Install-DevConfigWingetPackage { [Parameter(Mandatory)] [string] $Id ) Invoke-DevConfigRetry -Name "winget install $Id" -ScriptBlock { - & winget install --id $Id --exact --source winget --silent --accept-package-agreements --accept-source-agreements - if ($LASTEXITCODE -ne 0) { - throw "winget install $Id failed with exit code $LASTEXITCODE" + $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive + # NoApplicableUpgrade: already installed and up to date, not a failure (module's equivalent of the + # CLI's APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE exit code). + if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { + throw "winget install $Id failed: $($result.ErrorMessage())" } } } function Invoke-PackagesPhase { + Show-DevConfigPhaseHeader + Install-DevConfigWinGetModule + Repair-DevConfigWinget + $packages = @( @{ Name = 'Terminal'; Id = 'Microsoft.WindowsTerminal' } @{ Name = 'PowerShell'; Id = 'Microsoft.PowerShell' } diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 index 97bc262..2b0f140 100644 --- a/src/windows-dev-config/steps/wsl.ps1 +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -14,7 +14,8 @@ function Test-DevConfigVmComputePresent { function Install-DevConfigWslComponents { Invoke-DevConfigRetry -Name 'wsl --install --no-distribution' -ScriptBlock { - Write-Host 'Running wsl --install --no-distribution...' + Write-Host 'Installing WSL platform components (wsl --install --no-distribution)...' + Write-Host '(A separate WSL window may pop up briefly -- that is normal. This can take a few minutes.)' -ForegroundColor DarkGray # No -NoNewWindow / -Redirect*: wsl's install bootstrap needs a real console to run against. $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '--no-distribution' -Wait -PassThru if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { @@ -52,7 +53,8 @@ function Install-DevConfigUbuntu { Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force Invoke-DevConfigRetry -Name 'wsl --install -d Ubuntu' -ScriptBlock { - Write-Host 'Running wsl --install -d Ubuntu --no-launch...' + Write-Host 'Downloading and installing Ubuntu (wsl --install -d Ubuntu --no-launch)...' + Write-Host '(A separate WSL window may pop up briefly -- that is normal. This can take a few minutes.)' -ForegroundColor DarkGray $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '-d', 'Ubuntu', '--no-launch' -Wait -PassThru if ($p.ExitCode -ne 0) { throw "wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" From d5e7c0cb7bef206e39dc20146a7ce5c85bfccb24 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:18:35 -0700 Subject: [PATCH 04/19] Enhance experience --- src/windows-dev-config/dev-config.ps1 | 95 ++++++---- src/windows-dev-config/steps/_console.ps1 | 38 +++- src/windows-dev-config/steps/_elevation.ps1 | 14 +- .../steps/_pwsh-bootstrap.ps1 | 60 +++++++ src/windows-dev-config/steps/_step-runner.ps1 | 58 +++++-- src/windows-dev-config/steps/_terminal.ps1 | 164 ++++++++++++++++++ src/windows-dev-config/steps/fonts.ps1 | 78 ++------- src/windows-dev-config/steps/packages.ps1 | 13 ++ .../steps/registry-taskbar-search.ps1 | 6 +- src/windows-dev-config/steps/terminal.ps1 | 74 ++++---- src/windows-dev-config/steps/wsl.ps1 | 42 ++++- 11 files changed, 479 insertions(+), 163 deletions(-) create mode 100644 src/windows-dev-config/steps/_pwsh-bootstrap.ps1 create mode 100644 src/windows-dev-config/steps/_terminal.ps1 diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index 0a49882..46a2aea 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -35,9 +35,17 @@ $stepsDir = Join-Path $PSScriptRoot 'steps' . (Join-Path $stepsDir '_registry.ps1') . (Join-Path $stepsDir '_environment.ps1') . (Join-Path $stepsDir '_retry.ps1') +. (Join-Path $stepsDir '_terminal.ps1') +. (Join-Path $stepsDir '_pwsh-bootstrap.ps1') Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate +# WinGet's PowerShell module is unreliable on Windows PowerShell, so get onto PowerShell 7 before anything else. +Invoke-DevConfigEnsurePwsh -ScriptPath $PSCommandPath -Resumed:$Resumed + +# Past both relaunches, so this is the process that does the work and owns the log file. +Start-DevConfigLog -Path (Join-Path $PSScriptRoot 'devconfig-log.txt') -Append:$Resumed + # Whether this is a fresh start or the post-reboot resume, any leftover task is done with. Clear-DevConfigResume @@ -67,46 +75,73 @@ $phases = @( @{ File = 'wsl.ps1'; Function = 'Invoke-WslPhase'; Title = 'WSL + Ubuntu' } ) -$phaseIndex = 0 -foreach ($phase in $phases) { - $phaseIndex++ - $path = Join-Path $stepsDir $phase.File - if (-not (Test-Path -LiteralPath $path)) { - Write-Host "-- $($phase.File) not written yet, skipping" -ForegroundColor DarkGray - continue +$failure = $null +try { + $phaseIndex = 0 + foreach ($phase in $phases) { + $phaseIndex++ + $path = Join-Path $stepsDir $phase.File + if (-not (Test-Path -LiteralPath $path)) { + Write-Host "-- $($phase.File) not written yet, skipping" -ForegroundColor DarkGray + continue + } + + # Read by Invoke-DevConfigSteps to print this phase's header, without threading params through every phase file. + $Script:DevConfigPhaseIndex = $phaseIndex + $Script:DevConfigPhaseTotal = $phases.Count + $Script:DevConfigPhaseTitle = $phase.Title + $Script:DevConfigPhaseHeaderShown = $false + + . $path + if ($phase.File -eq 'wsl.ps1') { + # The WSL phase needs the orchestrator's own path to register the reboot-resume task. + Invoke-WslPhase -OrchestratorPath $PSCommandPath + } else { + & $phase.Function + } + + if ($phase.File -eq 'packages.ps1') { + # Packages installed above (pwsh, dotnet, git, ...) won't resolve on PATH until this refreshes. + Update-DevConfigSessionPath + } } - # Read by Invoke-DevConfigSteps to print this phase's header, without threading params through every phase file. - $Script:DevConfigPhaseIndex = $phaseIndex - $Script:DevConfigPhaseTotal = $phases.Count - $Script:DevConfigPhaseTitle = $phase.Title - $Script:DevConfigPhaseHeaderShown = $false - - . $path - if ($phase.File -eq 'wsl.ps1') { - # The WSL phase needs the orchestrator's own path to register the reboot-resume task. - Invoke-WslPhase -OrchestratorPath $PSCommandPath - } else { - & $phase.Function + Show-DevConfigSilentSkipSummary + Write-Host '' + Write-Host 'Calm OS setup complete.' -ForegroundColor Green + $tally = $Script:DevConfigTally + $summaryParts = @("$($tally.Done) changed", "$($tally.AlreadyOk) already up to date") + if ($tally.Warned -gt 0) { + $summaryParts += "$($tally.Warned) flagged" } + Write-Host " $($summaryParts -join ', ')" -ForegroundColor DarkGray + Write-Host ' A few Explorer and taskbar changes appear once you sign out and back in.' -ForegroundColor DarkGray +} catch { + $failure = $_ +} - if ($phase.File -eq 'packages.ps1') { - # Packages installed above (pwsh, dotnet, git, ...) won't resolve on PATH until this refreshes. - Update-DevConfigSessionPath +if ($failure) { + Write-Host '' + Write-Host 'Calm OS setup stopped early.' -ForegroundColor Red + Write-Host " $($failure.Exception.Message)" -ForegroundColor Red + $origin = $failure.InvocationInfo + if ($origin -and $origin.ScriptName) { + Write-Host " ($(Split-Path -Leaf $origin.ScriptName) line $($origin.ScriptLineNumber))" -ForegroundColor DarkGray } + Write-Host ' Nothing already applied was undone -- running this again picks up where it left off.' -ForegroundColor DarkGray } -Show-DevConfigSilentSkipSummary -Write-Host '' -Write-Host 'Calm OS setup complete.' -ForegroundColor Green -$tally = $Script:DevConfigTally -$summaryParts = @("$($tally.Done) changed", "$($tally.AlreadyOk) already up to date") -if ($tally.Warned -gt 0) { - $summaryParts += "$($tally.Warned) flagged" +$logPath = Get-DevConfigLogPath +if ($logPath) { + Write-Host " Full log: $logPath" -ForegroundColor DarkGray } -Write-Host " $($summaryParts -join ', ')" -ForegroundColor DarkGray if (-not $Script:DevConfigResumed) { # When resumed, the wrapper's own window owns the final pause instead (see _resume-wrapper.ps1). Wait-DevConfigKeyPress } + +Stop-DevConfigLog +if ($failure) { + exit 1 +} diff --git a/src/windows-dev-config/steps/_console.ps1 b/src/windows-dev-config/steps/_console.ps1 index ccc375e..c195353 100644 --- a/src/windows-dev-config/steps/_console.ps1 +++ b/src/windows-dev-config/steps/_console.ps1 @@ -1,12 +1,46 @@ <# .SYNOPSIS - Small shared console helper used at the very end of a run, so a window nobody is - watching doesn't just vanish the moment the last line prints. + Small shared console helpers: the run's log file, and the end-of-run pause so a window + nobody is watching doesn't just vanish the moment the last line prints. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +$Script:DevConfigLogPath = $null + +# One file for the whole run, appended to across the reboot, so there's something to read (or send +# on) when a step fails. Start this only in the process that does the work: the elevation and +# PowerShell 7 relaunches would otherwise leave two processes writing to the same file. +function Start-DevConfigLog { + param( + [Parameter(Mandatory)] [string] $Path, + [switch] $Append + ) + try { + Start-Transcript -LiteralPath $Path -Append:$Append -Force | Out-Null + $Script:DevConfigLogPath = $Path + } catch { + Write-Verbose "Could not start the log file: $($_.Exception.Message)" + $Script:DevConfigLogPath = $null + } +} + +function Stop-DevConfigLog { + if (-not $Script:DevConfigLogPath) { + return + } + try { + Stop-Transcript | Out-Null + } catch { + Write-Verbose "Could not stop the log file: $($_.Exception.Message)" + } +} + +function Get-DevConfigLogPath { + return $Script:DevConfigLogPath +} + function Wait-DevConfigKeyPress { param( [string] $Message = 'Press any key to close this window...', diff --git a/src/windows-dev-config/steps/_elevation.ps1 b/src/windows-dev-config/steps/_elevation.ps1 index 8ed2bac..3c6bd5d 100644 --- a/src/windows-dev-config/steps/_elevation.ps1 +++ b/src/windows-dev-config/steps/_elevation.ps1 @@ -35,8 +35,16 @@ function Invoke-DevConfigElevate { $shell = Get-DevConfigShellExe $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') - Start-Process -FilePath $shell -ArgumentList $relaunchArgs -Verb RunAs -Wait + try { + $proc = Start-Process -FilePath $shell -ArgumentList $relaunchArgs -Verb RunAs -Wait -PassThru + } catch { + # Declining the UAC prompt lands here; it's a choice, not a crash, so say so plainly. + Write-Host '' + Write-Host 'Setup needs Administrator rights to continue, so nothing was changed.' -ForegroundColor Yellow + Write-Host 'Run it again and accept the prompt, or start it from an elevated terminal.' -ForegroundColor Yellow + exit 1 + } - # The elevated relaunch already did the work; nothing left for this process to do. - exit 0 + # The elevated relaunch did the work, so this process reports whatever that one concluded. + exit $proc.ExitCode } diff --git a/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 new file mode 100644 index 0000000..42c0a5b --- /dev/null +++ b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 @@ -0,0 +1,60 @@ +<# +.SYNOPSIS + Makes sure PowerShell 7 is installed and in use before any real work starts -- the WinGet + module the rest of this script relies on is documented as unreliable on Windows PowerShell. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-DevConfigHasPwsh { + [bool](Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) +} + +# Verified via Get-Command (PATH) afterward, not a WinGet read cmdlet -- that's the part that's unreliable here. +function Install-DevConfigPwshBootstrap { + for ($attempt = 1; $attempt -le 2; $attempt++) { + try { + winget install --id Microsoft.PowerShell --source winget --silent ` + --accept-package-agreements --accept-source-agreements --disable-interactivity | Out-Null + } catch { + Write-Verbose "winget install Microsoft.PowerShell attempt ${attempt}: $($_.Exception.Message)" + } + Update-DevConfigSessionPath + if (Test-DevConfigHasPwsh) { + return + } + Start-Sleep -Seconds 5 + } +} + +function Invoke-DevConfigEnsurePwsh { + param( + [Parameter(Mandatory)] [string] $ScriptPath, + [switch] $Resumed + ) + + if ($PSVersionTable.PSEdition -eq 'Core') { + return + } + + if (-not (Test-DevConfigHasPwsh)) { + Write-Host '' + Write-Host 'Installing PowerShell 7 first -- WinGet is more reliable on it than on Windows PowerShell.' -ForegroundColor Yellow + Write-Host '(One-time. Takes about a minute.)' -ForegroundColor DarkGray + Install-DevConfigPwshBootstrap + } + + if (-not (Test-DevConfigHasPwsh)) { + Write-Warning 'Could not install PowerShell 7 -- continuing on Windows PowerShell.' + return + } + + Write-Host 'Switching this setup over to PowerShell 7...' -ForegroundColor DarkCyan + $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') + if ($Resumed) { $relaunchArgs += '-Resumed' } + $proc = Start-Process -FilePath 'pwsh.exe' -ArgumentList $relaunchArgs -Wait -NoNewWindow -PassThru + + # The relaunch already did the work; nothing left for this (Windows PowerShell) process to do. + exit $proc.ExitCode +} diff --git a/src/windows-dev-config/steps/_step-runner.ps1 b/src/windows-dev-config/steps/_step-runner.ps1 index 4c5033e..3988ad5 100644 --- a/src/windows-dev-config/steps/_step-runner.ps1 +++ b/src/windows-dev-config/steps/_step-runner.ps1 @@ -16,6 +16,7 @@ $Script:DevConfigTally = @{ Done = 0; AlreadyOk = 0; Warned = 0 } # Names of steps ever flagged, so a permanently-blocked step doesn't count twice across the reboot. $Script:DevConfigWarnedSteps = @() $Script:DevConfigSilentSkips = 0 +$Script:DevConfigStepUnverified = $null $Script:DevConfigPhaseIndex = 0 $Script:DevConfigPhaseTotal = 0 $Script:DevConfigPhaseTitle = '' @@ -114,13 +115,48 @@ function New-DevConfigStep { } } +# Dedup by name: a permanently-blocked step would otherwise flag again every leg, forever. +function Write-DevConfigStepFlag { + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $Label, + [Parameter(Mandatory)] [string] $Message + ) + if ($Script:DevConfigWarnedSteps -notcontains $Name) { + $Script:DevConfigWarnedSteps += $Name + } + $Script:DevConfigTally.Warned = $Script:DevConfigWarnedSteps.Count + Write-Warning "${Name}: $Message" + Write-Host " ! $Label flagged (see warning above)" -ForegroundColor Yellow +} + +# Called by a step's Apply when the work went through but couldn't be confirmed -- e.g. WinGet's +# catalog still not listing a package it just installed. The step is reported honestly as flagged +# with the reason, rather than given a green tick it hasn't earned or failing the whole run. +function Set-DevConfigStepUnverified { + param( + [Parameter(Mandatory)] [string] $Reason + ) + $Script:DevConfigStepUnverified = $Reason +} + function Invoke-DevConfigSteps { param( [Parameter(Mandatory)] [object[]] $Steps ) + # A fresh run has nothing to collapse, so announce the phase before the checks rather than after. + # Otherwise a phase with slow checks (fifteen package lookups) sits silent with nothing on screen + # to explain the wait. + if (-not $Script:DevConfigResumed) { + Show-DevConfigPhaseHeader + Write-Host " Checking what's already set up..." -ForegroundColor DarkGray + } + # Check first (cheap by design) so a fully-idle resumed phase can collapse before printing anything. - $checked = foreach ($step in $Steps) { + # @() forces array semantics; a single-step phase would otherwise yield a bare object, and .Count + # on one of those throws under StrictMode in Windows PowerShell 5.1. + $checked = @(foreach ($step in $Steps) { $alreadyDone = $false try { # Splat (@) needs a plain variable, not a property-access expression. @@ -134,7 +170,7 @@ function Invoke-DevConfigSteps { $Script:DevConfigTally.AlreadyOk++ } [pscustomobject]@{ Step = $step; AlreadyDone = $alreadyDone } - } + }) # After a reboot, collapse a fully no-op phase into a running count instead of repeating every step. $allAlreadyOk = -not ($checked | Where-Object { -not $_.AlreadyDone }) @@ -161,22 +197,20 @@ function Invoke-DevConfigSteps { Write-Host " -> $what..." -ForegroundColor DarkCyan # BestEffort steps warn and move on instead of blocking the whole run (e.g. OS-blocked registry values). + $Script:DevConfigStepUnverified = $null try { & $step.Apply @stepArgs - if (-not [bool](& $step.Check @stepArgs)) { + if ($Script:DevConfigStepUnverified) { + Write-DevConfigStepFlag -Name $step.Name -Label $label -Message $Script:DevConfigStepUnverified + } elseif (-not [bool](& $step.Check @stepArgs)) { throw "ran, but the follow-up check still says it isn't done." + } else { + $Script:DevConfigTally.Done++ + Write-Host " $Script:DevConfigCheckMark $label done" -ForegroundColor Green } - $Script:DevConfigTally.Done++ - Write-Host " $Script:DevConfigCheckMark $label done" -ForegroundColor Green } catch { if ($step.BestEffort) { - # Dedup by name: a permanently-blocked step would otherwise flag again every leg, forever. - if ($Script:DevConfigWarnedSteps -notcontains $step.Name) { - $Script:DevConfigWarnedSteps += $step.Name - } - $Script:DevConfigTally.Warned = $Script:DevConfigWarnedSteps.Count - Write-Warning "$($step.Name): $($_.Exception.Message) (best-effort step, continuing)" - Write-Host " ! $label flagged (see warning above)" -ForegroundColor Yellow + Write-DevConfigStepFlag -Name $step.Name -Label $label -Message "$($_.Exception.Message) (best-effort step, continuing)" } else { throw } diff --git a/src/windows-dev-config/steps/_terminal.ps1 b/src/windows-dev-config/steps/_terminal.ps1 new file mode 100644 index 0000000..9d98a08 --- /dev/null +++ b/src/windows-dev-config/steps/_terminal.ps1 @@ -0,0 +1,164 @@ +<# +.SYNOPSIS + Shared Windows Terminal settings helpers: locating settings.json, reading it as JSONC, + writing it back safely, and the small JSON object helpers those need. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Terminal's settings nest several levels deep (profiles.list[].font.face, actions, schemes). +# Too small a depth makes ConvertTo-Json silently truncate a customized file into a string. +$Script:DevConfigTerminalJsonDepth = 32 + +# Terminal's name for the PowerShell 7 profile. Usable in place of a GUID: the settings schema +# documents defaultProfile as accepting "GUID or profile name as a string". +$Script:DevConfigPs7ProfileName = 'PowerShell' + +# Where a packaged (MSIX) Terminal keeps its settings, whether or not the file exists yet. +# Stable before Preview, so a machine with both configures the one it actually launches. +function Get-DevConfigTerminalPackagedSettingsPath { + $packagesDir = Join-Path $env:LOCALAPPDATA 'Packages' + foreach ($pattern in 'Microsoft.WindowsTerminal_*', 'Microsoft.WindowsTerminalPreview_*') { + $dir = Get-ChildItem -Path $packagesDir -Filter $pattern -Directory -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($dir) { + return Join-Path $dir.FullName 'LocalState\settings.json' + } + } + return $null +} + +function Get-DevConfigTerminalUnpackagedSettingsPath { + Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' +} + +# The settings file as it exists right now, or $null when Terminal has never written one. +function Get-DevConfigTerminalSettingsPath { + $candidates = @( + Get-DevConfigTerminalPackagedSettingsPath + Get-DevConfigTerminalUnpackagedSettingsPath + ) + return $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 +} + +# Where the settings file belongs, whether or not it has been written yet. +# $null means Terminal isn't installed, so there is genuinely nothing to configure. +function Get-DevConfigTerminalSettingsTarget { + $existing = Get-DevConfigTerminalSettingsPath + if ($existing) { + return $existing + } + return Get-DevConfigTerminalPackagedSettingsPath +} + +# Terminal only writes settings.json on its first launch, so on a freshly installed machine the file +# is missing. An empty object lets callers treat "not written yet" like any other starting point: +# Terminal fills in everything we leave out from its own defaults. +function Read-DevConfigTerminalSettings { + param( + [Parameter(Mandatory)] [string] $Path + ) + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{} + } + + # Get-Content -Raw hands back $null (not an empty string) for a zero-byte file, and a write + # interrupted partway through leaves exactly that. + $raw = Get-Content -LiteralPath $Path -Raw + if ([string]::IsNullOrWhiteSpace($raw)) { + return [pscustomobject]@{} + } + + # settings.json is JSONC; strip block and line comments before parsing. + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + if ([string]::IsNullOrWhiteSpace($clean)) { + return [pscustomobject]@{} + } + return $clean | ConvertFrom-Json +} + +# Keeps a .bak alongside the file: the JSONC round-trip above drops any comments the user had written. +function Save-DevConfigTerminalSettings { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [object] $Settings + ) + $parent = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + if (Test-Path -LiteralPath $Path) { + Copy-Item -LiteralPath $Path -Destination "$Path.bak" -Force + } + $Settings | ConvertTo-Json -Depth $Script:DevConfigTerminalJsonDepth | + Set-Content -LiteralPath $Path -Encoding UTF8 +} + +# Walks an object path such as profiles -> defaults -> font, creating any level that's missing, +# and hands back the leaf so a caller can set values on it. +function Resolve-DevConfigJsonBranch { + param( + [Parameter(Mandatory)] [object] $Object, + [Parameter(Mandatory)] [string[]] $Path + ) + $node = $Object + foreach ($name in $Path) { + if (-not $node.PSObject.Properties[$name]) { + $node | Add-Member -NotePropertyName $name -NotePropertyValue ([pscustomobject]@{}) + } + $node = $node.PSObject.Properties[$name].Value + } + return $node +} + +# Add-Member only creates; assignment only updates. This does whichever applies. +function Set-DevConfigJsonProperty { + param( + [Parameter(Mandatory)] [object] $Object, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] $Value + ) + if ($Object.PSObject.Properties[$Name]) { + $Object.PSObject.Properties[$Name].Value = $Value + } else { + $Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value + } +} + +# Reads a nested value without throwing under strict mode when any level along the way is absent. +function Get-DevConfigJsonValue { + param( + [Parameter(Mandatory)] [object] $Object, + [Parameter(Mandatory)] [string[]] $Path + ) + $node = $Object + foreach ($name in $Path) { + if ($null -eq $node) { + return $null + } + $property = $node.PSObject.Properties[$name] + if (-not $property) { + return $null + } + $node = $property.Value + } + return $node +} + +# Terminal's PowerShell 7 entry. Built-in profiles such as "Windows PowerShell" have no 'source' +# property at all, so every level is read defensively rather than dotted into. +function Find-DevConfigPs7Profile { + param( + [Parameter(Mandatory)] [object] $Settings + ) + $list = Get-DevConfigJsonValue -Object $Settings -Path 'profiles', 'list' + if (-not $list) { + return $null + } + return $list | Where-Object { + (Get-DevConfigJsonValue -Object $_ -Path 'source') -eq 'Windows.Terminal.PowershellCore' -or + (Get-DevConfigJsonValue -Object $_ -Path 'name') -eq $Script:DevConfigPs7ProfileName + } | Select-Object -First 1 +} diff --git a/src/windows-dev-config/steps/fonts.ps1 b/src/windows-dev-config/steps/fonts.ps1 index 2465c8b..a8c0e0a 100644 --- a/src/windows-dev-config/steps/fonts.ps1 +++ b/src/windows-dev-config/steps/fonts.ps1 @@ -83,83 +83,43 @@ function Install-DevConfigCascadiaFonts { Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." } -function Get-DevConfigTerminalSettingsPath { - # Packaged (MSIX) Terminal first, then the unpackaged/portable location. - $candidates = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) - return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -} - -function Get-DevConfigTerminalSettingsRaw { - param( - [Parameter(Mandatory)] [string] $Path - ) - # Terminal's settings.json is JSONC; strip block and line comments before parsing. - $raw = Get-Content -LiteralPath $Path -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - return [regex]::Replace($clean, '(?m)^\s*//.*$', '') -} - -function Get-DevConfigTerminalDefaultFontFace { - param( - [Parameter(Mandatory)] [object] $Settings - ) - # Walk profiles.defaults.font.face defensively: any level may be absent, and strict - # mode throws on a direct dot-access to a missing property. - $profilesProp = $Settings.PSObject.Properties['profiles'] - if (-not $profilesProp) { return $null } - $defaultsProp = $profilesProp.Value.PSObject.Properties['defaults'] - if (-not $defaultsProp) { return $null } - $fontProp = $defaultsProp.Value.PSObject.Properties['font'] - if (-not $fontProp) { return $null } - $faceProp = $fontProp.Value.PSObject.Properties['face'] - if (-not $faceProp) { return $null } - return $faceProp.Value -} - function Test-DevConfigCascadiaDefaultFont { $path = Get-DevConfigTerminalSettingsPath if (-not $path) { - return $true + # Terminal writes settings.json on its first launch. Installed-but-never-opened still has work + # to do -- answering "already OK" here is what made this step silently do nothing on a fresh machine. + return (-not (Get-DevConfigTerminalSettingsTarget)) } - $settings = Get-DevConfigTerminalSettingsRaw -Path $path | ConvertFrom-Json - return (Get-DevConfigTerminalDefaultFontFace -Settings $settings) -eq $Script:CascadiaDefaultFontFace + $settings = Read-DevConfigTerminalSettings -Path $path + return (Get-DevConfigJsonValue -Object $settings -Path 'profiles', 'defaults', 'font', 'face') -eq $Script:CascadiaDefaultFontFace } function Set-DevConfigCascadiaDefaultFont { - $path = Get-DevConfigTerminalSettingsPath + $path = Get-DevConfigTerminalSettingsTarget if (-not $path) { - throw 'Windows Terminal settings.json not found.' - } - Write-Host "Using: $path" - Copy-Item -LiteralPath $path -Destination "$path.bak" -Force - - # Plain ConvertFrom-Json (not -AsHashtable, which needs PowerShell 6+) so this also runs on Windows PowerShell 5.1. - $json = Get-DevConfigTerminalSettingsRaw -Path $path | ConvertFrom-Json - if (-not $json.PSObject.Properties['profiles']) { $json | Add-Member -NotePropertyName profiles -NotePropertyValue ([pscustomobject]@{}) } - if (-not $json.profiles.PSObject.Properties['defaults']) { $json.profiles | Add-Member -NotePropertyName defaults -NotePropertyValue ([pscustomobject]@{}) } - if (-not $json.profiles.defaults.PSObject.Properties['font']) { $json.profiles.defaults | Add-Member -NotePropertyName font -NotePropertyValue ([pscustomobject]@{}) } - if ($json.profiles.defaults.font.PSObject.Properties['face']) { - $json.profiles.defaults.font.face = $Script:CascadiaDefaultFontFace - } else { - $json.profiles.defaults.font | Add-Member -NotePropertyName face -NotePropertyValue $Script:CascadiaDefaultFontFace + throw 'Windows Terminal is not installed, so its default font cannot be set.' } - $json | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $path -Encoding utf8 - Write-Host "Set Terminal default font to '$($Script:CascadiaDefaultFontFace)' (backup: $path.bak)" + $settings = Read-DevConfigTerminalSettings -Path $path + $font = Resolve-DevConfigJsonBranch -Object $settings -Path 'profiles', 'defaults', 'font' + Set-DevConfigJsonProperty -Object $font -Name 'face' -Value $Script:CascadiaDefaultFontFace + + Save-DevConfigTerminalSettings -Path $path -Settings $settings + Write-Host "Set the Windows Terminal default font to '$($Script:CascadiaDefaultFontFace)' in $path" } function Invoke-FontsPhase { + # BestEffort: both steps are cosmetic and the download depends on GitHub's release CDN, so a hiccup + # here must not stop the substantive phases that follow (Terminal, profile, Copilot, WSL). $steps = @( New-DevConfigStep -Name 'CascadiaFonts' -Description 'Install Cascadia Code Nerd Fonts' ` -Check { Test-DevConfigCascadiaFontsInstalled } ` - -Apply { Install-DevConfigCascadiaFonts } + -Apply { Install-DevConfigCascadiaFonts } ` + -BestEffort New-DevConfigStep -Name 'CascadiaDefaultFont' -Description 'Set Cascadia Mono NF as the Windows Terminal default font' ` -Check { Test-DevConfigCascadiaDefaultFont } ` - -Apply { Set-DevConfigCascadiaDefaultFont } + -Apply { Set-DevConfigCascadiaDefaultFont } ` + -BestEffort ) Invoke-DevConfigSteps -Steps $steps diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index 40ea458..f2dea49 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -66,6 +66,19 @@ function Install-DevConfigWingetPackage { throw "winget install $Id failed: $($result.ErrorMessage())" } } + + # Get-WinGetPackage's catalog read can lag right after a successful install (an upstream WinGet + # quirk, not specific to one PowerShell edition), so give it a moment before judging the result. + for ($attempt = 1; $attempt -le 5; $attempt++) { + if (Test-DevConfigWingetPackageInstalled -Id $Id) { + return + } + if ($attempt -eq 1) { + Write-Host ' (Installed -- just waiting for it to finish registering...)' -ForegroundColor DarkGray + } + Start-Sleep -Seconds 3 + } + Set-DevConfigStepUnverified -Reason "WinGet reported $Id installed, but its catalog still doesn't list it as current 15s later. It's on the machine -- re-run to confirm." } function Invoke-PackagesPhase { diff --git a/src/windows-dev-config/steps/registry-taskbar-search.ps1 b/src/windows-dev-config/steps/registry-taskbar-search.ps1 index 6592639..4738dc6 100644 --- a/src/windows-dev-config/steps/registry-taskbar-search.ps1 +++ b/src/windows-dev-config/steps/registry-taskbar-search.ps1 @@ -11,13 +11,12 @@ function Invoke-RegistryTaskbarSearchPhase { $tweaks = @( @{ Name = 'DoNotDisturb'; KeyPath = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings'; ValueName = 'NOC_GLOBAL_SETTING_TOASTS_ENABLED'; Value = 0; Description = 'Enable Do Not Disturb (disable all notifications)' } - # Windows 24H2+ blocks direct writes to TaskbarDa even for admins; WidgetServiceOff below covers the same intent. - @{ Name = 'TaskbarHideWidgets'; KeyPath = $advanced; ValueName = 'TaskbarDa'; Value = 0; Description = 'Hide Widgets button on the taskbar'; BestEffort = $true } @{ Name = 'BluetoothOff'; KeyPath = 'HKCU\Control Panel\Bluetooth'; ValueName = 'Notification Area Icon'; Value = 0; Description = 'Hide Bluetooth icon in taskbar notification area' } @{ Name = 'EndTask'; KeyPath = $advanced; ValueName = 'TaskbarEndTask'; Value = 1; Description = 'Enable "End Task" on right-click of taskbar icons' } @{ Name = 'WebSearchOff'; KeyPath = 'HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer'; ValueName = 'DisableSearchBoxSuggestions'; Value = 1; Description = 'Disable web search in Start/Search' } @{ Name = 'SearchHightlightOff'; KeyPath = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings'; ValueName = 'IsDynamicSearchBoxEnabled'; Value = 0; Description = 'Disable Show search highlights' } @{ Name = 'StartRecommendations'; KeyPath = $advanced; ValueName = 'Start_IrisRecommendations'; Value = 0; Description = 'Disable Start menu recommendations' } + # Disables Widgets at the OS policy level; the direct taskbar-icon key is blocked outright on Windows 24H2+, so this is the only Widgets tweak. @{ Name = 'WidgetServiceOff'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Dsh'; ValueName = 'AllowNewsAndInterests'; Value = 0; Description = 'Disable Widget service' } ) @@ -26,8 +25,7 @@ function Invoke-RegistryTaskbarSearchPhase { New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` -Apply { param($KeyPath, $ValueName, $Value) Set-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` - -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value) ` - -BestEffort:($tweak.Contains('BestEffort') -and $tweak.BestEffort) + -ArgumentList @($tweak.KeyPath, $tweak.ValueName, $tweak.Value) } Invoke-DevConfigSteps -Steps $steps diff --git a/src/windows-dev-config/steps/terminal.ps1 b/src/windows-dev-config/steps/terminal.ps1 index fd4060e..eb6c633 100644 --- a/src/windows-dev-config/steps/terminal.ps1 +++ b/src/windows-dev-config/steps/terminal.ps1 @@ -19,63 +19,47 @@ function Set-DevConfigDarkTheme { Set-ItemProperty -Path $regPath -Name 'SystemUsesLightTheme' -Value 0 } -function Get-DevConfigTerminalSettingsPath { - # Packaged (MSIX) Terminal first, then the unpackaged/portable location. - $candidates = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -ErrorAction SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) - return $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -} - -function Get-DevConfigTerminalSettings { - param( - [Parameter(Mandatory)] [string] $Path - ) - # Terminal's settings.json is JSONC; strip block and line comments before parsing. - $raw = Get-Content -LiteralPath $Path -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - return $clean | ConvertFrom-Json -} - -function Find-DevConfigPs7Profile { - param( - [Parameter(Mandatory)] [object] $Settings - ) - # Some built-in profiles (e.g. "Windows PowerShell") have no 'source' property at all; - # index into PSObject.Properties instead of dotting into it so strict mode doesn't throw. - return $Settings.profiles.list | Where-Object { - $sourceProp = $_.PSObject.Properties['source'] - (($sourceProp) -and ($sourceProp.Value -eq 'Windows.Terminal.PowershellCore')) -or ($_.name -eq 'PowerShell') - } | Select-Object -First 1 -} - function Test-DevConfigPs7DefaultProfile { $path = Get-DevConfigTerminalSettingsPath if (-not $path) { - return $true + # Terminal writes settings.json on its first launch. Installed-but-never-opened still has work + # to do -- answering "already OK" here is what made this step silently do nothing on a fresh machine. + return (-not (Get-DevConfigTerminalSettingsTarget)) } - $settings = Get-DevConfigTerminalSettings -Path $path - $ps7 = Find-DevConfigPs7Profile -Settings $settings - if (-not $ps7) { + + $settings = Read-DevConfigTerminalSettings -Path $path + $current = Get-DevConfigJsonValue -Object $settings -Path 'defaultProfile' + if (-not $current) { + return $false + } + if ($current -eq $Script:DevConfigPs7ProfileName) { return $true } - return ($settings.defaultProfile -eq $ps7.guid) + + $ps7 = Find-DevConfigPs7Profile -Settings $settings + return [bool]($ps7 -and $current -eq (Get-DevConfigJsonValue -Object $ps7 -Path 'guid')) } function Set-DevConfigPs7DefaultProfile { - $path = Get-DevConfigTerminalSettingsPath + $path = Get-DevConfigTerminalSettingsTarget if (-not $path) { - return + throw 'Windows Terminal is not installed, so its default profile cannot be set.' } - $settings = Get-DevConfigTerminalSettings -Path $path - $ps7 = Find-DevConfigPs7Profile -Settings $settings - if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { - $settings.defaultProfile = $ps7.guid - $settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding UTF8 + + $settings = Read-DevConfigTerminalSettings -Path $path + $ps7 = Find-DevConfigPs7Profile -Settings $settings + + # Terminal only lists its PowerShell 7 profile once it has run since PowerShell 7 was installed. + # Until then the documented name form still resolves, and keeps working after Terminal fills the list in. + $profileRef = if ($ps7) { + Get-DevConfigJsonValue -Object $ps7 -Path 'guid' + } else { + $Script:DevConfigPs7ProfileName } + + Set-DevConfigJsonProperty -Object $settings -Name 'defaultProfile' -Value $profileRef + Save-DevConfigTerminalSettings -Path $path -Settings $settings + Write-Host "Set the Windows Terminal default profile to '$profileRef'." } function Invoke-TerminalPhase { diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 index 2b0f140..f68ae8d 100644 --- a/src/windows-dev-config/steps/wsl.ps1 +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -40,7 +40,10 @@ function Test-DevConfigUbuntuInstalled { $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | ForEach-Object { ($_ -replace "`0", '').Trim() } | Where-Object { $_ }) - return $distros.Count -gt 0 + # Match Ubuntu specifically, including versioned registrations such as Ubuntu-24.04. Counting + # any distro at all let an unrelated one (docker-desktop, Debian) satisfy this step, so a + # machine that uses Docker would silently never get Ubuntu. + return @($distros | Where-Object { $_ -like 'Ubuntu*' }).Count -gt 0 } finally { Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue } @@ -62,21 +65,44 @@ function Install-DevConfigUbuntu { } } +$Script:DevConfigWslInactiveMessage = @' +WSL's platform components are installed but still inactive after a restart, so restarting +again would not help. This machine most likely has virtualization turned off: enable it in +the BIOS/UEFI, or turn on nested virtualization if this is a virtual machine, then run this +script again. +'@ + +function Install-DevConfigWslPlatform { + param( + [Parameter(Mandatory)] [string] $OrchestratorPath + ) + + Install-DevConfigWslComponents + if (Test-DevConfigVmComputePresent) { + return + } + + # One restart activates the components. If they are still inactive after it, another restart + # would only repeat the same result, so stop with an explanation instead of rebooting in a loop. + if ($Script:DevConfigResumed) { + throw $Script:DevConfigWslInactiveMessage + } + + # Never returns: registers the resume task, reboots, and exits this process. + Suspend-DevConfigForReboot -ScriptPath $OrchestratorPath +} + function Invoke-WslPhase { param( [Parameter(Mandatory)] [string] $OrchestratorPath ) + # ArgumentList binds the orchestrator path at call time instead of relying on closure capture. $steps = @( New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' ` -Check { Test-DevConfigVmComputePresent } ` - -Apply { - Install-DevConfigWslComponents - if (-not (Test-DevConfigVmComputePresent)) { - # Never returns: registers the resume task, reboots, and exits this process. - Suspend-DevConfigForReboot -ScriptPath $OrchestratorPath - } - } + -Apply { param($OrchestratorPath) Install-DevConfigWslPlatform -OrchestratorPath $OrchestratorPath } ` + -ArgumentList @($OrchestratorPath) New-DevConfigStep -Name 'WslUbuntu' -Description 'Install the default Ubuntu distro' ` -Check { Test-DevConfigUbuntuInstalled } ` -Apply { Install-DevConfigUbuntu } From 6a0f25bb9f4e745fcd6de9de65a8a50070517ad6 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:24:33 -0700 Subject: [PATCH 05/19] Enhance experience --- src/windows-dev-config/dev-config.ps1 | 22 +- src/windows-dev-config/steps/_elevation.ps1 | 74 +++++- src/windows-dev-config/steps/_environment.ps1 | 118 ++++++++- .../steps/_pwsh-bootstrap.ps1 | 14 +- .../steps/_reboot-resume.ps1 | 24 +- .../steps/_resume-wrapper.ps1 | 2 +- src/windows-dev-config/steps/_retry.ps1 | 11 +- src/windows-dev-config/steps/_step-runner.ps1 | 9 +- src/windows-dev-config/steps/_terminal.ps1 | 20 +- src/windows-dev-config/steps/_winget.ps1 | 217 ++++++++++++++++ src/windows-dev-config/steps/copilot.ps1 | 89 ++++--- src/windows-dev-config/steps/fonts.ps1 | 19 +- src/windows-dev-config/steps/packages.ps1 | 102 ++------ .../steps/powershell-profile.ps1 | 15 +- src/windows-dev-config/steps/terminal.ps1 | 20 +- src/windows-dev-config/steps/wsl.ps1 | 231 +++++++++++++++--- 16 files changed, 791 insertions(+), 196 deletions(-) create mode 100644 src/windows-dev-config/steps/_winget.ps1 diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index 46a2aea..c29713e 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -36,14 +36,26 @@ $stepsDir = Join-Path $PSScriptRoot 'steps' . (Join-Path $stepsDir '_environment.ps1') . (Join-Path $stepsDir '_retry.ps1') . (Join-Path $stepsDir '_terminal.ps1') +. (Join-Path $stepsDir '_winget.ps1') . (Join-Path $stepsDir '_pwsh-bootstrap.ps1') -Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate +# Everything downstream downloads something, so settle the transport before the first request. +Enable-DevConfigModernTls + +Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate -Resumed:$Resumed # WinGet's PowerShell module is unreliable on Windows PowerShell, so get onto PowerShell 7 before anything else. Invoke-DevConfigEnsurePwsh -ScriptPath $PSCommandPath -Resumed:$Resumed # Past both relaunches, so this is the process that does the work and owns the log file. +if (-not (Enter-DevConfigSingleInstance)) { + Write-Host '' + Write-Host 'Calm OS setup is already running in another window.' -ForegroundColor Yellow + Write-Host 'Switch to it rather than starting a second copy -- they would fight over the same installs.' -ForegroundColor DarkGray + Wait-DevConfigKeyPress + exit 1 +} + Start-DevConfigLog -Path (Join-Path $PSScriptRoot 'devconfig-log.txt') -Append:$Resumed # Whether this is a fresh start or the post-reboot resume, any leftover task is done with. @@ -115,6 +127,11 @@ try { $summaryParts += "$($tally.Warned) flagged" } Write-Host " $($summaryParts -join ', ')" -ForegroundColor DarkGray + # Naming them beats a bare count: the flags themselves scrolled past a long time ago. + if ($tally.Warned -gt 0) { + Write-Host " Flagged: $($Script:DevConfigWarnedSteps -join ', ')" -ForegroundColor Yellow + Write-Host ' These were skipped or could not be confirmed. Running this again retries just those.' -ForegroundColor DarkGray + } Write-Host ' A few Explorer and taskbar changes appear once you sign out and back in.' -ForegroundColor DarkGray } catch { $failure = $_ @@ -136,6 +153,9 @@ if ($logPath) { Write-Host " Full log: $logPath" -ForegroundColor DarkGray } +# The work is done and the summary is on screen; let the next run start even while this window waits. +Exit-DevConfigSingleInstance + if (-not $Script:DevConfigResumed) { # When resumed, the wrapper's own window owns the final pause instead (see _resume-wrapper.ps1). Wait-DevConfigKeyPress diff --git a/src/windows-dev-config/steps/_elevation.ps1 b/src/windows-dev-config/steps/_elevation.ps1 index 3c6bd5d..bb1b488 100644 --- a/src/windows-dev-config/steps/_elevation.ps1 +++ b/src/windows-dev-config/steps/_elevation.ps1 @@ -1,11 +1,54 @@ <# .SYNOPSIS - Admin check plus a one-time elevation relaunch, so the whole flow needs only a single UAC prompt. + Getting a run started safely: the admin check, the one-time elevation relaunch so the whole flow + needs only a single UAC prompt, and the guard that stops two copies running over each other. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +$Script:DevConfigRunMutex = $null + +# Two copies at once (an impatient double-click, or a manual start while the post-reboot resume is +# already going) collide inside WinGet and the registry, and the errors that come back explain +# nothing. Machine-wide scope, because the changes themselves are machine-wide. +# Held until the process exits: Windows releases a mutex automatically when its owner dies, so a +# crashed run can never leave the next one locked out. +function Enter-DevConfigSingleInstance { + $mutex = [System.Threading.Mutex]::new($false, 'Global\WindowsDevConfigSetup') + try { + $acquired = $mutex.WaitOne(0) + } catch [System.Threading.AbandonedMutexException] { + # The previous owner exited without releasing it, which means ownership passed to us. + $acquired = $true + } + + if (-not $acquired) { + $mutex.Dispose() + return $false + } + + $Script:DevConfigRunMutex = $mutex + return $true +} + +# The lock is only there to stop two runs doing work at the same time. Once the summary is printed +# the run is over and the window is merely waiting to be dismissed, so holding the lock through that +# pause would tell the next run "already running in another window" for up to fifteen minutes after +# this one finished -- with no log written, because the guard sits before logging starts. +function Exit-DevConfigSingleInstance { + if (-not $Script:DevConfigRunMutex) { + return + } + try { + $Script:DevConfigRunMutex.ReleaseMutex() + } catch { + Write-Verbose "The run lock was already released: $($_.Exception.Message)" + } + $Script:DevConfigRunMutex.Dispose() + $Script:DevConfigRunMutex = $null +} + function Test-DevConfigIsAdmin { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() $principal = [System.Security.Principal.WindowsPrincipal]::new($id) @@ -17,10 +60,26 @@ function Get-DevConfigShellExe { if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } } +# Start-Process joins -ArgumentList with spaces and quotes nothing itself, so an unquoted script path +# under C:\Users\First Last\Desktop is read as two arguments and the relaunch dies before it starts. +# Every relaunch (elevation, the PowerShell 7 switchover, the post-reboot resume) goes through here. +function Get-DevConfigRelaunchArguments { + param( + [Parameter(Mandatory)] [string] $ScriptPath, + [switch] $Resumed + ) + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$ScriptPath`"", '-NoElevate') + if ($Resumed) { + $arguments += '-Resumed' + } + return $arguments +} + function Invoke-DevConfigElevate { param( [Parameter(Mandatory)] [string] $ScriptPath, - [switch] $NoElevate + [switch] $NoElevate, + [switch] $Resumed ) if (Test-DevConfigIsAdmin) { @@ -33,15 +92,20 @@ function Invoke-DevConfigElevate { Write-Host 'This needs to run elevated once (a UAC prompt will appear)...' -ForegroundColor Yellow - $shell = Get-DevConfigShellExe - $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') + $shell = Get-DevConfigShellExe + # Carrying -Resumed across matters: without it the relaunched run believes it is a first run and + # asks for the WSL reboot all over again, which is a reboot loop rather than a finished setup. + $relaunchArgs = Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed:$Resumed try { $proc = Start-Process -FilePath $shell -ArgumentList $relaunchArgs -Verb RunAs -Wait -PassThru } catch { - # Declining the UAC prompt lands here; it's a choice, not a crash, so say so plainly. + # Declining the UAC prompt lands here; it's a choice, not a crash, so say so plainly. The + # pause matters as much as the words: launched from Explorer this is the only window there + # is, and exiting straight away would take the explanation off screen with it. Write-Host '' Write-Host 'Setup needs Administrator rights to continue, so nothing was changed.' -ForegroundColor Yellow Write-Host 'Run it again and accept the prompt, or start it from an elevated terminal.' -ForegroundColor Yellow + Wait-DevConfigKeyPress exit 1 } diff --git a/src/windows-dev-config/steps/_environment.ps1 b/src/windows-dev-config/steps/_environment.ps1 index 767e1ee..c5b3f1c 100644 --- a/src/windows-dev-config/steps/_environment.ps1 +++ b/src/windows-dev-config/steps/_environment.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS - Refreshes this process's PATH from the registry, so tools installed earlier in the same run become runnable. + Process-level environment fixes: refreshing PATH so tools installed earlier in the same run become + runnable, raising TLS so every download in the run can reach a modern HTTPS endpoint, running + native commands safely, and reading and writing text files without corrupting them. #> $ErrorActionPreference = 'Stop' @@ -9,5 +11,117 @@ Set-StrictMode -Version Latest function Update-DevConfigSessionPath { $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') - $env:Path = @($machinePath, $userPath) -join ';' + # A machine with no per-user PATH is normal; joining it in blind would leave a stray separator. + $env:Path = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' +} + +# Windows PowerShell 5.1 turns any native command that writes to stderr into a terminating +# NativeCommandError once its output is merged with 2>&1, and PowerShell 7 can be configured to treat +# a non-zero exit code the same way. Both fire before the exit code can be read, which is the one +# signal that is actually stable across tool versions and locales. Preference variables assigned here +# are function-scoped, so they shadow the caller's values only for the duration of the call. +function Invoke-DevConfigNativeCommand { + param( + [Parameter(Mandatory)] [string] $FilePath, + [string[]] $Arguments = @() + ) + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + + $output = & $FilePath @Arguments 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } +} + +# Some of the installers this script drives -- wsl --install above all -- can block forever: they +# reach out to the Store, or raise a window on a desktop nobody is watching, and Start-Process -Wait +# has no way to give up. A run that sits on one line for an hour is worse than one that fails, so the +# wait is bounded and the process is stopped when it overruns; callers treat that as "try another way". +# The heartbeat exists because these are the longest steps in the run, and silence reads as a freeze. +function Invoke-DevConfigProcess { + param( + [Parameter(Mandatory)] [string] $FilePath, + [string[]] $Arguments = @(), + [Parameter(Mandatory)] [int] $TimeoutSeconds, + [switch] $NoNewWindow, + [string] $RedirectStandardOutput, + [string] $RedirectStandardError + ) + $start = @{ FilePath = $FilePath; PassThru = $true } + if ($Arguments.Count) { $start.ArgumentList = $Arguments } + if ($NoNewWindow) { $start.NoNewWindow = $true } + if ($RedirectStandardOutput) { $start.RedirectStandardOutput = $RedirectStandardOutput } + if ($RedirectStandardError) { $start.RedirectStandardError = $RedirectStandardError } + + $process = Start-Process @start + # Touching Handle caches it while the process is alive. Without that, Windows PowerShell releases + # the handle on exit and ExitCode reads back as nothing at all, so every caller here would decide + # a perfectly successful install had failed. + try { $null = $process.Handle } catch { Write-Verbose "Could not hold a handle on $FilePath." } + $startedAt = Get-Date + $deadline = $startedAt.AddSeconds($TimeoutSeconds) + $nextBeat = $startedAt.AddSeconds(60) + while (-not $process.HasExited) { + $now = Get-Date + if ($now -ge $deadline) { + try { $process.Kill() } catch { Write-Verbose "Could not stop $FilePath : $($_.Exception.Message)" } + $minutes = [Math]::Round($TimeoutSeconds / 60) + # A TimeoutException rather than a plain string: this is the one failure that must not be + # retried, and the retry helper decides that by type rather than by matching on wording. + throw [System.TimeoutException]::new("$FilePath did not finish within $minutes minutes, so it was stopped.") + } + if ($now -ge $nextBeat) { + Write-Host " still working -- $([int]($now - $startedAt).TotalMinutes)m so far" -ForegroundColor DarkGray + $nextBeat = $now.AddSeconds(60) + } + Start-Sleep -Milliseconds 500 + } + $process.WaitForExit() + return $process.ExitCode +} + +# Windows PowerShell 5.1 on older Windows still negotiates TLS 1.0 by default, which the PowerShell +# Gallery, GitHub releases and githubassets all refuse -- and they refuse it as a connection failure, +# so it surfaces as "the network is down" rather than anything actionable. Raised once for the whole +# process so every download in the run benefits, not just the first one that thought to ask. +function Enable-DevConfigModernTls { + try { + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch { + Write-Verbose "Could not raise the TLS version: $($_.Exception.Message)" + } +} + +# Every file this script edits -- settings.json, the PowerShell profile -- is UTF-8 without a BOM, +# and is read here only to be written back. Get-Content on Windows PowerShell 5.1 decodes such a file +# using the system ANSI code page, so a profile name, font face or comment holding any non-ASCII +# character comes back as mojibake and is then saved that way, permanently damaging the user's file. +# ReadAllText honours a BOM when there is one and falls back to UTF-8, which is right on both editions. +# $null for a missing file matches Get-Content -Raw, so callers keep their existing "nothing yet" test. +function Read-DevConfigTextFile { + param( + [Parameter(Mandatory)] [string] $Path + ) + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + return [System.IO.File]::ReadAllText($Path) +} + +# The write half of the same story, plus atomicity. Set-Content truncates before writing, so an +# interruption mid-write leaves a zero-byte file and the reading app silently falls back to its +# defaults; writing beside the target and renaming means the file is only ever whole. -Encoding UTF8 +# is inconsistent across editions too: 5.1 emits a BOM, 7 does not, and Windows Terminal wants none. +function Write-DevConfigTextFile { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $Content + ) + $parent = Split-Path -Parent $Path + if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + $temp = "$Path.new" + [System.IO.File]::WriteAllText($temp, $Content, [System.Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $temp -Destination $Path -Force } diff --git a/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 index 42c0a5b..3359677 100644 --- a/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 +++ b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 @@ -15,8 +15,13 @@ function Test-DevConfigHasPwsh { function Install-DevConfigPwshBootstrap { for ($attempt = 1; $attempt -le 2; $attempt++) { try { - winget install --id Microsoft.PowerShell --source winget --silent ` - --accept-package-agreements --accept-source-agreements --disable-interactivity | Out-Null + # Bounded: a machine whose App Installer is half-broken can leave winget waiting on the + # Store forever, and this runs before anything has been printed, so a hang here looks + # exactly like a script that never started. + Invoke-DevConfigProcess -FilePath 'winget.exe' -NoNewWindow -TimeoutSeconds 600 -Arguments @( + 'install', '--id', 'Microsoft.PowerShell', '--source', 'winget', '--silent', + '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity' + ) | Out-Null } catch { Write-Verbose "winget install Microsoft.PowerShell attempt ${attempt}: $($_.Exception.Message)" } @@ -46,13 +51,12 @@ function Invoke-DevConfigEnsurePwsh { } if (-not (Test-DevConfigHasPwsh)) { - Write-Warning 'Could not install PowerShell 7 -- continuing on Windows PowerShell.' + Write-Host 'Could not install PowerShell 7 -- carrying on with Windows PowerShell.' -ForegroundColor Yellow return } Write-Host 'Switching this setup over to PowerShell 7...' -ForegroundColor DarkCyan - $relaunchArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate') - if ($Resumed) { $relaunchArgs += '-Resumed' } + $relaunchArgs = Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed:$Resumed $proc = Start-Process -FilePath 'pwsh.exe' -ArgumentList $relaunchArgs -Wait -NoNewWindow -PassThru # The relaunch already did the work; nothing left for this (Windows PowerShell) process to do. diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 index c5820b4..2338103 100644 --- a/src/windows-dev-config/steps/_reboot-resume.ps1 +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -31,6 +31,15 @@ function Suspend-DevConfigForReboot { $trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser $principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Highest + # At the instant of logon the desktop is still assembling and the network stack often has no + # address yet, which would make the resumed run's package checks fail for no real reason. Half a + # minute costs nothing and lets the machine settle first. + try { + $trigger.Delay = 'PT30S' + } catch { + Write-Verbose "Could not delay the resume trigger: $($_.Exception.Message)" + } + Clear-DevConfigResume Register-ScheduledTask -TaskName $Script:DevConfigResumeTask -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null Save-DevConfigTally -Path (Join-Path (Split-Path -Path $ScriptPath -Parent) 'devconfig-tally.json') @@ -39,7 +48,20 @@ function Suspend-DevConfigForReboot { Write-Host 'WSL needs a restart to finish. Rebooting in 10s -- setup continues automatically' -ForegroundColor Yellow Write-Host 'after you log back in. This is expected, not an error.' -ForegroundColor Yellow Start-Sleep -Seconds 10 - Restart-Computer -Force + + # Group policy or a pending servicing operation can refuse the restart. The resume task is already + # registered at this point, so a manual restart picks up exactly where an automatic one would have. + try { + Restart-Computer -Force + } catch { + Write-Host '' + Write-Host "Windows would not let setup restart this machine ($($_.Exception.Message))." -ForegroundColor Yellow + Write-Host 'Restart when convenient -- setup carries on by itself once you log back in.' -ForegroundColor Yellow + # The restart is the one thing left for the user to do, so keep the window up long enough to + # read it rather than closing on the only instruction that still matters. + Wait-DevConfigKeyPress + exit 0 + } # Restart-Computer -Force signals the reboot but returns immediately; sleep so this # process doesn't fall through to code that assumes the reboot already happened. diff --git a/src/windows-dev-config/steps/_resume-wrapper.ps1 b/src/windows-dev-config/steps/_resume-wrapper.ps1 index a130112..d9e5287 100644 --- a/src/windows-dev-config/steps/_resume-wrapper.ps1 +++ b/src/windows-dev-config/steps/_resume-wrapper.ps1 @@ -31,7 +31,7 @@ Remove-Item $masterLog, $innerOut, $innerErr -ErrorAction SilentlyContinue $shell = Get-DevConfigShellExe $proc = Start-Process -FilePath $shell ` - -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $ScriptPath, '-NoElevate', '-Resumed') ` + -ArgumentList (Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed) ` -RedirectStandardOutput $innerOut -RedirectStandardError $innerErr -NoNewWindow -PassThru # Tee: mirror new lines to the console (visible on screen) and one combined log file. diff --git a/src/windows-dev-config/steps/_retry.ps1 b/src/windows-dev-config/steps/_retry.ps1 index 5187bc9..77942eb 100644 --- a/src/windows-dev-config/steps/_retry.ps1 +++ b/src/windows-dev-config/steps/_retry.ps1 @@ -21,10 +21,19 @@ function Invoke-DevConfigRetry { & $ScriptBlock return } catch { + # A step that had to be stopped for running too long already had its full allowance, so + # trying it a second time only spends that allowance again before reaching the same + # fallback. Give up on it immediately and let the caller take the other route. + if ($_.Exception -is [System.TimeoutException]) { + throw + } if ($attempt -ge $MaxAttempts) { throw } - Write-Warning "${Name}: attempt $attempt failed ($($_.Exception.Message)); retrying in ${delay}s..." + # Write-Host, not Write-Warning: the warning stream becomes stderr once this process is + # relaunched with redirected output after the reboot, and would then only surface at the + # very end, in red, long after the retry it describes. + Write-Host " ... $Name didn't take on attempt $attempt ($($_.Exception.Message)). Trying again in ${delay}s." -ForegroundColor DarkYellow Start-Sleep -Seconds $delay $delay = $delay * 2 } diff --git a/src/windows-dev-config/steps/_step-runner.ps1 b/src/windows-dev-config/steps/_step-runner.ps1 index 3988ad5..1d13b95 100644 --- a/src/windows-dev-config/steps/_step-runner.ps1 +++ b/src/windows-dev-config/steps/_step-runner.ps1 @@ -116,6 +116,9 @@ function New-DevConfigStep { } # Dedup by name: a permanently-blocked step would otherwise flag again every leg, forever. +# Printed with Write-Host rather than Write-Warning on purpose: after the reboot this process writes +# to a pipe, and PowerShell puts the warning stream on stderr, which the resume wrapper can only show +# once the run is over. A flag that matters mid-run has to appear where it happened. function Write-DevConfigStepFlag { param( [Parameter(Mandatory)] [string] $Name, @@ -126,8 +129,8 @@ function Write-DevConfigStepFlag { $Script:DevConfigWarnedSteps += $Name } $Script:DevConfigTally.Warned = $Script:DevConfigWarnedSteps.Count - Write-Warning "${Name}: $Message" - Write-Host " ! $Label flagged (see warning above)" -ForegroundColor Yellow + Write-Host " ! $Label flagged" -ForegroundColor Yellow + Write-Host " $Message" -ForegroundColor Yellow } # Called by a step's Apply when the work went through but couldn't be confirmed -- e.g. WinGet's @@ -163,7 +166,7 @@ function Invoke-DevConfigSteps { $stepArgs = $step.ArgumentList $alreadyDone = [bool](& $step.Check @stepArgs) } catch { - Write-Warning "$($step.Name): Check threw ($($_.Exception.Message)); applying anyway." + Write-Host " ? $($step.Name): couldn't tell whether this was already done ($($_.Exception.Message)); doing it anyway." -ForegroundColor DarkYellow } # Tallied here (not in the print loop below) so a collapsed/silent-skipped phase still counts correctly. if ($alreadyDone) { diff --git a/src/windows-dev-config/steps/_terminal.ps1 b/src/windows-dev-config/steps/_terminal.ps1 index 9d98a08..4150543 100644 --- a/src/windows-dev-config/steps/_terminal.ps1 +++ b/src/windows-dev-config/steps/_terminal.ps1 @@ -65,7 +65,7 @@ function Read-DevConfigTerminalSettings { # Get-Content -Raw hands back $null (not an empty string) for a zero-byte file, and a write # interrupted partway through leaves exactly that. - $raw = Get-Content -LiteralPath $Path -Raw + $raw = Read-DevConfigTextFile -Path $Path if ([string]::IsNullOrWhiteSpace($raw)) { return [pscustomobject]@{} } @@ -76,7 +76,15 @@ function Read-DevConfigTerminalSettings { if ([string]::IsNullOrWhiteSpace($clean)) { return [pscustomobject]@{} } - return $clean | ConvertFrom-Json + + # A hand-edited settings.json can be genuinely invalid. Treating that as "no settings yet" would + # overwrite the user's file, so stop instead, and say which file and why rather than surfacing a + # parser's character offset. + try { + return $clean | ConvertFrom-Json + } catch { + throw "Windows Terminal's settings file couldn't be read as JSON, so it was left untouched. Fix or rename $Path and run this again." + } } # Keeps a .bak alongside the file: the JSONC round-trip above drops any comments the user had written. @@ -85,15 +93,11 @@ function Save-DevConfigTerminalSettings { [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [object] $Settings ) - $parent = Split-Path -Parent $Path - if (-not (Test-Path -LiteralPath $parent)) { - New-Item -ItemType Directory -Path $parent -Force | Out-Null - } if (Test-Path -LiteralPath $Path) { Copy-Item -LiteralPath $Path -Destination "$Path.bak" -Force } - $Settings | ConvertTo-Json -Depth $Script:DevConfigTerminalJsonDepth | - Set-Content -LiteralPath $Path -Encoding UTF8 + $json = $Settings | ConvertTo-Json -Depth $Script:DevConfigTerminalJsonDepth + Write-DevConfigTextFile -Path $Path -Content $json } # Walks an object path such as profiles -> defaults -> font, creating any level that's missing, diff --git a/src/windows-dev-config/steps/_winget.ps1 b/src/windows-dev-config/steps/_winget.ps1 new file mode 100644 index 0000000..9c7e65d --- /dev/null +++ b/src/windows-dev-config/steps/_winget.ps1 @@ -0,0 +1,217 @@ +<# +.SYNOPSIS + How this script talks to WinGet: acquiring a working front end, repairing a broken install, + and querying or installing individual packages. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# WinGet has two front ends: the PowerShell module (preferred -- structured results, nothing to +# parse) and winget.exe. The module comes from the PowerShell Gallery, which a proxied, offline or +# locked-down network may not reach, so fall back to the CLI that already ships with Windows rather +# than failing the whole run over it. +$Script:DevConfigWinGetMode = 'Module' + +# The health probe is worth doing once per run, not once per caller. +$Script:DevConfigWingetRepairChecked = $false + +# WinGet's own exit codes, which are stable across locales -- unlike its console text. +$Script:DevConfigWingetNotFound = -1978335212 # 0x8A150014 no installed package matched +$Script:DevConfigWingetNoUpgrade = -1978335189 # 0x8A15002B already at the latest applicable version + +function Install-DevConfigWinGetModule { + Enable-DevConfigModernTls + + # A fresh machine can prompt to install the NuGet provider on first use; bootstrap it non-interactively first. + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force -ErrorAction Stop | Out-Null + } + if (-not (Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue)) { + Register-PSRepository -Default -ErrorAction Stop + } + + # A VPN reconnect or a waking proxy routinely outlasts a couple of seconds, and this is the most + # network-dependent call in the whole run. + Invoke-DevConfigRetry -Name 'WinGet module download' -MaxAttempts 4 -InitialDelaySeconds 10 -ScriptBlock { + Install-Module -Name Microsoft.WinGet.Client -Repository PSGallery -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop | Out-Null + } +} + +# Safe to call repeatedly: the second call onwards is a no-op once a front end is chosen. +function Initialize-DevConfigWinGet { + if (Get-Module -Name Microsoft.WinGet.Client) { + return + } + if ($Script:DevConfigWinGetMode -eq 'Cli') { + return + } + + if (Get-Module -ListAvailable -Name Microsoft.WinGet.Client) { + try { + Import-Module -Name Microsoft.WinGet.Client -ErrorAction Stop + $Script:DevConfigWinGetMode = 'Module' + return + } catch { + # A module folder left half-written by an interrupted run imports no better on a second + # attempt, but reinstalling over it usually does fix it -- so fall through rather than + # failing phase 1 outright with winget.exe sitting right there unused. + $reason = $_.Exception.Message + Write-Host ' The WinGet module is installed but did not load -- reinstalling it.' -ForegroundColor Yellow + } + } + + Write-Host ' Setting up the WinGet PowerShell module...' -ForegroundColor DarkCyan + Write-Host ' (First time only. This can take a few minutes.)' -ForegroundColor DarkGray + try { + Install-DevConfigWinGetModule + Import-Module -Name Microsoft.WinGet.Client -ErrorAction Stop + $Script:DevConfigWinGetMode = 'Module' + return + } catch { + $reason = $_.Exception.Message + } + + if (-not (Test-DevConfigWingetCliUsable)) { + throw "The WinGet PowerShell module isn't usable on this machine ($reason), and the built-in winget command isn't working either. Check your internet connection or proxy settings, then run this again." + } + + Write-Host ' Using the built-in winget command instead.' -ForegroundColor Yellow + Write-Verbose "WinGet module unavailable: $reason" + $Script:DevConfigWinGetMode = 'Cli' +} + +# Exit code, not console text: winget's output is localised and reformatted between versions. +function Invoke-DevConfigWingetCli { + param( + [Parameter(Mandatory)] [string[]] $Arguments + ) + return Invoke-DevConfigNativeCommand -FilePath 'winget.exe' -Arguments $Arguments +} + +# winget.exe on PATH is an App Execution Alias: a zero-byte stub that satisfies Get-Command even when +# the App Installer package behind it isn't registered for this account -- which is one of the very +# failure modes this script exists to survive. Launching such a stub fails outright instead of +# returning an exit code, so only a real invocation settles whether the CLI is usable. +function Test-DevConfigWingetCliUsable { + if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { + return $false + } + try { + return (Invoke-DevConfigWingetCli -Arguments @('--version')).ExitCode -eq 0 + } catch { + Write-Verbose "winget.exe is present but could not run: $($_.Exception.Message)" + return $false + } +} + +# Best-effort: fixes the odd App Execution Alias glitches winget occasionally hits, before any real installs start. +# Get-WinGetVersion is a quick health check -- only pay for the slower repair when it says WinGet isn't responding. +function Repair-DevConfigWinget { + if ($Script:DevConfigWingetRepairChecked) { + return + } + $Script:DevConfigWingetRepairChecked = $true + + if ($Script:DevConfigWinGetMode -eq 'Cli') { + # Repair-WinGetPackageManager has no CLI equivalent, and CLI mode is only ever entered after + # winget.exe has answered a version probe, so there is nothing left to repair here. + Write-Host ' Using the built-in winget command.' -ForegroundColor DarkGray + return + } + + try { + $version = Get-WinGetVersion -ErrorAction Stop + Write-Host " WinGet $version looks healthy -- skipping repair." -ForegroundColor DarkGray + return + } catch { + Write-Host ' WinGet is not responding as expected -- repairing...' -ForegroundColor DarkCyan + Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray + } + + try { + Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null + Write-Host ' WinGet repair finished.' -ForegroundColor DarkGray + return + } catch { + Write-Host " WinGet repair did not complete: $($_.Exception.Message)" -ForegroundColor Yellow + } + + # Repair failed outright, so the module front end is unlikely to work for any package. winget.exe + # is a separate implementation and usually still answers; switching now beats letting every + # package step fail one at a time for the same underlying reason. + if (Test-DevConfigWingetCliUsable) { + Write-Host ' Falling back to the built-in winget command instead.' -ForegroundColor Yellow + $Script:DevConfigWinGetMode = 'Cli' + } else { + Write-Host ' WinGet could not be repaired and winget.exe is unavailable; the package steps below may not succeed.' -ForegroundColor Yellow + } +} + +function Test-DevConfigWingetPackageInstalled { + param( + [Parameter(Mandatory)] [string] $Id + ) + if ($Script:DevConfigWinGetMode -eq 'Cli') { + $listed = Invoke-DevConfigWingetCli -Arguments @('list', '--id', $Id, '--exact', '--accept-source-agreements') + if ($listed.ExitCode -eq $Script:DevConfigWingetNotFound) { + return $false + } + if ($listed.ExitCode -ne 0) { + throw "winget list $Id failed with exit code $($listed.ExitCode)" + } + # No upgrade probe here: winget list --upgrade-available exits 0 for any installed package, + # upgrade or not, so testing its exit code marked every package as missing and reinstalled + # and flagged all of them on every run. Installed is the bar the CLI can actually answer for. + return $true + } + + # EqualsCaseInsensitive avoids ambiguous substring matches (e.g. an MSIX-correlated entry sharing the same Id text). + $pkg = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive + if (-not $pkg) { + return $false + } + + # useLatest: true in the original -- an available upgrade means this step isn't satisfied yet. + return -not $pkg.IsUpdateAvailable +} + +function Install-DevConfigWingetPackage { + param( + [Parameter(Mandatory)] [string] $Id + ) + Invoke-DevConfigRetry -Name "winget install $Id" -ScriptBlock { + if ($Script:DevConfigWinGetMode -eq 'Cli') { + $r = Invoke-DevConfigWingetCli -Arguments @('install', '--id', $Id, '--exact', '--source', 'winget', '--silent', '--accept-package-agreements', '--accept-source-agreements') + if ($r.ExitCode -ne 0 -and $r.ExitCode -ne $Script:DevConfigWingetNoUpgrade) { + throw "winget install $Id failed with exit code $($r.ExitCode)" + } + return + } + + $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive + # NoApplicableUpgrade: already installed and up to date, not a failure (module's equivalent of the + # CLI's APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE exit code). + if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { + throw "winget install $Id failed: $($result.ErrorMessage())" + } + } +} + +# Get-WinGetPackage's catalog read can lag right after a successful install (an upstream WinGet +# quirk, not specific to one PowerShell edition), so give it a moment before judging the result. +function Wait-DevConfigWingetPackageSettled { + param( + [Parameter(Mandatory)] [string] $Id + ) + for ($attempt = 1; $attempt -le 5; $attempt++) { + if (Test-DevConfigWingetPackageInstalled -Id $Id) { + return + } + if ($attempt -eq 1) { + Write-Host ' (Installed -- just waiting for it to finish registering...)' -ForegroundColor DarkGray + } + Start-Sleep -Seconds 3 + } + Set-DevConfigStepUnverified -Reason "WinGet reported $Id installed, but its catalog still doesn't list it as current 15s later. It's on the machine -- re-run to confirm." +} diff --git a/src/windows-dev-config/steps/copilot.ps1 b/src/windows-dev-config/steps/copilot.ps1 index 38e00a6..37476cf 100644 --- a/src/windows-dev-config/steps/copilot.ps1 +++ b/src/windows-dev-config/steps/copilot.ps1 @@ -21,26 +21,32 @@ function Set-DevConfigCopilotTerminalProfile { $fragmentsDir = Get-DevConfigCopilotFragmentDir New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null - # Icon lives alongside the fragment file so its relative path resolves correctly. + # Icon lives alongside the fragment file so its relative path resolves correctly. A missing icon + # only costs the profile its picture, so a download failure must not fail the step. $iconPath = Join-Path $fragmentsDir 'copilot.png' - Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing - - $fragment = @{ - profiles = @( - @{ - guid = $Script:CopilotFragmentGuid - name = 'GitHub Copilot' - commandline = 'pwsh.exe -NoExit -Command "copilot"' - icon = 'copilot.png' - startingDirectory = '%USERPROFILE%' - hidden = $false - tabTitle = 'Copilot' - } - ) + $iconName = $null + try { + Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing -TimeoutSec 60 + $iconName = 'copilot.png' + } catch { + Write-Host " (Couldn't download the Copilot icon -- the profile will use the default one.)" } + $profileEntry = [ordered]@{ + guid = $Script:CopilotFragmentGuid + name = 'GitHub Copilot' + commandline = 'pwsh.exe -NoExit -Command "copilot"' + startingDirectory = '%USERPROFILE%' + hidden = $false + tabTitle = 'Copilot' + } + if ($iconName) { + $profileEntry['icon'] = $iconName + } + $fragment = @{ profiles = @($profileEntry) } + $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' - $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 + Write-DevConfigTextFile -Path $fragmentFile -Content ($fragment | ConvertTo-Json -Depth 8) # Touch settings.json so Windows Terminal's hot-reload re-scans Fragments\*.json. @( @@ -56,38 +62,59 @@ function Set-DevConfigCopilotTerminalProfile { } function Test-DevConfigWinUITemplatesInstalled { - return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) + if (-not (Get-Command 'dotnet' -ErrorAction SilentlyContinue)) { + return $false + } + $r = Invoke-DevConfigNativeCommand -FilePath 'dotnet' -Arguments @('new', 'list') + return $r.ExitCode -eq 0 -and $r.Output -match '(?i)winui' } function Install-DevConfigWinUITemplates { - $output = dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - Write-Host $output - throw "dotnet new install failed with exit code $LASTEXITCODE" + if (-not (Get-Command 'dotnet' -ErrorAction SilentlyContinue)) { + throw 'dotnet is not on PATH yet, so the WinUI templates cannot be installed. Re-run once the .NET SDK is in place.' + } + $r = Invoke-DevConfigNativeCommand -FilePath 'dotnet' -Arguments @('new', 'install', 'Microsoft.WindowsAppSDK.WinUI.CSharp.Templates') + if ($r.ExitCode -ne 0) { + Write-Host $r.Output + throw "dotnet new install failed with exit code $($r.ExitCode)" } } function Test-DevConfigWinSkillsMarketplaceAdded { - return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') + if (-not (Get-Command 'copilot' -ErrorAction SilentlyContinue)) { + return $false + } + $r = Invoke-DevConfigNativeCommand -FilePath 'copilot' -Arguments @('plugin', 'marketplace', 'list') + return $r.ExitCode -eq 0 -and $r.Output -match 'win-dev-skills' } function Add-DevConfigWinSkillsMarketplace { - $output = copilot plugin marketplace add microsoft/win-dev-skills 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - Write-Host $output - throw "copilot plugin marketplace add failed with exit code $LASTEXITCODE" + if (-not (Get-Command 'copilot' -ErrorAction SilentlyContinue)) { + throw 'The copilot command is not on PATH yet, so its marketplace cannot be configured. Re-run once GitHub Copilot CLI is in place.' + } + $r = Invoke-DevConfigNativeCommand -FilePath 'copilot' -Arguments @('plugin', 'marketplace', 'add', 'microsoft/win-dev-skills') + if ($r.ExitCode -ne 0) { + Write-Host $r.Output + throw "copilot plugin marketplace add failed with exit code $($r.ExitCode)" } } function Test-DevConfigWinUIPluginInstalled { - return [bool](copilot plugin list 2>&1 | Select-String 'winui') + if (-not (Get-Command 'copilot' -ErrorAction SilentlyContinue)) { + return $false + } + $r = Invoke-DevConfigNativeCommand -FilePath 'copilot' -Arguments @('plugin', 'list') + return $r.ExitCode -eq 0 -and $r.Output -match '(?i)winui' } function Install-DevConfigWinUIPlugin { - $output = copilot plugin install winui@win-dev-skills 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - Write-Host $output - throw "copilot plugin install winui failed with exit code $LASTEXITCODE" + if (-not (Get-Command 'copilot' -ErrorAction SilentlyContinue)) { + throw 'The copilot command is not on PATH yet, so the WinUI plugin cannot be installed. Re-run once GitHub Copilot CLI is in place.' + } + $r = Invoke-DevConfigNativeCommand -FilePath 'copilot' -Arguments @('plugin', 'install', 'winui@win-dev-skills') + if ($r.ExitCode -ne 0) { + Write-Host $r.Output + throw "copilot plugin install winui failed with exit code $($r.ExitCode)" } } diff --git a/src/windows-dev-config/steps/fonts.ps1 b/src/windows-dev-config/steps/fonts.ps1 index a8c0e0a..b9a7059 100644 --- a/src/windows-dev-config/steps/fonts.ps1 +++ b/src/windows-dev-config/steps/fonts.ps1 @@ -36,15 +36,18 @@ function Install-DevConfigCascadiaFonts { New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null Write-Host "Downloading $zipUrl ..." + Write-Host ' (About 10 MB from GitHub. This usually takes a few seconds.)' -ForegroundColor DarkGray $ProgressPreference = 'SilentlyContinue' - Invoke-DevConfigRetry -Name 'Cascadia fonts download' -ScriptBlock { - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - } - $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash - if ($actualHash -ne $Script:CascadiaZipSha256) { - Remove-Item $zipPath -Force - throw "Hash mismatch for CascadiaCode-$version.zip: expected $($Script:CascadiaZipSha256), got $actualHash" + # Bounded and hash-checked inside the retry: a stalled CDN connection would otherwise hang the + # run with no way out, and a truncated download is exactly the transient failure retrying fixes. + Invoke-DevConfigRetry -Name 'Cascadia fonts download' -ScriptBlock { + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 300 + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash + if ($actualHash -ne $Script:CascadiaZipSha256) { + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + throw "the downloaded file didn't match the expected contents (expected hash $($Script:CascadiaZipSha256), got $actualHash)" + } } Add-Type -AssemblyName System.IO.Compression.FileSystem @@ -55,7 +58,7 @@ function Install-DevConfigCascadiaFonts { foreach ($name in $Script:CascadiaWantedFonts) { $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 if (-not $entry) { - Write-Warning "Not found in archive: $name" + Write-Host " ! $name is not in the downloaded archive; skipping it." -ForegroundColor Yellow continue } diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index f2dea49..f5e79f1 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -6,84 +6,9 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Required: every check/install below is a Microsoft.WinGet.Client cmdlet, so the module has to load. -function Install-DevConfigWinGetModule { - if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) { - Write-Host ' Setting up the WinGet PowerShell module...' -ForegroundColor DarkCyan - Write-Host ' (First time only. This can take a few minutes.)' -ForegroundColor DarkGray - # A fresh machine can prompt to install the NuGet provider on first use; bootstrap it non-interactively first. - if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { - Install-PackageProvider -Name NuGet -Force -ErrorAction Stop | Out-Null - } - Install-Module -Name Microsoft.WinGet.Client -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop | Out-Null - } - Import-Module -Name Microsoft.WinGet.Client -ErrorAction Stop -} - -# Best-effort: fixes the odd App Execution Alias glitches winget occasionally hits, before any real installs start. -# Get-WinGetVersion is a quick health check -- only pay for the slower repair when it says WinGet isn't responding. -function Repair-DevConfigWinget { - try { - $version = Get-WinGetVersion -ErrorAction Stop - Write-Host " WinGet $version looks healthy -- skipping repair." -ForegroundColor DarkGray - return - } catch { - Write-Host ' WinGet is not responding as expected -- repairing...' -ForegroundColor DarkCyan - Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray - } - - try { - Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null - Write-Host ' WinGet repair finished.' -ForegroundColor DarkGray - } catch { - Write-Warning "WinGet repair skipped: $($_.Exception.Message) (continuing anyway)" - } -} - -function Test-DevConfigWingetPackageInstalled { - param( - [Parameter(Mandatory)] [string] $Id - ) - # EqualsCaseInsensitive avoids ambiguous substring matches (e.g. an MSIX-correlated entry sharing the same Id text). - $pkg = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive - if (-not $pkg) { - return $false - } - - # useLatest: true in the original -- an available upgrade means this step isn't satisfied yet. - return -not $pkg.IsUpdateAvailable -} - -function Install-DevConfigWingetPackage { - param( - [Parameter(Mandatory)] [string] $Id - ) - Invoke-DevConfigRetry -Name "winget install $Id" -ScriptBlock { - $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive - # NoApplicableUpgrade: already installed and up to date, not a failure (module's equivalent of the - # CLI's APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE exit code). - if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { - throw "winget install $Id failed: $($result.ErrorMessage())" - } - } - - # Get-WinGetPackage's catalog read can lag right after a successful install (an upstream WinGet - # quirk, not specific to one PowerShell edition), so give it a moment before judging the result. - for ($attempt = 1; $attempt -le 5; $attempt++) { - if (Test-DevConfigWingetPackageInstalled -Id $Id) { - return - } - if ($attempt -eq 1) { - Write-Host ' (Installed -- just waiting for it to finish registering...)' -ForegroundColor DarkGray - } - Start-Sleep -Seconds 3 - } - Set-DevConfigStepUnverified -Reason "WinGet reported $Id installed, but its catalog still doesn't list it as current 15s later. It's on the machine -- re-run to confirm." -} - function Invoke-PackagesPhase { Show-DevConfigPhaseHeader - Install-DevConfigWinGetModule + Initialize-DevConfigWinGet Repair-DevConfigWinget $packages = @( @@ -92,8 +17,8 @@ function Invoke-PackagesPhase { @{ Name = 'Git'; Id = 'Git.Git' } @{ Name = 'GitHubCLI'; Id = 'GitHub.cli' } @{ Name = 'GitHubCopilot'; Id = 'GitHub.Copilot' } - @{ Name = 'VSCode'; Id = 'Microsoft.VisualStudioCode' } - @{ Name = 'DotnetSdk'; Id = 'Microsoft.DotNet.SDK.10' } + @{ Name = 'VSCode'; Id = 'Microsoft.VisualStudioCode'; Large = $true } + @{ Name = 'DotnetSdk'; Id = 'Microsoft.DotNet.SDK.10'; Large = $true } @{ Name = 'Python'; Id = 'Python.Python.3.14' } @{ Name = 'UV'; Id = 'astral-sh.uv' } @{ Name = 'NodeJS'; Id = 'OpenJS.NodeJS.LTS' } @@ -101,15 +26,26 @@ function Invoke-PackagesPhase { @{ Name = 'Coreutils'; Id = 'Microsoft.Coreutils' } @{ Name = 'OhMyPosh'; Id = 'JanDeDobbeleer.OhMyPosh' } @{ Name = 'winappCli'; Id = 'Microsoft.WinAppCli' } - @{ Name = 'PowerToys'; Id = 'Microsoft.PowerToys' } + @{ Name = 'PowerToys'; Id = 'Microsoft.PowerToys'; Large = $true } ) # ArgumentList binds each package's Id at call time instead of relying on closure capture. + # BestEffort: one package having a bad day upstream is no reason to abandon the other fourteen and + # the nine phases behind them. Everything that depends on a package checks for it first, and the + # summary names whatever was flagged. A WinGet that is broken outright is caught above instead, + # where it can be reported once rather than fifteen times. $steps = foreach ($pkg in $packages) { - New-DevConfigStep -Name $pkg.Name -Description "winget install $($pkg.Id)" ` - -Check { param($Id) Test-DevConfigWingetPackageInstalled -Id $Id } ` - -Apply { param($Id) Install-DevConfigWingetPackage -Id $Id } ` - -ArgumentList @($pkg.Id) + New-DevConfigStep -Name $pkg.Name -Description "winget install $($pkg.Id)" -BestEffort ` + -Check { param($Id, $Large) Test-DevConfigWingetPackageInstalled -Id $Id } ` + -Apply { + param($Id, $Large) + # WinGet gives no progress back while it downloads, and these three take long enough + # that a bare "->" line reads as a hung console. Say so before the quiet starts. + if ($Large) { Write-Host ' (Large download -- several quiet minutes here are normal.)' -ForegroundColor DarkGray } + Install-DevConfigWingetPackage -Id $Id + Wait-DevConfigWingetPackageSettled -Id $Id + } ` + -ArgumentList @($pkg.Id, $pkg.ContainsKey('Large')) } $steps += New-DevConfigStep -Name 'PowerToysAOT' -Description 'Turn off PowerToys always-on-top notifications' ` diff --git a/src/windows-dev-config/steps/powershell-profile.ps1 b/src/windows-dev-config/steps/powershell-profile.ps1 index bda9acd..47993d3 100644 --- a/src/windows-dev-config/steps/powershell-profile.ps1 +++ b/src/windows-dev-config/steps/powershell-profile.ps1 @@ -38,7 +38,7 @@ function Test-DevConfigOhMyPoshInitLinePresent { } # Scan from the end: the last non-comment matching line is what counts, matching the source resource. - $lines = @(Get-Content -LiteralPath $ProfilePath) + $lines = @((Read-DevConfigTextFile -Path $ProfilePath) -split "`r?`n") for ($i = $lines.Count - 1; $i -ge 0; $i--) { if ($lines[$i].TrimStart().StartsWith('#')) { continue @@ -64,17 +64,12 @@ function Set-DevConfigOhMyPoshProfile { throw 'pwsh.exe not found; install the PowerShell package first.' } - if (-not (Test-Path -LiteralPath $profilePath)) { - New-Item -ItemType Directory -Path (Split-Path -Parent $profilePath) -Force | Out-Null - New-Item -ItemType File -Path $profilePath -Force | Out-Null - } - if (Test-DevConfigOhMyPoshInitLinePresent -ProfilePath $profilePath) { return } # Mirrors the resource's own shellCommand(): the whole block piped to Invoke-Expression. - $content = Get-Content -LiteralPath $profilePath -Raw + $content = Read-DevConfigTextFile -Path $profilePath if (-not $content) { $content = '' } @@ -83,13 +78,15 @@ function Set-DevConfigOhMyPoshProfile { } $content += "$Script:OhMyPoshInitCommand`n | Invoke-Expression`n" - Set-Content -LiteralPath $profilePath -Value $content -NoNewline + Write-DevConfigTextFile -Path $profilePath -Content $content Write-Host "Added Oh My Posh init to $profilePath" } function Invoke-PowerShellProfilePhase { + # BestEffort: this only changes what the prompt looks like, so it must never cost the run the + # Copilot and WSL phases behind it. $steps = @( - New-DevConfigStep -Name 'OhMyPoshProfile' -Description 'Add Oh My Posh init to the PowerShell 7 profile' ` + New-DevConfigStep -Name 'OhMyPoshProfile' -Description 'Add Oh My Posh init to the PowerShell 7 profile' -BestEffort ` -Check { Test-DevConfigOhMyPoshProfileConfigured } ` -Apply { Set-DevConfigOhMyPoshProfile } ) diff --git a/src/windows-dev-config/steps/terminal.ps1 b/src/windows-dev-config/steps/terminal.ps1 index eb6c633..d7480b9 100644 --- a/src/windows-dev-config/steps/terminal.ps1 +++ b/src/windows-dev-config/steps/terminal.ps1 @@ -6,17 +6,16 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +$Script:DevConfigThemeKey = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + function Test-DevConfigDarkThemeSet { - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -ErrorAction SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -ErrorAction SilentlyContinue - return ($apps -eq 0 -and $system -eq 0) + return (Test-DevConfigRegistryValue -KeyPath $Script:DevConfigThemeKey -ValueName 'AppsUseLightTheme' -Value 0) -and + (Test-DevConfigRegistryValue -KeyPath $Script:DevConfigThemeKey -ValueName 'SystemUsesLightTheme' -Value 0) } function Set-DevConfigDarkTheme { - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - Set-ItemProperty -Path $regPath -Name 'AppsUseLightTheme' -Value 0 - Set-ItemProperty -Path $regPath -Name 'SystemUsesLightTheme' -Value 0 + Set-DevConfigRegistryValue -KeyPath $Script:DevConfigThemeKey -ValueName 'AppsUseLightTheme' -Value 0 + Set-DevConfigRegistryValue -KeyPath $Script:DevConfigThemeKey -ValueName 'SystemUsesLightTheme' -Value 0 } function Test-DevConfigPs7DefaultProfile { @@ -63,11 +62,14 @@ function Set-DevConfigPs7DefaultProfile { } function Invoke-TerminalPhase { + # Both of these are appearance preferences that nothing else depends on, and the settings file + # belongs to the user: it can be unreadable, mid-edit or held open by Windows Terminal itself. + # Letting that end the run cost the profile, Copilot and WSL phases behind it over a colour scheme. $steps = @( - New-DevConfigStep -Name 'DarkTheme' -Description 'Force dark app/system theme' ` + New-DevConfigStep -Name 'DarkTheme' -Description 'Force dark app/system theme' -BestEffort ` -Check { Test-DevConfigDarkThemeSet } ` -Apply { Set-DevConfigDarkTheme } - New-DevConfigStep -Name 'Ps7DefaultProfile' -Description 'Set PowerShell 7 as the default Windows Terminal profile' ` + New-DevConfigStep -Name 'Ps7DefaultProfile' -Description 'Set PowerShell 7 as the default Windows Terminal profile' -BestEffort ` -Check { Test-DevConfigPs7DefaultProfile } ` -Apply { Set-DevConfigPs7DefaultProfile } ) diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 index f68ae8d..fc1d38e 100644 --- a/src/windows-dev-config/steps/wsl.ps1 +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -6,35 +6,137 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -function Test-DevConfigVmComputePresent { - # vmcompute (Hyper-V Host Compute Service) only registers once Virtual Machine Platform is active. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc +# Windows sets this key while a component change waits on a restart, and clears it once the restart +# happens. It is the signal that matters straight after wsl --install: wsl.exe answers --version and +# --status perfectly well at that point, while the platform underneath it is not live yet -- which is +# how an Ubuntu install could report success and put nothing on the machine at all. Component +# servicing only, deliberately: PendingFileRenameOperations is set by ordinary app installers too +# (the fifteen packages in phase 1 among them) and would force a restart on almost every run. +function Test-DevConfigServicingRebootPending { + return (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') } +# Runs a short wsl query with its output redirected and the wait bounded, and hands back the exit +# code -- or $null when wsl.exe cannot be launched at all, which is what the App Execution Alias stub +# does on a machine that has never had WSL. Exit codes only, deliberately: wsl's text is localised. +function Get-DevConfigWslExitCode { + param( + [Parameter(Mandatory)] [string[]] $Arguments, + [int] $TimeoutSeconds = 120 + ) + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + return $null + } + + $stdout = [System.IO.Path]::GetTempFileName() + $stderr = [System.IO.Path]::GetTempFileName() + try { + return Invoke-DevConfigProcess -FilePath 'wsl.exe' -Arguments $Arguments -TimeoutSeconds $TimeoutSeconds ` + -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr + } catch { + Write-Verbose "wsl $($Arguments -join ' ') could not run: $($_.Exception.Message)" + return $null + } finally { + Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue + } +} + +# Windows ships an old WSL inside the image, and the modern WSL that can actually install and host a +# distro comes separately. Only the modern one understands --version; the inbox one answers -1. That +# single exit code is the difference that matters, and nothing else reports it honestly: on a 22H2 +# machine with the inbox WSL and no kernel at all, "wsl --status" still exits 0 and cheerfully says +# the default version is 2 -- after which every distro install fails with exit -1. +function Test-DevConfigWslPlatformActive { + return ((Get-DevConfigWslExitCode -Arguments @('--version')) -eq 0) +} + +# wsl --update fetches the modern WSL and its kernel. The Store route is tried first because it is +# the one Microsoft keeps current; --web-download is the same package without the Store, for machines +# where policy has removed it. Nothing here is fatal on its own: the caller decides what happens next. +function Update-DevConfigWslRuntime { + Write-Host ' This machine has the older WSL that ships inside Windows; a distro needs the current one.' -ForegroundColor DarkGray + Write-Host ' Updating WSL (wsl --update)...' -ForegroundColor DarkCyan + + foreach ($arguments in @(@('--update'), @('--update', '--web-download'))) { + $exitCode = Get-DevConfigWslExitCode -Arguments $arguments -TimeoutSeconds 900 + if ($exitCode -eq 0 -and (Test-DevConfigWslPlatformActive)) { + return $true + } + Write-Verbose "wsl $($arguments -join ' ') returned $exitCode" + } + + Write-Host ' WSL could not be updated here.' -ForegroundColor Yellow + return $false +} + + function Install-DevConfigWslComponents { - Invoke-DevConfigRetry -Name 'wsl --install --no-distribution' -ScriptBlock { - Write-Host 'Installing WSL platform components (wsl --install --no-distribution)...' - Write-Host '(A separate WSL window may pop up briefly -- that is normal. This can take a few minutes.)' -ForegroundColor DarkGray - # No -NoNewWindow / -Redirect*: wsl's install bootstrap needs a real console to run against. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '--no-distribution' -Wait -PassThru - if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { - Write-Host 'WSL components installed; a reboot is required to activate them.' - } elseif ($p.ExitCode -ne 0) { - throw "wsl --install --no-distribution failed with exit code $($p.ExitCode)" + try { + Invoke-DevConfigRetry -Name 'wsl --install --no-distribution' -MaxAttempts 2 -ScriptBlock { + Write-Host 'Installing WSL platform components (wsl --install --no-distribution)...' + Write-Host '(A separate WSL window may pop up briefly -- that is normal. This can take a few minutes.)' -ForegroundColor DarkGray + # No -NoNewWindow: wsl's install bootstrap needs a real console to run against. + $exitCode = Invoke-DevConfigProcess -FilePath 'wsl.exe' -Arguments @('--install', '--no-distribution') -TimeoutSeconds 900 + if ($exitCode -eq 3010 -or $exitCode -eq 1641) { + Write-Host 'WSL components installed; a reboot is required to activate them.' + $Script:DevConfigWslRestartSignalled = $true + } elseif ($exitCode -ne 0) { + throw "wsl --install --no-distribution failed with exit code $exitCode" + } + } + } catch { + # Older builds, machines where the Microsoft Store is blocked by policy, and machines where + # wsl's own bootstrap simply never returns. The underlying Windows features still get us a + # working WSL, so that path is worth taking rather than failing the phase. + Write-Host " WSL's own installer could not run here ($($_.Exception.Message))." -ForegroundColor Yellow + Write-Host ' Turning on the WSL Windows features directly instead.' -ForegroundColor Yellow + Enable-DevConfigWslFeatures + } + + # Turning the Windows features on is only half the job. A 22H2 machine that took the dism path is + # left with both features enabled, the inbox WSL, and no WSL2 kernel at all -- a state in which + # every distro install fails with exit -1. Fetching the current WSL is what closes that gap, and + # it is a quick no-op on any machine that already has it. + if (-not (Test-DevConfigWslPlatformActive)) { + Update-DevConfigWslRuntime | Out-Null + } +} + +# dism.exe rather than Enable-WindowsOptionalFeature: its exit codes are stable and locale-independent, +# and it avoids pulling the DISM module through PowerShell 7's Windows PowerShell compatibility layer. +# Re-enabling an already-enabled feature is a fast no-op, so no state query is needed first. +function Enable-DevConfigWslFeatures { + foreach ($feature in @('VirtualMachinePlatform', 'Microsoft-Windows-Subsystem-Linux')) { + Write-Host " Turning on the $feature Windows feature..." -ForegroundColor DarkCyan + $exitCode = Invoke-DevConfigProcess -FilePath 'dism.exe' -NoNewWindow -TimeoutSeconds 1200 -Arguments @( + '/online', '/enable-feature', "/featurename:$feature", '/all', '/norestart', '/quiet' + ) + # 3010 is "enabled, restart required", which is the expected outcome here. + if ($exitCode -eq 3010) { + $Script:DevConfigWslRestartSignalled = $true + } elseif ($exitCode -ne 0) { + throw "Could not turn on the $feature Windows feature (dism exit code $exitCode)." } } } function Test-DevConfigUbuntuInstalled { + # Windows 10 builds without the WSL feature have no wsl.exe at all; that is a clean "not installed", + # not an error worth surfacing. + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + return $false + } + $env:WSL_UTF8 = '1' $out = [System.IO.Path]::GetTempFileName() $err = [System.IO.Path]::GetTempFileName() try { # Redirect wsl's output here: this is a query, not a bootstrap step, so no console is needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list', '--quiet' ` - -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -ne 0) { + # A wedged LxssManager can make even this listing hang, and a check that never returns would + # strand the run before the phase has printed anything, so treat "no answer" as "not there". + $exitCode = Invoke-DevConfigProcess -FilePath 'wsl.exe' -Arguments @('--list', '--quiet') ` + -NoNewWindow -TimeoutSeconds 120 -RedirectStandardOutput $out -RedirectStandardError $err + if ($exitCode -ne 0) { return $false } $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | @@ -44,32 +146,94 @@ function Test-DevConfigUbuntuInstalled { # any distro at all let an unrelated one (docker-desktop, Debian) satisfy this step, so a # machine that uses Docker would silently never get Ubuntu. return @($distros | Where-Object { $_ -like 'Ubuntu*' }).Count -gt 0 + } catch { + Write-Verbose "Could not list WSL distros: $($_.Exception.Message)" + return $false } finally { Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue } } +# A distro installed with --no-launch is not always listed by wsl --list the instant the install +# exits. Observed on both 22621 and 26663, so give the listing a moment to catch up before deciding +# the install did nothing -- the same shape as the WinGet catalog lag. +function Wait-DevConfigUbuntuVisible { + for ($attempt = 1; $attempt -le 10; $attempt++) { + if (Test-DevConfigUbuntuInstalled) { + return $true + } + if ($attempt -eq 1) { + Write-Host ' (Waiting for WSL to list the new distro...)' -ForegroundColor DarkGray + } + Start-Sleep -Seconds 3 + } + return $false +} + +# Runs one install route and reports whether Ubuntu actually arrived. Both halves matter: wsl can +# fail loudly (non-zero exit) and it can also exit 0 having installed nothing at all, and only the +# listing afterwards tells those apart from a real success. +function Install-DevConfigUbuntuVia { + param( + [Parameter(Mandatory)] [string[]] $Arguments, + [int] $MaxAttempts = 3 + ) + try { + Invoke-DevConfigWslUbuntuInstall -Arguments $Arguments -MaxAttempts $MaxAttempts + } catch { + Write-Host " That route did not work ($($_.Exception.Message))." -ForegroundColor Yellow + return $false + } + return (Wait-DevConfigUbuntuVisible) +} + function Install-DevConfigUbuntu { + # Without the platform components there is nothing for a distro to run on, and every install + # attempt would fail slowly. Say so once instead. + if (-not (Test-DevConfigWslPlatformActive)) { + throw "WSL isn't active on this machine, so Ubuntu can't be installed yet (see the note above)." + } + # Suppresses the "Welcome to WSL" first-run GUI. $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' New-Item -Path $lxssPath -Force | Out-Null Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force - Invoke-DevConfigRetry -Name 'wsl --install -d Ubuntu' -ScriptBlock { - Write-Host 'Downloading and installing Ubuntu (wsl --install -d Ubuntu --no-launch)...' + if (Install-DevConfigUbuntuVia -Arguments @('--install', '-d', 'Ubuntu', '--no-launch') -MaxAttempts 2) { + return + } + + # Reached both when the Store is unreachable and when it accepted the request and quietly did + # nothing; the web download does not depend on the Store either way. + Write-Host ' The Store copy of Ubuntu did not take. Downloading Ubuntu from the web instead.' -ForegroundColor Yellow + if (Install-DevConfigUbuntuVia -Arguments @('--install', '-d', 'Ubuntu', '--no-launch', '--web-download')) { + return + } + + Set-DevConfigStepUnverified -Reason "Ubuntu did not finish installing. Everything else is set up -- run this again, or install Ubuntu from the Start menu." +} + +function Invoke-DevConfigWslUbuntuInstall { + param( + [Parameter(Mandatory)] [string[]] $Arguments, + [int] $MaxAttempts = 3 + ) + Invoke-DevConfigRetry -Name "wsl $($Arguments -join ' ')" -MaxAttempts $MaxAttempts -ScriptBlock { + Write-Host "Downloading and installing Ubuntu (wsl $($Arguments -join ' '))..." Write-Host '(A separate WSL window may pop up briefly -- that is normal. This can take a few minutes.)' -ForegroundColor DarkGray - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install', '-d', 'Ubuntu', '--no-launch' -Wait -PassThru - if ($p.ExitCode -ne 0) { - throw "wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" + $exitCode = Invoke-DevConfigProcess -FilePath 'wsl.exe' -Arguments $Arguments -TimeoutSeconds 1200 + if ($exitCode -ne 0) { + throw "wsl $($Arguments -join ' ') failed with exit code $exitCode" } } } $Script:DevConfigWslInactiveMessage = @' -WSL's platform components are installed but still inactive after a restart, so restarting -again would not help. This machine most likely has virtualization turned off: enable it in -the BIOS/UEFI, or turn on nested virtualization if this is a virtual machine, then run this -script again. +WSL's platform components are installed but still not usable after a restart, so restarting +again would not help. The usual cause is virtualization being turned off: enable it in the +BIOS/UEFI, or turn on nested virtualization if this is a virtual machine. If virtualization is +already on, this machine could not reach the WSL download. Either way, run this script again +once that is sorted. '@ function Install-DevConfigWslPlatform { @@ -77,8 +241,15 @@ function Install-DevConfigWslPlatform { [Parameter(Mandatory)] [string] $OrchestratorPath ) + $Script:DevConfigWslRestartSignalled = $false Install-DevConfigWslComponents - if (Test-DevConfigVmComputePresent) { + + # Skip the restart only when nothing was actually staged. Asking WSL again is not enough on its + # own: a component change Windows is holding until reboot leaves wsl.exe answering --status + # normally while the platform beneath it is dead, and Ubuntu then "installs" into nothing. + if (-not $Script:DevConfigWslRestartSignalled -and + -not (Test-DevConfigServicingRebootPending) -and + (Test-DevConfigWslPlatformActive)) { return } @@ -98,12 +269,14 @@ function Invoke-WslPhase { ) # ArgumentList binds the orchestrator path at call time instead of relying on closure capture. + # BestEffort: a machine with virtualization switched off in firmware genuinely cannot run WSL, and + # that is no reason to throw away the nine phases that already succeeded -- say so and finish. $steps = @( - New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' ` - -Check { Test-DevConfigVmComputePresent } ` + New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' -BestEffort ` + -Check { Test-DevConfigWslPlatformActive } ` -Apply { param($OrchestratorPath) Install-DevConfigWslPlatform -OrchestratorPath $OrchestratorPath } ` -ArgumentList @($OrchestratorPath) - New-DevConfigStep -Name 'WslUbuntu' -Description 'Install the default Ubuntu distro' ` + New-DevConfigStep -Name 'WslUbuntu' -Description 'Install the default Ubuntu distro' -BestEffort ` -Check { Test-DevConfigUbuntuInstalled } ` -Apply { Install-DevConfigUbuntu } ) From 50d8a071d035892b9f86904f4a8b27ee4ef396b9 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:17:07 -0700 Subject: [PATCH 06/19] Add prereq --- src/windows-dev-config/dev-config.ps1 | 3 +- src/windows-dev-config/steps/_winget.ps1 | 83 +++++++++++++------ src/windows-dev-config/steps/packages.ps1 | 11 ++- .../steps/prerequisites.ps1 | 48 +++++++++++ src/windows-dev-config/steps/wsl.ps1 | 2 +- 5 files changed, 117 insertions(+), 30 deletions(-) create mode 100644 src/windows-dev-config/steps/prerequisites.ps1 diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index c29713e..5b3ba8b 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -70,11 +70,12 @@ Write-Host '' if ($Script:DevConfigResumed) { Write-Host 'Welcome back. Resuming Calm OS setup after the reboot...' -ForegroundColor Cyan } else { - Write-Host 'Calm OS setup -- 10 phases, one reboot along the way (expected, not an error)' -ForegroundColor Cyan + Write-Host 'Calm OS setup -- 11 phases, one reboot along the way (expected, not an error)' -ForegroundColor Cyan } # WSL is last on purpose -- see the file header. $phases = @( + @{ File = 'prerequisites.ps1'; Function = 'Invoke-PrerequisitesPhase'; Title = 'Getting ready' } @{ File = 'packages.ps1'; Function = 'Invoke-PackagesPhase'; Title = 'Packages' } @{ File = 'registry-system.ps1'; Function = 'Invoke-RegistrySystemPhase'; Title = 'System settings' } @{ File = 'registry-explorer.ps1'; Function = 'Invoke-RegistryExplorerPhase'; Title = 'File Explorer tweaks' } diff --git a/src/windows-dev-config/steps/_winget.ps1 b/src/windows-dev-config/steps/_winget.ps1 index 9c7e65d..d7ab041 100644 --- a/src/windows-dev-config/steps/_winget.ps1 +++ b/src/windows-dev-config/steps/_winget.ps1 @@ -13,13 +13,16 @@ Set-StrictMode -Version Latest # than failing the whole run over it. $Script:DevConfigWinGetMode = 'Module' -# The health probe is worth doing once per run, not once per caller. -$Script:DevConfigWingetRepairChecked = $false - # WinGet's own exit codes, which are stable across locales -- unlike its console text. $Script:DevConfigWingetNotFound = -1978335212 # 0x8A150014 no installed package matched $Script:DevConfigWingetNoUpgrade = -1978335189 # 0x8A15002B already at the latest applicable version +# The oldest WinGet this script is willing to drive. Every install below passes --disable-interactivity, +# which older builds reject outright as an unknown argument, and the Microsoft.WinGet.Client module +# needs a comparable vintage to talk to the package manager at all. Answering a version probe is not +# enough on its own: a WinGet can respond perfectly while being too old to accept the work. +$Script:DevConfigWinGetMinimumVersion = [version]'1.6.0' + function Install-DevConfigWinGetModule { Enable-DevConfigModernTls @@ -105,33 +108,67 @@ function Test-DevConfigWingetCliUsable { } } -# Best-effort: fixes the odd App Execution Alias glitches winget occasionally hits, before any real installs start. -# Get-WinGetVersion is a quick health check -- only pay for the slower repair when it says WinGet isn't responding. -function Repair-DevConfigWinget { - if ($Script:DevConfigWingetRepairChecked) { - return +# Reads the version from whichever front end is in use, as a [version] that can be compared. +# WinGet reports it as text ("v1.29.280", sometimes with a -preview suffix), so it needs parsing +# rather than casting. Returns $null when WinGet does not answer at all. +function Get-DevConfigWinGetVersion { + $text = $null + if ($Script:DevConfigWinGetMode -eq 'Cli') { + try { + $result = Invoke-DevConfigWingetCli -Arguments @('--version') + if ($result.ExitCode -eq 0) { + $text = $result.Output + } + } catch { + Write-Verbose "winget.exe --version could not run: $($_.Exception.Message)" + } + } else { + try { + $text = Get-WinGetVersion -ErrorAction Stop + } catch { + Write-Verbose "Get-WinGetVersion failed: $($_.Exception.Message)" + } } - $Script:DevConfigWingetRepairChecked = $true - if ($Script:DevConfigWinGetMode -eq 'Cli') { - # Repair-WinGetPackageManager has no CLI equivalent, and CLI mode is only ever entered after - # winget.exe has answered a version probe, so there is nothing left to repair here. - Write-Host ' Using the built-in winget command.' -ForegroundColor DarkGray - return + if (-not $text) { + return $null + } + $match = [regex]::Match([string]$text, '(\d+)\.(\d+)(?:\.(\d+))?') + if (-not $match.Success) { + return $null } + $build = if ($match.Groups[3].Success) { $match.Groups[3].Value } else { '0' } + return [version]"$($match.Groups[1].Value).$($match.Groups[2].Value).$build" +} - try { - $version = Get-WinGetVersion -ErrorAction Stop - Write-Host " WinGet $version looks healthy -- skipping repair." -ForegroundColor DarkGray +# WinGet is ready when it answers and is new enough to accept the work this script gives it. +function Test-DevConfigWinGetReady { + $version = Get-DevConfigWinGetVersion + if (-not $version) { + return $false + } + if ($version -lt $Script:DevConfigWinGetMinimumVersion) { + Write-Host " WinGet $version is older than $($Script:DevConfigWinGetMinimumVersion), which this script needs." -ForegroundColor DarkGray + return $false + } + return $true +} + +# Only reached when the check above found WinGet missing, broken, or too old, so a healthy machine +# never pays for the slow repair. +function Repair-DevConfigWinget { + if ($Script:DevConfigWinGetMode -eq 'Cli') { + # Repair-WinGetPackageManager has no winget.exe equivalent, so there is nothing to try here. + Set-DevConfigStepUnverified -Reason 'The built-in winget command is too old for this script and cannot be updated from here. Update App Installer from the Microsoft Store, then run this again.' return - } catch { - Write-Host ' WinGet is not responding as expected -- repairing...' -ForegroundColor DarkCyan - Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray } + Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray try { - Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null - Write-Host ' WinGet repair finished.' -ForegroundColor DarkGray + # *>&1 into $null so nothing the repair narrates can reach the console: it probes for a + # winget that is missing or broken by definition here, and says so in its own streams. + # A genuine failure still throws to the catch below, and the follow-up check still decides. + $null = Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop *>&1 return } catch { Write-Host " WinGet repair did not complete: $($_.Exception.Message)" -ForegroundColor Yellow @@ -143,8 +180,6 @@ function Repair-DevConfigWinget { if (Test-DevConfigWingetCliUsable) { Write-Host ' Falling back to the built-in winget command instead.' -ForegroundColor Yellow $Script:DevConfigWinGetMode = 'Cli' - } else { - Write-Host ' WinGet could not be repaired and winget.exe is unavailable; the package steps below may not succeed.' -ForegroundColor Yellow } } diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index f5e79f1..3e4d931 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -7,9 +7,12 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Invoke-PackagesPhase { - Show-DevConfigPhaseHeader + # See prerequisites.ps1: header first so any WinGet setup message lands under it, but not on the + # resumed leg, where this phase collapses into the running "already OK" count. + if (-not $Script:DevConfigResumed) { + Show-DevConfigPhaseHeader + } Initialize-DevConfigWinGet - Repair-DevConfigWinget $packages = @( @{ Name = 'Terminal'; Id = 'Microsoft.WindowsTerminal' } @@ -31,8 +34,8 @@ function Invoke-PackagesPhase { # ArgumentList binds each package's Id at call time instead of relying on closure capture. # BestEffort: one package having a bad day upstream is no reason to abandon the other fourteen and - # the nine phases behind them. Everything that depends on a package checks for it first, and the - # summary names whatever was flagged. A WinGet that is broken outright is caught above instead, + # the phases behind them. Everything that depends on a package checks for it first, and the + # summary names whatever was flagged. A WinGet that is broken outright is caught before this, # where it can be reported once rather than fifteen times. $steps = foreach ($pkg in $packages) { New-DevConfigStep -Name $pkg.Name -Description "winget install $($pkg.Id)" -BestEffort ` diff --git a/src/windows-dev-config/steps/prerequisites.ps1 b/src/windows-dev-config/steps/prerequisites.ps1 new file mode 100644 index 0000000..43df3ef --- /dev/null +++ b/src/windows-dev-config/steps/prerequisites.ps1 @@ -0,0 +1,48 @@ +<# +.SYNOPSIS + Brings the two things everything else leans on -- PowerShell 7 and WinGet -- to a known state + before any real work starts. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# The switch to PowerShell 7 happens before this phase, before the log is even open, because it +# replaces the running process. Arriving here on Windows PowerShell therefore means that bootstrap +# could not install it. Try once more, then say plainly which shell this run is using: the WinGet +# module is documented as unreliable on Windows PowerShell, so it is worth being clear about. +function Confirm-DevConfigPwshInUse { + if (-not (Test-DevConfigHasPwsh)) { + Install-DevConfigPwshBootstrap + } + + if (Test-DevConfigHasPwsh) { + Set-DevConfigStepUnverified -Reason 'PowerShell 7 is installed now, but this run had already started without it. Run this again and it will use PowerShell 7.' + return + } + + Set-DevConfigStepUnverified -Reason 'PowerShell 7 could not be installed, so this run is using Windows PowerShell. Everything below still runs; PowerShell 7 is simply the more reliable host for it.' +} + +function Invoke-PrerequisitesPhase { + # Only ahead of the checks, and only on a fresh run: it puts the WinGet module setup message + # under a header, but on the resumed leg an unconditional header would print with nothing under + # it and break the collapsed "re-checked N earlier steps" summary. + if (-not $Script:DevConfigResumed) { + Show-DevConfigPhaseHeader + } + Initialize-DevConfigWinGet + + # BestEffort on both: neither is worth abandoning the run over, and each explains what it could + # not do. The package steps behind them check for what they need anyway. + $steps = @( + New-DevConfigStep -Name 'PowerShell7' -Description 'Install PowerShell 7' -BestEffort ` + -Check { $PSVersionTable.PSEdition -eq 'Core' } ` + -Apply { Confirm-DevConfigPwshInUse } + New-DevConfigStep -Name 'WinGet' -Description 'Update WinGet to a version this script can drive' -BestEffort ` + -Check { Test-DevConfigWinGetReady } ` + -Apply { Repair-DevConfigWinget } + ) + + Invoke-DevConfigSteps -Steps $steps +} diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 index fc1d38e..781519c 100644 --- a/src/windows-dev-config/steps/wsl.ps1 +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -270,7 +270,7 @@ function Invoke-WslPhase { # ArgumentList binds the orchestrator path at call time instead of relying on closure capture. # BestEffort: a machine with virtualization switched off in firmware genuinely cannot run WSL, and - # that is no reason to throw away the nine phases that already succeeded -- say so and finish. + # that is no reason to throw away the phases that already succeeded -- say so and finish. $steps = @( New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' -BestEffort ` -Check { Test-DevConfigWslPlatformActive } ` From 0d412f035b5e4add53f40e130f76eddb4c80d8c5 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:48:11 -0700 Subject: [PATCH 07/19] Init bootstrap --- src/windows-dev-config/bootstrap.ps1 | 152 +++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/windows-dev-config/bootstrap.ps1 diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 new file mode 100644 index 0000000..f8fc441 --- /dev/null +++ b/src/windows-dev-config/bootstrap.ps1 @@ -0,0 +1,152 @@ +<# +.SYNOPSIS + Fetches the Calm OS developer workstation setup and starts it. + +.DESCRIPTION + Meant to be run straight from the web: + + irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex + + The setup itself cannot run from a piped-in string: it loads two dozen files from its own + folder, relaunches itself elevated and on PowerShell 7, and registers a task to resume after + the one reboot it needs. All of that wants real files in a real folder, so this puts them + somewhere that survives a restart and then hands over. + + To pick a branch or pin a tag, run it as a script block instead: + + & ([scriptblock]::Create((irm ))) -Ref 'v1.2.3' +#> + +[CmdletBinding()] +param( + [string] $Ref = 'main', + [string] $InstallRoot, + [switch] $NoLaunch +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repo = 'microsoft/WindowsDeveloperConfig' + +if (-not $InstallRoot) { + # Per-user and outside the profile's roaming path: it has to still be there after the reboot, + # and the elevated relaunch is the same user, so this resolves to the same place either way. + $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:TEMP } + $InstallRoot = Join-Path $base 'CalmOS' +} + +# Windows PowerShell 5.1 still defaults to protocols GitHub no longer accepts. +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +} catch { + Write-Verbose "Could not raise the TLS version: $($_.Exception.Message)" +} + +function Save-CalmOsArchive { + param( + [Parameter(Mandatory)] [string] $Destination + ) + + # Branches need the refs/heads form, while tags and commit SHAs resolve under the short one. + $candidates = @( + "https://github.com/$repo/archive/refs/heads/$Ref.zip" + "https://github.com/$repo/archive/$Ref.zip" + ) + + $lastError = $null + $everyAttemptWas404 = $true + foreach ($url in $candidates) { + foreach ($attempt in 1..3) { + try { + # -UseBasicParsing because a freshly imaged machine may have no Internet Explorer + # engine for the parser to initialise, which fails the download for no real reason. + Invoke-WebRequest -Uri $url -OutFile $Destination -UseBasicParsing -TimeoutSec 300 + return + } catch { + $lastError = $_ + $status = $null + try { $status = [int]$_.Exception.Response.StatusCode } catch { } + if ($status -eq 404) { + # The ref simply isn't there under this form; retrying cannot change that. + break + } + $everyAttemptWas404 = $false + if ($attempt -lt 3) { + Write-Host " Download attempt $attempt didn't work -- trying again..." -ForegroundColor DarkGray + Start-Sleep -Seconds (5 * $attempt) + } + } + } + } + + if ($everyAttemptWas404) { + throw "$repo has no branch, tag or commit called '$Ref'. Check the name and run this again." + } + throw "Could not download '$Ref' from $repo ($($lastError.Exception.Message)). Check your internet connection or proxy settings, then run this again." +} + +Write-Host '' +Write-Host 'Calm OS setup' -ForegroundColor Cyan +Write-Host " Fetching '$Ref' from $repo..." -ForegroundColor DarkGray + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("calm-os-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $work -Force | Out-Null + +try { + $zip = Join-Path $work 'source.zip' + Save-CalmOsArchive -Destination $zip + + $expanded = Join-Path $work 'expanded' + Expand-Archive -LiteralPath $zip -DestinationPath $expanded -Force + + # The archive's top folder is named after the ref, so find the orchestrator rather than + # rebuilding that name and getting it wrong for a branch with slashes in it. + $orchestrator = Get-ChildItem -LiteralPath $expanded -Recurse -Filter 'dev-config.ps1' -File | + Where-Object { Test-Path (Join-Path $_.DirectoryName 'steps') } | + Select-Object -First 1 + if (-not $orchestrator) { + throw "The download from '$Ref' doesn't contain the setup files. Check that the branch or tag name is right." + } + + New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null + + # Copied over the top rather than replacing the folder: a run that is waiting on its reboot + # keeps its log and its tally of what has already been done sitting right here. + Copy-Item -LiteralPath $orchestrator.FullName -Destination $InstallRoot -Force + Copy-Item -LiteralPath (Join-Path $orchestrator.DirectoryName 'steps') -Destination $InstallRoot -Recurse -Force + + # Files that arrived in a zip from the internet are marked as such, and PowerShell refuses to + # load a marked file under the default policy -- which would stop the setup on its first line. + Get-ChildItem -LiteralPath $InstallRoot -Recurse -Filter '*.ps1' -File | Unblock-File + + # Cleared here rather than in the finally block below: the setup restarts the machine partway + # through, so this process never comes back to run it, and the download would be left behind. + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + + $target = Join-Path $InstallRoot 'dev-config.ps1' + Write-Host " Ready in $InstallRoot" -ForegroundColor DarkGray + + if ($NoLaunch) { + Write-Host '' + Write-Host "Run it when you're ready:" -ForegroundColor Cyan + Write-Host " & '$target'" -ForegroundColor DarkGray + return + } + + # A child process with an explicit policy, because the file on disk is subject to the machine's + # execution policy even though this bootstrap arrived as a string that wasn't. Same window: the + # setup asks for elevation itself, and that prompt opens the window it actually runs in. + $shell = if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$target`"") + $proc = Start-Process -FilePath $shell -ArgumentList $arguments -NoNewWindow -Wait -PassThru + + # Deliberately no 'exit': this script is usually running inside the user's own console, and + # exiting would close their window along with whatever the setup just told them. + if ($proc.ExitCode -ne 0) { + Write-Host '' + Write-Host "Setup finished with exit code $($proc.ExitCode). The log is in $InstallRoot." -ForegroundColor Yellow + } +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} From d24c7256e93bed43d57231212df18eacb91a6002 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:03:52 -0700 Subject: [PATCH 08/19] Update bootstrap script --- src/windows-dev-config/bootstrap.ps1 | 72 +++++++++++++++++----------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index f8fc441..67c46b5 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -5,12 +5,13 @@ .DESCRIPTION Meant to be run straight from the web: - irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex + irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/windows-dev-config/bootstrap.ps1 | iex - The setup itself cannot run from a piped-in string: it loads two dozen files from its own - folder, relaunches itself elevated and on PowerShell 7, and registers a task to resume after - the one reboot it needs. All of that wants real files in a real folder, so this puts them - somewhere that survives a restart and then hands over. + That is the signed copy the release pipeline publishes; src/windows-dev-config/bootstrap.ps1 is + the same script, so either address works and both install the signed setup when there is one. + + The setup itself cannot run from a piped-in string: it loads two dozen files from its own folder, + relaunches itself elevated, and resumes after a reboot. So this puts it somewhere real first. To pick a branch or pin a tag, run it as a script block instead: @@ -30,8 +31,7 @@ Set-StrictMode -Version Latest $repo = 'microsoft/WindowsDeveloperConfig' if (-not $InstallRoot) { - # Per-user and outside the profile's roaming path: it has to still be there after the reboot, - # and the elevated relaunch is the same user, so this resolves to the same place either way. + # Per-user and outside the roaming profile: it has to still be there after the reboot. $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:TEMP } $InstallRoot = Join-Path $base 'CalmOS' } @@ -59,8 +59,7 @@ function Save-CalmOsArchive { foreach ($url in $candidates) { foreach ($attempt in 1..3) { try { - # -UseBasicParsing because a freshly imaged machine may have no Internet Explorer - # engine for the parser to initialise, which fails the download for no real reason. + # -UseBasicParsing: a freshly imaged machine may have no Internet Explorer engine. Invoke-WebRequest -Uri $url -OutFile $Destination -UseBasicParsing -TimeoutSec 300 return } catch { @@ -100,28 +99,46 @@ try { $expanded = Join-Path $work 'expanded' Expand-Archive -LiteralPath $zip -DestinationPath $expanded -Force - # The archive's top folder is named after the ref, so find the orchestrator rather than - # rebuilding that name and getting it wrong for a branch with slashes in it. - $orchestrator = Get-ChildItem -LiteralPath $expanded -Recurse -Filter 'dev-config.ps1' -File | - Where-Object { Test-Path (Join-Path $_.DirectoryName 'steps') } | - Select-Object -First 1 - if (-not $orchestrator) { - throw "The download from '$Ref' doesn't contain the setup files. Check that the branch or tag name is right." + # The signed copy the release pipeline publishes, then the source it was built from. + $top = Get-ChildItem -LiteralPath $expanded -Directory | Select-Object -First 1 + if (-not $top) { + throw "The download from '$Ref' was empty. Check that the branch or tag name is right." + } + + $signed = Join-Path $top.FullName 'windows-dev-config' + $source = Join-Path (Join-Path $top.FullName 'src') 'windows-dev-config' + + $setupDir = $null + foreach ($candidate in @($signed, $source)) { + if ((Test-Path (Join-Path $candidate 'dev-config.ps1')) -and (Test-Path (Join-Path $candidate 'steps'))) { + $setupDir = $candidate + break + } + } + + if (-not $setupDir) { + # A folder having moved is not on its own a reason to give up. + $found = Get-ChildItem -LiteralPath $expanded -Recurse -Filter 'dev-config.ps1' -File | + Where-Object { Test-Path (Join-Path $_.DirectoryName 'steps') } | + Select-Object -First 1 + if (-not $found) { + throw "The download from '$Ref' doesn't contain the setup files. Check that the branch or tag name is right." + } + $setupDir = $found.DirectoryName + } elseif ($setupDir -eq $source) { + Write-Host " '$Ref' has no signed copy yet, so its source files are being used." -ForegroundColor DarkGray } New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null - # Copied over the top rather than replacing the folder: a run that is waiting on its reboot - # keeps its log and its tally of what has already been done sitting right here. - Copy-Item -LiteralPath $orchestrator.FullName -Destination $InstallRoot -Force - Copy-Item -LiteralPath (Join-Path $orchestrator.DirectoryName 'steps') -Destination $InstallRoot -Recurse -Force + # Copied over the top so a run waiting on its reboot keeps its log and its tally. + Copy-Item -LiteralPath (Join-Path $setupDir 'dev-config.ps1') -Destination $InstallRoot -Force + Copy-Item -LiteralPath (Join-Path $setupDir 'steps') -Destination $InstallRoot -Recurse -Force - # Files that arrived in a zip from the internet are marked as such, and PowerShell refuses to - # load a marked file under the default policy -- which would stop the setup on its first line. + # PowerShell refuses to load a file marked as downloaded, which is every file in this zip. Get-ChildItem -LiteralPath $InstallRoot -Recurse -Filter '*.ps1' -File | Unblock-File - # Cleared here rather than in the finally block below: the setup restarts the machine partway - # through, so this process never comes back to run it, and the download would be left behind. + # Cleared here because the setup restarts the machine, so the finally block never runs. Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue $target = Join-Path $InstallRoot 'dev-config.ps1' @@ -134,15 +151,12 @@ try { return } - # A child process with an explicit policy, because the file on disk is subject to the machine's - # execution policy even though this bootstrap arrived as a string that wasn't. Same window: the - # setup asks for elevation itself, and that prompt opens the window it actually runs in. + # The file on disk is subject to the execution policy even though this script wasn't. $shell = if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$target`"") $proc = Start-Process -FilePath $shell -ArgumentList $arguments -NoNewWindow -Wait -PassThru - # Deliberately no 'exit': this script is usually running inside the user's own console, and - # exiting would close their window along with whatever the setup just told them. + # No 'exit': this usually runs in the user's own console and would close their window. if ($proc.ExitCode -ne 0) { Write-Host '' Write-Host "Setup finished with exit code $($proc.ExitCode). The log is in $InstallRoot." -ForegroundColor Yellow From 9613520c73b19bc4ec9c1c1e474906b255094c0b Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:03:16 -0700 Subject: [PATCH 09/19] Code enhancement --- src/windows-dev-config/bootstrap.ps1 | 7 +- src/windows-dev-config/dev-config.ps1 | 34 ++++----- src/windows-dev-config/steps/_console.ps1 | 11 ++- src/windows-dev-config/steps/_elevation.ps1 | 31 +++----- src/windows-dev-config/steps/_environment.ps1 | 42 +++-------- .../steps/_pwsh-bootstrap.ps1 | 11 ++- .../steps/_reboot-resume.ps1 | 20 ++---- src/windows-dev-config/steps/_registry.ps1 | 2 +- .../steps/_resume-wrapper.ps1 | 15 ++-- src/windows-dev-config/steps/_retry.ps1 | 10 +-- src/windows-dev-config/steps/_step-runner.ps1 | 43 +++++------ src/windows-dev-config/steps/_terminal.ps1 | 40 ++++------- src/windows-dev-config/steps/_winget.ps1 | 59 +++++---------- src/windows-dev-config/steps/copilot.ps1 | 8 +-- src/windows-dev-config/steps/edge.ps1 | 2 +- src/windows-dev-config/steps/fonts.ps1 | 12 ++-- src/windows-dev-config/steps/packages.ps1 | 11 +-- .../steps/powershell-profile.ps1 | 12 ++-- .../steps/prerequisites.ps1 | 15 ++-- .../steps/registry-explorer.ps1 | 2 +- .../steps/registry-system.ps1 | 2 +- .../steps/registry-taskbar-search.ps1 | 4 +- src/windows-dev-config/steps/terminal.ps1 | 10 +-- src/windows-dev-config/steps/wsl.ps1 | 71 +++++-------------- 24 files changed, 152 insertions(+), 322 deletions(-) diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index 67c46b5..0b09d72 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -7,11 +7,10 @@ irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/windows-dev-config/bootstrap.ps1 | iex - That is the signed copy the release pipeline publishes; src/windows-dev-config/bootstrap.ps1 is - the same script, so either address works and both install the signed setup when there is one. + src/windows-dev-config/bootstrap.ps1 is the same script, so either address works. - The setup itself cannot run from a piped-in string: it loads two dozen files from its own folder, - relaunches itself elevated, and resumes after a reboot. So this puts it somewhere real first. + The setup cannot run from a piped-in string: it loads two dozen files from its own folder, + relaunches itself elevated, and resumes after a reboot. This puts it somewhere real first. To pick a branch or pin a tag, run it as a script block instead: diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index 5b3ba8b..7e011c4 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -1,12 +1,6 @@ <# .SYNOPSIS - Calm OS developer workstation setup, in plain PowerShell. - -.DESCRIPTION - Configures apps, desktop/taskbar tweaks, the PowerShell profile, and WSL + Ubuntu. - Safe to re-run: each phase skips work that's already done. The WSL phase runs - last on purpose, so the one disruptive reboot it needs happens after everything - else is configured; it resumes automatically after you log back in. + Configures a Windows developer workstation and resumes after the WSL reboot. #> [CmdletBinding()] @@ -18,7 +12,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Windows PowerShell 5.1 defaults to the ANSI code page; force UTF-8 so glyphs render correctly. +# Windows PowerShell 5.1 defaults to ANSI; force UTF-8 for console symbols. try { $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = $utf8NoBom @@ -39,15 +33,15 @@ $stepsDir = Join-Path $PSScriptRoot 'steps' . (Join-Path $stepsDir '_winget.ps1') . (Join-Path $stepsDir '_pwsh-bootstrap.ps1') -# Everything downstream downloads something, so settle the transport before the first request. +# TLS is configured before any download step runs. Enable-DevConfigModernTls Invoke-DevConfigElevate -ScriptPath $PSCommandPath -NoElevate:$NoElevate -Resumed:$Resumed -# WinGet's PowerShell module is unreliable on Windows PowerShell, so get onto PowerShell 7 before anything else. +# WinGet module behavior is more consistent in PowerShell 7 than in Windows PowerShell 5.1. Invoke-DevConfigEnsurePwsh -ScriptPath $PSCommandPath -Resumed:$Resumed -# Past both relaunches, so this is the process that does the work and owns the log file. +# The lock starts after relaunches so the worker process owns the log file. if (-not (Enter-DevConfigSingleInstance)) { Write-Host '' Write-Host 'Calm OS setup is already running in another window.' -ForegroundColor Yellow @@ -58,12 +52,12 @@ if (-not (Enter-DevConfigSingleInstance)) { Start-DevConfigLog -Path (Join-Path $PSScriptRoot 'devconfig-log.txt') -Append:$Resumed -# Whether this is a fresh start or the post-reboot resume, any leftover task is done with. +# Any prior resume task is stale once this run starts. Clear-DevConfigResume $Script:DevConfigResumed = [bool]$Resumed if ($Script:DevConfigResumed) { - # Brings back the tally from before the reboot so the final summary covers the whole run. + # Restore the pre-reboot tally so the final summary covers the whole run. Restore-DevConfigTally -Path (Join-Path $PSScriptRoot 'devconfig-tally.json') } Write-Host '' @@ -73,7 +67,7 @@ if ($Script:DevConfigResumed) { Write-Host 'Calm OS setup -- 11 phases, one reboot along the way (expected, not an error)' -ForegroundColor Cyan } -# WSL is last on purpose -- see the file header. +# WSL stays last so its required reboot happens after other phases. $phases = @( @{ File = 'prerequisites.ps1'; Function = 'Invoke-PrerequisitesPhase'; Title = 'Getting ready' } @{ File = 'packages.ps1'; Function = 'Invoke-PackagesPhase'; Title = 'Packages' } @@ -99,7 +93,7 @@ try { continue } - # Read by Invoke-DevConfigSteps to print this phase's header, without threading params through every phase file. + # Script-scoped phase metadata avoids passing header state through every phase file. $Script:DevConfigPhaseIndex = $phaseIndex $Script:DevConfigPhaseTotal = $phases.Count $Script:DevConfigPhaseTitle = $phase.Title @@ -107,14 +101,14 @@ try { . $path if ($phase.File -eq 'wsl.ps1') { - # The WSL phase needs the orchestrator's own path to register the reboot-resume task. + # The WSL phase registers resume using this orchestrator path. Invoke-WslPhase -OrchestratorPath $PSCommandPath } else { & $phase.Function } if ($phase.File -eq 'packages.ps1') { - # Packages installed above (pwsh, dotnet, git, ...) won't resolve on PATH until this refreshes. + # New package locations are visible in this process only after PATH is refreshed. Update-DevConfigSessionPath } } @@ -128,7 +122,7 @@ try { $summaryParts += "$($tally.Warned) flagged" } Write-Host " $($summaryParts -join ', ')" -ForegroundColor DarkGray - # Naming them beats a bare count: the flags themselves scrolled past a long time ago. + # Names are shown because the detailed flags may have scrolled off screen. if ($tally.Warned -gt 0) { Write-Host " Flagged: $($Script:DevConfigWarnedSteps -join ', ')" -ForegroundColor Yellow Write-Host ' These were skipped or could not be confirmed. Running this again retries just those.' -ForegroundColor DarkGray @@ -154,11 +148,11 @@ if ($logPath) { Write-Host " Full log: $logPath" -ForegroundColor DarkGray } -# The work is done and the summary is on screen; let the next run start even while this window waits. +# Release the run lock before the final pause so a completed run does not block the next start. Exit-DevConfigSingleInstance if (-not $Script:DevConfigResumed) { - # When resumed, the wrapper's own window owns the final pause instead (see _resume-wrapper.ps1). + # On resume, the wrapper window owns the final pause instead. Wait-DevConfigKeyPress } diff --git a/src/windows-dev-config/steps/_console.ps1 b/src/windows-dev-config/steps/_console.ps1 index c195353..deb61b8 100644 --- a/src/windows-dev-config/steps/_console.ps1 +++ b/src/windows-dev-config/steps/_console.ps1 @@ -1,7 +1,6 @@ <# .SYNOPSIS - Small shared console helpers: the run's log file, and the end-of-run pause so a window - nobody is watching doesn't just vanish the moment the last line prints. + Shared console helpers for run logging and the optional end-of-run pause. #> $ErrorActionPreference = 'Stop' @@ -9,9 +8,7 @@ Set-StrictMode -Version Latest $Script:DevConfigLogPath = $null -# One file for the whole run, appended to across the reboot, so there's something to read (or send -# on) when a step fails. Start this only in the process that does the work: the elevation and -# PowerShell 7 relaunches would otherwise leave two processes writing to the same file. +# Logging starts only in the worker process so relaunches do not write to the same transcript. function Start-DevConfigLog { param( [Parameter(Mandatory)] [string] $Path, @@ -51,7 +48,7 @@ function Wait-DevConfigKeyPress { $minutes = [Math]::Round($TimeoutSeconds / 60) Write-Host "$Message (closes on its own in $minutes minutes if you step away)" -ForegroundColor DarkGray - # Polls instead of a blocking ReadKey so an unattended window still closes eventually. + # Polling allows unattended windows to close without waiting for a key press. $deadline = (Get-Date).AddSeconds($TimeoutSeconds) try { while ((Get-Date) -lt $deadline) { @@ -62,6 +59,6 @@ function Wait-DevConfigKeyPress { Start-Sleep -Milliseconds 200 } } catch { - # No real console attached (e.g. input redirected) -- nothing to wait on. + # Input may be redirected, leaving no console to read from. } } diff --git a/src/windows-dev-config/steps/_elevation.ps1 b/src/windows-dev-config/steps/_elevation.ps1 index bb1b488..37b2f18 100644 --- a/src/windows-dev-config/steps/_elevation.ps1 +++ b/src/windows-dev-config/steps/_elevation.ps1 @@ -1,7 +1,6 @@ <# .SYNOPSIS - Getting a run started safely: the admin check, the one-time elevation relaunch so the whole flow - needs only a single UAC prompt, and the guard that stops two copies running over each other. + Handles elevation, relaunch arguments, and the single-run guard. #> $ErrorActionPreference = 'Stop' @@ -9,17 +8,13 @@ Set-StrictMode -Version Latest $Script:DevConfigRunMutex = $null -# Two copies at once (an impatient double-click, or a manual start while the post-reboot resume is -# already going) collide inside WinGet and the registry, and the errors that come back explain -# nothing. Machine-wide scope, because the changes themselves are machine-wide. -# Held until the process exits: Windows releases a mutex automatically when its owner dies, so a -# crashed run can never leave the next one locked out. +# The mutex prevents concurrent machine-wide WinGet and registry changes from overlapping. function Enter-DevConfigSingleInstance { $mutex = [System.Threading.Mutex]::new($false, 'Global\WindowsDevConfigSetup') try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { - # The previous owner exited without releasing it, which means ownership passed to us. + # An abandoned mutex grants ownership to this process. $acquired = $true } @@ -32,10 +27,7 @@ function Enter-DevConfigSingleInstance { return $true } -# The lock is only there to stop two runs doing work at the same time. Once the summary is printed -# the run is over and the window is merely waiting to be dismissed, so holding the lock through that -# pause would tell the next run "already running in another window" for up to fifteen minutes after -# this one finished -- with no log written, because the guard sits before logging starts. +# Release the mutex before the final pause so a completed run does not block the next start. function Exit-DevConfigSingleInstance { if (-not $Script:DevConfigRunMutex) { return @@ -56,13 +48,11 @@ function Test-DevConfigIsAdmin { } function Get-DevConfigShellExe { - # Prefer pwsh if it's already on PATH; Windows PowerShell 5.1 is always present as a fallback. + # Prefer pwsh when it is on PATH; Windows PowerShell 5.1 is always available as fallback. if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } } -# Start-Process joins -ArgumentList with spaces and quotes nothing itself, so an unquoted script path -# under C:\Users\First Last\Desktop is read as two arguments and the relaunch dies before it starts. -# Every relaunch (elevation, the PowerShell 7 switchover, the post-reboot resume) goes through here. +# Quote the script path because Start-Process joins arguments with spaces without adding quotes. function Get-DevConfigRelaunchArguments { param( [Parameter(Mandatory)] [string] $ScriptPath, @@ -93,15 +83,12 @@ function Invoke-DevConfigElevate { Write-Host 'This needs to run elevated once (a UAC prompt will appear)...' -ForegroundColor Yellow $shell = Get-DevConfigShellExe - # Carrying -Resumed across matters: without it the relaunched run believes it is a first run and - # asks for the WSL reboot all over again, which is a reboot loop rather than a finished setup. + # Preserve -Resumed so the elevated process continues after the WSL reboot. $relaunchArgs = Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed:$Resumed try { $proc = Start-Process -FilePath $shell -ArgumentList $relaunchArgs -Verb RunAs -Wait -PassThru } catch { - # Declining the UAC prompt lands here; it's a choice, not a crash, so say so plainly. The - # pause matters as much as the words: launched from Explorer this is the only window there - # is, and exiting straight away would take the explanation off screen with it. + # A declined UAC prompt returns here; pause so Explorer-launched users can read the reason. Write-Host '' Write-Host 'Setup needs Administrator rights to continue, so nothing was changed.' -ForegroundColor Yellow Write-Host 'Run it again and accept the prompt, or start it from an elevated terminal.' -ForegroundColor Yellow @@ -109,6 +96,6 @@ function Invoke-DevConfigElevate { exit 1 } - # The elevated relaunch did the work, so this process reports whatever that one concluded. + # The elevated relaunch did the work, so this process reports its exit code. exit $proc.ExitCode } diff --git a/src/windows-dev-config/steps/_environment.ps1 b/src/windows-dev-config/steps/_environment.ps1 index c5b3f1c..4307705 100644 --- a/src/windows-dev-config/steps/_environment.ps1 +++ b/src/windows-dev-config/steps/_environment.ps1 @@ -1,8 +1,6 @@ <# .SYNOPSIS - Process-level environment fixes: refreshing PATH so tools installed earlier in the same run become - runnable, raising TLS so every download in the run can reach a modern HTTPS endpoint, running - native commands safely, and reading and writing text files without corrupting them. + Shared helpers for PATH refresh, TLS, native process execution, and UTF-8 text I/O. #> $ErrorActionPreference = 'Stop' @@ -11,15 +9,11 @@ Set-StrictMode -Version Latest function Update-DevConfigSessionPath { $machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') - # A machine with no per-user PATH is normal; joining it in blind would leave a stray separator. + # A missing per-user PATH is normal, so empty values are filtered before joining. $env:Path = (@($machinePath, $userPath) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';' } -# Windows PowerShell 5.1 turns any native command that writes to stderr into a terminating -# NativeCommandError once its output is merged with 2>&1, and PowerShell 7 can be configured to treat -# a non-zero exit code the same way. Both fire before the exit code can be read, which is the one -# signal that is actually stable across tool versions and locales. Preference variables assigned here -# are function-scoped, so they shadow the caller's values only for the duration of the call. +# Normalize native failures to exit codes so callers are not tied to shell-specific error behavior. function Invoke-DevConfigNativeCommand { param( [Parameter(Mandatory)] [string] $FilePath, @@ -32,11 +26,7 @@ function Invoke-DevConfigNativeCommand { return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } } -# Some of the installers this script drives -- wsl --install above all -- can block forever: they -# reach out to the Store, or raise a window on a desktop nobody is watching, and Start-Process -Wait -# has no way to give up. A run that sits on one line for an hour is worse than one that fails, so the -# wait is bounded and the process is stopped when it overruns; callers treat that as "try another way". -# The heartbeat exists because these are the longest steps in the run, and silence reads as a freeze. +# Some installers can wait indefinitely, so process waits are bounded and emit periodic progress. function Invoke-DevConfigProcess { param( [Parameter(Mandatory)] [string] $FilePath, @@ -53,9 +43,7 @@ function Invoke-DevConfigProcess { if ($RedirectStandardError) { $start.RedirectStandardError = $RedirectStandardError } $process = Start-Process @start - # Touching Handle caches it while the process is alive. Without that, Windows PowerShell releases - # the handle on exit and ExitCode reads back as nothing at all, so every caller here would decide - # a perfectly successful install had failed. + # Cache the process handle before exit so Windows PowerShell can still report ExitCode. try { $null = $process.Handle } catch { Write-Verbose "Could not hold a handle on $FilePath." } $startedAt = Get-Date $deadline = $startedAt.AddSeconds($TimeoutSeconds) @@ -65,8 +53,7 @@ function Invoke-DevConfigProcess { if ($now -ge $deadline) { try { $process.Kill() } catch { Write-Verbose "Could not stop $FilePath : $($_.Exception.Message)" } $minutes = [Math]::Round($TimeoutSeconds / 60) - # A TimeoutException rather than a plain string: this is the one failure that must not be - # retried, and the retry helper decides that by type rather than by matching on wording. + # TimeoutException lets retry logic distinguish a bounded wait from retryable install failures. throw [System.TimeoutException]::new("$FilePath did not finish within $minutes minutes, so it was stopped.") } if ($now -ge $nextBeat) { @@ -79,10 +66,7 @@ function Invoke-DevConfigProcess { return $process.ExitCode } -# Windows PowerShell 5.1 on older Windows still negotiates TLS 1.0 by default, which the PowerShell -# Gallery, GitHub releases and githubassets all refuse -- and they refuse it as a connection failure, -# so it surfaces as "the network is down" rather than anything actionable. Raised once for the whole -# process so every download in the run benefits, not just the first one that thought to ask. +# TLS 1.2 is enabled once so downloads work on Windows PowerShell 5.1 defaults. function Enable-DevConfigModernTls { try { [Net.ServicePointManager]::SecurityProtocol = @@ -92,12 +76,7 @@ function Enable-DevConfigModernTls { } } -# Every file this script edits -- settings.json, the PowerShell profile -- is UTF-8 without a BOM, -# and is read here only to be written back. Get-Content on Windows PowerShell 5.1 decodes such a file -# using the system ANSI code page, so a profile name, font face or comment holding any non-ASCII -# character comes back as mojibake and is then saved that way, permanently damaging the user's file. -# ReadAllText honours a BOM when there is one and falls back to UTF-8, which is right on both editions. -# $null for a missing file matches Get-Content -Raw, so callers keep their existing "nothing yet" test. +# ReadAllText preserves UTF-8 files without relying on Windows PowerShell 5.1 ANSI decoding. function Read-DevConfigTextFile { param( [Parameter(Mandatory)] [string] $Path @@ -108,10 +87,7 @@ function Read-DevConfigTextFile { return [System.IO.File]::ReadAllText($Path) } -# The write half of the same story, plus atomicity. Set-Content truncates before writing, so an -# interruption mid-write leaves a zero-byte file and the reading app silently falls back to its -# defaults; writing beside the target and renaming means the file is only ever whole. -Encoding UTF8 -# is inconsistent across editions too: 5.1 emits a BOM, 7 does not, and Windows Terminal wants none. +# Write through a UTF-8 no-BOM temp file to avoid truncation and edition-specific encoding behavior. function Write-DevConfigTextFile { param( [Parameter(Mandatory)] [string] $Path, diff --git a/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 index 3359677..a8d3820 100644 --- a/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 +++ b/src/windows-dev-config/steps/_pwsh-bootstrap.ps1 @@ -1,7 +1,6 @@ <# .SYNOPSIS - Makes sure PowerShell 7 is installed and in use before any real work starts -- the WinGet - module the rest of this script relies on is documented as unreliable on Windows PowerShell. + Installs PowerShell 7 when needed and relaunches setup before WinGet module work starts. #> $ErrorActionPreference = 'Stop' @@ -11,13 +10,11 @@ function Test-DevConfigHasPwsh { [bool](Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) } -# Verified via Get-Command (PATH) afterward, not a WinGet read cmdlet -- that's the part that's unreliable here. +# PATH is checked directly because WinGet read cmdlets are not used during bootstrap. function Install-DevConfigPwshBootstrap { for ($attempt = 1; $attempt -le 2; $attempt++) { try { - # Bounded: a machine whose App Installer is half-broken can leave winget waiting on the - # Store forever, and this runs before anything has been printed, so a hang here looks - # exactly like a script that never started. + # The timeout keeps early bootstrap visible if winget waits without producing output. Invoke-DevConfigProcess -FilePath 'winget.exe' -NoNewWindow -TimeoutSeconds 600 -Arguments @( 'install', '--id', 'Microsoft.PowerShell', '--source', 'winget', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity' @@ -59,6 +56,6 @@ function Invoke-DevConfigEnsurePwsh { $relaunchArgs = Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed:$Resumed $proc = Start-Process -FilePath 'pwsh.exe' -ArgumentList $relaunchArgs -Wait -NoNewWindow -PassThru - # The relaunch already did the work; nothing left for this (Windows PowerShell) process to do. + # The relaunch performs the setup work, so this Windows PowerShell process exits with its code. exit $proc.ExitCode } diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 index 2338103..e4c01b2 100644 --- a/src/windows-dev-config/steps/_reboot-resume.ps1 +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -9,7 +9,7 @@ Set-StrictMode -Version Latest $Script:DevConfigResumeTask = 'WindowsDevConfigResume' function Clear-DevConfigResume { - # Safe to call even when no task is registered. + # SilentlyContinue allows cleanup when no resume task is registered. Unregister-ScheduledTask -TaskName $Script:DevConfigResumeTask -Confirm:$false -ErrorAction SilentlyContinue } @@ -21,19 +21,16 @@ function Suspend-DevConfigForReboot { $shell = Get-DevConfigShellExe $wrapperPath = Join-Path $PSScriptRoot '_resume-wrapper.ps1' - # The wrapper (not cmd.exe) handles output capture, so the resumed run stays visible on screen. + # The wrapper handles output capture so the resumed run stays visible on screen. $arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$wrapperPath`" -ScriptPath `"$ScriptPath`"" $action = New-ScheduledTaskAction -Execute $shell -Argument $arguments - # WindowsIdentity's Name gives DOMAIN\User (or MACHINE\User for local accounts), - # which is what the scheduled task's logon matching needs. + # Scheduled task logon matching requires the DOMAIN\User or MACHINE\User account name. $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name $trigger = New-ScheduledTaskTrigger -AtLogOn -User $currentUser $principal = New-ScheduledTaskPrincipal -UserId $currentUser -LogonType Interactive -RunLevel Highest - # At the instant of logon the desktop is still assembling and the network stack often has no - # address yet, which would make the resumed run's package checks fail for no real reason. Half a - # minute costs nothing and lets the machine settle first. + # A short delay lets desktop and network initialization complete before package checks resume. try { $trigger.Delay = 'PT30S' } catch { @@ -49,22 +46,19 @@ function Suspend-DevConfigForReboot { Write-Host 'after you log back in. This is expected, not an error.' -ForegroundColor Yellow Start-Sleep -Seconds 10 - # Group policy or a pending servicing operation can refuse the restart. The resume task is already - # registered at this point, so a manual restart picks up exactly where an automatic one would have. + # The resume task is already registered, so a manual restart continues from the same point. try { Restart-Computer -Force } catch { Write-Host '' Write-Host "Windows would not let setup restart this machine ($($_.Exception.Message))." -ForegroundColor Yellow Write-Host 'Restart when convenient -- setup carries on by itself once you log back in.' -ForegroundColor Yellow - # The restart is the one thing left for the user to do, so keep the window up long enough to - # read it rather than closing on the only instruction that still matters. + # Keep the window open so the remaining manual restart instruction is visible. Wait-DevConfigKeyPress exit 0 } - # Restart-Computer -Force signals the reboot but returns immediately; sleep so this - # process doesn't fall through to code that assumes the reboot already happened. + # Restart-Computer can return before reboot begins, so pause before any fall-through code. Start-Sleep -Seconds 60 exit 0 } diff --git a/src/windows-dev-config/steps/_registry.ps1 b/src/windows-dev-config/steps/_registry.ps1 index af577da..d172822 100644 --- a/src/windows-dev-config/steps/_registry.ps1 +++ b/src/windows-dev-config/steps/_registry.ps1 @@ -10,7 +10,7 @@ function Convert-DevConfigRegistryPath { param( [Parameter(Mandatory)] [string] $KeyPath ) - # Source data uses paths with no drive colon (HKCU\...); the registry PS provider needs one (HKCU:\...). + # Source data omits the drive colon required by the registry PowerShell provider. return $KeyPath -replace '^(HKCU|HKLM|HKCR|HKU|HKCC)\\', '$1:\' } diff --git a/src/windows-dev-config/steps/_resume-wrapper.ps1 b/src/windows-dev-config/steps/_resume-wrapper.ps1 index d9e5287..cd0fc52 100644 --- a/src/windows-dev-config/steps/_resume-wrapper.ps1 +++ b/src/windows-dev-config/steps/_resume-wrapper.ps1 @@ -1,7 +1,6 @@ <# .SYNOPSIS - Post-reboot scheduled-task entry point: runs the orchestrator with output both shown live - on screen and mirrored to a log file, without masking the real exit code. + Post-reboot scheduled-task entry point that shows output live and mirrors it to a log. #> param( @@ -11,7 +10,7 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Own console needs the same UTF-8 fix as dev-config.ps1, so relayed glyphs render correctly here too. +# This wrapper sets UTF-8 output so relayed characters render consistently. try { $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [Console]::OutputEncoding = $utf8NoBom @@ -34,11 +33,11 @@ $proc = Start-Process -FilePath $shell ` -ArgumentList (Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed) ` -RedirectStandardOutput $innerOut -RedirectStandardError $innerErr -NoNewWindow -PassThru -# Tee: mirror new lines to the console (visible on screen) and one combined log file. +# Mirroring new lines keeps resumed output visible while preserving one combined log. $shown = 0 function Show-DevConfigResumeNewLines { - # @() forces array semantics; Get-Content returns a bare string for single-line files. - # -Encoding UTF8 matches how the redirected child process actually writes its output. + # @() keeps single-line files from being treated as a scalar string. + # UTF-8 matches the encoding used by the redirected child process output. $lines = @(Get-Content -Path $innerOut -Encoding UTF8 -ErrorAction SilentlyContinue) if ($lines.Count -gt $script:shown) { $lines[$script:shown..($lines.Count - 1)] | ForEach-Object { @@ -55,7 +54,7 @@ while (-not $proc.HasExited) { } Show-DevConfigResumeNewLines -# Errors are terminal, so showing them last matches when they actually happened. +# Errors are read after process exit, which preserves their terminal placement. if (Test-Path -LiteralPath $innerErr) { Get-Content -Path $innerErr -Encoding UTF8 | ForEach-Object { Write-Host $_ -ForegroundColor Red @@ -63,7 +62,7 @@ if (Test-Path -LiteralPath $innerErr) { } } -# This window is what's actually visible after the reboot, so it owns the "don't just vanish" pause. +# The post-reboot window owns the closing pause because it is the visible process. Wait-DevConfigKeyPress exit $proc.ExitCode diff --git a/src/windows-dev-config/steps/_retry.ps1 b/src/windows-dev-config/steps/_retry.ps1 index 77942eb..c671fc8 100644 --- a/src/windows-dev-config/steps/_retry.ps1 +++ b/src/windows-dev-config/steps/_retry.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Retries a script block with exponential backoff, for flaky network calls. + Retries a script block with exponential backoff for transient failures. #> $ErrorActionPreference = 'Stop' @@ -21,18 +21,14 @@ function Invoke-DevConfigRetry { & $ScriptBlock return } catch { - # A step that had to be stopped for running too long already had its full allowance, so - # trying it a second time only spends that allowance again before reaching the same - # fallback. Give up on it immediately and let the caller take the other route. + # Timeout exceptions already consumed their allowance, so callers handle the fallback path. if ($_.Exception -is [System.TimeoutException]) { throw } if ($attempt -ge $MaxAttempts) { throw } - # Write-Host, not Write-Warning: the warning stream becomes stderr once this process is - # relaunched with redirected output after the reboot, and would then only surface at the - # very end, in red, long after the retry it describes. + # Write-Warning becomes redirected stderr after reboot and would appear after the retry. Write-Host " ... $Name didn't take on attempt $attempt ($($_.Exception.Message)). Trying again in ${delay}s." -ForegroundColor DarkYellow Start-Sleep -Seconds $delay $delay = $delay * 2 diff --git a/src/windows-dev-config/steps/_step-runner.ps1 b/src/windows-dev-config/steps/_step-runner.ps1 index 1d13b95..11a8c5f 100644 --- a/src/windows-dev-config/steps/_step-runner.ps1 +++ b/src/windows-dev-config/steps/_step-runner.ps1 @@ -1,19 +1,18 @@ <# .SYNOPSIS - Runs a named list of steps; each step checks first, and only applies itself if needed. + Runs named setup steps, applying only the steps that are not already complete. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# [char] avoids embedding a literal multi-byte glyph in the source file, which Windows PowerShell -# 5.1 can misread without a BOM. +# [char] avoids a literal multi-byte glyph that Windows PowerShell 5.1 can misread without a BOM. $Script:DevConfigCheckMark = [char]0x2713 -# dev-config.ps1 sets these; defaults here cover the fresh-run case. +# Defaults allow the step runner to load before the orchestrator sets run state. $Script:DevConfigResumed = $false $Script:DevConfigTally = @{ Done = 0; AlreadyOk = 0; Warned = 0 } -# Names of steps ever flagged, so a permanently-blocked step doesn't count twice across the reboot. +# Persist flagged names so a blocked step is counted once across the reboot. $Script:DevConfigWarnedSteps = @() $Script:DevConfigSilentSkips = 0 $Script:DevConfigStepUnverified = $null @@ -32,8 +31,7 @@ function Write-DevConfigPhaseHeader { Write-Host "Phase $Index/$Total -- $Title" -ForegroundColor Cyan } -# Guarded so a phase that does work before its step list even starts (e.g. Packages' WinGet bootstrap) -# can show the header up front without Invoke-DevConfigSteps printing it a second time afterwards. +# The guard lets phases print early without a duplicate header. function Show-DevConfigPhaseHeader { if ($Script:DevConfigPhaseHeaderShown -or -not $Script:DevConfigPhaseTitle) { return @@ -42,7 +40,7 @@ function Show-DevConfigPhaseHeader { $Script:DevConfigPhaseHeaderShown = $true } -# Hands the tally across the reboot so the final summary covers the whole run, not just the resumed leg. +# Save the tally across the reboot so the final summary covers the whole run. function Save-DevConfigTally { param( [Parameter(Mandatory)] [string] $Path @@ -59,7 +57,7 @@ function Save-DevConfigTally { } } -# Best-effort: a missing or unreadable file just means the summary covers only this leg. +# Best-effort restore: a missing or unreadable file limits the summary to this process. function Restore-DevConfigTally { param( [Parameter(Mandatory)] [string] $Path @@ -86,7 +84,7 @@ function Restore-DevConfigTally { } } -# Flushes the running count of steps collapsed during resume, right before anything else prints. +# Flush silent-skip counts before later output so the summary stays in context. function Show-DevConfigSilentSkipSummary { if ($Script:DevConfigSilentSkips -gt 0) { Write-Host '' @@ -104,7 +102,7 @@ function New-DevConfigStep { [object[]] $ArgumentList = @(), [switch] $BestEffort ) - # ArgumentList is passed positionally to Check/Apply at call time, not captured by closure. + # ArgumentList is passed positionally at call time, not captured by closure. [pscustomobject]@{ Name = $Name Description = $Description @@ -115,10 +113,7 @@ function New-DevConfigStep { } } -# Dedup by name: a permanently-blocked step would otherwise flag again every leg, forever. -# Printed with Write-Host rather than Write-Warning on purpose: after the reboot this process writes -# to a pipe, and PowerShell puts the warning stream on stderr, which the resume wrapper can only show -# once the run is over. A flag that matters mid-run has to appear where it happened. +# Deduplicate flags and keep them on the main stream so resume output shows them immediately. function Write-DevConfigStepFlag { param( [Parameter(Mandatory)] [string] $Name, @@ -133,9 +128,7 @@ function Write-DevConfigStepFlag { Write-Host " $Message" -ForegroundColor Yellow } -# Called by a step's Apply when the work went through but couldn't be confirmed -- e.g. WinGet's -# catalog still not listing a package it just installed. The step is reported honestly as flagged -# with the reason, rather than given a green tick it hasn't earned or failing the whole run. +# Allows unverified work to be flagged without failing the run when confirmation lags the apply action. function Set-DevConfigStepUnverified { param( [Parameter(Mandatory)] [string] $Reason @@ -148,17 +141,13 @@ function Invoke-DevConfigSteps { [Parameter(Mandatory)] [object[]] $Steps ) - # A fresh run has nothing to collapse, so announce the phase before the checks rather than after. - # Otherwise a phase with slow checks (fifteen package lookups) sits silent with nothing on screen - # to explain the wait. + # Fresh runs print before slow checks so the console shows why it is waiting. if (-not $Script:DevConfigResumed) { Show-DevConfigPhaseHeader Write-Host " Checking what's already set up..." -ForegroundColor DarkGray } - # Check first (cheap by design) so a fully-idle resumed phase can collapse before printing anything. - # @() forces array semantics; a single-step phase would otherwise yield a bare object, and .Count - # on one of those throws under StrictMode in Windows PowerShell 5.1. + # Checks run before output so no-op resumed phases collapse; @() preserves StrictMode array behavior. $checked = @(foreach ($step in $Steps) { $alreadyDone = $false try { @@ -168,7 +157,7 @@ function Invoke-DevConfigSteps { } catch { Write-Host " ? $($step.Name): couldn't tell whether this was already done ($($_.Exception.Message)); doing it anyway." -ForegroundColor DarkYellow } - # Tallied here (not in the print loop below) so a collapsed/silent-skipped phase still counts correctly. + # Tally before printing so collapsed phases still count. if ($alreadyDone) { $Script:DevConfigTally.AlreadyOk++ } @@ -195,11 +184,11 @@ function Invoke-DevConfigSteps { continue } - # Printed live, right before the (possibly slow) Apply runs, so the console never sits silent unexplained. + # Print before slow apply work so the console shows current progress. $what = if ($step.Description) { $step.Description } else { $step.Name } Write-Host " -> $what..." -ForegroundColor DarkCyan - # BestEffort steps warn and move on instead of blocking the whole run (e.g. OS-blocked registry values). + # BestEffort steps flag and continue instead of blocking the whole run. $Script:DevConfigStepUnverified = $null try { & $step.Apply @stepArgs diff --git a/src/windows-dev-config/steps/_terminal.ps1 b/src/windows-dev-config/steps/_terminal.ps1 index 4150543..58f0187 100644 --- a/src/windows-dev-config/steps/_terminal.ps1 +++ b/src/windows-dev-config/steps/_terminal.ps1 @@ -1,22 +1,18 @@ <# .SYNOPSIS - Shared Windows Terminal settings helpers: locating settings.json, reading it as JSONC, - writing it back safely, and the small JSON object helpers those need. + Shared helpers for locating, reading, and safely writing Windows Terminal settings. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Terminal's settings nest several levels deep (profiles.list[].font.face, actions, schemes). -# Too small a depth makes ConvertTo-Json silently truncate a customized file into a string. +# Terminal settings are deeply nested, so ConvertTo-Json needs a depth that preserves custom files. $Script:DevConfigTerminalJsonDepth = 32 -# Terminal's name for the PowerShell 7 profile. Usable in place of a GUID: the settings schema -# documents defaultProfile as accepting "GUID or profile name as a string". +# The settings schema accepts a profile name for defaultProfile when a GUID is not available. $Script:DevConfigPs7ProfileName = 'PowerShell' -# Where a packaged (MSIX) Terminal keeps its settings, whether or not the file exists yet. -# Stable before Preview, so a machine with both configures the one it actually launches. +# Stable Terminal is preferred over Preview because it is the profile users launch by default. function Get-DevConfigTerminalPackagedSettingsPath { $packagesDir = Join-Path $env:LOCALAPPDATA 'Packages' foreach ($pattern in 'Microsoft.WindowsTerminal_*', 'Microsoft.WindowsTerminalPreview_*') { @@ -33,7 +29,6 @@ function Get-DevConfigTerminalUnpackagedSettingsPath { Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' } -# The settings file as it exists right now, or $null when Terminal has never written one. function Get-DevConfigTerminalSettingsPath { $candidates = @( Get-DevConfigTerminalPackagedSettingsPath @@ -42,8 +37,7 @@ function Get-DevConfigTerminalSettingsPath { return $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 } -# Where the settings file belongs, whether or not it has been written yet. -# $null means Terminal isn't installed, so there is genuinely nothing to configure. +# A null target means Terminal is not installed, so configuration can be skipped. function Get-DevConfigTerminalSettingsTarget { $existing = Get-DevConfigTerminalSettingsPath if ($existing) { @@ -52,9 +46,7 @@ function Get-DevConfigTerminalSettingsTarget { return Get-DevConfigTerminalPackagedSettingsPath } -# Terminal only writes settings.json on its first launch, so on a freshly installed machine the file -# is missing. An empty object lets callers treat "not written yet" like any other starting point: -# Terminal fills in everything we leave out from its own defaults. +# An empty object lets first-run Terminal settings merge with Terminal defaults. function Read-DevConfigTerminalSettings { param( [Parameter(Mandatory)] [string] $Path @@ -63,23 +55,20 @@ function Read-DevConfigTerminalSettings { return [pscustomobject]@{} } - # Get-Content -Raw hands back $null (not an empty string) for a zero-byte file, and a write - # interrupted partway through leaves exactly that. + # A zero-byte settings file is treated like an unwritten first-run file. $raw = Read-DevConfigTextFile -Path $Path if ([string]::IsNullOrWhiteSpace($raw)) { return [pscustomobject]@{} } - # settings.json is JSONC; strip block and line comments before parsing. + # Terminal settings are JSONC, so comments are removed before ConvertFrom-Json. $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') if ([string]::IsNullOrWhiteSpace($clean)) { return [pscustomobject]@{} } - # A hand-edited settings.json can be genuinely invalid. Treating that as "no settings yet" would - # overwrite the user's file, so stop instead, and say which file and why rather than surfacing a - # parser's character offset. + # Invalid JSON stops the run so a hand-edited settings file is not overwritten. try { return $clean | ConvertFrom-Json } catch { @@ -87,7 +76,7 @@ function Read-DevConfigTerminalSettings { } } -# Keeps a .bak alongside the file: the JSONC round-trip above drops any comments the user had written. +# Backup preserves the original JSONC because JSON conversion drops comments. function Save-DevConfigTerminalSettings { param( [Parameter(Mandatory)] [string] $Path, @@ -100,8 +89,6 @@ function Save-DevConfigTerminalSettings { Write-DevConfigTextFile -Path $Path -Content $json } -# Walks an object path such as profiles -> defaults -> font, creating any level that's missing, -# and hands back the leaf so a caller can set values on it. function Resolve-DevConfigJsonBranch { param( [Parameter(Mandatory)] [object] $Object, @@ -117,7 +104,7 @@ function Resolve-DevConfigJsonBranch { return $node } -# Add-Member only creates; assignment only updates. This does whichever applies. +# Add-Member cannot update existing properties, so creation and assignment are handled separately. function Set-DevConfigJsonProperty { param( [Parameter(Mandatory)] [object] $Object, @@ -131,7 +118,7 @@ function Set-DevConfigJsonProperty { } } -# Reads a nested value without throwing under strict mode when any level along the way is absent. +# Strict mode requires defensive reads when any nested setting may be absent. function Get-DevConfigJsonValue { param( [Parameter(Mandatory)] [object] $Object, @@ -151,8 +138,7 @@ function Get-DevConfigJsonValue { return $node } -# Terminal's PowerShell 7 entry. Built-in profiles such as "Windows PowerShell" have no 'source' -# property at all, so every level is read defensively rather than dotted into. +# Built-in profiles may omit source, so profile fields are read defensively. function Find-DevConfigPs7Profile { param( [Parameter(Mandatory)] [object] $Settings diff --git a/src/windows-dev-config/steps/_winget.ps1 b/src/windows-dev-config/steps/_winget.ps1 index d7ab041..c8cdbcd 100644 --- a/src/windows-dev-config/steps/_winget.ps1 +++ b/src/windows-dev-config/steps/_winget.ps1 @@ -1,26 +1,19 @@ <# .SYNOPSIS - How this script talks to WinGet: acquiring a working front end, repairing a broken install, - and querying or installing individual packages. + Selects a WinGet front end and installs or queries packages. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# WinGet has two front ends: the PowerShell module (preferred -- structured results, nothing to -# parse) and winget.exe. The module comes from the PowerShell Gallery, which a proxied, offline or -# locked-down network may not reach, so fall back to the CLI that already ships with Windows rather -# than failing the whole run over it. +# Prefer the WinGet module for structured results; fall back to winget.exe when PSGallery is unreachable. $Script:DevConfigWinGetMode = 'Module' -# WinGet's own exit codes, which are stable across locales -- unlike its console text. +# Exit codes are stable across locales; console text is not. $Script:DevConfigWingetNotFound = -1978335212 # 0x8A150014 no installed package matched $Script:DevConfigWingetNoUpgrade = -1978335189 # 0x8A15002B already at the latest applicable version -# The oldest WinGet this script is willing to drive. Every install below passes --disable-interactivity, -# which older builds reject outright as an unknown argument, and the Microsoft.WinGet.Client module -# needs a comparable vintage to talk to the package manager at all. Answering a version probe is not -# enough on its own: a WinGet can respond perfectly while being too old to accept the work. +# --disable-interactivity requires WinGet 1.6.0 or newer. $Script:DevConfigWinGetMinimumVersion = [version]'1.6.0' function Install-DevConfigWinGetModule { @@ -34,8 +27,7 @@ function Install-DevConfigWinGetModule { Register-PSRepository -Default -ErrorAction Stop } - # A VPN reconnect or a waking proxy routinely outlasts a couple of seconds, and this is the most - # network-dependent call in the whole run. + # Retry module download because it is the most network-dependent call in the run. Invoke-DevConfigRetry -Name 'WinGet module download' -MaxAttempts 4 -InitialDelaySeconds 10 -ScriptBlock { Install-Module -Name Microsoft.WinGet.Client -Repository PSGallery -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop | Out-Null } @@ -56,9 +48,7 @@ function Initialize-DevConfigWinGet { $Script:DevConfigWinGetMode = 'Module' return } catch { - # A module folder left half-written by an interrupted run imports no better on a second - # attempt, but reinstalling over it usually does fix it -- so fall through rather than - # failing phase 1 outright with winget.exe sitting right there unused. + # Reinstall the module if an earlier run left a partial module folder. $reason = $_.Exception.Message Write-Host ' The WinGet module is installed but did not load -- reinstalling it.' -ForegroundColor Yellow } @@ -84,7 +74,7 @@ function Initialize-DevConfigWinGet { $Script:DevConfigWinGetMode = 'Cli' } -# Exit code, not console text: winget's output is localised and reformatted between versions. +# Exit code, not console text: winget output is localized and reformatted between versions. function Invoke-DevConfigWingetCli { param( [Parameter(Mandatory)] [string[]] $Arguments @@ -92,10 +82,7 @@ function Invoke-DevConfigWingetCli { return Invoke-DevConfigNativeCommand -FilePath 'winget.exe' -Arguments $Arguments } -# winget.exe on PATH is an App Execution Alias: a zero-byte stub that satisfies Get-Command even when -# the App Installer package behind it isn't registered for this account -- which is one of the very -# failure modes this script exists to survive. Launching such a stub fails outright instead of -# returning an exit code, so only a real invocation settles whether the CLI is usable. +# App Execution Alias stubs can exist without a registered App Installer package, so invoke winget.exe. function Test-DevConfigWingetCliUsable { if (-not (Get-Command winget.exe -ErrorAction SilentlyContinue)) { return $false @@ -108,9 +95,7 @@ function Test-DevConfigWingetCliUsable { } } -# Reads the version from whichever front end is in use, as a [version] that can be compared. -# WinGet reports it as text ("v1.29.280", sometimes with a -preview suffix), so it needs parsing -# rather than casting. Returns $null when WinGet does not answer at all. +# WinGet reports versions as text with optional prefixes or suffixes, so parse before comparing. function Get-DevConfigWinGetVersion { $text = $null if ($Script:DevConfigWinGetMode -eq 'Cli') { @@ -141,7 +126,6 @@ function Get-DevConfigWinGetVersion { return [version]"$($match.Groups[1].Value).$($match.Groups[2].Value).$build" } -# WinGet is ready when it answers and is new enough to accept the work this script gives it. function Test-DevConfigWinGetReady { $version = Get-DevConfigWinGetVersion if (-not $version) { @@ -154,8 +138,7 @@ function Test-DevConfigWinGetReady { return $true } -# Only reached when the check above found WinGet missing, broken, or too old, so a healthy machine -# never pays for the slow repair. +# Repair runs only after readiness checks fail, so healthy machines skip the slower path. function Repair-DevConfigWinget { if ($Script:DevConfigWinGetMode -eq 'Cli') { # Repair-WinGetPackageManager has no winget.exe equivalent, so there is nothing to try here. @@ -165,18 +148,14 @@ function Repair-DevConfigWinget { Write-Host ' (This can take a few minutes.)' -ForegroundColor DarkGray try { - # *>&1 into $null so nothing the repair narrates can reach the console: it probes for a - # winget that is missing or broken by definition here, and says so in its own streams. - # A genuine failure still throws to the catch below, and the follow-up check still decides. + # Suppress repair output; exceptions and the follow-up readiness check decide the result. $null = Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop *>&1 return } catch { Write-Host " WinGet repair did not complete: $($_.Exception.Message)" -ForegroundColor Yellow } - # Repair failed outright, so the module front end is unlikely to work for any package. winget.exe - # is a separate implementation and usually still answers; switching now beats letting every - # package step fail one at a time for the same underlying reason. + # If module repair fails, winget.exe may still be usable for package operations. if (Test-DevConfigWingetCliUsable) { Write-Host ' Falling back to the built-in winget command instead.' -ForegroundColor Yellow $Script:DevConfigWinGetMode = 'Cli' @@ -195,19 +174,17 @@ function Test-DevConfigWingetPackageInstalled { if ($listed.ExitCode -ne 0) { throw "winget list $Id failed with exit code $($listed.ExitCode)" } - # No upgrade probe here: winget list --upgrade-available exits 0 for any installed package, - # upgrade or not, so testing its exit code marked every package as missing and reinstalled - # and flagged all of them on every run. Installed is the bar the CLI can actually answer for. + # winget list --upgrade-available exits 0 for any installed package, with or without an upgrade. return $true } - # EqualsCaseInsensitive avoids ambiguous substring matches (e.g. an MSIX-correlated entry sharing the same Id text). + # EqualsCaseInsensitive avoids ambiguous substring matches. $pkg = Get-WinGetPackage -Id $Id -Source winget -MatchOption EqualsCaseInsensitive if (-not $pkg) { return $false } - # useLatest: true in the original -- an available upgrade means this step isn't satisfied yet. + # useLatest requires the package to be current, not only installed. return -not $pkg.IsUpdateAvailable } @@ -225,16 +202,14 @@ function Install-DevConfigWingetPackage { } $result = Install-WinGetPackage -Id $Id -Source winget -Mode Silent -MatchOption EqualsCaseInsensitive - # NoApplicableUpgrade: already installed and up to date, not a failure (module's equivalent of the - # CLI's APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE exit code). + # NoApplicableUpgrade means the package is already installed and current. if (-not $result.Succeeded() -and $result.Status -ne 'NoApplicableUpgrade') { throw "winget install $Id failed: $($result.ErrorMessage())" } } } -# Get-WinGetPackage's catalog read can lag right after a successful install (an upstream WinGet -# quirk, not specific to one PowerShell edition), so give it a moment before judging the result. +# Get-WinGetPackage catalog reads can lag after install, so wait before checking the result. function Wait-DevConfigWingetPackageSettled { param( [Parameter(Mandatory)] [string] $Id diff --git a/src/windows-dev-config/steps/copilot.ps1 b/src/windows-dev-config/steps/copilot.ps1 index 37476cf..7ee5e24 100644 --- a/src/windows-dev-config/steps/copilot.ps1 +++ b/src/windows-dev-config/steps/copilot.ps1 @@ -21,8 +21,7 @@ function Set-DevConfigCopilotTerminalProfile { $fragmentsDir = Get-DevConfigCopilotFragmentDir New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null - # Icon lives alongside the fragment file so its relative path resolves correctly. A missing icon - # only costs the profile its picture, so a download failure must not fail the step. + # The icon is colocated with the fragment so the relative path resolves; download failure is non-fatal. $iconPath = Join-Path $fragmentsDir 'copilot.png' $iconName = $null try { @@ -48,7 +47,7 @@ function Set-DevConfigCopilotTerminalProfile { $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' Write-DevConfigTextFile -Path $fragmentFile -Content ($fragment | ConvertTo-Json -Depth 8) - # Touch settings.json so Windows Terminal's hot-reload re-scans Fragments\*.json. + # Touch settings.json so Windows Terminal hot reload re-scans Fragments\*.json. @( "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", @@ -119,8 +118,7 @@ function Install-DevConfigWinUIPlugin { } function Invoke-CopilotPhase { - # BestEffort throughout: these are bonus integrations layered on top of Calm OS, all network-dependent - # (GitHub asset CDN, NuGet.org, Copilot marketplace) -- a hiccup in any of them must not block Phase 10 (WSL + reboot). + # BestEffort keeps network-dependent integrations from blocking the WSL and reboot phase. $steps = @( New-DevConfigStep -Name 'GitHubCopilotProfile' -Description 'Add a GitHub Copilot profile to Windows Terminal' ` -Check { Test-DevConfigCopilotTerminalProfile } ` diff --git a/src/windows-dev-config/steps/edge.ps1 b/src/windows-dev-config/steps/edge.ps1 index 5960321..ad494d6 100644 --- a/src/windows-dev-config/steps/edge.ps1 +++ b/src/windows-dev-config/steps/edge.ps1 @@ -12,7 +12,7 @@ function Invoke-EdgePhase { @{ Name = 'EdgeOOBE'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Edge'; ValueName = 'HideFirstRunExperience'; Value = 1; Type = 'DWord'; Description = 'Disable Edge first-run experience' } ) - # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + # ArgumentList binds each tweak's values at call time instead of closure capture. $steps = foreach ($tweak in $tweaks) { New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` -Check { param($KeyPath, $ValueName, $Value, $Type) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` diff --git a/src/windows-dev-config/steps/fonts.ps1 b/src/windows-dev-config/steps/fonts.ps1 index b9a7059..fee0d4d 100644 --- a/src/windows-dev-config/steps/fonts.ps1 +++ b/src/windows-dev-config/steps/fonts.ps1 @@ -1,6 +1,7 @@ <# .SYNOPSIS - Downloads and installs Cascadia Code Nerd Fonts, and sets Cascadia Mono NF as the Windows Terminal default font. + Installs Cascadia Code Nerd Fonts. + Sets Cascadia Mono NF as the Windows Terminal default font. #> $ErrorActionPreference = 'Stop' @@ -39,8 +40,7 @@ function Install-DevConfigCascadiaFonts { Write-Host ' (About 10 MB from GitHub. This usually takes a few seconds.)' -ForegroundColor DarkGray $ProgressPreference = 'SilentlyContinue' - # Bounded and hash-checked inside the retry: a stalled CDN connection would otherwise hang the - # run with no way out, and a truncated download is exactly the transient failure retrying fixes. + # The retry covers timeout-bound download stalls and hash mismatches from incomplete downloads. Invoke-DevConfigRetry -Name 'Cascadia fonts download' -ScriptBlock { Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 300 $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash @@ -89,8 +89,7 @@ function Install-DevConfigCascadiaFonts { function Test-DevConfigCascadiaDefaultFont { $path = Get-DevConfigTerminalSettingsPath if (-not $path) { - # Terminal writes settings.json on its first launch. Installed-but-never-opened still has work - # to do -- answering "already OK" here is what made this step silently do nothing on a fresh machine. + # Terminal writes settings.json on first launch; no target path means no default font can be verified. return (-not (Get-DevConfigTerminalSettingsTarget)) } $settings = Read-DevConfigTerminalSettings -Path $path @@ -112,8 +111,7 @@ function Set-DevConfigCascadiaDefaultFont { } function Invoke-FontsPhase { - # BestEffort: both steps are cosmetic and the download depends on GitHub's release CDN, so a hiccup - # here must not stop the substantive phases that follow (Terminal, profile, Copilot, WSL). + # BestEffort keeps later setup phases running if the font download or settings update cannot complete. $steps = @( New-DevConfigStep -Name 'CascadiaFonts' -Description 'Install Cascadia Code Nerd Fonts' ` -Check { Test-DevConfigCascadiaFontsInstalled } ` diff --git a/src/windows-dev-config/steps/packages.ps1 b/src/windows-dev-config/steps/packages.ps1 index 3e4d931..a669448 100644 --- a/src/windows-dev-config/steps/packages.ps1 +++ b/src/windows-dev-config/steps/packages.ps1 @@ -7,8 +7,7 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest function Invoke-PackagesPhase { - # See prerequisites.ps1: header first so any WinGet setup message lands under it, but not on the - # resumed leg, where this phase collapses into the running "already OK" count. + # Show the header before WinGet setup; skip it when a resumed run summarizes this phase. if (-not $Script:DevConfigResumed) { Show-DevConfigPhaseHeader } @@ -33,17 +32,13 @@ function Invoke-PackagesPhase { ) # ArgumentList binds each package's Id at call time instead of relying on closure capture. - # BestEffort: one package having a bad day upstream is no reason to abandon the other fourteen and - # the phases behind them. Everything that depends on a package checks for it first, and the - # summary names whatever was flagged. A WinGet that is broken outright is caught before this, - # where it can be reported once rather than fifteen times. + # BestEffort lets independent packages continue; dependent phases verify packages before use. $steps = foreach ($pkg in $packages) { New-DevConfigStep -Name $pkg.Name -Description "winget install $($pkg.Id)" -BestEffort ` -Check { param($Id, $Large) Test-DevConfigWingetPackageInstalled -Id $Id } ` -Apply { param($Id, $Large) - # WinGet gives no progress back while it downloads, and these three take long enough - # that a bare "->" line reads as a hung console. Say so before the quiet starts. + # Large packages can have several quiet download minutes because WinGet reports no progress here. if ($Large) { Write-Host ' (Large download -- several quiet minutes here are normal.)' -ForegroundColor DarkGray } Install-DevConfigWingetPackage -Id $Id Wait-DevConfigWingetPackageSettled -Id $Id diff --git a/src/windows-dev-config/steps/powershell-profile.ps1 b/src/windows-dev-config/steps/powershell-profile.ps1 index 47993d3..42d8434 100644 --- a/src/windows-dev-config/steps/powershell-profile.ps1 +++ b/src/windows-dev-config/steps/powershell-profile.ps1 @@ -6,8 +6,7 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Matches the oh-my-posh DSC resource's own detection: any non-commented line calling -# "oh-my-posh init" is treated as a valid, already-configured init, ours or user-customized. +# Any oh-my-posh init line that is not commented out means the profile is already configured. $Script:OhMyPoshInitLineRegex = 'oh-my-posh(?:\.exe)?\s+init' $Script:OhMyPoshInitCommand = @' @@ -25,7 +24,7 @@ function Get-DevConfigPwshProfilePath { if (-not $pwsh) { return $null } - # Ask pwsh itself for $PROFILE rather than hardcoding the path. + # Ask pwsh for $PROFILE so the path follows the installed shell. return & $pwsh.Source -NoProfile -Command '$PROFILE' } @@ -37,7 +36,7 @@ function Test-DevConfigOhMyPoshInitLinePresent { return $false } - # Scan from the end: the last non-comment matching line is what counts, matching the source resource. + # Scan from the end so the last non-comment matching line controls the result. $lines = @((Read-DevConfigTextFile -Path $ProfilePath) -split "`r?`n") for ($i = $lines.Count - 1; $i -ge 0; $i--) { if ($lines[$i].TrimStart().StartsWith('#')) { @@ -68,7 +67,7 @@ function Set-DevConfigOhMyPoshProfile { return } - # Mirrors the resource's own shellCommand(): the whole block piped to Invoke-Expression. + # The whole block is piped to Invoke-Expression, which is the documented Oh My Posh init form. $content = Read-DevConfigTextFile -Path $profilePath if (-not $content) { $content = '' @@ -83,8 +82,7 @@ function Set-DevConfigOhMyPoshProfile { } function Invoke-PowerShellProfilePhase { - # BestEffort: this only changes what the prompt looks like, so it must never cost the run the - # Copilot and WSL phases behind it. + # BestEffort keeps prompt customization from blocking later phases. $steps = @( New-DevConfigStep -Name 'OhMyPoshProfile' -Description 'Add Oh My Posh init to the PowerShell 7 profile' -BestEffort ` -Check { Test-DevConfigOhMyPoshProfileConfigured } ` diff --git a/src/windows-dev-config/steps/prerequisites.ps1 b/src/windows-dev-config/steps/prerequisites.ps1 index 43df3ef..44f7ce0 100644 --- a/src/windows-dev-config/steps/prerequisites.ps1 +++ b/src/windows-dev-config/steps/prerequisites.ps1 @@ -1,16 +1,12 @@ <# .SYNOPSIS - Brings the two things everything else leans on -- PowerShell 7 and WinGet -- to a known state - before any real work starts. + Prepares PowerShell 7 and WinGet before later phases run. #> $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# The switch to PowerShell 7 happens before this phase, before the log is even open, because it -# replaces the running process. Arriving here on Windows PowerShell therefore means that bootstrap -# could not install it. Try once more, then say plainly which shell this run is using: the WinGet -# module is documented as unreliable on Windows PowerShell, so it is worth being clear about. +# PowerShell 7 bootstrap replaces the process before logging; retry here if Windows PowerShell remains. function Confirm-DevConfigPwshInUse { if (-not (Test-DevConfigHasPwsh)) { Install-DevConfigPwshBootstrap @@ -25,16 +21,13 @@ function Confirm-DevConfigPwshInUse { } function Invoke-PrerequisitesPhase { - # Only ahead of the checks, and only on a fresh run: it puts the WinGet module setup message - # under a header, but on the resumed leg an unconditional header would print with nothing under - # it and break the collapsed "re-checked N earlier steps" summary. + # Show the header before WinGet setup; skip it when a resumed run summarizes this phase. if (-not $Script:DevConfigResumed) { Show-DevConfigPhaseHeader } Initialize-DevConfigWinGet - # BestEffort on both: neither is worth abandoning the run over, and each explains what it could - # not do. The package steps behind them check for what they need anyway. + # BestEffort allows later package checks to run even if one prerequisite remains unverified. $steps = @( New-DevConfigStep -Name 'PowerShell7' -Description 'Install PowerShell 7' -BestEffort ` -Check { $PSVersionTable.PSEdition -eq 'Core' } ` diff --git a/src/windows-dev-config/steps/registry-explorer.ps1 b/src/windows-dev-config/steps/registry-explorer.ps1 index eb39df1..e7318e2 100644 --- a/src/windows-dev-config/steps/registry-explorer.ps1 +++ b/src/windows-dev-config/steps/registry-explorer.ps1 @@ -22,7 +22,7 @@ function Invoke-RegistryExplorerPhase { @{ Name = 'TipsOff'; KeyPath = $advanced; ValueName = 'ShowSyncProviderNotifications'; Value = 0; Description = 'Disable sync provider notifications (tips)' } ) - # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + # ArgumentList binds each tweak's values at call time instead of closure capture. $steps = foreach ($tweak in $tweaks) { New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` diff --git a/src/windows-dev-config/steps/registry-system.ps1 b/src/windows-dev-config/steps/registry-system.ps1 index 6ce45f0..0444897 100644 --- a/src/windows-dev-config/steps/registry-system.ps1 +++ b/src/windows-dev-config/steps/registry-system.ps1 @@ -14,7 +14,7 @@ function Invoke-RegistrySystemPhase { @{ Name = 'RemoteDesktop'; KeyPath = 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server'; ValueName = 'fDenyTSConnections'; Value = 0; Description = 'Enable Remote Desktop (firewall rule still needs separate enable)' } ) - # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + # ArgumentList binds each tweak's values at call time instead of closure capture. $steps = foreach ($tweak in $tweaks) { New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` diff --git a/src/windows-dev-config/steps/registry-taskbar-search.ps1 b/src/windows-dev-config/steps/registry-taskbar-search.ps1 index 4738dc6..d17d2f0 100644 --- a/src/windows-dev-config/steps/registry-taskbar-search.ps1 +++ b/src/windows-dev-config/steps/registry-taskbar-search.ps1 @@ -16,11 +16,11 @@ function Invoke-RegistryTaskbarSearchPhase { @{ Name = 'WebSearchOff'; KeyPath = 'HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer'; ValueName = 'DisableSearchBoxSuggestions'; Value = 1; Description = 'Disable web search in Start/Search' } @{ Name = 'SearchHightlightOff'; KeyPath = 'HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings'; ValueName = 'IsDynamicSearchBoxEnabled'; Value = 0; Description = 'Disable Show search highlights' } @{ Name = 'StartRecommendations'; KeyPath = $advanced; ValueName = 'Start_IrisRecommendations'; Value = 0; Description = 'Disable Start menu recommendations' } - # Disables Widgets at the OS policy level; the direct taskbar-icon key is blocked outright on Windows 24H2+, so this is the only Widgets tweak. + # Widgets are configured at OS policy level because the direct taskbar icon key is blocked on 24H2+. @{ Name = 'WidgetServiceOff'; KeyPath = 'HKLM\SOFTWARE\Policies\Microsoft\Dsh'; ValueName = 'AllowNewsAndInterests'; Value = 0; Description = 'Disable Widget service' } ) - # ArgumentList binds each tweak's values at call time instead of relying on closure capture. + # ArgumentList binds each tweak's values at call time instead of closure capture. $steps = foreach ($tweak in $tweaks) { New-DevConfigStep -Name $tweak.Name -Description $tweak.Description ` -Check { param($KeyPath, $ValueName, $Value) Test-DevConfigRegistryValue -KeyPath $KeyPath -ValueName $ValueName -Value $Value } ` diff --git a/src/windows-dev-config/steps/terminal.ps1 b/src/windows-dev-config/steps/terminal.ps1 index d7480b9..7133ebf 100644 --- a/src/windows-dev-config/steps/terminal.ps1 +++ b/src/windows-dev-config/steps/terminal.ps1 @@ -21,8 +21,7 @@ function Set-DevConfigDarkTheme { function Test-DevConfigPs7DefaultProfile { $path = Get-DevConfigTerminalSettingsPath if (-not $path) { - # Terminal writes settings.json on its first launch. Installed-but-never-opened still has work - # to do -- answering "already OK" here is what made this step silently do nothing on a fresh machine. + # Missing settings still need configuration when Terminal is installed but has not launched. return (-not (Get-DevConfigTerminalSettingsTarget)) } @@ -48,8 +47,7 @@ function Set-DevConfigPs7DefaultProfile { $settings = Read-DevConfigTerminalSettings -Path $path $ps7 = Find-DevConfigPs7Profile -Settings $settings - # Terminal only lists its PowerShell 7 profile once it has run since PowerShell 7 was installed. - # Until then the documented name form still resolves, and keeps working after Terminal fills the list in. + # The documented profile name works before Terminal has listed the PowerShell 7 profile. $profileRef = if ($ps7) { Get-DevConfigJsonValue -Object $ps7 -Path 'guid' } else { @@ -62,9 +60,7 @@ function Set-DevConfigPs7DefaultProfile { } function Invoke-TerminalPhase { - # Both of these are appearance preferences that nothing else depends on, and the settings file - # belongs to the user: it can be unreadable, mid-edit or held open by Windows Terminal itself. - # Letting that end the run cost the profile, Copilot and WSL phases behind it over a colour scheme. + # These user preferences are best-effort so later setup phases can continue. $steps = @( New-DevConfigStep -Name 'DarkTheme' -Description 'Force dark app/system theme' -BestEffort ` -Check { Test-DevConfigDarkThemeSet } ` diff --git a/src/windows-dev-config/steps/wsl.ps1 b/src/windows-dev-config/steps/wsl.ps1 index 781519c..08b52c9 100644 --- a/src/windows-dev-config/steps/wsl.ps1 +++ b/src/windows-dev-config/steps/wsl.ps1 @@ -6,19 +6,12 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -# Windows sets this key while a component change waits on a restart, and clears it once the restart -# happens. It is the signal that matters straight after wsl --install: wsl.exe answers --version and -# --status perfectly well at that point, while the platform underneath it is not live yet -- which is -# how an Ubuntu install could report success and put nothing on the machine at all. Component -# servicing only, deliberately: PendingFileRenameOperations is set by ordinary app installers too -# (the fifteen packages in phase 1 among them) and would force a restart on almost every run. +# This CBS key signals component servicing pending restart; app installer restart flags are ignored. function Test-DevConfigServicingRebootPending { return (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') } -# Runs a short wsl query with its output redirected and the wait bounded, and hands back the exit -# code -- or $null when wsl.exe cannot be launched at all, which is what the App Execution Alias stub -# does on a machine that has never had WSL. Exit codes only, deliberately: wsl's text is localised. +# WSL output is redirected and bounded; exit codes are used because message text is localized. function Get-DevConfigWslExitCode { param( [Parameter(Mandatory)] [string[]] $Arguments, @@ -41,18 +34,12 @@ function Get-DevConfigWslExitCode { } } -# Windows ships an old WSL inside the image, and the modern WSL that can actually install and host a -# distro comes separately. Only the modern one understands --version; the inbox one answers -1. That -# single exit code is the difference that matters, and nothing else reports it honestly: on a 22H2 -# machine with the inbox WSL and no kernel at all, "wsl --status" still exits 0 and cheerfully says -# the default version is 2 -- after which every distro install fails with exit -1. +# The current WSL package supports --version; the inbox WSL returns a nonzero exit code. function Test-DevConfigWslPlatformActive { return ((Get-DevConfigWslExitCode -Arguments @('--version')) -eq 0) } -# wsl --update fetches the modern WSL and its kernel. The Store route is tried first because it is -# the one Microsoft keeps current; --web-download is the same package without the Store, for machines -# where policy has removed it. Nothing here is fatal on its own: the caller decides what happens next. +# The Store update is tried first; --web-download provides the same package when Store access is unavailable. function Update-DevConfigWslRuntime { Write-Host ' This machine has the older WSL that ships inside Windows; a distro needs the current one.' -ForegroundColor DarkGray Write-Host ' Updating WSL (wsl --update)...' -ForegroundColor DarkCyan @@ -85,26 +72,19 @@ function Install-DevConfigWslComponents { } } } catch { - # Older builds, machines where the Microsoft Store is blocked by policy, and machines where - # wsl's own bootstrap simply never returns. The underlying Windows features still get us a - # working WSL, so that path is worth taking rather than failing the phase. + # Direct feature enablement can still prepare WSL when wsl --install is unavailable. Write-Host " WSL's own installer could not run here ($($_.Exception.Message))." -ForegroundColor Yellow Write-Host ' Turning on the WSL Windows features directly instead.' -ForegroundColor Yellow Enable-DevConfigWslFeatures } - # Turning the Windows features on is only half the job. A 22H2 machine that took the dism path is - # left with both features enabled, the inbox WSL, and no WSL2 kernel at all -- a state in which - # every distro install fails with exit -1. Fetching the current WSL is what closes that gap, and - # it is a quick no-op on any machine that already has it. + # Enabling features may leave only the inbox WSL; updating ensures the current WSL package is present. if (-not (Test-DevConfigWslPlatformActive)) { Update-DevConfigWslRuntime | Out-Null } } -# dism.exe rather than Enable-WindowsOptionalFeature: its exit codes are stable and locale-independent, -# and it avoids pulling the DISM module through PowerShell 7's Windows PowerShell compatibility layer. -# Re-enabling an already-enabled feature is a fast no-op, so no state query is needed first. +# dism.exe provides stable exit codes and avoids the Windows PowerShell compatibility layer. function Enable-DevConfigWslFeatures { foreach ($feature in @('VirtualMachinePlatform', 'Microsoft-Windows-Subsystem-Linux')) { Write-Host " Turning on the $feature Windows feature..." -ForegroundColor DarkCyan @@ -121,8 +101,7 @@ function Enable-DevConfigWslFeatures { } function Test-DevConfigUbuntuInstalled { - # Windows 10 builds without the WSL feature have no wsl.exe at all; that is a clean "not installed", - # not an error worth surfacing. + # Without wsl.exe, Ubuntu is treated as not installed rather than as an error. if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { return $false } @@ -131,9 +110,7 @@ function Test-DevConfigUbuntuInstalled { $out = [System.IO.Path]::GetTempFileName() $err = [System.IO.Path]::GetTempFileName() try { - # Redirect wsl's output here: this is a query, not a bootstrap step, so no console is needed. - # A wedged LxssManager can make even this listing hang, and a check that never returns would - # strand the run before the phase has printed anything, so treat "no answer" as "not there". + # This query is bounded and redirected so a nonresponsive listing is treated as not installed. $exitCode = Invoke-DevConfigProcess -FilePath 'wsl.exe' -Arguments @('--list', '--quiet') ` -NoNewWindow -TimeoutSeconds 120 -RedirectStandardOutput $out -RedirectStandardError $err if ($exitCode -ne 0) { @@ -142,9 +119,7 @@ function Test-DevConfigUbuntuInstalled { $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | ForEach-Object { ($_ -replace "`0", '').Trim() } | Where-Object { $_ }) - # Match Ubuntu specifically, including versioned registrations such as Ubuntu-24.04. Counting - # any distro at all let an unrelated one (docker-desktop, Debian) satisfy this step, so a - # machine that uses Docker would silently never get Ubuntu. + # Match Ubuntu specifically, including versioned registrations such as Ubuntu-24.04. return @($distros | Where-Object { $_ -like 'Ubuntu*' }).Count -gt 0 } catch { Write-Verbose "Could not list WSL distros: $($_.Exception.Message)" @@ -154,9 +129,7 @@ function Test-DevConfigUbuntuInstalled { } } -# A distro installed with --no-launch is not always listed by wsl --list the instant the install -# exits. Observed on both 22621 and 26663, so give the listing a moment to catch up before deciding -# the install did nothing -- the same shape as the WinGet catalog lag. +# A --no-launch install can complete before wsl --list shows the distro, so the listing is retried. function Wait-DevConfigUbuntuVisible { for ($attempt = 1; $attempt -le 10; $attempt++) { if (Test-DevConfigUbuntuInstalled) { @@ -170,9 +143,7 @@ function Wait-DevConfigUbuntuVisible { return $false } -# Runs one install route and reports whether Ubuntu actually arrived. Both halves matter: wsl can -# fail loudly (non-zero exit) and it can also exit 0 having installed nothing at all, and only the -# listing afterwards tells those apart from a real success. +# Success requires both a zero exit code and Ubuntu appearing in wsl --list afterward. function Install-DevConfigUbuntuVia { param( [Parameter(Mandatory)] [string[]] $Arguments, @@ -188,8 +159,7 @@ function Install-DevConfigUbuntuVia { } function Install-DevConfigUbuntu { - # Without the platform components there is nothing for a distro to run on, and every install - # attempt would fail slowly. Say so once instead. + # A distro install requires active platform components, so fail early when they are not active. if (-not (Test-DevConfigWslPlatformActive)) { throw "WSL isn't active on this machine, so Ubuntu can't be installed yet (see the note above)." } @@ -203,8 +173,7 @@ function Install-DevConfigUbuntu { return } - # Reached both when the Store is unreachable and when it accepted the request and quietly did - # nothing; the web download does not depend on the Store either way. + # The web-download path does not depend on Store access or Store registration timing. Write-Host ' The Store copy of Ubuntu did not take. Downloading Ubuntu from the web instead.' -ForegroundColor Yellow if (Install-DevConfigUbuntuVia -Arguments @('--install', '-d', 'Ubuntu', '--no-launch', '--web-download')) { return @@ -244,22 +213,18 @@ function Install-DevConfigWslPlatform { $Script:DevConfigWslRestartSignalled = $false Install-DevConfigWslComponents - # Skip the restart only when nothing was actually staged. Asking WSL again is not enough on its - # own: a component change Windows is holding until reboot leaves wsl.exe answering --status - # normally while the platform beneath it is dead, and Ubuntu then "installs" into nothing. + # Skip restart only when no servicing restart is pending and the WSL platform is active. if (-not $Script:DevConfigWslRestartSignalled -and -not (Test-DevConfigServicingRebootPending) -and (Test-DevConfigWslPlatformActive)) { return } - # One restart activates the components. If they are still inactive after it, another restart - # would only repeat the same result, so stop with an explanation instead of rebooting in a loop. + # After one resume, stop instead of repeating restarts if the platform is still inactive. if ($Script:DevConfigResumed) { throw $Script:DevConfigWslInactiveMessage } - # Never returns: registers the resume task, reboots, and exits this process. Suspend-DevConfigForReboot -ScriptPath $OrchestratorPath } @@ -268,9 +233,7 @@ function Invoke-WslPhase { [Parameter(Mandatory)] [string] $OrchestratorPath ) - # ArgumentList binds the orchestrator path at call time instead of relying on closure capture. - # BestEffort: a machine with virtualization switched off in firmware genuinely cannot run WSL, and - # that is no reason to throw away the phases that already succeeded -- say so and finish. + # ArgumentList binds the path at call time; BestEffort preserves prior phases if WSL cannot start. $steps = @( New-DevConfigStep -Name 'WslComponents' -Description 'Install WSL platform components' -BestEffort ` -Check { Test-DevConfigWslPlatformActive } ` From 782ed53d80433dab79daac69717213e0d96c6b69 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:38:04 -0700 Subject: [PATCH 10/19] Update docs --- .gitignore | 4 + README.md | 41 +- src/docs/development.md | 51 +- src/future/cmdpal/README.md | 6 + src/manifest.yml | 19 +- src/tests/calm-os/probe.ps1 | 11 +- src/windows-dev-config/README.md | 646 ++++++++----- src/windows-dev-config/bootstrap.ps1 | 7 +- src/windows-dev-config/dev-config.winget | 1056 ---------------------- src/windows-dev-config/install.ps1 | 29 - windows-dev-config/README.md | 648 ++++++++----- windows-dev-config/dev-config.winget | 1055 --------------------- windows-dev-config/install.ps1 | 242 ----- 13 files changed, 901 insertions(+), 2914 deletions(-) delete mode 100644 src/windows-dev-config/dev-config.winget delete mode 100644 src/windows-dev-config/install.ps1 delete mode 100644 windows-dev-config/dev-config.winget delete mode 100644 windows-dev-config/install.ps1 diff --git a/.gitignore b/.gitignore index d5a18de..e04e80a 100644 --- a/.gitignore +++ b/.gitignore @@ -427,3 +427,7 @@ FodyWeavers.xsd *.msix *.msm *.msp + +# Windows Dev Config run artifacts, written next to dev-config.ps1 when the +# flow is run from a checkout (*.log above already covers devconfig-log.txt). +devconfig-tally.json diff --git a/README.md b/README.md index 249d4ad..f505441 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ --- -Go from a fresh Windows install to a fully configured dev box in one command. These declarative, CI-tested configs set up your tools, settings, and shells the same way every time — so any machine can be your machine in minutes. +Go from a fresh Windows install to a fully configured dev box in one command. These CI-tested setups install your tools, settings, and shells the same way every time — so any machine can be your machine in minutes. ## 🎯 Pick your setup @@ -28,11 +28,11 @@ Three developer setups live in this repo. Pick the one that matches what you wan | You want... | Go to | | --- | --- | -| A complete dev workstation: tools, OS settings, WSL, and terminal. One command, may reboot. | [Windows Dev Config](#%EF%B8%8F-windows-dev-config) | +| A complete dev workstation: tools, OS settings, WSL, and terminal. One command, restarts once. | [Windows Dev Config](#%EF%B8%8F-windows-dev-config) | | A polished WSL shell: zsh/bash, Starship, CLI tools, and a themed terminal profile. Interactive or unattended. | [WSL Comfort](#-wsl-comfort) | | A single language toolchain: Node, Python, SQL, PowerShell, .NET, Rust, Go, Java, PHP, WinForms, or WinUI 3. One command each. | [Workloads](#-single-language-workloads) | -Most of them use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure). If you've never used it before, enable it once: +Most of the single-language workloads use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure). If you've never used it before, enable it once: ```powershell winget configure --enable @@ -49,7 +49,7 @@ winget configure --enable > winget install Microsoft.VCRedist.2015+.arm64 > ``` -If that fails or `winget configure` is still not recognized, see [Troubleshooting](#-troubleshooting). +If that fails or `winget configure` is still not recognized, see [Troubleshooting](#-troubleshooting). Windows Dev Config doesn't use `winget configure` and needs none of this.
@@ -57,40 +57,29 @@ If that fails or `winget configure` is still not recognized, see [Troubleshootin *Turns a fresh Windows 11 box into a clean, distraction-free dev workstation in one shot.* -A single [winget configuration](https://learn.microsoft.com/en-us/windows/package-manager/configuration/) file that installs dev tools, applies opinionated Windows settings, and bootstraps WSL + Ubuntu through the required reboot. Non-interactive. Idempotent. Safe to re-run on an existing machine. +A set of PowerShell scripts that installs dev tools, applies opinionated Windows settings, and sets up WSL + Ubuntu through the required reboot. Nothing to clone, nothing to install first. Idempotent, so it's safe to re-run on an existing machine. -First, get the files onto the box. The config is invoked from a local path, but the bootstrap itself is what installs Git — so on a clean Windows install you'll typically download the repo as a ZIP. If Git is already there, clone it: +Open any PowerShell window — elevated or not — and run: ```powershell -# Git already installed: -git clone https://github.com/microsoft/WindowsDeveloperConfig.git -cd WindowsDeveloperConfig - -# Otherwise, download and extract the ZIP: -Invoke-WebRequest -Uri https://github.com/microsoft/WindowsDeveloperConfig/archive/refs/heads/main.zip -OutFile WindowsDeveloperConfig.zip -Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . -cd .\WindowsDeveloperConfig-main +irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex ``` -Then apply the configuration: +You'll get one UAC prompt. Expect about 30 minutes on a clean machine. -```powershell -winget configure -f .\windows-dev-config\dev-config.winget --accept-configuration-agreements --disable-interactivity -``` - -> ⚠️ **May reboot.** Enabling WSL needs a Windows optional feature that requires a restart. A `RunOnce` task picks the configuration back up after you sign in, installs Ubuntu, and finishes the run. Expect one hard reboot plus about a minute of post-login work. Save your work first. +> ⚠️ **It will restart your machine, once.** Enabling WSL needs a Windows optional feature that requires a restart. You get a 10-second warning, and a scheduled task finishes the run automatically after you sign back in. **Save your work before you start.**
What you get -- **Dev tools:** PowerShell 7, Git, GitHub CLI, VS Code, .NET SDK 10, Python 3.14 + uv, Node.js, Coreutils for Windows, Oh My Posh, and PowerToys. -- **Terminal:** PowerShell 7 is the default profile, Oh My Posh is enabled, and Cascadia Mono NF is set as the default font. -- **Windows settings:** Dark theme, developer mode, long paths, File Explorer defaults, Start/Search cleanup, Edge policies, and other workstation defaults. -- **WSL:** WSL platform + Ubuntu, including the reboot and the `RunOnce` resume step. +- **Dev tools:** Windows Terminal, PowerShell 7, Git, GitHub CLI, GitHub Copilot CLI, VS Code, .NET SDK 10, Python 3.14 + uv, Node.js LTS + nvm, Coreutils for Windows, Windows App CLI, Oh My Posh, and PowerToys. +- **Terminal:** PowerShell 7 as the default profile, Oh My Posh in your prompt, Cascadia Mono NF as the default font, and a GitHub Copilot profile in the dropdown. +- **Windows settings:** Dark theme, Developer Mode, Sudo, long paths, File Explorer defaults, Start/Search cleanup, Do Not Disturb, widgets off, and Edge policies. +- **WSL:** WSL platform + Ubuntu, including the restart and the automatic resume afterwards.
-Full details: [`windows-dev-config/README.md`](./windows-dev-config/README.md). +Full details — every setting it changes, how to undo them, and troubleshooting: [`windows-dev-config/README.md`](./src/windows-dev-config/README.md).
@@ -195,7 +184,7 @@ Open a new terminal, or run the matching `install.ps1` shim to refresh PATH in t
Windows Dev Config rebooted the machine and looks stuck -It registered a `RunOnce` entry, so `winget configure` resumes once you sign back in. Give it a minute after login. +It registered a scheduled task named `WindowsDevConfigResume`, so the run picks itself back up about 30 seconds after you sign back in. A window opens on its own and finishes the WSL setup. If nothing appears after a couple of minutes, run the one-liner again — it's safe to re-run and skips everything already done. More detail in [`windows-dev-config/README.md`](./src/windows-dev-config/README.md#troubleshooting).
diff --git a/src/docs/development.md b/src/docs/development.md index b408197..69806af 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -5,35 +5,41 @@ > CI / "how the sausage gets made" guide. Opinionated, CI-validated configurations for bootstrapping developer -toolchains and Windows-desktop personalities using `winget` / -`winget configure`. - -On Windows the **core artifact of each flow is a [winget DSC configuration -file](https://learn.microsoft.com/windows/package-manager/configuration/)** -(`configuration.winget` for language toolchains, `dev-config.winget` for the -Calm OS flow) — a declarative, idempotent description of the machine state -required for that flow. Where winget alone is not enough (e.g. `npm install ---global typescript`, registry tweaks, or a `RunOnce` reboot dance) the +toolchains and Windows-desktop personalities. + +Most flows are built around a [winget DSC configuration +file](https://learn.microsoft.com/windows/package-manager/configuration/) +(`configuration.winget`) — a declarative, idempotent description of the +machine state required for that flow. Where winget alone is not enough +(e.g. `npm install --global typescript` or a registry tweak) the configuration calls a DSC `Script` / `RunCommandOnSet` / `Registry` resource, so everything the flow needs lives in one YAML file. A small `install.ps1` shim next to it applies the config with `winget configure` and handles session-level glue (PATH refresh, CI sentinel). -Every flow is **exercised on a real GitHub-hosted runner** on every push, pull -request, and nightly: the DSC config is applied, then a canonical "hello +Two flows are **PowerShell-native** instead: Calm OS +(`src/windows-dev-config/`) and Comfort Shell (`src/wsl-comfort/`). They +need work a configuration file can't express — elevation, a reboot with an +automatic resume, an interactive progress display — so they ship as +PowerShell scripts with no configuration file at all. They keep the same +idempotency contract: every step checks current state, acts only when +needed, and verifies the result. + +Every automated flow is **exercised on a real GitHub-hosted runner** on every +push, pull request, and nightly: the flow is applied, then a canonical "hello world" is built and executed, and its stdout is diffed against a checked-in expected output. If a flow's hello world prints the right thing, we know the configuration actually produced a working toolchain. ## Supported flows -Each flow's `configuration.winget` (or `dev-config.winget` for Calm OS) -is the source of truth for what gets installed; the table below -summarizes it for quick scanning. Flows marked **manual** are excluded -from the automated CI matrix (they need an interactive desktop session -or pull multi-GB workloads we don't want to chew minutes on), but are -still verified end-to-end on demand and surfaced in the Command Palette -extension. +Each flow's `configuration.winget` — or, for the two PowerShell-native +flows, its entry script — is the source of truth for what gets installed; +the table below summarizes it for quick scanning. Flows marked **manual** +are excluded from the automated CI matrix (they need an interactive +desktop session or pull multi-GB workloads we don't want to chew minutes +on), but are still verified end-to-end on demand and surfaced in the +Command Palette extension. | Flow | CI status | Installs | | ----------------- | ------------- | --------------------------------------------------------------------------------------- | @@ -48,7 +54,7 @@ extension. | PowerShell | ✅ automated | `Microsoft.PowerShell`, `Microsoft.VisualStudioCode`, VS Code PowerShell/Pester extensions + PSScriptAnalyzer settings | | WinForms | 🙋 manual | `Microsoft.DotNet.SDK.10` + the .NET desktop workload (multi-GB; manual to spare CI minutes) | | WinUI 3 | 🙋 manual | `Microsoft.DotNet.SDK.10`, `Microsoft.VisualStudio.Community`, `Microsoft.WinAppCLI` + WinUI/Universal/ManagedDesktop VS workloads | -| Calm OS | 🙋 manual | A full distraction-free workstation: apps + ~24 registry tweaks + WSL + Ubuntu (see [`windows-dev-config/README.md`](../windows-dev-config/README.md)) | +| Calm OS | 🙋 manual | A full distraction-free workstation, in PowerShell: 15 apps + 25 registry values + fonts + Windows Terminal + WSL + Ubuntu (see [`windows-dev-config/README.md`](../windows-dev-config/README.md)) | | Comfort Shell | 🙋 manual | WSL distro + zsh/bash + starship + modern CLI bundle + Cascadia Code Nerd Font + themed Windows Terminal profile (see [`wsl-comfort/readme.md`](../wsl-comfort/readme.md)) | See [`manifest.yml`](../manifest.yml) for the canonical declarative @@ -80,7 +86,7 @@ Workloads/ rust/ # configuration.winget (core) + install.ps1 (thin shim) winforms/ # configuration.winget (core) + install.ps1 (thin shim) winui/ # configuration.winget (core) + install.ps1 (thin shim) -windows-dev-config/ # Calm OS — dev-config.winget (single-file DSC) + install.ps1 + README.md +windows-dev-config/ # Calm OS — bootstrap.ps1 (remote entry) + dev-config.ps1 (orchestrator) + steps/*.ps1 + README.md wsl-comfort/ # Comfort Shell — install.ps1 (Windows side) + comfort-shell-bootstrap.sh (Linux side, self-contained) + readme.md tests/ _harness/ # build-run-diff harness used by CI: @@ -122,16 +128,19 @@ This repo carries **two parallel copies** of every flow: | `src/docs/development.md` | Contributor docs (CI, validation, how to add a language). | **Yes** | n/a | | `src/tests/` | Hello-world programs + expected stdout used by the CI harness. | **Yes** | CI only | -**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** on purpose. If you're following the README on a Windows box you don't need to know `src/` exists. Every `winget configure -f .\windows-dev-config\dev-config.winget`-style invocation in the README is correct as written. +**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** wherever those copies exist, so on a Windows box you don't need to know `src/` exists. The one exception is Calm OS: its `bootstrap.ps1` is new and hasn't been through a sign cycle yet, so the README's one-liner points at `src/windows-dev-config/bootstrap.ps1`. That's deliberate — the bootstrap still installs the *signed* payload when the ref it downloads has one, so the address you fetch the bootstrap from doesn't change what gets run. Repoint the README at the top-level copy once it lands. **Contributors**: edit `src/`. The top-level paths are **regenerated** by [`.pipelines/OneBranch.SignAndPackage.yml`](../../.pipelines/OneBranch.SignAndPackage.yml), which Authenticode-signs every `src/**/*.ps1` and ships them (plus the `.winget` configs and the manifest) as the release artifact. The signed copies were merged into `main` from the `signed` branch in [PR #6](https://github.com/microsoft/WindowsDeveloperConfig/pull/6). A change to a `src/` script becomes a new signed top-level copy on the next sign cycle, not at PR merge, so the two can briefly disagree on a script's body until that cycle runs. +**Deleting a file is the one case where you must touch both trees.** The sign pipeline only adds and overwrites — it never deletes. A file removed from `src/` therefore stays at the top level forever, still published and still runnable, until someone removes it by hand. So when you delete or rename a flow artifact, `git rm` it from **both** `src/…` and the matching top-level path in the same PR. (The drift guard won't catch this for you: a file that exists in neither tree produces no report entry at all.) + **CI**: GitHub Actions ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)) runs the **unsigned `src/` copies** (e.g. `./src/Workloads/_common/preflight.ps1`). This is intentional: CI exercises what contributors edit; signing is a release-time concern, not a build-time one. **Don't**: - Don't edit a top-level signed copy directly. The next sign cycle will overwrite it, and the cycle signs `src/`, not the top level. - Don't expect the two trees to be byte-identical. The signed copies carry an Authenticode signature block (`# SIG # Begin signature block` … `# SIG # End signature block`); the bodies above that marker should match what's in `src/`. They will diverge for the window between a `src/` change landing on `main` and the next sign cycle catching up. +- Don't delete from `src/` only. See above — removals are the one change the pipeline can't propagate. - Don't add a third copy of anything. Both copies exist for one reason only (to ship signed PS1s without losing the unsigned source), and any new flow or shared script lives only in `src/` until the sign pipeline mirrors it. ### Signed-copy drift guard diff --git a/src/future/cmdpal/README.md b/src/future/cmdpal/README.md index d1111d6..7b55303 100644 --- a/src/future/cmdpal/README.md +++ b/src/future/cmdpal/README.md @@ -99,6 +99,12 @@ If `windows.configuration` is omitted in `manifest.yml`, the extension falls back to `/configuration.winget` — i.e. the WindowsDevSetupScripts convention. +> **Known gap.** Two flows are PowerShell-native and have no configuration +> file at all: Calm OS (`calm-os`) and Comfort Shell (`comfort-shell`). The +> fallback above resolves them to a path that doesn't exist, so the extension +> can't launch them today. Before this extension ships, teach it to run +> `windows.install` directly when `windows.configuration` is absent. + ## Confirmation dialog `winget configure` against a real DSC config can install packages, change diff --git a/src/manifest.yml b/src/manifest.yml index d7631c9..48368e9 100644 --- a/src/manifest.yml +++ b/src/manifest.yml @@ -62,6 +62,9 @@ # configuration: (optional) path to winget DSC configuration.winget the # extension applies via `winget configure`. Defaults to # "/configuration.winget" when omitted. +# PowerShell-native flows (calm-os, comfort-shell) have no +# DSC document and omit this key; the extension needs a +# script-launch path before it can offer them. # build: shell command to build the hello world (run from repo # root). "" to skip. # run: shell command whose stdout is compared to "expected" @@ -279,21 +282,21 @@ flows: - id: calm-os name: Calm OS - description: Distraction-free dev workstation — apps + OS settings + WSL, all in one DSC + description: Distraction-free dev workstation — apps + OS settings + WSL, in one PowerShell run category: user-experience tags: [user-experience, calm-os, distraction-free, taskbar, wsl, ubuntu] icon: 🧘 onboardingUrl: https://dev.windows.com - # Heavy machine-state changes (Sudo, Recall off, Click To Do off, WSL + - # Ubuntu install with a forced reboot, etc.) — keep out of the automated - # matrix. The probe under src/tests/calm-os/probe.ps1 is for a human running - # the flow locally; it asserts that `git` resolves on PATH after the - # install (the apps module's first dep) as a fast smoke signal. + # PowerShell-native flow: there is no configuration.winget, so the + # `configuration` key is omitted (same shape as comfort-shell). + # Heavy machine-state changes (Sudo, Developer Mode, WSL + Ubuntu with a + # forced reboot) keep it out of the automated matrix. The probe under + # src/tests/calm-os/probe.ps1 is for a human running the flow locally; it + # asserts that `git` resolves on PATH afterwards as a fast smoke signal. manual_test: true os: [windows] windows: - install: windows-dev-config/install.ps1 - configuration: windows-dev-config/dev-config.winget + install: windows-dev-config/dev-config.ps1 build: "" run: pwsh -NoProfile -File src/tests/calm-os/probe.ps1 expected: src/tests/calm-os/expected.txt diff --git a/src/tests/calm-os/probe.ps1 b/src/tests/calm-os/probe.ps1 index a73dc46..66451ed 100644 --- a/src/tests/calm-os/probe.ps1 +++ b/src/tests/calm-os/probe.ps1 @@ -1,11 +1,10 @@ # Smoke-test probe for the calm-os user-experience flow. # -# After the master config has been applied, the apps module installs -# git via winget. The simplest signal a human can use to confirm the -# flow worked is: does `git --version` exit 0 after the run? If so, -# the apps module reached completion (git is the first dep in the -# chain). If not, something tripped during install and the user -# should look at the install transcript. +# After the flow has run, the packages phase has installed git via winget. +# The simplest signal a human can use to confirm the flow worked is: does +# `git --version` exit 0 afterwards? If so, the packages phase reached +# completion. If not, something tripped during install and the user should +# look at devconfig-log.txt next to dev-config.ps1. # # Output: `OK` if git is on PATH and `git --version` exits 0; # throw otherwise (which the harness surfaces as a failure). diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index b475d35..60199b8 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -1,282 +1,460 @@ -# Dev Configuration - -A WinGet Configuration (DSC) file that sets up a clean, lightweight, distraction-free developer workstation. The goal is a PC state that devs actually love using: no clutter, no noise, just the tools you need. - -This mirrors the curated environment currently provided by Cloud PC, so developers get a consistent experience regardless of device. - -The flow is a single DSC document (`dev-config.winget`) that handles everything end-to-end: elevation, the OS tweaks, the apps, the fonts, the shell prompt, and the WSL platform + Ubuntu install (including the reboot dance). - -> **Author:** Hamza Usmani. - -## Table of Contents - -- [Goals](#goals) -- [Prerequisites](#prerequisites) -- [Usage](#usage) -- [What this configures](#what-this-configures) -- [Configuration details](#configuration-details) - - [Phase resources (elevation + WSL)](#phase-resources-elevation--wsl) - - [Apps](#apps) - - [Theme and OS](#theme-and-os) - - [File Explorer](#file-explorer) - - [Taskbar](#taskbar) - - [Start, Search, Notifications](#start-search-notifications) - - [Services and features](#services-and-features) - - [Edge](#edge) - - [Fonts](#fonts) - - [Windows Terminal](#windows-terminal) - - [PowerShell profile](#powershell-profile) -- [Customization](#customization) -- [Design decisions](#design-decisions) -- [Known caveats](#known-caveats) +# Windows Dev Config + +*Turns a fresh Windows 11 machine into a clean, distraction-free developer workstation in one command.* + +This flow installs the tools you'd install anyway, applies the Windows settings you'd change anyway, and sets up WSL + Ubuntu including the reboot in the middle. It is a set of PowerShell scripts: no configuration file to point at, no repo to clone, nothing to install first. + +It is **idempotent** — every change is checked before it's made, so re-running it only fixes what has drifted. It is also **resumable** — if it fails, or you close the window, running it again picks up where it left off. + +> **Original design and curation:** Hamza Usmani. + +## Table of contents + +- [Quick start](#quick-start) +- [What to expect](#what-to-expect) +- [Requirements](#requirements) +- [Before you run this](#before-you-run-this) +- [What it changes](#what-it-changes) +- [How it works](#how-it-works) +- [Running it other ways](#running-it-other-ways) +- [Security](#security) +- [Troubleshooting](#troubleshooting) +- [Undoing it](#undoing-it) +- [Customizing it](#customizing-it) +- [Known limitations](#known-limitations) +- [For contributors](#for-contributors) --- -## Goals - -- **A PC devs actually want to use.** Clean Explorer, dark theme, no pop-ups, no recommendations, no widgets. Just your code and your tools. -- **Cloud PC parity.** Same tooling, OS settings, and policies as the current Cloud PC image. -- **One command.** `winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity` takes a fresh Windows machine to fully ready, including WSL + Ubuntu (with an auto-resume across the required reboot). -- **Idempotent.** Safe to re-run on existing machines to apply updates or fix drift. Every resource has a `testScript` or DSC-native idempotency. - -## Prerequisites - -- Windows 11 (latest). -- `winget` with the DSC v3 processor available (the file uses `Microsoft.WinGet/Package`, `Microsoft.Windows/Registry`, and `Microsoft.DSC.Transitional/*`). -- Administrator rights — the `ElevationCheck` resource will auto-relaunch winget elevated via `Start-Process -Verb RunAs` if you started in an unelevated session, but you'll need to consent at the UAC prompt. -- The Microsoft Visual C++ Redistributable when invoking `winget` from a non-elevated environment. Without it, `winget configure` fails with an internal error. See [aka.ms/vcredist](https://aka.ms/vcredist) or install via winget (see the Usage callout below). -- The repo on disk. `winget configure` reads a local file path, and the bootstrap is what installs Git, so on a fresh machine you'll either `git clone` (if Git is already installed) or download the repo as a ZIP from GitHub and extract it before running. -- **Hardware virtualization must be available to the OS** before WSL can install. On bare metal, this means virtualization (VT-x / AMD-V) is enabled in BIOS/UEFI. Inside a VM, it means the host has exposed nested virtualization to the guest. See the Usage callout below. - -## Usage - -> [!IMPORTANT] -> If `winget` is being invoked from a **non-elevated** environment, the Microsoft Visual C++ Redistributable ([aka.ms/vcredist](https://aka.ms/vcredist)) must also be installed — without it `winget configure` fails with an internal error. Install it once with the command for your machine's architecture: -> -> ```powershell -> # x64: -> winget install Microsoft.VCRedist.2015+.x64 -> -> # ARM64: -> winget install Microsoft.VCRedist.2015+.arm64 -> ``` - -> [!IMPORTANT] -> **WSL needs hardware virtualization.** If virtualization isn't available to the OS, the `InstallUbuntu` step fails with `wsl --install ... failed with exit code -1`. -> -> - **On bare metal:** enable virtualization (VT-x / AMD-V) in your BIOS/UEFI. The exact label varies by vendor — check your motherboard or laptop manufacturer's documentation if you can't find it. Reboot into firmware settings, toggle it on, save, and reboot back into Windows. -> - **Inside a VM:** the host must expose nested virtualization to the guest. For a Hyper-V host, run this from an elevated PowerShell session **on the host** (with the guest VM powered off): -> -> ```powershell -> Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true -> ``` -> -> Other hypervisors have their own equivalent settings — check your hypervisor's documentation. - -**Get the files first** (skip if you already have the repo locally): +## Quick start + +Open **any** PowerShell window — Windows PowerShell or PowerShell 7, elevated or not — and run: + +```powershell +irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex +``` + +That's the whole thing. You'll get one UAC prompt, and the machine will restart once. + +
+What that command actually does + +`irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text and `iex` (`Invoke-Expression`) runs it. The bootstrap then: + +1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. +2. Copies the setup — [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder — into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. + +The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. + +
+ +## What to expect + +Roughly **30 minutes** on a clean machine with a good connection, most of it spent downloading Visual Studio Code, the .NET SDK, PowerToys, and Ubuntu. + +| # | What happens | Your involvement | +| - | ------------ | ---------------- | +| 1 | A UAC prompt appears | **Accept it.** Most of the settings are machine-wide and need Administrator. | +| 2 | PowerShell 7 is installed if it isn't already, and the setup restarts itself on it | None | +| 3 | Ten phases run: packages, Windows settings, fonts, Terminal, prompt, Copilot | None. Long silent stretches during big downloads are normal — a "still working" note prints every minute | +| 4 | WSL is installed. The machine warns you and **restarts after 10 seconds** | **Save your work before you start.** | +| 5 | You sign back in; a window opens by itself and finishes the run | None | +| 6 | A summary prints: how many things changed, how many were already fine | Press a key to close, or leave it — it closes itself after 15 minutes | + +Afterwards, open **Ubuntu** from the Start menu once to create your Linux username and password. Some Explorer and taskbar changes appear after you sign out and back in. + +## Requirements + +- **Windows 11.** Built and tested against current Windows 11 releases. A few of the settings only exist on newer builds; on older ones those steps are skipped rather than failing the run. Windows 10 is not supported. +- **Administrator rights** on the machine, and the ability to accept a UAC prompt. +- **Internet access** to `github.com`, `raw.githubusercontent.com`, the PowerShell Gallery, and the winget package sources. Behind a proxy, the run needs your proxy configured for WinHTTP and for `winget`. +- **Hardware virtualization available to the OS** — WSL cannot install without it. On a physical machine that means VT-x / AMD-V enabled in BIOS/UEFI. In a VM it means the host has exposed nested virtualization to the guest. Everything except WSL still works without it; see [Troubleshooting](#troubleshooting). +- **About 15 GB of free disk space** for the full package set. + +You do **not** need Git, a repository clone, `winget configure`, the Visual C++ Redistributable, or PowerShell 7 beforehand. The flow handles all of those. + +## Before you run this + +This flow is opinionated, and a few of its choices are worth knowing about up front rather than discovering later. + +| Change | Why it might matter to you | +| ------ | -------------------------- | +| **Remote Desktop is enabled** | `fDenyTSConnections` is set to `0`, which allows incoming RDP sessions. The Windows Firewall rule is *not* opened, so this alone doesn't expose the machine to your network — but it is a real change to the machine's posture. | +| **Two Edge settings are applied as policy** | They're written under `HKLM\SOFTWARE\Policies\Microsoft\Edge`, so Edge will report "managed by your organization" and grey those two settings out in its UI. | +| **All notifications are turned off** | Do Not Disturb is enabled globally, not just for a quiet-hours window. Teams, Outlook, and everything else stop raising toasts until you turn it back on. | +| **Both Node.js LTS and nvm-windows are installed** | They are two different ways to manage Node. If you plan to use nvm, uninstall Node.js first so nvm owns the PATH entry. | +| **Windows Terminal's `settings.json` is rewritten** | A `settings.json.bak` is written next to it first, but any comments in your settings file are lost, because the file is round-tripped through JSON. If the file can't be parsed the run stops and leaves it untouched. | +| **There's no uninstall** | Nothing that gets applied is reverted automatically. [Undoing it](#undoing-it) lists the manual reversals. | + +Every one of these is listed in full detail in [What it changes](#what-it-changes). + +## What it changes + +51 individual steps across 11 phases. Each one is checked first and skipped if the machine is already in that state. + +### Packages + +Installed with winget from the `winget` source, silently, with agreements accepted: + +| Package | winget id | +| ------- | --------- | +| Windows Terminal | `Microsoft.WindowsTerminal` | +| PowerShell 7 | `Microsoft.PowerShell` | +| Git | `Git.Git` | +| GitHub CLI | `GitHub.cli` | +| GitHub Copilot CLI | `GitHub.Copilot` | +| Visual Studio Code | `Microsoft.VisualStudioCode` | +| .NET SDK 10 | `Microsoft.DotNet.SDK.10` | +| Python 3.14 | `Python.Python.3.14` | +| uv | `astral-sh.uv` | +| Node.js LTS | `OpenJS.NodeJS.LTS` | +| nvm for Windows | `CoreyButler.NVMforWindows` | +| Coreutils for Windows | `Microsoft.Coreutils` | +| Oh My Posh | `JanDeDobbeleer.OhMyPosh` | +| Windows App CLI | `Microsoft.WinAppCli` | +| PowerToys | `Microsoft.PowerToys` | + +A package counts as done only when winget reports it installed **and** current, so a re-run also picks up available updates. + +
+Windows settings — all 25 registry values + +**System** (`HKLM`, requires Administrator) + +| Setting | Key | Value | +| ------- | --- | ----- | +| Sudo, inline mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo\Enabled` | `3` | +| Developer Mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | `1` | +| Win32 long paths | `SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` | `1` | +| Remote Desktop allowed | `SYSTEM\CurrentControlSet\Control\Terminal Server\fDenyTSConnections` | `0` | + +**File Explorer** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Show file extensions | `Advanced\HideFileExt` | `0` | +| Show hidden files | `Advanced\Hidden` | `1` | +| Full path in the title bar | `Advanced\FullPathAddress` | `1` | +| Open Explorer to This PC | `Advanced\LaunchTo` | `1` | +| No frequent folders in Quick Access | `Advanced\ShowFrequent` | `0` | +| No recent files in Quick Access | `ShowRecent` | `0` | +| No recommended or cloud files | `ShowCloudFilesInQuickAccess` | `0` | +| Git status columns in Explorer | `Advanced\NavPaneShowVersionControl` | `1` | +| No sync-provider tips | `Advanced\ShowSyncProviderNotifications` | `0` | + +**Taskbar, Start, search and notifications** + +| Setting | Key | Value | +| ------- | --- | ----- | +| Do Not Disturb (all toasts off) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | `0` | +| Hide the Bluetooth tray icon | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | `0` | +| "End Task" on taskbar right-click | `HKCU\...\Explorer\Advanced\TaskbarEndTask` | `1` | +| No web results in search | `HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer\DisableSearchBoxSuggestions` | `1` | +| No search highlights | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | `0` | +| No Start menu recommendations | `HKCU\...\Explorer\Advanced\Start_IrisRecommendations` | `0` | +| Widgets off | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | `0` | +| No PowerToys always-on-top toasts | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | `0` | + +Widgets are turned off through the OS policy value because the per-user taskbar icon value no longer takes effect on Windows 11 24H2 and later. + +**Microsoft Edge** (`HKLM\SOFTWARE\Policies\Microsoft\Edge`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Blank new tab page | `NewTabPageLocation` | `about:blank` | +| Skip the first-run experience | `HideFirstRunExperience` | `1` | + +**Theme** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Dark mode for apps | `AppsUseLightTheme` | `0` | +| Dark mode for the system | `SystemUsesLightTheme` | `0` | + +
+ +### Fonts, Terminal and prompt + +- **Cascadia Code NF** and **Cascadia Mono NF** are downloaded from the pinned [`microsoft/cascadia-code`](https://github.com/microsoft/cascadia-code/releases) release `2407.24`, verified against a known SHA-256, and installed **per-user** under `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. +- **Windows Terminal** gets Cascadia Mono NF as its default font face and PowerShell 7 as its default profile. `settings.json` is backed up to `settings.json.bak` before either change. +- **Oh My Posh** is initialized from your PowerShell 7 `$PROFILE`. If an `oh-my-posh init` line is already there, nothing is added. +- A **GitHub Copilot** profile is added to Windows Terminal as a settings fragment in `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`, so it appears in the dropdown without editing your settings file. + +### Developer extras + +These are **best-effort**: they need the network and a PATH that has just been updated, so a failure is flagged in the summary rather than stopping the run. + +- The **WinUI templates** for `dotnet new` (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`). +- The **`microsoft/win-dev-skills`** marketplace and its **WinUI plugin**, registered with the GitHub Copilot CLI. + +### WSL + +- The WSL platform components, via `wsl --install --no-distribution`. If that isn't available, the `VirtualMachinePlatform` and `Microsoft-Windows-Subsystem-Linux` Windows features are enabled directly with `dism.exe` instead. +- A restart, if one is needed — see [Reboot and resume](#reboot-and-resume). +- **Ubuntu**, via `wsl --install -d Ubuntu --no-launch`, falling back to `--web-download` if the Microsoft Store route doesn't complete. The distro's first-run welcome screen is suppressed; open Ubuntu from the Start menu to create your Linux user. + +Nothing *inside* the distro is configured by this flow. For that, see [WSL Comfort](../wsl-comfort/readme.md). + +## How it works + +### The phases + +| # | Phase | Notes | +| - | ----- | ----- | +| 1 | Getting ready | Confirms PowerShell 7 and a winget new enough to drive non-interactively (1.6.0+), repairing winget if not | +| 2 | Packages | The 15 packages above, plus the PowerToys notification setting | +| 3 | System settings | Sudo, Developer Mode, long paths, Remote Desktop | +| 4 | File Explorer tweaks | | +| 5 | Taskbar, search & start tweaks | | +| 6 | Microsoft Edge tweaks | | +| 7 | Fonts | | +| 8 | Windows Terminal | | +| 9 | PowerShell profile | | +| 10 | GitHub Copilot | The Terminal profile, WinUI templates, and the Copilot CLI plugin — all best-effort | +| 11 | WSL + Ubuntu | Last on purpose, so its restart happens after everything else is done | + +### Check, apply, verify + +Every step is a triple: a check, an apply, and the same check again. + +- If the check passes first time, the step prints `already OK` and nothing runs. +- If the apply runs but the check still fails afterwards, that's an error — not a silent success. +- Steps that aren't worth stopping the whole run for are marked **best-effort**. If one of those fails it's reported as **flagged**, the run continues, and the summary names it at the end so it doesn't scroll past you. + +That's why the totals in the summary can add up to more than 51: the tally is saved across the reboot and carried into the resumed run, which re-checks every step it already did. Steps counted before the restart are counted again when they're confirmed after it. + +### Elevation and PowerShell 7 + +The setup relaunches itself twice before doing any work: + +1. **Elevated**, via UAC, if it wasn't already. Declining the prompt stops the run cleanly without changing anything. +2. **On PowerShell 7**, installing it first if necessary. The WinGet PowerShell module behaves more consistently there than on Windows PowerShell 5.1. If PowerShell 7 can't be installed the run continues on Windows PowerShell and says so. + +A machine-wide lock (`Global\WindowsDevConfigSetup`) means a second copy won't start while one is running — it tells you to switch windows instead of letting two runs fight over the same installs. + +### Reboot and resume + +Enabling the WSL platform requires a restart. When one is needed, the setup: + +1. Registers a scheduled task named **`WindowsDevConfigResume`** that runs at your next logon, as you, elevated, after a 30-second delay. +2. Saves its progress so far to `devconfig-tally.json`. +3. Prints a warning and restarts after **10 seconds**. + +After you sign in, the task opens a window, finishes the run, prints the combined summary for both halves, and removes itself. If Windows refuses the restart, the setup tells you and leaves the task registered — restart whenever you like and it still resumes. + +Only one restart is ever performed. If WSL still isn't usable after it, the run stops and explains why rather than rebooting again. + +### Logs + +A full transcript is written to **`devconfig-log.txt`** next to `dev-config.ps1` — so `%LOCALAPPDATA%\CalmOS\devconfig-log.txt` for the one-liner. The path is printed at the end of every run. + +The transcript is more verbose than the console on purpose: it records handled errors and raw command output that are deliberately kept off screen. Text in the log that isn't on your console is usually something the run recovered from. + +## Running it other ways + +**From a clone, with the repo already on disk:** ```powershell -# Git already installed: -git clone https://github.com/microsoft/WindowsDeveloperConfig.git -cd WindowsDeveloperConfig\windows-dev-config - -# Otherwise, download and extract the ZIP: -Invoke-WebRequest -Uri https://github.com/microsoft/WindowsDeveloperConfig/archive/refs/heads/main.zip -OutFile WindowsDeveloperConfig.zip -Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . -cd .\WindowsDeveloperConfig-main\windows-dev-config +.\src\windows-dev-config\dev-config.ps1 ``` -**Full setup (recommended):** +**Pin a tag, or try a branch.** `-Ref` takes a branch, tag, or commit SHA. Passing arguments needs the script-block form rather than `| iex`: ```powershell -winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Ref 'v1.2.3' ``` -This is the canonical invocation documented in the header of `dev-config.winget`. +**Download it but don't run it**, so you can read it first: -**What to expect:** +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -NoLaunch +``` -1. The first phase applies all OS tweaks, installs apps, installs Cascadia Code/Mono Nerd Fonts, and configures Windows Terminal and the PowerShell profile. -2. WSL platform components install; the DSC reboots the machine and registers a `RunOnce` resume. -3. After login, winget configure resumes automatically and installs the default Ubuntu distro. -4. Open Ubuntu from the Start menu to complete its first-launch setup (create a UNIX username and password). +**Install somewhere else:** `-InstallRoot 'D:\tools\devconfig'`. The location has to survive the reboot, so avoid `%TEMP%`. -The configuration is idempotent, so it is safe to re-run after reboot or at any later point. +**Already elevated and want it to stay that way:** `dev-config.ps1 -NoElevate` fails fast instead of prompting. -## What this configures +## Security -- **14 apps** via winget (PowerShell 7, Git, GitHub CLI, GitHub Copilot CLI, VS Code, .NET SDK 10, Python 3.14, UV, Node.js LTS, NVM for Windows, Coreutils for Windows, Windows Application CLI, plus optional Oh My Posh and PowerToys). -- **WSL + Ubuntu**, installed via 3 transitional script resources that bracket a reboot (Phase 2/3/4 below). -- **~24 registry settings** for theme/OS, Explorer, Taskbar, Search, Start, Notifications, Edge, Sudo, and the Widget service. -- **Cascadia Code & Cascadia Mono Nerd Fonts** downloaded from the `microsoft/cascadia-code` GitHub release and registered per-user. -- **5 script resources** beyond the WSL phases: - - `ElevationCheck` — re-launches winget elevated if not already admin. - - `darkTheme` — applies the built-in `dark.theme` to switch to dark mode. - - `InstallCascadiaCodeNerdFonts` — downloads and installs the Nerd Font variants of Cascadia Code/Mono. - - `SetCascadiaNfAsDefault` — sets `Cascadia Mono NF` as the default font face in Windows Terminal's `settings.json`. - - `ps7default` — sets PowerShell 7 as Windows Terminal's default profile. - - `ohMyPoshProfileSet` — adds `oh-my-posh init pwsh | Invoke-Expression` to `$PROFILE` and dot-sources it. +**What runs elevated.** The whole setup, after the single UAC prompt. It needs Administrator for the `HKLM` settings, the WSL Windows features, and machine-wide package installs. ---- +**What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. -## Configuration details +**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. Whichever address you fetch the bootstrap from, it installs the signed copy of the setup when the ref you asked for has one, and tells you in its output when it falls back to the source copy instead. -All resources are dscv3 (`$schema: .../DSC/main/schemas/2023/08/config/document.json`, `metadata.winget.processor.identifier: dscv3`). Every resource that touches HKLM or runs elevated tools depends on `ElevationCheck`. +**What it does not do.** It doesn't collect or send telemetry, doesn't sign you in to anything, doesn't change credentials or Windows Defender settings, and doesn't touch files in your user profile beyond the PowerShell profile and Windows Terminal settings described above. -Package resources use `Microsoft.WinGet/Package` with `source: winget` and `useLatest: true` (except `Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, and `OpenJS.NodeJS.LTS`, which are pinned by id). +## Troubleshooting -### Phase resources (elevation + WSL) +
+The run stopped and said it needs Administrator -| Name | Type | What it does | -|------|------|--------------| -| `ElevationCheck` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` checks `IsInRole(Administrator)`. If false, `setScript` re-invokes `winget configure --file --accept-configuration-agreements --disable-interactivity --wait` via `Start-Process -Verb RunAs`, then throws so the unelevated session ends cleanly. | -| `InstallWslComponents` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` probes for the `vmcompute` service (presence ⇒ Virtual Machine Platform is active). `setScript` runs `wsl --install --no-distribution`. | -| `RebootForVmp` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | Same `vmcompute` test. `setScript` registers `HKCU:\...\RunOnce\DSCConfigureResume` with the same `winget configure --file --accept-configuration-agreements` command, then `Restart-Computer -Force` and throws so DSC stops the current run. | -| `InstallUbuntu` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` runs `wsl --list --quiet` and returns true if any distro is already registered. `setScript` runs `wsl --install -d Ubuntu --no-launch`. | +The UAC prompt was declined. Nothing was changed. Run the command again and accept it, or start from a terminal that's already elevated. -All app resources that need WSL present depend on `InstallUbuntu` so the OS work happens before the reboot — but the WSL install is still part of the same `winget configure` invocation thanks to the RunOnce resume. +
-### Apps +
+"Calm OS setup is already running in another window" -| Resource name | Package id | Notes | -|---------------|-----------|-------| -| `PowerShell` | `Microsoft.PowerShell` | Direct dependency on `ElevationCheck`. | -| `Git` | `Git.Git` | Depends on `ElevationCheck` + `InstallUbuntu`. | -| `GitHubCLI` | `GitHub.Cli` | Depends on `Git` + `InstallUbuntu`. | -| `GitHubCopilot` | `GitHub.Copilot` | Depends on `Git` + `InstallUbuntu`. | -| `VSCode` | `Microsoft.VisualStudioCode` | | -| `DotnetSdk` | `Microsoft.dotnet.SDK.10` | Pinned to v10. | -| `Python` | `Python.Python.3.14` | Pinned to 3.14. | -| `UV` | `astral-sh.uv` | | -| `NodeJS` | `OpenJS.NodeJS.LTS` | Pinned to the LTS line (currently Node 24 LTS). | -| `nvmForNode` | `CoreyButler.NVMforWindows` | Node version manager for Windows. | -| `Coreutils` | `Microsoft.Coreutils` | Microsoft-maintained Coreutils for Windows. Command integration is handled by the package itself after install. | -| `OhMyPosh` | `JanDeDobbeleer.OhMyPosh` | Marked Optional in the comments. Triggers `ohMyPoshProfileSet`. | -| `winappCli` | `Microsoft.winappcli` | Windows Application CLI. | -| `PowerToys` | `Microsoft.PowerToys` | Marked Optional. Followed by `PowerToysAOT` which disables AOT notifications via registry. | +Exactly what it says — switch to the other window. Two copies would fight over the same installs. If you're sure nothing is running, the previous process didn't exit cleanly; sign out and back in, or restart, and try again. -### Theme and OS +
-Dark theme is applied via a `RunCommandOnSet` resource named `darkTheme` (not via registry): +
+Some steps came back "flagged" -| Resource | Type | What it does | -|----------|------|--------------| -| `darkTheme` | `Microsoft.DSC.Transitional/RunCommandOnSet` | `Start-Process` on `C:\Windows\Resources\Themes\dark.theme`, sleeps 2 s, then stops `SystemSettings` so the Settings window doesn't linger. Depends on `PowerShell`. | +Flagged means best-effort work that couldn't be completed or confirmed. The run finishes and names them in the summary. Everything else was applied. -The remaining theme/OS entries below are `Microsoft.Windows/Registry`. +The most common cause is a step that needs a package that hasn't finished registering yet — the WinUI templates need the .NET SDK on `PATH`, and the Copilot plugin steps need the GitHub Copilot CLI. **Run the command again**: the steps that already succeeded are skipped in seconds and only the flagged ones are retried. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Sudo enabled (inline mode) | `HKLM\...\Sudo\Enabled` | DWord `3` | -| Developer Mode | `HKLM\...\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | DWord `1` | -| Long path support | `HKLM\...\FileSystem\LongPathsEnabled` | DWord `1` | -| Remote Desktop on | `HKLM\...\Terminal Server\fDenyTSConnections` | DWord `0` | +
-### File Explorer +
+WSL fails, or Ubuntu doesn't install -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Show file extensions | `HKCU\...\Advanced\HideFileExt` | DWord `0` | -| Show hidden files | `HKCU\...\Advanced\Hidden` | DWord `1` | -| Full path in titlebar | `HKCU\...\Advanced\FullPathAddress` | DWord `1` | -| Open to This PC | `HKCU\...\Advanced\LaunchTo` | DWord `1` | -| Frequent folders off | `HKCU\...\Advanced\ShowFrequent` | DWord `0` | -| Frequent files off | `HKCU\...\Explorer\ShowRecent` | DWord `0` | -| Recommended/cloud files off | `HKCU\...\Explorer\ShowCloudFilesInQuickAccess` | DWord `0` | -| Git integration in Explorer | `HKCU\...\Advanced\NavPaneShowVersionControl` | DWord `1` | -| Tips/sync-provider notifications off | `HKCU\...\Advanced\ShowSyncProviderNotifications` | DWord `0` | +Almost always hardware virtualization not being available to the OS. -### Taskbar +- **Physical machine:** enable virtualization (VT-x / AMD-V) in BIOS/UEFI. The label varies by vendor — check your manufacturer's documentation. Reboot into firmware settings, turn it on, save, and boot back into Windows. +- **Virtual machine:** the host has to expose nested virtualization to the guest. On a Hyper-V host, with the guest powered off: -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widgets button hidden | `HKCU\...\Advanced\TaskbarDa` | DWord `0` | -| Bluetooth notification icon off | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | DWord `0` | -| End Task on right-click | `HKCU\...\Advanced\TaskbarEndTask` | DWord `1` | + ```powershell + Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true + ``` -### Start, Search, Notifications + Other hypervisors have their own equivalent. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Web search suggestions off | `HKCU\...\Policies\Explorer\DisableSearchBoxSuggestions` | DWord `1` | -| Search highlights off | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | DWord `0` | -| Start menu recommendations off | `HKCU\...\Advanced\Start_Layout` | DWord `1` | -| Toast notifications off (Do Not Disturb) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | DWord `0` | +Then run the setup again. Everything else stays applied; only the WSL steps are retried. -### Services and features +If virtualization is definitely on and WSL still won't activate after the restart, the run says so and stops rather than rebooting in a loop. The other likely cause is that the machine couldn't reach the WSL download. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widget service off (HKLM policy) | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | DWord `0` | -| PowerToys AOT notifications off | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | DWord `0` | +
-### Edge +
+"WinGet is older than 1.6.0" or winget can't be updated -HKLM policies, applied via `Microsoft.Windows/Registry`: +The setup needs a winget that supports non-interactive installs, and tries to repair or update it. If it can't — usually because the built-in `winget` command is being used and the PowerShell module isn't reachable — update **App Installer** from the Microsoft Store, or install the latest release from [microsoft/winget-cli](https://github.com/microsoft/winget-cli/releases/latest), then run the setup again. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| New tab blank | `HKLM\SOFTWARE\Policies\Microsoft\Edge\NewTabPageLocation` | String `about:blank` | -| First-run experience off | `HKLM\SOFTWARE\Policies\Microsoft\Edge\HideFirstRunExperience` | DWord `1` | +
-### Fonts +
+Nothing happened after the restart -| Resource | Type | What it does | -|----------|------|--------------| -| `InstallCascadiaCodeNerdFonts` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Downloads `CascadiaCode-2407.24.zip` from `microsoft/cascadia-code` GitHub Releases, extracts `CascadiaCodeNF.ttf` and `CascadiaMonoNF.ttf` to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, and registers each under `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`. Per-user install — no admin required for this step. Depends on `PowerShell`. | +The resume task waits 30 seconds after logon before starting, and the first thing it does is re-check what's already done, which is quiet. Give it a couple of minutes. -### Windows Terminal - -| Resource | Type | What it does | -|----------|------|--------------| -| `SetCascadiaNfAsDefault` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Locates Windows Terminal's `settings.json` (Store or unpackaged install), backs it up to `settings.json.bak`, and sets `profiles.defaults.font.face = "Cascadia Mono NF"`. Depends on `InstallCascadiaCodeNerdFonts`. | -| `ps7default` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Invokes `pwsh.exe -NoProfile -NoLogo -Command ...` which reads `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, finds the PowerShell 7 profile, and sets it as `defaultProfile`. Depends on `PowerShell`. | +If nothing appears at all, check the task exists: -### PowerShell profile +```powershell +Get-ScheduledTask -TaskName WindowsDevConfigResume +``` -| Resource | Type | What it does | -|----------|------|--------------| -| `ohMyPoshProfileSet` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Creates `$PROFILE` if missing and appends `oh-my-posh init pwsh | Invoke-Expression` (idempotent — uses `Select-String` to check first), then dot-sources `$PROFILE`. Depends on `OhMyPosh`. | +Either way, running the original command again is safe and picks up exactly where it left off. ---- +
+ +
+"Windows Terminal's settings file couldn't be read as JSON" + +Your `settings.json` has a syntax error, so the setup stopped rather than overwrite a file it couldn't understand. Fix or rename the file named in the message, then run the setup again. + +
+ +
+Downloads fail or time out + +The setup retries with backoff and raises TLS 1.2 for you, so this is usually a proxy. `winget` and WinHTTP each need to know about it: + +```powershell +netsh winhttp show proxy +``` + +Configure your proxy for both, then run the setup again. + +
+ +
+Where do I look when none of the above fits? + +`devconfig-log.txt`, in the same folder as `dev-config.ps1` (`%LOCALAPPDATA%\CalmOS` when you used the one-liner). The path is printed at the end of every run. + +Then please [open an issue](https://github.com/microsoft/WindowsDeveloperConfig/issues) with your Windows build (`winver`), the command you ran, and the relevant part of that log. Setup that fails on a real machine is a bug worth fixing. + +
+ +## Undoing it + +There is no automatic undo, and the setup never removes anything on its own. The reversals below are the ones most people ask about. Registry changes under `HKLM` need an elevated prompt. + +```powershell +# Remote Desktop off again +Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' fDenyTSConnections 1 + +# Drop the two Edge policies (removes "managed by your organization" for them) +Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' NewTabPageLocation, HideFirstRunExperience + +# Notifications back on +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings' NOC_GLOBAL_SETTING_TOASTS_ENABLED 1 + +# Widgets back on +Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Dsh' AllowNewsAndInterests + +# Back to light mode +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' AppsUseLightTheme 1 +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' SystemUsesLightTheme 1 +``` -## Customization - -- **Pick and choose packages.** Comment out any `Microsoft.WinGet/Package` block to skip that install — most have no `dependsOn` chain beyond `InstallUbuntu` (exceptions: `GitHubCLI` and `GitHubCopilot` depend on `Git`; `PowerToysAOT` depends on `PowerToys`; `ohMyPoshProfileSet` depends on `OhMyPosh`). -- **Pin or unpin versions.** Switch `id: Python.Python.3.14` (pinned) to `id: Python.Python.3` if you want to drift forward, or switch `OpenJS.NodeJS.LTS` to `OpenJS.NodeJS` for current. Vice versa for the unpinned packages. -- **Toggle registry values.** Most settings are `DWord: 0` or `DWord: 1`; flip the value to invert the behavior. -- **Re-enable commented-out tweaks.** `HideDesktopIcons` ships commented out (it over-fires on some user setups). Uncomment to enable. -- **Change the WSL distro.** Edit the `wsl --install -d Ubuntu --no-launch` line inside the `InstallUbuntu` resource. -- **Change the terminal font.** Edit `$fontFace = 'Cascadia Mono NF'` inside `SetCascadiaNfAsDefault`, or change the `$WantedFonts` array in `InstallCascadiaCodeNerdFonts` to install a different Cascadia variant. -- **Skip the dark theme step.** Comment out the `darkTheme` resource if you prefer light mode (or want to set it manually). - -## Design decisions - -| Decision | Rationale | -|----------|-----------| -| Single dscv3 document, no modules | Easier to reason about and easier to re-run. The whole flow is one `winget configure` call. | -| `Microsoft.Windows/Registry` everywhere instead of `Microsoft.Windows.Developer/*` or `Microsoft.Windows.Settings/WindowsSettings` | Direct registry control is reliable across Windows 11 builds and avoids dependencies on legacy resource modules. | -| `Microsoft.DSC.Transitional/WindowsPowerShellScript` (not `PSDscResources/Script`) | The dscv3 transitional resource is the supported equivalent under the new processor. | -| Self-relaunch elevated from `ElevationCheck` | A user can double-click into an unelevated shell and the DSC will UAC-prompt itself rather than failing. | -| Reboot + RunOnce inside the DSC | The DSC owns the reboot and the resume, so the user only invokes `winget configure` once. The throw after `Restart-Computer -Force` is required because `Restart-Computer` returns immediately after signalling shutdown; without the throw DSC would treat the resource as succeeded and continue. | -| `useLatest: true` on most packages | Cloud PC parity tracks "current" tools. Pinned ids (`Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, `OpenJS.NodeJS.LTS`) are used where a major-version line matters. | -| Dark theme via `dark.theme` file (not registry) | Applying the shipped `.theme` file flips both `AppsUseLightTheme` and `SystemUsesLightTheme` *and* applies the matching color scheme/cursors atomically, which the broadcast-message dance you'd otherwise need from a registry-only approach often misses. | -| Per-user font install | Avoids requiring admin for the font step and keeps the font registration under `HKCU`, which is what modern Windows + Terminal expect. | -| `RunCommandOnSet` to mutate `settings.json` | Windows Terminal's settings are JSON-based and not registry-mapped; a small pwsh fragment is the cleanest way. | - -## Known caveats - -| Area | Caveat | -|------|--------| -| **`acceptAgreements` not on packages** | None of the `Microsoft.WinGet/Package` resources set `acceptAgreements: true`. The header comment compensates by passing `--accept-configuration-agreements` on the command line. | -| **WSL reboot** | `RebootForVmp` will hard-reboot the machine via `Restart-Computer -Force`. Save your work before running. The RunOnce key resumes the config on next login. | -| **Ubuntu first-launch** | After `InstallUbuntu`, you still need to open Ubuntu from the Start menu once to create a UNIX user. Nothing inside the distro is configured by this flow. | -| **`useLatest: true`** | Each run grabs the latest available version. Builds may differ between machines applying the config on different days. | -| **HKLM registry keys** | Sudo, the Widget service policy, Edge policies, Remote Desktop, Long Paths, and Developer Mode all live in HKLM. The `ElevationCheck` gate guarantees the run is elevated; without it these would silently fail. | -| **PowerToys AOT path** | `HKCU\...\Notifications\Settings\PowerToys\Enabled` targets a specific registry path that may change across PowerToys versions. | -| **Idempotency of WSL phases** | `InstallWslComponents` and `RebootForVmp` both test for `vmcompute`. Re-running after the reboot is a no-op for those resources. `InstallUbuntu` queries `wsl --list --quiet`, so it skips once any distro is registered. | -| **Pinned font release** | `InstallCascadiaCodeNerdFonts` hard-codes Cascadia Code release `2407.24` from `microsoft/cascadia-code`. Bump `$Version` to pick up newer releases. | -| **Windows Terminal settings overwrite** | `SetCascadiaNfAsDefault` and `ps7default` rewrite `settings.json` via `ConvertTo-Json`. `SetCascadiaNfAsDefault` writes a `settings.json.bak` first; `ps7default` does not. JSON comments will not survive the round-trip. | -| **`ohMyPoshProfileSet` runs `. $PROFILE`** | Dot-sourcing the profile inside `pwsh -NoProfile` can surface errors from the user's existing profile during DSC apply. | -| **`darkTheme` opens Settings briefly** | Applying `dark.theme` pops the Settings app open; the script kills it after 2 seconds. On slow machines the window may flash visibly. | -| **Currently commented out** | The `HideDesktopIcons` block lives in the file but is commented out. Uncomment to hide desktop icons. | +Everything else: + +- **Packages:** `winget uninstall --id ` using the ids in [Packages](#packages). +- **Explorer, Start and search settings:** all of them are also in Settings and Explorer's Options dialog. Sign out and back in for them to take effect. +- **Windows Terminal:** restore the `settings.json.bak` written next to `settings.json`. +- **The Copilot Terminal profile:** delete `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`. +- **The Oh My Posh prompt:** remove the `oh-my-posh init` block from your PowerShell 7 `$PROFILE`. +- **Ubuntu:** `wsl --unregister Ubuntu`. This permanently deletes the distro's file system. +- **The setup itself:** delete `%LOCALAPPDATA%\CalmOS`. + +## Customizing it + +Everything lives in a named file under [`steps/`](./steps), so changing what runs is a local edit rather than a fork of a large document. Take a copy of the repository, edit, and run `dev-config.ps1` directly. + +| To... | Edit | +| ----- | ---- | +| Add or remove a package | The `$packages` list in [`steps/packages.ps1`](./steps/packages.ps1) | +| Change or drop a Windows setting | The `$tweaks` list in the matching `steps/registry-*.ps1` | +| Skip the Edge policies entirely | Remove `edge.ps1` from the `$phases` list in [`dev-config.ps1`](./dev-config.ps1) | +| Keep Remote Desktop off | Delete the `RemoteDesktop` entry in [`steps/registry-system.ps1`](./steps/registry-system.ps1) | +| Change the terminal font | `$Script:CascadiaDefaultFontFace` in [`steps/fonts.ps1`](./steps/fonts.ps1) | +| Install a different distro | The `wsl --install -d Ubuntu` arguments in [`steps/wsl.ps1`](./steps/wsl.ps1) | +| Add something new | Copy the shape of any phase file: build steps with `New-DevConfigStep` and pass them to `Invoke-DevConfigSteps` | + +A phase is just a file plus an entry in the `$phases` list. Files prefixed with `_` are shared helpers, not phases. + +## Known limitations + +| Area | Detail | +| ---- | ------ | +| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `Restart-Computer -Force`. Save your work before you begin. | +| **Ubuntu's first launch is still manual** | You have to open Ubuntu once to create a Linux username and password. | +| **Package versions move** | Packages are installed at whatever winget currently publishes, so two machines set up on different days can differ. `Microsoft.DotNet.SDK.10` and `Python.Python.3.14` pin a major version and will need bumping as those age. | +| **The font release is pinned** | Cascadia Code `2407.24`, verified by hash. Newer releases need both the version and the hash updated in `steps/fonts.ps1`. | +| **Terminal settings lose their comments** | `settings.json` is round-tripped through JSON, so comments don't survive. A `.bak` is written first. | +| **No package selection at run time** | It's the full set or a local edit. There's no `-Skip` switch and no prompt. | +| **No dry run** | There's no `-WhatIf`. The `already OK` output tells you what a re-run *would* skip, but only after the fact. | +| **Git and GitHub CLI are installed, not configured** | No `git config user.name`, no `gh auth login`. | +| **`%LOCALAPPDATA%\CalmOS` stays behind** | The installed copy and its log are left in place so a resumed or repeated run works. Delete it when you're done. | +| **Some changes need a sign-out** | Several Explorer and taskbar values are read by Explorer at logon. | + +## For contributors + +Source of truth for this flow is `src/windows-dev-config/`. The copy at the repository root is the Authenticode-signed release copy, regenerated by the sign pipeline — don't edit it directly. See [`src/docs/development.md`](https://github.com/microsoft/WindowsDeveloperConfig/blob/main/src/docs/development.md#repo-layout-signed-vs-source). + +| File | What it is | +| ---- | ---------- | +| `bootstrap.ps1` | The remote entry point. Downloads, resolves signed-versus-source, installs, launches. | +| `dev-config.ps1` | The orchestrator. Elevation, PowerShell 7, run lock, logging, the phase list, the summary. | +| `steps/_step-runner.ps1` | The check/apply/verify engine, the tally, and the flag reporting. | +| `steps/_*.ps1` | Shared helpers: elevation, reboot and resume, winget, registry, Terminal settings, retry, process execution, console. | +| `steps/.ps1` | One file per phase, each exporting a single `Invoke-Phase` function. | + +Adding a phase means adding one file and one line in the `$phases` list. Adding a step to an existing phase means one `New-DevConfigStep` call. Keep every step's check cheap and side-effect free — it runs on every invocation, including the fast path where nothing needs doing. diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index 0b09d72..9b8a75a 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -5,13 +5,14 @@ .DESCRIPTION Meant to be run straight from the web: - irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/windows-dev-config/bootstrap.ps1 | iex - - src/windows-dev-config/bootstrap.ps1 is the same script, so either address works. + irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex The setup cannot run from a piped-in string: it loads two dozen files from its own folder, relaunches itself elevated, and resumes after a reboot. This puts it somewhere real first. + The files it installs come from the signed release copy when the ref has one, and from + src/ otherwise, whichever address this script itself was fetched from. + To pick a branch or pin a tag, run it as a script block instead: & ([scriptblock]::Create((irm ))) -Ref 'v1.2.3' diff --git a/src/windows-dev-config/dev-config.winget b/src/windows-dev-config/dev-config.winget deleted file mode 100644 index 33772be..0000000 --- a/src/windows-dev-config/dev-config.winget +++ /dev/null @@ -1,1056 +0,0 @@ -# Dev-Config — Developer Workstation Setup -# Apply with: winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity - -$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json -metadata: - winget: - processor: - identifier: dscv3 -resources: - -# --------------------------------------------------------------------------- -# Phase 0 — Elevation check (runs on initial invoke AND post-reboot resume) -# --------------------------------------------------------------------------- -# -# - name: ElevationCheck -# type: Microsoft.DSC.Transitional/WindowsPowerShellScript -# properties: -# getScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return @{ elevated = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } -# testScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -# setScript: | -# $configFile = "${WinGetConfigRoot}\dev-config.winget" -# -# if (-not (Test-Path $configFile)) { -# $hint = "winget configure --file `"$configFile`" --accept-configuration-agreements --disable-interactivity" -# throw "ElevationCheck: not elevated and cannot locate config file to re-launch automatically. Please re-run in an elevated terminal: $hint" -# } -# -# Write-Host "ElevationCheck: not elevated — re-launching winget configure as Administrator..." -# -# $wingetArgs = @( -# 'configure', -# '--file', "`"$configFile`"", -# '--accept-configuration-agreements', -# '--disable-interactivity', -# '--wait' -# ) -# Start-Process winget -ArgumentList $wingetArgs -Verb RunAs -# -# throw "ElevationCheck: re-launched elevated successfully. This unelevated session is now complete — check the elevated window for results." - -# --------------------------------------------------------------------------- -# WSL Phase 1 — Enable WSL optional components via wsl --install -# (enables Virtual Machine Platform; reboot required) -# --------------------------------------------------------------------------- - -- name: InstallWslComponents - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - properties: - getScript: | - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # If vmcompute is present, VMP is active — components already installed. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - Write-Host "InstallWslComponents: running wsl --install --no-distribution..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','--no-distribution' -Wait -PassThru - if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { - Write-Host "WSL installed successfully, but a system reboot is required." - } - elseif ($p.ExitCode -ne 0) { - throw "InstallWslComponents: wsl --install failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# --------------------------------------------------------------------------- -# WSL Phase 2 — Reboot so VMP takes effect -# --------------------------------------------------------------------------- - -- name: RebootForVmp - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - InstallWslComponents - properties: - getScript: | - # Get-CimInstance returns $null without error when the service doesn't - # exist (unlike Get-Service, which sets HadErrors at the hosting layer - # even with -ErrorAction Ignore or try/catch). - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # vmcompute (Hyper-V Host Compute Service) is registered once Virtual - # Machine Platform is active post-reboot. Presence alone is sufficient - # — no need to check if it is running. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - # ${WinGetConfigRoot} is set by winget configure to the directory - # containing the config file being applied. - $configFile = "${WinGetConfigRoot}\dev-config.winget" - $resumeCmd = "winget configure --file `"$configFile`" --accept-configuration-agreements" - - # RunOnce entries are deleted by Windows automatically before they - # are executed, so no cleanup step is needed in this config. - $runOncePath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' - Set-ItemProperty -Path $runOncePath -Name 'DSCConfigureResume' -Value $resumeCmd -Force - - Write-Host "RebootForVmp: registered RunOnce resume key." - Write-Host " HKCU:\...\RunOnce\DSCConfigureResume = $resumeCmd" - Write-Host "RebootForVmp: rebooting now to activate Virtual Machine Platform..." - Restart-Computer -Force - - # Restart-Computer -Force returns immediately after signalling the OS - # to reboot — it does not block until the machine goes down. Without - # an explicit failure here DSC would consider this resource succeeded - # and attempt to run InstallUbuntu before the reboot happens. - # Throwing forces DSC to mark the current run as failed; the RunOnce - # key handles resuming on the next login. - Start-Sleep -Seconds 60 # give the OS time to initiate shutdown - throw "Reboot initiated to activate Virtual Machine Platform. DSC will resume via RunOnce on next login." - metadata: - securityContext: elevated - - -# --------------------------------------------------------------------------- -# WSL Phase 3 — Install default Ubuntu distro (VMP now active post-reboot) -# --------------------------------------------------------------------------- - -- name: InstallUbuntu - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - RebootForVmp - properties: - getScript: | - # Run wsl --list via Start-Process with redirected output. Calling wsl.exe - # directly leaks its non-zero exit code (when no distro is installed) into - # $LASTEXITCODE and routes its stderr into PowerShell's error stream — both - # of which the dscv3 PowerShell adapter treats as a resource failure. - # Start-Process isolates the native call: stderr never reaches the error - # stream and $LASTEXITCODE is untouched. - $env:WSL_UTF8 = '1' - $distros = @() - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -eq 0) { - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - } - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return @{ distroCount = $distros.Count; distros = ($distros -join ',') } - testScript: | - $env:WSL_UTF8 = '1' - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -ne 0) { - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $false - } - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $distros.Count -gt 0 - setScript: | - # Suppress the "Welcome to WSL" first-run GUI/OOBE - $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' - New-Item -Path $lxssPath -Force | Out-Null - Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force - - Write-Host "InstallUbuntu: running wsl --install -d Ubuntu --no-launch..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','-d','Ubuntu','--no-launch' -Wait -PassThru - if ($p.ExitCode -ne 0) { - throw "InstallUbuntu: wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# ============================================================================= -# Terminal and PowerShell 7 -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Terminal - properties: - id: Microsoft.WindowsTerminal - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Windows Terminal - -- type: Microsoft.WinGet/Package - name: PowerShell - properties: - id: Microsoft.PowerShell - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerShell 7 - -# ============================================================================= -# Force dark theme -# Uses app and system theme registry values to detect dark theme. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: darkTheme - dependsOn: - - PowerShell - properties: - getScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return @{ AppsUseLightTheme = [int]$apps; SystemUsesLightTheme = [int]$system } - testScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return ($apps -eq 0 -and $system -eq 0) - setScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - Set-ItemProperty -Path $regPath -Name "AppsUseLightTheme" -Value 0 - Set-ItemProperty -Path $regPath -Name "SystemUsesLightTheme" -Value 0 - metadata: - description: Sets dark theme - -# ============================================================================= -# Install Cascadia Code Nerd Fonts -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallCascadiaCodeNerdFonts - dependsOn: - - PowerShell - properties: - getScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return @{ filesInstalled = $filesOk; registryEntries = $regOk } - testScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return ($filesOk -and $regOk) - setScript: | - $ErrorActionPreference = 'Stop' - - $Version = '2407.24' - $WantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$Version/CascadiaCode-$Version.zip" - $workDir = Join-Path $env:TEMP "CascadiaCode-$Version" - $zipPath = Join-Path $workDir 'CascadiaCode.zip' - New-Item -ItemType Directory -Path $workDir -Force | Out-Null - - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null - - Write-Host "Downloading $zipUrl ..." - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - $expectedHash = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' - $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash - if ($actualHash -ne $expectedHash) { - Remove-Item $zipPath -Force - throw "Hash mismatch for CascadiaCode-$Version.zip: expected $expectedHash, got $actualHash" - } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - Add-Type -AssemblyName System.Drawing - - $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) - try { - foreach ($name in $WantedFonts) { - $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 - if (-not $entry) { Write-Warning "Not found in archive: $name"; continue } - - $dest = Join-Path $fontsDir $name - Write-Host "Installing $name -> $dest" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) - - $pfc = New-Object System.Drawing.Text.PrivateFontCollection - try { - $pfc.AddFontFile($dest) - $family = $pfc.Families[0].Name - } finally { $pfc.Dispose() } - - $regName = "$family (TrueType)" - New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null - Write-Host " registered as '$regName'" - } - } - finally { - $zip.Dispose() - } - - Remove-Item $zipPath -Force - Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." - metadata: - description: Install Cascadia Code Nerd Fonts - -# ============================================================================= -# Set Cascadia Mono NF as default Windows Terminal font -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not profiles.defaults (which applies settings across all profiles). -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: SetCascadiaNfAsDefault - dependsOn: - - PowerShell - - InstallCascadiaCodeNerdFonts - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ fontFace = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return @{ fontFace = $json.profiles.defaults.font.face } - testScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return ($json.profiles.defaults.font.face -eq $fontFace) - setScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { throw 'Windows Terminal settings.json not found.' } - Write-Host "Using: $settingsPath" - - Copy-Item $settingsPath "$settingsPath.bak" -Force - - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json -AsHashtable - - if (-not $json.profiles) { $json.profiles = [ordered]@{} } - if (-not $json.profiles.defaults) { $json.profiles.defaults = [ordered]@{} } - if (-not $json.profiles.defaults.font) { $json.profiles.defaults.font = [ordered]@{} } - $json.profiles.defaults.font.face = $fontFace - - $json | ConvertTo-Json -Depth 32 | Set-Content $settingsPath -Encoding utf8 - Write-Host "Set font to '$fontFace' (backup: $settingsPath.bak)" - metadata: - description: Making Cascadia fonts default - -# ============================================================================= -# Set PowerShell 7 as the default Windows Terminal profile -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not the top-level defaultProfile setting in settings.json. -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: ps7default - dependsOn: - - PowerShell - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ defaultProfile = $null; ps7ProfileGuid = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - return @{ defaultProfile = $settings.defaultProfile; ps7ProfileGuid = $ps7.guid } - testScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if (-not $ps7) { return $true } - return ($settings.defaultProfile -eq $ps7.guid) - setScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { - $settings.defaultProfile = $ps7.guid - $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath -Encoding UTF8 - } - metadata: - description: Set PowerShell 7 as the default Windows Terminal profile - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# ============================================================================= - - -# ============================================================================= -# Theme and OS -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: Sudo - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo - valueName: Enabled - valueData: - DWord: 3 - metadata: - description: Enable Sudo in inline mode - securityContext: elevated - -# Developer Mode: AllowDevelopmentWithoutDevLicense=1 (replaces -# Microsoft.Windows.Settings/WindowsSettings DeveloperMode:true) -- type: Microsoft.Windows/Registry - name: DeveloperMode - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock - valueName: AllowDevelopmentWithoutDevLicense - valueData: - DWord: 1 - metadata: - description: Enable Developer Mode (sideload + dev features) - securityContext: elevated - -# Long path support (replaces Microsoft.Windows.Developer/EnableLongPathSupport) -- type: Microsoft.Windows/Registry - name: LongPaths - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\FileSystem - valueName: LongPathsEnabled - valueData: - DWord: 1 - metadata: - description: Enable Win32 long path support - securityContext: elevated - -# Remote Desktop (replaces Microsoft.Windows.Developer/EnableRemoteDesktop) -- type: Microsoft.Windows/Registry - name: RemoteDesktop - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server - valueName: fDenyTSConnections - valueData: - DWord: 0 - metadata: - description: Enable Remote Desktop (firewall rule still needs separate enable) - securityContext: elevated - -# ============================================================================= -# File Explorer and Desktop -# ============================================================================= - -#- type: Microsoft.Windows/Registry -# name: HideDesktopIcons -# dependsOn: -# - ElevationCheck -# properties: -# keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced -# valueName: HideIcons -# valueData: -# DWord: 1 -# metadata: -# description: Hide desktop icons - -# Show file extensions (replaces Microsoft.Windows.Developer/WindowsExplorer -# FileExtensions:Show) -- type: Microsoft.Windows/Registry - name: ShowFileExtensions - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: HideFileExt - valueData: - DWord: 0 - metadata: - description: Show file extensions in Explorer - securityContext: elevated - -# Show hidden files (replaces Microsoft.Windows.Developer/WindowsExplorer -# HiddenFiles:Show) -- type: Microsoft.Windows/Registry - name: ShowHiddenFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Hidden - valueData: - DWord: 1 - metadata: - description: Show hidden files in Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FullPathTitlebar - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: FullPathAddress - valueData: - DWord: 1 - metadata: - description: Show full path in Explorer titlebar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: OpenThisPC - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: LaunchTo - valueData: - DWord: 1 - metadata: - description: Open File Explorer to This PC - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowFrequent - valueData: - DWord: 0 - metadata: - description: Disable frequent folders in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowRecent - valueData: - DWord: 0 - metadata: - description: Disable frequent files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: RecommendedFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowCloudFilesInQuickAccess - valueData: - DWord: 0 - metadata: - description: Disable recommended/cloud files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: GitCodeFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: NavPaneShowVersionControl - valueData: - DWord: 1 - metadata: - description: Enable Git integration in File Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: TipsOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowSyncProviderNotifications - valueData: - DWord: 0 - metadata: - description: Disable sync provider notifications (tips) - securityContext: elevated - -# ============================================================================= -# Notifications and Lock Screen -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: DoNotDisturb - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings - valueName: NOC_GLOBAL_SETTING_TOASTS_ENABLED - valueData: - DWord: 0 - metadata: - description: Enable Do Not Disturb (disable all notifications) - securityContext: elevated - -# ============================================================================= -# Taskbar -# ============================================================================= - -# Hide Widgets button (replaces Microsoft.Windows.Developer/Taskbar -# WidgetsButton:Hide). 0 = hidden. -- type: Microsoft.Windows/Registry - name: TaskbarHideWidgets - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarDa - valueData: - DWord: 0 - metadata: - description: Hide Widgets button on the taskbar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: BluetoothOff - properties: - keyPath: HKCU\Control Panel\Bluetooth - valueName: Notification Area Icon - valueData: - DWord: 0 - metadata: - description: Hide Bluetooth icon in taskbar notification area - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EndTask - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarEndTask - valueData: - DWord: 1 - metadata: - description: Enable "End Task" on right-click of taskbar icons - securityContext: elevated - -# ============================================================================= -# Start and Search -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WebSearchOff - properties: - keyPath: HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer - valueName: DisableSearchBoxSuggestions - valueData: - DWord: 1 - metadata: - description: Disable web search in Start/Search - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: SearchHightlightOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings - valueName: IsDynamicSearchBoxEnabled - valueData: - DWord: 0 - metadata: - description: Disable Show search highlights - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: StartRecommendations - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Start_IrisRecommendations - valueData: - DWord: 0 - metadata: - description: Disable Start menu recommendations - securityContext: elevated - -# ============================================================================= -# Services and Features -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WidgetServiceOff - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Dsh - valueName: AllowNewsAndInterests - valueData: - DWord: 0 - metadata: - description: Disable Widget service - securityContext: elevated - - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# -# Microsoft Edge policies — https://learn.microsoft.com/deployedge/microsoft-edge-policies#newtabpage -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: EdgeNewTab - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: NewTabPageLocation - valueData: - String: about:blank - metadata: - description: Set Edge new tab to blank - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EdgeOOBE - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: HideFirstRunExperience - valueData: - DWord: 1 - metadata: - description: Disable Edge first-run experience - securityContext: elevated - -# ============================================================================= -# Software Installs -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Git - properties: - id: Git.Git - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Git - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCLI - dependsOn: - - Git - properties: - id: GitHub.Cli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub CLI - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCopilot - dependsOn: - - Git - properties: - id: GitHub.Copilot - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub Copilot - -- type: Microsoft.WinGet/Package - name: VSCode - properties: - id: Microsoft.VisualStudioCode - source: winget - useLatest: true - installMode: silent - metadata: - description: Install VS Code - -- type: Microsoft.WinGet/Package - name: DotnetSdk - properties: - id: Microsoft.dotnet.SDK.10 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install dotnet SDK - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Python - properties: - id: Python.Python.3.14 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Python 3.14 - -- type: Microsoft.WinGet/Package - name: UV - properties: - id: astral-sh.uv - source: winget - useLatest: true - installMode: silent - metadata: - description: Install UV (Python tool) - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: NodeJS - properties: - id: OpenJS.NodeJS.LTS - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Node.js 24 LTS - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: nvmForNode - properties: - id: CoreyButler.NVMforWindows - source: winget - useLatest: true - installMode: silent - metadata: - description: Install NVM - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Coreutils - properties: - id: Microsoft.Coreutils - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Coreutils for Windows - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: OhMyPosh - properties: - id: JanDeDobbeleer.OhMyPosh - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Oh My Posh (Optional) - -- type: Microsoft.WinGet/Package - name: winappCli - properties: - id: Microsoft.winappcli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install winAppCLI - -- type: Microsoft.WinGet/Package - name: PowerToys - properties: - id: Microsoft.PowerToys - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerToys (Optional) - -- type: Microsoft.Windows/Registry - name: PowerToysAOT - dependsOn: - - PowerToys - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys - valueName: Enabled - valueData: - DWord: 0 - metadata: - description: Disable PowerToys AOT notifications - -# ============================================================================= -# Include Oh My Posh init in PowerShell 7 profile -# ============================================================================= -- type: OhMyPosh/Shell - name: ohMyPoshProfileSet - dependsOn: - - OhMyPosh - properties: - states: - - name: pwsh - command: | - $(if (Get-Command 'oh-my-posh' -ErrorAction SilentlyContinue) { - oh-my-posh init pwsh - # Set output encoding to UTF-8 - [Console]::OutputEncoding =[System.Text.Encoding]::UTF8 - # Set input encoding to UTF-8 (for reading user input with non-ASCII chars) - [Console]::InputEncoding =[System.Text.Encoding]::UTF8 - }) - skipExistingInit: true - metadata: - description: Setup OhMyPosh in PowerShell 7 - -# ============================================================================= -# Add GitHub Copilot profile to Windows Terminal -# Uses a fragment file so settings.json is never touched directly. -# Fragment file: %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: GitHubCopilotProfile - dependsOn: - - PowerShell - - GitHubCopilot - - Terminal - properties: - getScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return @{ fragmentPresent = (Test-Path $fragmentPath) } - testScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return (Test-Path $fragmentPath) - setScript: | - $fragmentsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig' - New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null - - # Download icon alongside the fragment file so the relative path "copilot.png" resolves correctly. - $iconPath = Join-Path $fragmentsDir 'copilot.png' - Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing - - $fragment = @{ - profiles = @( - @{ - guid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' - name = 'GitHub Copilot' - commandline = 'pwsh.exe -NoExit -Command "copilot"' - icon = 'copilot.png' - startingDirectory = '%USERPROFILE%' - hidden = $false - tabTitle = 'Copilot' - } - ) - } - - $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' - $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 - - # Touch settings.json to trigger WT's hot-reload (re-scans Fragments\*.json). - @( - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | ForEach-Object { - try { (Get-Item -LiteralPath $_).LastWriteTime = Get-Date } catch {} - } - - Write-Host "GitHub Copilot profile fragment written to $fragmentFile" -ForegroundColor Green - Write-Host "Open Windows Terminal: the 'GitHub Copilot' profile is available in the dropdown." -ForegroundColor Cyan - metadata: - description: Create Github Copilot Profile - -# ============================================================================= -# Install Win Skills -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUITemplates - dependsOn: - - PowerShell - - DotnetSdk - properties: - getScript: | - $installed = [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - return @{ installed = $installed } - testScript: | - return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - setScript: | - dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates - metadata: - description: Install WinUI dotnet new templates - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: AddWinSkillsMarketplace - dependsOn: - - PowerShell - - GitHubCopilot - properties: - getScript: | - $present = [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - return @{ present = $present } - testScript: | - return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - setScript: | - copilot plugin marketplace add microsoft/win-dev-skills - metadata: - description: Add win-dev-skills to the Copilot plugin marketplace - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUIPlugin - dependsOn: - - PowerShell - - AddWinSkillsMarketplace - properties: - getScript: | - $installed = [bool](copilot plugin list 2>&1 | Select-String 'winui') - return @{ installed = $installed } - testScript: | - return [bool](copilot plugin list 2>&1 | Select-String 'winui') - setScript: | - copilot plugin install winui@win-dev-skills - metadata: - description: Install the WinUI Copilot plugin from win-dev-skills diff --git a/src/windows-dev-config/install.ps1 b/src/windows-dev-config/install.ps1 deleted file mode 100644 index 1194635..0000000 --- a/src/windows-dev-config/install.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -<# -.SYNOPSIS - Apply the Calm OS user-experience configuration on Windows. - -.DESCRIPTION - Thin CI/dev shim around `dev-config.winget`, a winget DSC configuration - that sets up the full Calm OS developer workstation (apps, distraction- - free desktop, taskbar polish, Recall off, dark theme, WSL + Ubuntu). - - The shim only: - * applies the DSC config with retry, - * rehydrates PATH in the current session, - * emits `INSTALL_OK: calm-os` for the test harness. - - RequireCommands lists `git` because the master config installs it - unconditionally; asserting it on PATH catches a clean-install failure - early. -#> - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -& (Join-Path $PSScriptRoot '..\Workloads\_common\apply-configuration.ps1') ` - -Id 'calm-os' ` - -ConfigFile (Join-Path $PSScriptRoot 'dev-config.winget') ` - -RequireCommands @('git') diff --git a/windows-dev-config/README.md b/windows-dev-config/README.md index b475d35..5f72de3 100644 --- a/windows-dev-config/README.md +++ b/windows-dev-config/README.md @@ -1,282 +1,462 @@ -# Dev Configuration - -A WinGet Configuration (DSC) file that sets up a clean, lightweight, distraction-free developer workstation. The goal is a PC state that devs actually love using: no clutter, no noise, just the tools you need. - -This mirrors the curated environment currently provided by Cloud PC, so developers get a consistent experience regardless of device. - -The flow is a single DSC document (`dev-config.winget`) that handles everything end-to-end: elevation, the OS tweaks, the apps, the fonts, the shell prompt, and the WSL platform + Ubuntu install (including the reboot dance). - -> **Author:** Hamza Usmani. - -## Table of Contents - -- [Goals](#goals) -- [Prerequisites](#prerequisites) -- [Usage](#usage) -- [What this configures](#what-this-configures) -- [Configuration details](#configuration-details) - - [Phase resources (elevation + WSL)](#phase-resources-elevation--wsl) - - [Apps](#apps) - - [Theme and OS](#theme-and-os) - - [File Explorer](#file-explorer) - - [Taskbar](#taskbar) - - [Start, Search, Notifications](#start-search-notifications) - - [Services and features](#services-and-features) - - [Edge](#edge) - - [Fonts](#fonts) - - [Windows Terminal](#windows-terminal) - - [PowerShell profile](#powershell-profile) -- [Customization](#customization) -- [Design decisions](#design-decisions) -- [Known caveats](#known-caveats) +# Windows Dev Config + +*Turns a fresh Windows 11 machine into a clean, distraction-free developer workstation in one command.* + +This flow installs the tools you'd install anyway, applies the Windows settings you'd change anyway, and sets up WSL + Ubuntu including the reboot in the middle. It is a set of PowerShell scripts: no configuration file to point at, no repo to clone, nothing to install first. + +It is **idempotent** — every change is checked before it's made, so re-running it only fixes what has drifted. It is also **resumable** — if it fails, or you close the window, running it again picks up where it left off. + +> **Original design and curation:** Hamza Usmani. + +## Table of contents + +- [Quick start](#quick-start) +- [What to expect](#what-to-expect) +- [Requirements](#requirements) +- [Before you run this](#before-you-run-this) +- [What it changes](#what-it-changes) +- [How it works](#how-it-works) +- [Running it other ways](#running-it-other-ways) +- [Security](#security) +- [Troubleshooting](#troubleshooting) +- [Undoing it](#undoing-it) +- [Customizing it](#customizing-it) +- [Known limitations](#known-limitations) +- [For contributors](#for-contributors) --- -## Goals - -- **A PC devs actually want to use.** Clean Explorer, dark theme, no pop-ups, no recommendations, no widgets. Just your code and your tools. -- **Cloud PC parity.** Same tooling, OS settings, and policies as the current Cloud PC image. -- **One command.** `winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity` takes a fresh Windows machine to fully ready, including WSL + Ubuntu (with an auto-resume across the required reboot). -- **Idempotent.** Safe to re-run on existing machines to apply updates or fix drift. Every resource has a `testScript` or DSC-native idempotency. - -## Prerequisites - -- Windows 11 (latest). -- `winget` with the DSC v3 processor available (the file uses `Microsoft.WinGet/Package`, `Microsoft.Windows/Registry`, and `Microsoft.DSC.Transitional/*`). -- Administrator rights — the `ElevationCheck` resource will auto-relaunch winget elevated via `Start-Process -Verb RunAs` if you started in an unelevated session, but you'll need to consent at the UAC prompt. -- The Microsoft Visual C++ Redistributable when invoking `winget` from a non-elevated environment. Without it, `winget configure` fails with an internal error. See [aka.ms/vcredist](https://aka.ms/vcredist) or install via winget (see the Usage callout below). -- The repo on disk. `winget configure` reads a local file path, and the bootstrap is what installs Git, so on a fresh machine you'll either `git clone` (if Git is already installed) or download the repo as a ZIP from GitHub and extract it before running. -- **Hardware virtualization must be available to the OS** before WSL can install. On bare metal, this means virtualization (VT-x / AMD-V) is enabled in BIOS/UEFI. Inside a VM, it means the host has exposed nested virtualization to the guest. See the Usage callout below. - -## Usage - -> [!IMPORTANT] -> If `winget` is being invoked from a **non-elevated** environment, the Microsoft Visual C++ Redistributable ([aka.ms/vcredist](https://aka.ms/vcredist)) must also be installed — without it `winget configure` fails with an internal error. Install it once with the command for your machine's architecture: -> -> ```powershell -> # x64: -> winget install Microsoft.VCRedist.2015+.x64 -> -> # ARM64: -> winget install Microsoft.VCRedist.2015+.arm64 -> ``` - -> [!IMPORTANT] -> **WSL needs hardware virtualization.** If virtualization isn't available to the OS, the `InstallUbuntu` step fails with `wsl --install ... failed with exit code -1`. -> -> - **On bare metal:** enable virtualization (VT-x / AMD-V) in your BIOS/UEFI. The exact label varies by vendor — check your motherboard or laptop manufacturer's documentation if you can't find it. Reboot into firmware settings, toggle it on, save, and reboot back into Windows. -> - **Inside a VM:** the host must expose nested virtualization to the guest. For a Hyper-V host, run this from an elevated PowerShell session **on the host** (with the guest VM powered off): -> -> ```powershell -> Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true -> ``` -> -> Other hypervisors have their own equivalent settings — check your hypervisor's documentation. - -**Get the files first** (skip if you already have the repo locally): +## Quick start + +Open **any** PowerShell window — Windows PowerShell or PowerShell 7, elevated or not — and run: ```powershell -# Git already installed: -git clone https://github.com/microsoft/WindowsDeveloperConfig.git -cd WindowsDeveloperConfig\windows-dev-config - -# Otherwise, download and extract the ZIP: -Invoke-WebRequest -Uri https://github.com/microsoft/WindowsDeveloperConfig/archive/refs/heads/main.zip -OutFile WindowsDeveloperConfig.zip -Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . -cd .\WindowsDeveloperConfig-main\windows-dev-config +irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex ``` -**Full setup (recommended):** +That's the whole thing. You'll get one UAC prompt, and the machine will restart once. + +
+What that command actually does + +`irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text and `iex` (`Invoke-Expression`) runs it. The bootstrap then: + +1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. +2. Copies the setup — [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder — into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. + +The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. + +
+ +## What to expect + +Roughly **30 minutes** on a clean machine with a good connection, most of it spent downloading Visual Studio Code, the .NET SDK, PowerToys, and Ubuntu. + +| # | What happens | Your involvement | +| - | ------------ | ---------------- | +| 1 | A UAC prompt appears | **Accept it.** Most of the settings are machine-wide and need Administrator. | +| 2 | PowerShell 7 is installed if it isn't already, and the setup restarts itself on it | None | +| 3 | Ten phases run: packages, Windows settings, fonts, Terminal, prompt, Copilot | None. Long silent stretches during big downloads are normal — a "still working" note prints every minute | +| 4 | WSL is installed. The machine warns you and **restarts after 10 seconds** | **Save your work before you start.** | +| 5 | You sign back in; a window opens by itself and finishes the run | None | +| 6 | A summary prints: how many things changed, how many were already fine | Press a key to close, or leave it — it closes itself after 15 minutes | + +Afterwards, open **Ubuntu** from the Start menu once to create your Linux username and password. Some Explorer and taskbar changes appear after you sign out and back in. + +## Requirements + +- **Windows 11.** Built and tested against current Windows 11 releases. A few of the settings only exist on newer builds; on older ones those steps are skipped rather than failing the run. Windows 10 is not supported. +- **Administrator rights** on the machine, and the ability to accept a UAC prompt. +- **Internet access** to `github.com`, `raw.githubusercontent.com`, the PowerShell Gallery, and the winget package sources. Behind a proxy, the run needs your proxy configured for WinHTTP and for `winget`. +- **Hardware virtualization available to the OS** — WSL cannot install without it. On a physical machine that means VT-x / AMD-V enabled in BIOS/UEFI. In a VM it means the host has exposed nested virtualization to the guest. Everything except WSL still works without it; see [Troubleshooting](#troubleshooting). +- **About 15 GB of free disk space** for the full package set. + +You do **not** need Git, a repository clone, `winget configure`, the Visual C++ Redistributable, or PowerShell 7 beforehand. The flow handles all of those. + +## Before you run this + +This flow is opinionated, and a few of its choices are worth knowing about up front rather than discovering later. + +| Change | Why it might matter to you | +| ------ | -------------------------- | +| **Remote Desktop is enabled** | `fDenyTSConnections` is set to `0`, which allows incoming RDP sessions. The Windows Firewall rule is *not* opened, so this alone doesn't expose the machine to your network — but it is a real change to the machine's posture. | +| **Two Edge settings are applied as policy** | They're written under `HKLM\SOFTWARE\Policies\Microsoft\Edge`, so Edge will report "managed by your organization" and grey those two settings out in its UI. | +| **All notifications are turned off** | Do Not Disturb is enabled globally, not just for a quiet-hours window. Teams, Outlook, and everything else stop raising toasts until you turn it back on. | +| **Both Node.js LTS and nvm-windows are installed** | They are two different ways to manage Node. If you plan to use nvm, uninstall Node.js first so nvm owns the PATH entry. | +| **Windows Terminal's `settings.json` is rewritten** | A `settings.json.bak` is written next to it first, but any comments in your settings file are lost, because the file is round-tripped through JSON. If the file can't be parsed the run stops and leaves it untouched. | +| **There's no uninstall** | Nothing that gets applied is reverted automatically. [Undoing it](#undoing-it) lists the manual reversals. | + +Every one of these is listed in full detail in [What it changes](#what-it-changes). + +## What it changes + +51 individual steps across 11 phases. Each one is checked first and skipped if the machine is already in that state. + +### Packages + +Installed with winget from the `winget` source, silently, with agreements accepted: + +| Package | winget id | +| ------- | --------- | +| Windows Terminal | `Microsoft.WindowsTerminal` | +| PowerShell 7 | `Microsoft.PowerShell` | +| Git | `Git.Git` | +| GitHub CLI | `GitHub.cli` | +| GitHub Copilot CLI | `GitHub.Copilot` | +| Visual Studio Code | `Microsoft.VisualStudioCode` | +| .NET SDK 10 | `Microsoft.DotNet.SDK.10` | +| Python 3.14 | `Python.Python.3.14` | +| uv | `astral-sh.uv` | +| Node.js LTS | `OpenJS.NodeJS.LTS` | +| nvm for Windows | `CoreyButler.NVMforWindows` | +| Coreutils for Windows | `Microsoft.Coreutils` | +| Oh My Posh | `JanDeDobbeleer.OhMyPosh` | +| Windows App CLI | `Microsoft.WinAppCli` | +| PowerToys | `Microsoft.PowerToys` | + +A package counts as done only when winget reports it installed **and** current, so a re-run also picks up available updates. + +
+Windows settings — all 25 registry values + +**System** (`HKLM`, requires Administrator) + +| Setting | Key | Value | +| ------- | --- | ----- | +| Sudo, inline mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo\Enabled` | `3` | +| Developer Mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | `1` | +| Win32 long paths | `SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` | `1` | +| Remote Desktop allowed | `SYSTEM\CurrentControlSet\Control\Terminal Server\fDenyTSConnections` | `0` | + +**File Explorer** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Show file extensions | `Advanced\HideFileExt` | `0` | +| Show hidden files | `Advanced\Hidden` | `1` | +| Full path in the title bar | `Advanced\FullPathAddress` | `1` | +| Open Explorer to This PC | `Advanced\LaunchTo` | `1` | +| No frequent folders in Quick Access | `Advanced\ShowFrequent` | `0` | +| No recent files in Quick Access | `ShowRecent` | `0` | +| No recommended or cloud files | `ShowCloudFilesInQuickAccess` | `0` | +| Git status columns in Explorer | `Advanced\NavPaneShowVersionControl` | `1` | +| No sync-provider tips | `Advanced\ShowSyncProviderNotifications` | `0` | + +**Taskbar, Start, search and notifications** + +| Setting | Key | Value | +| ------- | --- | ----- | +| Do Not Disturb (all toasts off) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | `0` | +| Hide the Bluetooth tray icon | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | `0` | +| "End Task" on taskbar right-click | `HKCU\...\Explorer\Advanced\TaskbarEndTask` | `1` | +| No web results in search | `HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer\DisableSearchBoxSuggestions` | `1` | +| No search highlights | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | `0` | +| No Start menu recommendations | `HKCU\...\Explorer\Advanced\Start_IrisRecommendations` | `0` | +| Widgets off | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | `0` | +| No PowerToys always-on-top toasts | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | `0` | + +Widgets are turned off through the OS policy value because the per-user taskbar icon value no longer takes effect on Windows 11 24H2 and later. + +**Microsoft Edge** (`HKLM\SOFTWARE\Policies\Microsoft\Edge`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Blank new tab page | `NewTabPageLocation` | `about:blank` | +| Skip the first-run experience | `HideFirstRunExperience` | `1` | + +**Theme** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`) + +| Setting | Value name | Value | +| ------- | ---------- | ----- | +| Dark mode for apps | `AppsUseLightTheme` | `0` | +| Dark mode for the system | `SystemUsesLightTheme` | `0` | + +
+ +### Fonts, Terminal and prompt + +- **Cascadia Code NF** and **Cascadia Mono NF** are downloaded from the pinned [`microsoft/cascadia-code`](https://github.com/microsoft/cascadia-code/releases) release `2407.24`, verified against a known SHA-256, and installed **per-user** under `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. +- **Windows Terminal** gets Cascadia Mono NF as its default font face and PowerShell 7 as its default profile. `settings.json` is backed up to `settings.json.bak` before either change. +- **Oh My Posh** is initialized from your PowerShell 7 `$PROFILE`. If an `oh-my-posh init` line is already there, nothing is added. +- A **GitHub Copilot** profile is added to Windows Terminal as a settings fragment in `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`, so it appears in the dropdown without editing your settings file. + +### Developer extras + +These are **best-effort**: they need the network and a PATH that has just been updated, so a failure is flagged in the summary rather than stopping the run. + +- The **WinUI templates** for `dotnet new` (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`). +- The **`microsoft/win-dev-skills`** marketplace and its **WinUI plugin**, registered with the GitHub Copilot CLI. + +### WSL + +- The WSL platform components, via `wsl --install --no-distribution`. If that isn't available, the `VirtualMachinePlatform` and `Microsoft-Windows-Subsystem-Linux` Windows features are enabled directly with `dism.exe` instead. +- A restart, if one is needed — see [Reboot and resume](#reboot-and-resume). +- **Ubuntu**, via `wsl --install -d Ubuntu --no-launch`, falling back to `--web-download` if the Microsoft Store route doesn't complete. The distro's first-run welcome screen is suppressed; open Ubuntu from the Start menu to create your Linux user. + +Nothing *inside* the distro is configured by this flow. For that, see [WSL Comfort](../wsl-comfort/readme.md). + +## How it works + +### The phases + +| # | Phase | Notes | +| - | ----- | ----- | +| 1 | Getting ready | Confirms PowerShell 7 and a winget new enough to drive non-interactively (1.6.0+), repairing winget if not | +| 2 | Packages | The 15 packages above, plus the PowerToys notification setting | +| 3 | System settings | Sudo, Developer Mode, long paths, Remote Desktop | +| 4 | File Explorer tweaks | | +| 5 | Taskbar, search & start tweaks | | +| 6 | Microsoft Edge tweaks | | +| 7 | Fonts | | +| 8 | Windows Terminal | | +| 9 | PowerShell profile | | +| 10 | GitHub Copilot | The Terminal profile, WinUI templates, and the Copilot CLI plugin — all best-effort | +| 11 | WSL + Ubuntu | Last on purpose, so its restart happens after everything else is done | + +### Check, apply, verify + +Every step is a triple: a check, an apply, and the same check again. + +- If the check passes first time, the step prints `already OK` and nothing runs. +- If the apply runs but the check still fails afterwards, that's an error — not a silent success. +- Steps that aren't worth stopping the whole run for are marked **best-effort**. If one of those fails it's reported as **flagged**, the run continues, and the summary names it at the end so it doesn't scroll past you. + +That's why the totals in the summary can add up to more than 51: the tally is saved across the reboot and carried into the resumed run, which re-checks every step it already did. Steps counted before the restart are counted again when they're confirmed after it. + +### Elevation and PowerShell 7 + +The setup relaunches itself twice before doing any work: + +1. **Elevated**, via UAC, if it wasn't already. Declining the prompt stops the run cleanly without changing anything. +2. **On PowerShell 7**, installing it first if necessary. The WinGet PowerShell module behaves more consistently there than on Windows PowerShell 5.1. If PowerShell 7 can't be installed the run continues on Windows PowerShell and says so. + +A machine-wide lock (`Global\WindowsDevConfigSetup`) means a second copy won't start while one is running — it tells you to switch windows instead of letting two runs fight over the same installs. + +### Reboot and resume + +Enabling the WSL platform requires a restart. When one is needed, the setup: + +1. Registers a scheduled task named **`WindowsDevConfigResume`** that runs at your next logon, as you, elevated, after a 30-second delay. +2. Saves its progress so far to `devconfig-tally.json`. +3. Prints a warning and restarts after **10 seconds**. + +After you sign in, the task opens a window, finishes the run, prints the combined summary for both halves, and removes itself. If Windows refuses the restart, the setup tells you and leaves the task registered — restart whenever you like and it still resumes. + +Only one restart is ever performed. If WSL still isn't usable after it, the run stops and explains why rather than rebooting again. + +### Logs + +A full transcript is written to **`devconfig-log.txt`** next to `dev-config.ps1` — so `%LOCALAPPDATA%\CalmOS\devconfig-log.txt` for the one-liner. The path is printed at the end of every run. + +The transcript is more verbose than the console on purpose: it records handled errors and raw command output that are deliberately kept off screen. Text in the log that isn't on your console is usually something the run recovered from. + +## Running it other ways + +**From a clone, with the repo already on disk:** ```powershell -winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity +.\src\windows-dev-config\dev-config.ps1 ``` -This is the canonical invocation documented in the header of `dev-config.winget`. +**Pin a tag, or try a branch.** `-Ref` takes a branch, tag, or commit SHA. Passing arguments needs the script-block form rather than `| iex`: -**What to expect:** +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Ref 'v1.2.3' +``` -1. The first phase applies all OS tweaks, installs apps, installs Cascadia Code/Mono Nerd Fonts, and configures Windows Terminal and the PowerShell profile. -2. WSL platform components install; the DSC reboots the machine and registers a `RunOnce` resume. -3. After login, winget configure resumes automatically and installs the default Ubuntu distro. -4. Open Ubuntu from the Start menu to complete its first-launch setup (create a UNIX username and password). +**Download it but don't run it**, so you can read it first: -The configuration is idempotent, so it is safe to re-run after reboot or at any later point. +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -NoLaunch +``` -## What this configures +**Install somewhere else:** `-InstallRoot 'D:\tools\devconfig'`. The location has to survive the reboot, so avoid `%TEMP%`. -- **14 apps** via winget (PowerShell 7, Git, GitHub CLI, GitHub Copilot CLI, VS Code, .NET SDK 10, Python 3.14, UV, Node.js LTS, NVM for Windows, Coreutils for Windows, Windows Application CLI, plus optional Oh My Posh and PowerToys). -- **WSL + Ubuntu**, installed via 3 transitional script resources that bracket a reboot (Phase 2/3/4 below). -- **~24 registry settings** for theme/OS, Explorer, Taskbar, Search, Start, Notifications, Edge, Sudo, and the Widget service. -- **Cascadia Code & Cascadia Mono Nerd Fonts** downloaded from the `microsoft/cascadia-code` GitHub release and registered per-user. -- **5 script resources** beyond the WSL phases: - - `ElevationCheck` — re-launches winget elevated if not already admin. - - `darkTheme` — applies the built-in `dark.theme` to switch to dark mode. - - `InstallCascadiaCodeNerdFonts` — downloads and installs the Nerd Font variants of Cascadia Code/Mono. - - `SetCascadiaNfAsDefault` — sets `Cascadia Mono NF` as the default font face in Windows Terminal's `settings.json`. - - `ps7default` — sets PowerShell 7 as Windows Terminal's default profile. - - `ohMyPoshProfileSet` — adds `oh-my-posh init pwsh | Invoke-Expression` to `$PROFILE` and dot-sources it. +**Already elevated and want it to stay that way:** `dev-config.ps1 -NoElevate` fails fast instead of prompting. ---- +## Security -## Configuration details +**Read it before you run it.** Piping a remote script into your shell is a real trust decision, and this one asks for Administrator. Everything it does is in this folder — [`bootstrap.ps1`](./bootstrap.ps1) is under 200 lines, [`dev-config.ps1`](./dev-config.ps1) is the orchestrator, and every change lives in a named file under [`steps/`](./steps). To read the exact copy you'd be running, use `-NoLaunch` above, or pass `-Ref` with a commit SHA to pin it. -All resources are dscv3 (`$schema: .../DSC/main/schemas/2023/08/config/document.json`, `metadata.winget.processor.identifier: dscv3`). Every resource that touches HKLM or runs elevated tools depends on `ElevationCheck`. +**What runs elevated.** The whole setup, after the single UAC prompt. It needs Administrator for the `HKLM` settings, the WSL Windows features, and machine-wide package installs. -Package resources use `Microsoft.WinGet/Package` with `source: winget` and `useLatest: true` (except `Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, and `OpenJS.NodeJS.LTS`, which are pinned by id). +**What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. -### Phase resources (elevation + WSL) +**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. Whichever address you fetch the bootstrap from, it installs the signed copy of the setup when the ref you asked for has one, and tells you in its output when it falls back to the source copy instead. -| Name | Type | What it does | -|------|------|--------------| -| `ElevationCheck` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` checks `IsInRole(Administrator)`. If false, `setScript` re-invokes `winget configure --file --accept-configuration-agreements --disable-interactivity --wait` via `Start-Process -Verb RunAs`, then throws so the unelevated session ends cleanly. | -| `InstallWslComponents` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` probes for the `vmcompute` service (presence ⇒ Virtual Machine Platform is active). `setScript` runs `wsl --install --no-distribution`. | -| `RebootForVmp` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | Same `vmcompute` test. `setScript` registers `HKCU:\...\RunOnce\DSCConfigureResume` with the same `winget configure --file --accept-configuration-agreements` command, then `Restart-Computer -Force` and throws so DSC stops the current run. | -| `InstallUbuntu` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` runs `wsl --list --quiet` and returns true if any distro is already registered. `setScript` runs `wsl --install -d Ubuntu --no-launch`. | +**What it does not do.** It doesn't collect or send telemetry, doesn't sign you in to anything, doesn't change credentials or Windows Defender settings, and doesn't touch files in your user profile beyond the PowerShell profile and Windows Terminal settings described above. -All app resources that need WSL present depend on `InstallUbuntu` so the OS work happens before the reboot — but the WSL install is still part of the same `winget configure` invocation thanks to the RunOnce resume. +## Troubleshooting -### Apps +
+The run stopped and said it needs Administrator -| Resource name | Package id | Notes | -|---------------|-----------|-------| -| `PowerShell` | `Microsoft.PowerShell` | Direct dependency on `ElevationCheck`. | -| `Git` | `Git.Git` | Depends on `ElevationCheck` + `InstallUbuntu`. | -| `GitHubCLI` | `GitHub.Cli` | Depends on `Git` + `InstallUbuntu`. | -| `GitHubCopilot` | `GitHub.Copilot` | Depends on `Git` + `InstallUbuntu`. | -| `VSCode` | `Microsoft.VisualStudioCode` | | -| `DotnetSdk` | `Microsoft.dotnet.SDK.10` | Pinned to v10. | -| `Python` | `Python.Python.3.14` | Pinned to 3.14. | -| `UV` | `astral-sh.uv` | | -| `NodeJS` | `OpenJS.NodeJS.LTS` | Pinned to the LTS line (currently Node 24 LTS). | -| `nvmForNode` | `CoreyButler.NVMforWindows` | Node version manager for Windows. | -| `Coreutils` | `Microsoft.Coreutils` | Microsoft-maintained Coreutils for Windows. Command integration is handled by the package itself after install. | -| `OhMyPosh` | `JanDeDobbeleer.OhMyPosh` | Marked Optional in the comments. Triggers `ohMyPoshProfileSet`. | -| `winappCli` | `Microsoft.winappcli` | Windows Application CLI. | -| `PowerToys` | `Microsoft.PowerToys` | Marked Optional. Followed by `PowerToysAOT` which disables AOT notifications via registry. | +The UAC prompt was declined. Nothing was changed. Run the command again and accept it, or start from a terminal that's already elevated. -### Theme and OS +
-Dark theme is applied via a `RunCommandOnSet` resource named `darkTheme` (not via registry): +
+"Calm OS setup is already running in another window" -| Resource | Type | What it does | -|----------|------|--------------| -| `darkTheme` | `Microsoft.DSC.Transitional/RunCommandOnSet` | `Start-Process` on `C:\Windows\Resources\Themes\dark.theme`, sleeps 2 s, then stops `SystemSettings` so the Settings window doesn't linger. Depends on `PowerShell`. | +Exactly what it says — switch to the other window. Two copies would fight over the same installs. If you're sure nothing is running, the previous process didn't exit cleanly; sign out and back in, or restart, and try again. -The remaining theme/OS entries below are `Microsoft.Windows/Registry`. +
-| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Sudo enabled (inline mode) | `HKLM\...\Sudo\Enabled` | DWord `3` | -| Developer Mode | `HKLM\...\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | DWord `1` | -| Long path support | `HKLM\...\FileSystem\LongPathsEnabled` | DWord `1` | -| Remote Desktop on | `HKLM\...\Terminal Server\fDenyTSConnections` | DWord `0` | +
+Some steps came back "flagged" -### File Explorer +Flagged means best-effort work that couldn't be completed or confirmed. The run finishes and names them in the summary. Everything else was applied. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Show file extensions | `HKCU\...\Advanced\HideFileExt` | DWord `0` | -| Show hidden files | `HKCU\...\Advanced\Hidden` | DWord `1` | -| Full path in titlebar | `HKCU\...\Advanced\FullPathAddress` | DWord `1` | -| Open to This PC | `HKCU\...\Advanced\LaunchTo` | DWord `1` | -| Frequent folders off | `HKCU\...\Advanced\ShowFrequent` | DWord `0` | -| Frequent files off | `HKCU\...\Explorer\ShowRecent` | DWord `0` | -| Recommended/cloud files off | `HKCU\...\Explorer\ShowCloudFilesInQuickAccess` | DWord `0` | -| Git integration in Explorer | `HKCU\...\Advanced\NavPaneShowVersionControl` | DWord `1` | -| Tips/sync-provider notifications off | `HKCU\...\Advanced\ShowSyncProviderNotifications` | DWord `0` | +The most common cause is a step that needs a package that hasn't finished registering yet — the WinUI templates need the .NET SDK on `PATH`, and the Copilot plugin steps need the GitHub Copilot CLI. **Run the command again**: the steps that already succeeded are skipped in seconds and only the flagged ones are retried. -### Taskbar +
-| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widgets button hidden | `HKCU\...\Advanced\TaskbarDa` | DWord `0` | -| Bluetooth notification icon off | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | DWord `0` | -| End Task on right-click | `HKCU\...\Advanced\TaskbarEndTask` | DWord `1` | +
+WSL fails, or Ubuntu doesn't install -### Start, Search, Notifications +Almost always hardware virtualization not being available to the OS. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Web search suggestions off | `HKCU\...\Policies\Explorer\DisableSearchBoxSuggestions` | DWord `1` | -| Search highlights off | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | DWord `0` | -| Start menu recommendations off | `HKCU\...\Advanced\Start_Layout` | DWord `1` | -| Toast notifications off (Do Not Disturb) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | DWord `0` | +- **Physical machine:** enable virtualization (VT-x / AMD-V) in BIOS/UEFI. The label varies by vendor — check your manufacturer's documentation. Reboot into firmware settings, turn it on, save, and boot back into Windows. +- **Virtual machine:** the host has to expose nested virtualization to the guest. On a Hyper-V host, with the guest powered off: -### Services and features + ```powershell + Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true + ``` -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widget service off (HKLM policy) | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | DWord `0` | -| PowerToys AOT notifications off | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | DWord `0` | + Other hypervisors have their own equivalent. -### Edge +Then run the setup again. Everything else stays applied; only the WSL steps are retried. -HKLM policies, applied via `Microsoft.Windows/Registry`: +If virtualization is definitely on and WSL still won't activate after the restart, the run says so and stops rather than rebooting in a loop. The other likely cause is that the machine couldn't reach the WSL download. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| New tab blank | `HKLM\SOFTWARE\Policies\Microsoft\Edge\NewTabPageLocation` | String `about:blank` | -| First-run experience off | `HKLM\SOFTWARE\Policies\Microsoft\Edge\HideFirstRunExperience` | DWord `1` | +
-### Fonts +
+"WinGet is older than 1.6.0" or winget can't be updated -| Resource | Type | What it does | -|----------|------|--------------| -| `InstallCascadiaCodeNerdFonts` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Downloads `CascadiaCode-2407.24.zip` from `microsoft/cascadia-code` GitHub Releases, extracts `CascadiaCodeNF.ttf` and `CascadiaMonoNF.ttf` to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, and registers each under `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`. Per-user install — no admin required for this step. Depends on `PowerShell`. | +The setup needs a winget that supports non-interactive installs, and tries to repair or update it. If it can't — usually because the built-in `winget` command is being used and the PowerShell module isn't reachable — update **App Installer** from the Microsoft Store, or install the latest release from [microsoft/winget-cli](https://github.com/microsoft/winget-cli/releases/latest), then run the setup again. -### Windows Terminal - -| Resource | Type | What it does | -|----------|------|--------------| -| `SetCascadiaNfAsDefault` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Locates Windows Terminal's `settings.json` (Store or unpackaged install), backs it up to `settings.json.bak`, and sets `profiles.defaults.font.face = "Cascadia Mono NF"`. Depends on `InstallCascadiaCodeNerdFonts`. | -| `ps7default` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Invokes `pwsh.exe -NoProfile -NoLogo -Command ...` which reads `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, finds the PowerShell 7 profile, and sets it as `defaultProfile`. Depends on `PowerShell`. | +
-### PowerShell profile +
+Nothing happened after the restart -| Resource | Type | What it does | -|----------|------|--------------| -| `ohMyPoshProfileSet` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Creates `$PROFILE` if missing and appends `oh-my-posh init pwsh | Invoke-Expression` (idempotent — uses `Select-String` to check first), then dot-sources `$PROFILE`. Depends on `OhMyPosh`. | +The resume task waits 30 seconds after logon before starting, and the first thing it does is re-check what's already done, which is quiet. Give it a couple of minutes. ---- +If nothing appears at all, check the task exists: + +```powershell +Get-ScheduledTask -TaskName WindowsDevConfigResume +``` + +Either way, running the original command again is safe and picks up exactly where it left off. + +
+ +
+"Windows Terminal's settings file couldn't be read as JSON" + +Your `settings.json` has a syntax error, so the setup stopped rather than overwrite a file it couldn't understand. Fix or rename the file named in the message, then run the setup again. + +
+ +
+Downloads fail or time out + +The setup retries with backoff and raises TLS 1.2 for you, so this is usually a proxy. `winget` and WinHTTP each need to know about it: + +```powershell +netsh winhttp show proxy +``` + +Configure your proxy for both, then run the setup again. + +
+ +
+Where do I look when none of the above fits? + +`devconfig-log.txt`, in the same folder as `dev-config.ps1` (`%LOCALAPPDATA%\CalmOS` when you used the one-liner). The path is printed at the end of every run. + +Then please [open an issue](https://github.com/microsoft/WindowsDeveloperConfig/issues) with your Windows build (`winver`), the command you ran, and the relevant part of that log. Setup that fails on a real machine is a bug worth fixing. + +
+ +## Undoing it + +There is no automatic undo, and the setup never removes anything on its own. The reversals below are the ones most people ask about. Registry changes under `HKLM` need an elevated prompt. + +```powershell +# Remote Desktop off again +Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' fDenyTSConnections 1 + +# Drop the two Edge policies (removes "managed by your organization" for them) +Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' NewTabPageLocation, HideFirstRunExperience + +# Notifications back on +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings' NOC_GLOBAL_SETTING_TOASTS_ENABLED 1 + +# Widgets back on +Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Dsh' AllowNewsAndInterests + +# Back to light mode +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' AppsUseLightTheme 1 +Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' SystemUsesLightTheme 1 +``` -## Customization - -- **Pick and choose packages.** Comment out any `Microsoft.WinGet/Package` block to skip that install — most have no `dependsOn` chain beyond `InstallUbuntu` (exceptions: `GitHubCLI` and `GitHubCopilot` depend on `Git`; `PowerToysAOT` depends on `PowerToys`; `ohMyPoshProfileSet` depends on `OhMyPosh`). -- **Pin or unpin versions.** Switch `id: Python.Python.3.14` (pinned) to `id: Python.Python.3` if you want to drift forward, or switch `OpenJS.NodeJS.LTS` to `OpenJS.NodeJS` for current. Vice versa for the unpinned packages. -- **Toggle registry values.** Most settings are `DWord: 0` or `DWord: 1`; flip the value to invert the behavior. -- **Re-enable commented-out tweaks.** `HideDesktopIcons` ships commented out (it over-fires on some user setups). Uncomment to enable. -- **Change the WSL distro.** Edit the `wsl --install -d Ubuntu --no-launch` line inside the `InstallUbuntu` resource. -- **Change the terminal font.** Edit `$fontFace = 'Cascadia Mono NF'` inside `SetCascadiaNfAsDefault`, or change the `$WantedFonts` array in `InstallCascadiaCodeNerdFonts` to install a different Cascadia variant. -- **Skip the dark theme step.** Comment out the `darkTheme` resource if you prefer light mode (or want to set it manually). - -## Design decisions - -| Decision | Rationale | -|----------|-----------| -| Single dscv3 document, no modules | Easier to reason about and easier to re-run. The whole flow is one `winget configure` call. | -| `Microsoft.Windows/Registry` everywhere instead of `Microsoft.Windows.Developer/*` or `Microsoft.Windows.Settings/WindowsSettings` | Direct registry control is reliable across Windows 11 builds and avoids dependencies on legacy resource modules. | -| `Microsoft.DSC.Transitional/WindowsPowerShellScript` (not `PSDscResources/Script`) | The dscv3 transitional resource is the supported equivalent under the new processor. | -| Self-relaunch elevated from `ElevationCheck` | A user can double-click into an unelevated shell and the DSC will UAC-prompt itself rather than failing. | -| Reboot + RunOnce inside the DSC | The DSC owns the reboot and the resume, so the user only invokes `winget configure` once. The throw after `Restart-Computer -Force` is required because `Restart-Computer` returns immediately after signalling shutdown; without the throw DSC would treat the resource as succeeded and continue. | -| `useLatest: true` on most packages | Cloud PC parity tracks "current" tools. Pinned ids (`Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, `OpenJS.NodeJS.LTS`) are used where a major-version line matters. | -| Dark theme via `dark.theme` file (not registry) | Applying the shipped `.theme` file flips both `AppsUseLightTheme` and `SystemUsesLightTheme` *and* applies the matching color scheme/cursors atomically, which the broadcast-message dance you'd otherwise need from a registry-only approach often misses. | -| Per-user font install | Avoids requiring admin for the font step and keeps the font registration under `HKCU`, which is what modern Windows + Terminal expect. | -| `RunCommandOnSet` to mutate `settings.json` | Windows Terminal's settings are JSON-based and not registry-mapped; a small pwsh fragment is the cleanest way. | - -## Known caveats - -| Area | Caveat | -|------|--------| -| **`acceptAgreements` not on packages** | None of the `Microsoft.WinGet/Package` resources set `acceptAgreements: true`. The header comment compensates by passing `--accept-configuration-agreements` on the command line. | -| **WSL reboot** | `RebootForVmp` will hard-reboot the machine via `Restart-Computer -Force`. Save your work before running. The RunOnce key resumes the config on next login. | -| **Ubuntu first-launch** | After `InstallUbuntu`, you still need to open Ubuntu from the Start menu once to create a UNIX user. Nothing inside the distro is configured by this flow. | -| **`useLatest: true`** | Each run grabs the latest available version. Builds may differ between machines applying the config on different days. | -| **HKLM registry keys** | Sudo, the Widget service policy, Edge policies, Remote Desktop, Long Paths, and Developer Mode all live in HKLM. The `ElevationCheck` gate guarantees the run is elevated; without it these would silently fail. | -| **PowerToys AOT path** | `HKCU\...\Notifications\Settings\PowerToys\Enabled` targets a specific registry path that may change across PowerToys versions. | -| **Idempotency of WSL phases** | `InstallWslComponents` and `RebootForVmp` both test for `vmcompute`. Re-running after the reboot is a no-op for those resources. `InstallUbuntu` queries `wsl --list --quiet`, so it skips once any distro is registered. | -| **Pinned font release** | `InstallCascadiaCodeNerdFonts` hard-codes Cascadia Code release `2407.24` from `microsoft/cascadia-code`. Bump `$Version` to pick up newer releases. | -| **Windows Terminal settings overwrite** | `SetCascadiaNfAsDefault` and `ps7default` rewrite `settings.json` via `ConvertTo-Json`. `SetCascadiaNfAsDefault` writes a `settings.json.bak` first; `ps7default` does not. JSON comments will not survive the round-trip. | -| **`ohMyPoshProfileSet` runs `. $PROFILE`** | Dot-sourcing the profile inside `pwsh -NoProfile` can surface errors from the user's existing profile during DSC apply. | -| **`darkTheme` opens Settings briefly** | Applying `dark.theme` pops the Settings app open; the script kills it after 2 seconds. On slow machines the window may flash visibly. | -| **Currently commented out** | The `HideDesktopIcons` block lives in the file but is commented out. Uncomment to hide desktop icons. | +Everything else: + +- **Packages:** `winget uninstall --id ` using the ids in [Packages](#packages). +- **Explorer, Start and search settings:** all of them are also in Settings and Explorer's Options dialog. Sign out and back in for them to take effect. +- **Windows Terminal:** restore the `settings.json.bak` written next to `settings.json`. +- **The Copilot Terminal profile:** delete `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`. +- **The Oh My Posh prompt:** remove the `oh-my-posh init` block from your PowerShell 7 `$PROFILE`. +- **Ubuntu:** `wsl --unregister Ubuntu`. This permanently deletes the distro's file system. +- **The setup itself:** delete `%LOCALAPPDATA%\CalmOS`. + +## Customizing it + +Everything lives in a named file under [`steps/`](./steps), so changing what runs is a local edit rather than a fork of a large document. Take a copy of the repository, edit, and run `dev-config.ps1` directly. + +| To... | Edit | +| ----- | ---- | +| Add or remove a package | The `$packages` list in [`steps/packages.ps1`](./steps/packages.ps1) | +| Change or drop a Windows setting | The `$tweaks` list in the matching `steps/registry-*.ps1` | +| Skip the Edge policies entirely | Remove `edge.ps1` from the `$phases` list in [`dev-config.ps1`](./dev-config.ps1) | +| Keep Remote Desktop off | Delete the `RemoteDesktop` entry in [`steps/registry-system.ps1`](./steps/registry-system.ps1) | +| Change the terminal font | `$Script:CascadiaDefaultFontFace` in [`steps/fonts.ps1`](./steps/fonts.ps1) | +| Install a different distro | The `wsl --install -d Ubuntu` arguments in [`steps/wsl.ps1`](./steps/wsl.ps1) | +| Add something new | Copy the shape of any phase file: build steps with `New-DevConfigStep` and pass them to `Invoke-DevConfigSteps` | + +A phase is just a file plus an entry in the `$phases` list. Files prefixed with `_` are shared helpers, not phases. + +## Known limitations + +| Area | Detail | +| ---- | ------ | +| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `Restart-Computer -Force`. Save your work before you begin. | +| **Ubuntu's first launch is still manual** | You have to open Ubuntu once to create a Linux username and password. | +| **Package versions move** | Packages are installed at whatever winget currently publishes, so two machines set up on different days can differ. `Microsoft.DotNet.SDK.10` and `Python.Python.3.14` pin a major version and will need bumping as those age. | +| **The font release is pinned** | Cascadia Code `2407.24`, verified by hash. Newer releases need both the version and the hash updated in `steps/fonts.ps1`. | +| **Terminal settings lose their comments** | `settings.json` is round-tripped through JSON, so comments don't survive. A `.bak` is written first. | +| **No package selection at run time** | It's the full set or a local edit. There's no `-Skip` switch and no prompt. | +| **No dry run** | There's no `-WhatIf`. The `already OK` output tells you what a re-run *would* skip, but only after the fact. | +| **Git and GitHub CLI are installed, not configured** | No `git config user.name`, no `gh auth login`. | +| **`%LOCALAPPDATA%\CalmOS` stays behind** | The installed copy and its log are left in place so a resumed or repeated run works. Delete it when you're done. | +| **Some changes need a sign-out** | Several Explorer and taskbar values are read by Explorer at logon. | + +## For contributors + +Source of truth for this flow is `src/windows-dev-config/`. The copy at the repository root is the Authenticode-signed release copy, regenerated by the sign pipeline — don't edit it directly. See [`src/docs/development.md`](https://github.com/microsoft/WindowsDeveloperConfig/blob/main/src/docs/development.md#repo-layout-signed-vs-source). + +| File | What it is | +| ---- | ---------- | +| `bootstrap.ps1` | The remote entry point. Downloads, resolves signed-versus-source, installs, launches. | +| `dev-config.ps1` | The orchestrator. Elevation, PowerShell 7, run lock, logging, the phase list, the summary. | +| `steps/_step-runner.ps1` | The check/apply/verify engine, the tally, and the flag reporting. | +| `steps/_*.ps1` | Shared helpers: elevation, reboot and resume, winget, registry, Terminal settings, retry, process execution, console. | +| `steps/.ps1` | One file per phase, each exporting a single `Invoke-Phase` function. | + +Adding a phase means adding one file and one line in the `$phases` list. Adding a step to an existing phase means one `New-DevConfigStep` call. Keep every step's check cheap and side-effect free — it runs on every invocation, including the fast path where nothing needs doing. diff --git a/windows-dev-config/dev-config.winget b/windows-dev-config/dev-config.winget deleted file mode 100644 index fd56b5b..0000000 --- a/windows-dev-config/dev-config.winget +++ /dev/null @@ -1,1055 +0,0 @@ -# Dev-Config — Developer Workstation Setup -# Apply with: winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity - -$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json -metadata: - winget: - processor: - identifier: dscv3 -resources: - -# --------------------------------------------------------------------------- -# Phase 0 — Elevation check (runs on initial invoke AND post-reboot resume) -# --------------------------------------------------------------------------- -# -# - name: ElevationCheck -# type: Microsoft.DSC.Transitional/WindowsPowerShellScript -# properties: -# getScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return @{ elevated = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } -# testScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -# setScript: | -# $configFile = "${WinGetConfigRoot}\dev-config.winget" -# -# if (-not (Test-Path $configFile)) { -# $hint = "winget configure --file `"$configFile`" --accept-configuration-agreements --disable-interactivity" -# throw "ElevationCheck: not elevated and cannot locate config file to re-launch automatically. Please re-run in an elevated terminal: $hint" -# } -# -# Write-Host "ElevationCheck: not elevated — re-launching winget configure as Administrator..." -# -# $wingetArgs = @( -# 'configure', -# '--file', "`"$configFile`"", -# '--accept-configuration-agreements', -# '--disable-interactivity', -# '--wait' -# ) -# Start-Process winget -ArgumentList $wingetArgs -Verb RunAs -# -# throw "ElevationCheck: re-launched elevated successfully. This unelevated session is now complete — check the elevated window for results." - -# --------------------------------------------------------------------------- -# WSL Phase 1 — Enable WSL optional components via wsl --install -# (enables Virtual Machine Platform; reboot required) -# --------------------------------------------------------------------------- - -- name: InstallWslComponents - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - properties: - getScript: | - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # If vmcompute is present, VMP is active — components already installed. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - Write-Host "InstallWslComponents: running wsl --install --no-distribution..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','--no-distribution' -Wait -PassThru - if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { - Write-Host "WSL installed successfully, but a system reboot is required." - } - elseif ($p.ExitCode -ne 0) { - throw "InstallWslComponents: wsl --install failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# --------------------------------------------------------------------------- -# WSL Phase 2 — Reboot so VMP takes effect -# --------------------------------------------------------------------------- - -- name: RebootForVmp - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - InstallWslComponents - properties: - getScript: | - # Get-CimInstance returns $null without error when the service doesn't - # exist (unlike Get-Service, which sets HadErrors at the hosting layer - # even with -ErrorAction Ignore or try/catch). - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # vmcompute (Hyper-V Host Compute Service) is registered once Virtual - # Machine Platform is active post-reboot. Presence alone is sufficient - # — no need to check if it is running. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - # ${WinGetConfigRoot} is set by winget configure to the directory - # containing the config file being applied. - $configFile = "${WinGetConfigRoot}\dev-config.winget" - $resumeCmd = "winget configure --file `"$configFile`" --accept-configuration-agreements" - - # RunOnce entries are deleted by Windows automatically before they - # are executed, so no cleanup step is needed in this config. - $runOncePath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' - Set-ItemProperty -Path $runOncePath -Name 'DSCConfigureResume' -Value $resumeCmd -Force - - Write-Host "RebootForVmp: registered RunOnce resume key." - Write-Host " HKCU:\...\RunOnce\DSCConfigureResume = $resumeCmd" - Write-Host "RebootForVmp: rebooting now to activate Virtual Machine Platform..." - Restart-Computer -Force - - # Restart-Computer -Force returns immediately after signalling the OS - # to reboot — it does not block until the machine goes down. Without - # an explicit failure here DSC would consider this resource succeeded - # and attempt to run InstallUbuntu before the reboot happens. - # Throwing forces DSC to mark the current run as failed; the RunOnce - # key handles resuming on the next login. - Start-Sleep -Seconds 60 # give the OS time to initiate shutdown - throw "Reboot initiated to activate Virtual Machine Platform. DSC will resume via RunOnce on next login." - metadata: - securityContext: elevated - - -# --------------------------------------------------------------------------- -# WSL Phase 3 — Install default Ubuntu distro (VMP now active post-reboot) -# --------------------------------------------------------------------------- - -- name: InstallUbuntu - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - RebootForVmp - properties: - getScript: | - # Run wsl --list via Start-Process with redirected output. Calling wsl.exe - # directly leaks its non-zero exit code (when no distro is installed) into - # $LASTEXITCODE and routes its stderr into PowerShell's error stream — both - # of which the dscv3 PowerShell adapter treats as a resource failure. - # Start-Process isolates the native call: stderr never reaches the error - # stream and $LASTEXITCODE is untouched. - $env:WSL_UTF8 = '1' - $distros = @() - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -eq 0) { - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - } - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return @{ distroCount = $distros.Count; distros = ($distros -join ',') } - testScript: | - $env:WSL_UTF8 = '1' - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -ne 0) { - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $false - } - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $distros.Count -gt 0 - setScript: | - # Suppress the "Welcome to WSL" first-run GUI/OOBE - $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' - New-Item -Path $lxssPath -Force | Out-Null - Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force - - Write-Host "InstallUbuntu: running wsl --install -d Ubuntu --no-launch..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','-d','Ubuntu','--no-launch' -Wait -PassThru - if ($p.ExitCode -ne 0) { - throw "InstallUbuntu: wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# ============================================================================= -# Terminal and PowerShell 7 -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Terminal - properties: - id: Microsoft.WindowsTerminal - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Windows Terminal - -- type: Microsoft.WinGet/Package - name: PowerShell - properties: - id: Microsoft.PowerShell - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerShell 7 - -# ============================================================================= -# Force dark theme -# Uses app and system theme registry values to detect dark theme. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: darkTheme - dependsOn: - - PowerShell - properties: - getScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return @{ AppsUseLightTheme = [int]$apps; SystemUsesLightTheme = [int]$system } - testScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return ($apps -eq 0 -and $system -eq 0) - setScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - Set-ItemProperty -Path $regPath -Name "AppsUseLightTheme" -Value 0 - Set-ItemProperty -Path $regPath -Name "SystemUsesLightTheme" -Value 0 - metadata: - description: Sets dark theme - -# ============================================================================= -# Install Cascadia Code Nerd Fonts -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallCascadiaCodeNerdFonts - dependsOn: - - PowerShell - properties: - getScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return @{ filesInstalled = $filesOk; registryEntries = $regOk } - testScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return ($filesOk -and $regOk) - setScript: | - $ErrorActionPreference = 'Stop' - - $Version = '2407.24' - $WantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$Version/CascadiaCode-$Version.zip" - $workDir = Join-Path $env:TEMP "CascadiaCode-$Version" - $zipPath = Join-Path $workDir 'CascadiaCode.zip' - New-Item -ItemType Directory -Path $workDir -Force | Out-Null - - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null - - Write-Host "Downloading $zipUrl ..." - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - $expectedHash = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' - $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash - if ($actualHash -ne $expectedHash) { - Remove-Item $zipPath -Force - throw "Hash mismatch for CascadiaCode-$Version.zip: expected $expectedHash, got $actualHash" - } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - Add-Type -AssemblyName System.Drawing - - $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) - try { - foreach ($name in $WantedFonts) { - $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 - if (-not $entry) { Write-Warning "Not found in archive: $name"; continue } - - $dest = Join-Path $fontsDir $name - Write-Host "Installing $name -> $dest" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) - - $pfc = New-Object System.Drawing.Text.PrivateFontCollection - try { - $pfc.AddFontFile($dest) - $family = $pfc.Families[0].Name - } finally { $pfc.Dispose() } - - $regName = "$family (TrueType)" - New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null - Write-Host " registered as '$regName'" - } - } - finally { - $zip.Dispose() - } - - Remove-Item $zipPath -Force - Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." - metadata: - description: Install Cascadia Code Nerd Fonts - -# ============================================================================= -# Set Cascadia Mono NF as default Windows Terminal font -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not profiles.defaults (which applies settings across all profiles). -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: SetCascadiaNfAsDefault - dependsOn: - - PowerShell - - InstallCascadiaCodeNerdFonts - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ fontFace = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return @{ fontFace = $json.profiles.defaults.font.face } - testScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return ($json.profiles.defaults.font.face -eq $fontFace) - setScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { throw 'Windows Terminal settings.json not found.' } - Write-Host "Using: $settingsPath" - - Copy-Item $settingsPath "$settingsPath.bak" -Force - - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json -AsHashtable - - if (-not $json.profiles) { $json.profiles = [ordered]@{} } - if (-not $json.profiles.defaults) { $json.profiles.defaults = [ordered]@{} } - if (-not $json.profiles.defaults.font) { $json.profiles.defaults.font = [ordered]@{} } - $json.profiles.defaults.font.face = $fontFace - - $json | ConvertTo-Json -Depth 32 | Set-Content $settingsPath -Encoding utf8 - Write-Host "Set font to '$fontFace' (backup: $settingsPath.bak)" - metadata: - description: Making Cascadia fonts default - -# ============================================================================= -# Set PowerShell 7 as the default Windows Terminal profile -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not the top-level defaultProfile setting in settings.json. -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: ps7default - dependsOn: - - PowerShell - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ defaultProfile = $null; ps7ProfileGuid = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - return @{ defaultProfile = $settings.defaultProfile; ps7ProfileGuid = $ps7.guid } - testScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if (-not $ps7) { return $true } - return ($settings.defaultProfile -eq $ps7.guid) - setScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { - $settings.defaultProfile = $ps7.guid - $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath -Encoding UTF8 - } - metadata: - description: Set PowerShell 7 as the default Windows Terminal profile - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# ============================================================================= - - -# ============================================================================= -# Theme and OS -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: Sudo - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo - valueName: Enabled - valueData: - DWord: 3 - metadata: - description: Enable Sudo in inline mode - securityContext: elevated - -# Developer Mode: AllowDevelopmentWithoutDevLicense=1 (replaces -# Microsoft.Windows.Settings/WindowsSettings DeveloperMode:true) -- type: Microsoft.Windows/Registry - name: DeveloperMode - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock - valueName: AllowDevelopmentWithoutDevLicense - valueData: - DWord: 1 - metadata: - description: Enable Developer Mode (sideload + dev features) - securityContext: elevated - -# Long path support (replaces Microsoft.Windows.Developer/EnableLongPathSupport) -- type: Microsoft.Windows/Registry - name: LongPaths - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\FileSystem - valueName: LongPathsEnabled - valueData: - DWord: 1 - metadata: - description: Enable Win32 long path support - securityContext: elevated - -# Remote Desktop (replaces Microsoft.Windows.Developer/EnableRemoteDesktop) -- type: Microsoft.Windows/Registry - name: RemoteDesktop - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server - valueName: fDenyTSConnections - valueData: - DWord: 0 - metadata: - description: Enable Remote Desktop (firewall rule still needs separate enable) - securityContext: elevated - -# ============================================================================= -# File Explorer and Desktop -# ============================================================================= - -#- type: Microsoft.Windows/Registry -# name: HideDesktopIcons -# dependsOn: -# - ElevationCheck -# properties: -# keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced -# valueName: HideIcons -# valueData: -# DWord: 1 -# metadata: -# description: Hide desktop icons - -# Show file extensions (replaces Microsoft.Windows.Developer/WindowsExplorer -# FileExtensions:Show) -- type: Microsoft.Windows/Registry - name: ShowFileExtensions - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: HideFileExt - valueData: - DWord: 0 - metadata: - description: Show file extensions in Explorer - securityContext: elevated - -# Show hidden files (replaces Microsoft.Windows.Developer/WindowsExplorer -# HiddenFiles:Show) -- type: Microsoft.Windows/Registry - name: ShowHiddenFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Hidden - valueData: - DWord: 1 - metadata: - description: Show hidden files in Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FullPathTitlebar - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: FullPathAddress - valueData: - DWord: 1 - metadata: - description: Show full path in Explorer titlebar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: OpenThisPC - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: LaunchTo - valueData: - DWord: 1 - metadata: - description: Open File Explorer to This PC - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowFrequent - valueData: - DWord: 0 - metadata: - description: Disable frequent folders in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowRecent - valueData: - DWord: 0 - metadata: - description: Disable frequent files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: RecommendedFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowCloudFilesInQuickAccess - valueData: - DWord: 0 - metadata: - description: Disable recommended/cloud files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: GitCodeFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: NavPaneShowVersionControl - valueData: - DWord: 1 - metadata: - description: Enable Git integration in File Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: TipsOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowSyncProviderNotifications - valueData: - DWord: 0 - metadata: - description: Disable sync provider notifications (tips) - securityContext: elevated - -# ============================================================================= -# Notifications and Lock Screen -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: DoNotDisturb - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings - valueName: NOC_GLOBAL_SETTING_TOASTS_ENABLED - valueData: - DWord: 0 - metadata: - description: Enable Do Not Disturb (disable all notifications) - securityContext: elevated - -# ============================================================================= -# Taskbar -# ============================================================================= - -# Hide Widgets button (replaces Microsoft.Windows.Developer/Taskbar -# WidgetsButton:Hide). 0 = hidden. -- type: Microsoft.Windows/Registry - name: TaskbarHideWidgets - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarDa - valueData: - DWord: 0 - metadata: - description: Hide Widgets button on the taskbar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: BluetoothOff - properties: - keyPath: HKCU\Control Panel\Bluetooth - valueName: Notification Area Icon - valueData: - DWord: 0 - metadata: - description: Hide Bluetooth icon in taskbar notification area - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EndTask - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarEndTask - valueData: - DWord: 1 - metadata: - description: Enable "End Task" on right-click of taskbar icons - securityContext: elevated - -# ============================================================================= -# Start and Search -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WebSearchOff - properties: - keyPath: HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer - valueName: DisableSearchBoxSuggestions - valueData: - DWord: 1 - metadata: - description: Disable web search in Start/Search - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: SearchHightlightOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings - valueName: IsDynamicSearchBoxEnabled - valueData: - DWord: 0 - metadata: - description: Disable Show search highlights - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: StartRecommendations - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Start_IrisRecommendations - valueData: - DWord: 0 - metadata: - description: Disable Start menu recommendations - securityContext: elevated - -# ============================================================================= -# Services and Features -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WidgetServiceOff - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Dsh - valueName: AllowNewsAndInterests - valueData: - DWord: 0 - metadata: - description: Disable Widget service - securityContext: elevated - - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# -# Microsoft Edge policies — https://learn.microsoft.com/deployedge/microsoft-edge-policies#newtabpage -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: EdgeNewTab - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: NewTabPageLocation - valueData: - String: about:blank - metadata: - description: Set Edge new tab to blank - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EdgeOOBE - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: HideFirstRunExperience - valueData: - DWord: 1 - metadata: - description: Disable Edge first-run experience - securityContext: elevated - -# ============================================================================= -# Software Installs -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Git - properties: - id: Git.Git - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Git - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCLI - dependsOn: - - Git - properties: - id: GitHub.Cli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub CLI - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCopilot - dependsOn: - - Git - properties: - id: GitHub.Copilot - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub Copilot - -- type: Microsoft.WinGet/Package - name: VSCode - properties: - id: Microsoft.VisualStudioCode - source: winget - useLatest: true - installMode: silent - metadata: - description: Install VS Code - -- type: Microsoft.WinGet/Package - name: DotnetSdk - properties: - id: Microsoft.dotnet.SDK.10 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install dotnet SDK - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Python - properties: - id: Python.Python.3.14 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Python 3.14 - -- type: Microsoft.WinGet/Package - name: UV - properties: - id: astral-sh.uv - source: winget - useLatest: true - installMode: silent - metadata: - description: Install UV (Python tool) - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: NodeJS - properties: - id: OpenJS.NodeJS.LTS - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Node.js 24 LTS - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: nvmForNode - properties: - id: CoreyButler.NVMforWindows - source: winget - useLatest: true - installMode: silent - metadata: - description: Install NVM - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Coreutils - properties: - id: Microsoft.Coreutils - source: winget - useLatest: true - metadata: - description: Install Coreutils for Windows - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: OhMyPosh - properties: - id: JanDeDobbeleer.OhMyPosh - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Oh My Posh (Optional) - -- type: Microsoft.WinGet/Package - name: winappCli - properties: - id: Microsoft.winappcli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install winAppCLI - -- type: Microsoft.WinGet/Package - name: PowerToys - properties: - id: Microsoft.PowerToys - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerToys (Optional) - -- type: Microsoft.Windows/Registry - name: PowerToysAOT - dependsOn: - - PowerToys - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys - valueName: Enabled - valueData: - DWord: 0 - metadata: - description: Disable PowerToys AOT notifications - -# ============================================================================= -# Include Oh My Posh init in PowerShell 7 profile -# ============================================================================= -- type: OhMyPosh/Shell - name: ohMyPoshProfileSet - dependsOn: - - OhMyPosh - properties: - states: - - name: pwsh - command: | - $(if (Get-Command 'oh-my-posh' -ErrorAction SilentlyContinue) { - oh-my-posh init pwsh - # Set output encoding to UTF-8 - [Console]::OutputEncoding =[System.Text.Encoding]::UTF8 - # Set input encoding to UTF-8 (for reading user input with non-ASCII chars) - [Console]::InputEncoding =[System.Text.Encoding]::UTF8 - }) - skipExistingInit: true - metadata: - description: Setup OhMyPosh in PowerShell 7 - -# ============================================================================= -# Add GitHub Copilot profile to Windows Terminal -# Uses a fragment file so settings.json is never touched directly. -# Fragment file: %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: GitHubCopilotProfile - dependsOn: - - PowerShell - - GitHubCopilot - - Terminal - properties: - getScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return @{ fragmentPresent = (Test-Path $fragmentPath) } - testScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return (Test-Path $fragmentPath) - setScript: | - $fragmentsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig' - New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null - - # Download icon alongside the fragment file so the relative path "copilot.png" resolves correctly. - $iconPath = Join-Path $fragmentsDir 'copilot.png' - Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing - - $fragment = @{ - profiles = @( - @{ - guid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' - name = 'GitHub Copilot' - commandline = 'pwsh.exe -NoExit -Command "copilot"' - icon = 'copilot.png' - startingDirectory = '%USERPROFILE%' - hidden = $false - tabTitle = 'Copilot' - } - ) - } - - $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' - $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 - - # Touch settings.json to trigger WT's hot-reload (re-scans Fragments\*.json). - @( - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | ForEach-Object { - try { (Get-Item -LiteralPath $_).LastWriteTime = Get-Date } catch {} - } - - Write-Host "GitHub Copilot profile fragment written to $fragmentFile" -ForegroundColor Green - Write-Host "Open Windows Terminal: the 'GitHub Copilot' profile is available in the dropdown." -ForegroundColor Cyan - metadata: - description: Create Github Copilot Profile - -# ============================================================================= -# Install Win Skills -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUITemplates - dependsOn: - - PowerShell - - DotnetSdk - properties: - getScript: | - $installed = [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - return @{ installed = $installed } - testScript: | - return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - setScript: | - dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates - metadata: - description: Install WinUI dotnet new templates - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: AddWinSkillsMarketplace - dependsOn: - - PowerShell - - GitHubCopilot - properties: - getScript: | - $present = [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - return @{ present = $present } - testScript: | - return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - setScript: | - copilot plugin marketplace add microsoft/win-dev-skills - metadata: - description: Add win-dev-skills to the Copilot plugin marketplace - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUIPlugin - dependsOn: - - PowerShell - - AddWinSkillsMarketplace - properties: - getScript: | - $installed = [bool](copilot plugin list 2>&1 | Select-String 'winui') - return @{ installed = $installed } - testScript: | - return [bool](copilot plugin list 2>&1 | Select-String 'winui') - setScript: | - copilot plugin install winui@win-dev-skills - metadata: - description: Install the WinUI Copilot plugin from win-dev-skills diff --git a/windows-dev-config/install.ps1 b/windows-dev-config/install.ps1 deleted file mode 100644 index 7e2c26c..0000000 --- a/windows-dev-config/install.ps1 +++ /dev/null @@ -1,242 +0,0 @@ -<# -.SYNOPSIS - Apply the Calm OS user-experience configuration on Windows. - -.DESCRIPTION - Thin CI/dev shim around `dev-config.winget`, a winget DSC configuration - that sets up the full Calm OS developer workstation (apps, distraction- - free desktop, taskbar polish, Recall off, dark theme, WSL + Ubuntu). - - The shim only: - * applies the DSC config with retry, - * rehydrates PATH in the current session, - * emits `INSTALL_OK: calm-os` for the test harness. - - RequireCommands lists `git` because the master config installs it - unconditionally; asserting it on PATH catches a clean-install failure - early. -#> - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -& (Join-Path $PSScriptRoot '..\Workloads\_common\apply-configuration.ps1') ` - -Id 'calm-os' ` - -ConfigFile (Join-Path $PSScriptRoot 'dev-config.winget') ` - -RequireCommands @('git') - -# SIG # Begin signature block -# MIInSQYJKoZIhvcNAQcCoIInOjCCJzYCAQExDzANBglghkgBZQMEAgEFADB5Bgor -# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG -# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDQFhDGnQSmZ/Yc -# GalqrJZknbCuanj0Z60K23TbV/riYaCCDLowggX1MIID3aADAgECAhMzAAACHU0Z -# yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD -# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD -# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1 -# OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE -# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD -# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB -# DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8 -# o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg -# 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4 -# Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R -# X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk -# ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B -# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O -# BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL -# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw -# HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg -# UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0 -# JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh -# MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv -# Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy -# dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9 -# s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H -# VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3 -# w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n -# 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs -# A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo -# Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb -# SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6 -# 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z -# V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v -# 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs -# /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA -# AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX -# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg -# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl -# IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow -# VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo -# MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ -# KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh -# emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h -# KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd -# M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp -# yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t -# Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5 -# REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs -# 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK -# Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5 -# pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW -# eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ -# 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC -# NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB -# gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU -# ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny -# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx -# MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0 -# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx -# MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI -# MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4 -# NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh -# ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q -# hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU -# nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb -# H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z -# uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u -# vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW -# 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV -# DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10 -# 1cY2L4A7GTQG1h32HHAvfQESWP0xghnlMIIZ4QIBATBuMFcxCzAJBgNVBAYTAlVT -# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv -# c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w -# DQYJYIZIAWUDBAIBBQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYK -# KwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIOKpePvQ -# SXhM7PKXvy4CGaIjjRTFiawCPXoTF+ktXLqxMEIGCisGAQQBgjcCAQwxNDAyoBSA -# EgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20w -# DQYJKoZIhvcNAQEBBQAEggEAcF2NQcxNvU1I2FUYH8+h+KqDmXWkbDWvYT8HyC6M -# OVwcPHtJL/ynvINM9Chf/5LHeIP/gKxpekDauBlfJgHeDG2AgMlPbneK19Il5dBo -# 6hC96Puj6uJiMJptksSv4NJGe5A13C3CKuFN5ia0Xl3pI75isohOYp7W48pSW7eO -# /v6Tt9NMvuYYy1TZOdYFtVjvlWQs2PXfViQXWZm6oQx2T/B5XMpw+RqX2L+s0x9k -# 31uEQmwMgWvGVs+2FZGe/aaAxQQMgTDyx6Fr1H+ufxZb6hOrLT50Eqw5qPqjiAni -# JnSodfhyKSq3t3y513+9hD+hApAdILfUMvfjOELmth6TGKGCF5cwgheTBgorBgEE -# AYI3AwMBMYIXgzCCF38GCSqGSIb3DQEHAqCCF3AwghdsAgEDMQ8wDQYJYIZIAWUD -# BAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9MIIBOQIBAQYKKwYBBAGEWQoD -# ATAxMA0GCWCGSAFlAwQCAQUABCB+BGKjf5jlRtBU4taa56dqtAzJUFcH/S6yELq3 -# 1F/XwwIGakfudnlyGBMyMDI2MDcwOTIzMTA0OS43NDhaMASAAgH0oIHRpIHOMIHL -# MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVk -# bW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxN -# aWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRT -# UyBFU046OTIwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0 -# YW1wIFNlcnZpY2WgghHtMIIHIDCCBQigAwIBAgITMwAAAiNP2WAkU8/+KwABAAAC -# IzANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu -# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv -# cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAe -# Fw0yNjAyMTkxOTM5NTdaFw0yNzA1MTcxOTM5NTdaMIHLMQswCQYDVQQGEwJVUzET -# MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV -# TWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmlj -# YSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTIwMC0wNUUw -# LUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggIi -# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCK6Q2nk5WUdKzSCSafp+UjUARs -# WxHKS63rJhFC/zSabFumTBuaJ0QNrmqevub5Db7fSj5qtwwKnjIO92+HXF67192f -# ujL7DFot5WEj/AtEZ/XrzFHimKlN1h6gEQwP5I67wizaPW5ZzSBNpaLBg5oHvASP -# OZtwdNUoZ+DQKF3hJl1KZuoIlVK+qi7cLjgak6s5oOZcRCMrKnuC3aoVa6wRDbYv -# KUuj7rkFx9KO0PsHJ/k+LnZMggRheh4AVdawyh+oOzKPjlQGUNfSeWUgym2U9CLa -# 8tt0mQX4DxDz6+ram50gj1oAfyQ6TQ7r96PADFOKBgaU7+cpHnaZG89dTegQ6ydB -# RGIycOw1dRX2eKDRRzziK3cn0WaIm/7OeGsyQKjIzEQuUTDv0Jj/9zQ7truLOOpJ -# D98BJVOK7je84Sz2hb3HvUST7j1j2N8peD6olkpFHR/1Z8Jz4F+mkrUF7MmPAirY -# HRzunbIg3HrDMNwFYN7yBkDA4/VMo9CY0y9oGUoq2yjbCwTibz9VYl93nB3QQiTC -# T9nW3M+TOWB+PMrZpExq1BSHmKPzIqehKqrUDoM33PK+dEKwpYLET6uXq4HuQRMX -# WT//sPubUnQAaaUMfQhAZSy23HtxwtN3eK9+T4wCav2wQFt57eUOwUW5/DCzMF9t -# ua5He1hNvgcAXaiG1wIDAQABo4IBSTCCAUUwHQYDVR0OBBYEFNbAh89v29nPY9bw -# Qb1QYCzxVgeXMB8GA1UdIwQYMBaAFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMF8GA1Ud -# HwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3Js -# L01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNybDBsBggr -# BgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6Ly93d3cubWljcm9zb2Z0LmNv -# bS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIw -# MTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgw -# DgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUAA4ICAQCHQwe7z5tp4NZwAf1c -# B+4c9J4svw3P6WqGBMxtqznS6DdzUzStXHCaPZhM41g1iKHNnmcnLjwLujOEaNjh -# SnUDiAZqQjW5ZapOBxgc7Egghh9k+r78qWAe3rJ4QohBbhSGdZtKivTRaeRqmnhy -# 8+ThrKhzCeEwaarXJimZwSpdQQUDbheWHeyAxASqultd5KO0m/UFvO03tfepqGXA -# 4tCg/WGECwKqOjJzpRAfPIB6y1HyVrk+vmL5rpEbTwwLOtX7WxFGG8+cYLk9HjaD -# kxraA/HYlKQRx1sdza+w/gulLwgOnByRJKF2rr8M7FNIlwoi6ywFpaNc8A7HewaG -# jgw/tfcE260I1XekGluANI9HnONOYWlI7BKBQbWE2teo6vsQ1Vg8B8rTZSePVdmX -# L1PPqqs3KVdFKM5kYocPCDM+6VL32IV96sESf2T7DjxanpCg2D2UYj4Z1i7cy8U1 -# LLDGg55KWs4af2RRBjH2MulHgAmW5obKxiZCDQjRaroJ2XElXUhigE9BzvhCFbT/ -# HDY2vpVpl5HnSpcCSxmL5i5lIT/xbAQMI7Luh75Xrm+IslfFWOGOGMlCp+24qEJE -# glXEP7xwsolNdBNndXihhyIefVGlI1DR7xGELiJrk8ifVWYo9XEbEXv/lbvp6F2R -# 2UsnweWckvq0y1HWnLHDqH6dPjCCB3EwggVZoAMCAQICEzMAAAAVxedrngKbSZkA -# AAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX -# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg -# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl -# IEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIyNVoXDTMwMDkzMDE4MzIyNVow -# fDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl -# ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMd -# TWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwggIiMA0GCSqGSIb3DQEBAQUA -# A4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9DtWC0/3unAcH0qlsTnXIyjVX -# 9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2Z4VGIwy1jRPPdzLAEBjoYH1q -# UoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N7dcP2CZTfDlhAnrEqv1yaa8d -# q6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXcag/PXfT+jlPP1uyFVk3v3byN -# pOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJj361VI/c+gVVmG1oO5pGve2k -# rnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjklqwBSru+cakXW2dg3viSkR4d -# Pf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37ZyL9t9X4C626p+Nuw2TPYrbqgS -# Uei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M269ewvPV2HM9Q07BMzlMjgK8 -# QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLXpyDwwvoSCtdjbwzJNmSLW6Cm -# gyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLUHMVr9lxSUV0S2yW6r1AFemzF -# ER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode2o+eFnJpxq57t7c+auIurQID -# AQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEAATAjBgkrBgEEAYI3FQIEFgQU -# KqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYEFJ+nFV0AXmJdg/Tl0mWnG1M1 -# GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0 -# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0 -# bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMA -# QTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbL -# j+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1p -# Y3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0w -# Ni0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3 -# Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIz -# LmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt4SwfZwExJFvhnnJL/Klv6lwU -# tj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsPMeTCj/ts0aGUGCLu6WZnOlNN -# 3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++Kctu2D9IdQHZGN5tggz1bSNU -# 5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9QKC/GbYSEhFdPSfgQJY4rPf5 -# KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2wh3DYXMuLGt7bj8sCXgU6ZGy -# qVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aRAfbOxnT99kxybxCrdTDFNLB6 -# 2FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5zbcqOCb2zAVdJVGTZc9d/HltE -# AY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nxt67I6IleT53S0Ex2tVdUCbFp -# AUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3Fb+0zj6lMVGEvL8CwYKiexcd -# FYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+ANuOaMmdbhIurwJ0I9JZTmdHRb -# atGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/ZcGNTTY3ugm2lBRDBcQZqELQd -# VTNYs6FwZvKhggNQMIICOAIBATCB+aGB0aSBzjCByzELMAkGA1UEBhMCVVMxEzAR -# BgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1p -# Y3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJpY2Eg -# T3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjkyMDAtMDVFMC1E -# OTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNloiMKAQEw -# BwYFKw4DAhoDFQA4RWFs+kTiZnoZiAj1BtYj8zCNaqCBgzCBgKR+MHwxCzAJBgNV -# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w -# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29m -# dCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3DQEBCwUAAgUA7fpVNzAiGA8y -# MDI2MDcwOTE3MTMyN1oYDzIwMjYwNzEwMTcxMzI3WjB3MD0GCisGAQQBhFkKBAEx -# LzAtMAoCBQDt+lU3AgEAMAoCAQACAgOqAgH/MAcCAQACAhK4MAoCBQDt+6a3AgEA -# MDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKgCjAIAgEAAgMHoSChCjAI -# AgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBAHlZmSRaw7pzDwCEp8iA/skt0HaL -# KJRGRQvW30895bSUuo+vE1UTIjO7SxKh1CVMqvUI6CwljFT+cPcgpYFKS21SKU9J -# UQmiLS/vwHvqHUDmjs+arJVMKXXoUxbO8IWaJVNR03001Kd006WQO/JHvUg3PLmJ -# qiTbpIWly4R2qS7+CXsTPi1l7ByE1tsyZxo4Vy+oVptJmkPEWAyAXpA3rX4mLb+s -# lPuRKbaoX/Raq295Mai6eFNm3FVIdHAJ27WXdMQoQJ/vEHtD1Pw0FADuz/yFY5m1 -# sYd7/Cv7lBd6vwU0kOEp2ds4Usu3ffMP7j6XF+lPo+THyYrokFK7VtThQAMxggQN -# MIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQ -# MA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u -# MSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAiNP -# 2WAkU8/+KwABAAACIzANBglghkgBZQMEAgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0G -# CyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCAtVuKKUgZVUDNHPora5xDOjnMN -# UMkvZl9P4qMoaKb0OjCB+gYLKoZIhvcNAQkQAi8xgeowgecwgeQwgb0EIJbwMywR -# bvcGiynjnwjAqcaD47yYvebKZRAvtEAR5u6zMIGYMIGApH4wfDELMAkGA1UEBhMC -# VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV -# BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp -# bWUtU3RhbXAgUENBIDIwMTACEzMAAAIjT9lgJFPP/isAAQAAAiMwIgQgA+lhR3SR -# u/WLXPFwgLDbiBnkJZ7WzC5ZZfzMi+dRQ1owDQYJKoZIhvcNAQELBQAEggIAVytW -# nTZHLT/XxBeWDJQfigYEubi/eGA11IQ/b/WPqE0csWSi0fKha4xevjl5mtdGz6oq -# f3wXGo0tXK3Ho/CDZDeYpbtp1Kvl7aOUCrFH/K51OhCAOD8fAciYx9SBuBXI+Kan -# uTIpPX1Rs6iUBhrlG+4thjkL0MmKeRf3maBtwbNfRAXYR6w2vzprE7Mtvsa8dZA+ -# mOMKLOZwcLm8Z5+zn+srwghmApEclfREllPyjQMRj3odaVhGuw7nRUvYreJB5k4I -# 1GKZjTPgG7Gg45P6FK9W8epa6U0RjWANYnqtzQZpieuKAJRHGi/kMr16UyCqdjEK -# zpsD2FENOe8ZPIu4ilHg1OUR9TcGks1qoLzavQeun90k4uGo8tX5XcJOuITJS0fk -# OgMVxYCItiB/hCTa2OX08xOt4yDLWZkcu+lWDlmpbQJcuc7wDoVC8VIeKXjEycuY -# gUXJBJRndg3/9UKEJURw/O/g0Gw2l/l1bIez+X4FTf2QnZAOAFOWreszSqMKsBEE -# hHK4p00vS/1PgO51NYi+P6DDn2hZO0ckqd7J6+R5yi9qXJLmsfaKxUdFmdNIzvJ9 -# bwmcU4jAOiTgXkpEWIYR/bKX3Z8WBhWZE8kX8xuuBjuKiYgv097OVDvHn79ocu6k -# OCS1M4L26J/z0jyylWy5Xkx79XVzd84d6JC2bu4= -# SIG # End signature block From 13159ffd303e35dbeda949e944191cd5766ce453 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:56:45 -0700 Subject: [PATCH 11/19] Update restart function --- src/windows-dev-config/README.md | 2 +- src/windows-dev-config/steps/_elevation.ps1 | 13 +++++++++++++ src/windows-dev-config/steps/_reboot-resume.ps1 | 16 ++++++++++------ src/windows-dev-config/steps/_resume-wrapper.ps1 | 2 +- windows-dev-config/README.md | 4 +--- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index 60199b8..f202901 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -434,7 +434,7 @@ A phase is just a file plus an entry in the `$phases` list. Files prefixed with | Area | Detail | | ---- | ------ | -| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `Restart-Computer -Force`. Save your work before you begin. | +| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `shutdown /r`. Save your work before you begin. | | **Ubuntu's first launch is still manual** | You have to open Ubuntu once to create a Linux username and password. | | **Package versions move** | Packages are installed at whatever winget currently publishes, so two machines set up on different days can differ. `Microsoft.DotNet.SDK.10` and `Python.Python.3.14` pin a major version and will need bumping as those age. | | **The font release is pinned** | Cascadia Code `2407.24`, verified by hash. Newer releases need both the version and the hash updated in `steps/fonts.ps1`. | diff --git a/src/windows-dev-config/steps/_elevation.ps1 b/src/windows-dev-config/steps/_elevation.ps1 index 37b2f18..807bfa9 100644 --- a/src/windows-dev-config/steps/_elevation.ps1 +++ b/src/windows-dev-config/steps/_elevation.ps1 @@ -52,6 +52,19 @@ function Get-DevConfigShellExe { if (Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue) { 'pwsh.exe' } else { 'powershell.exe' } } +function Get-DevConfigTaskShellExe { + # Scheduled tasks cannot launch the WindowsApps execution alias that a Store-installed + # PowerShell 7 leaves on PATH, so resolve to a real file under a machine-wide path. + foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramW6432)) { + if (-not $root) { continue } + $candidate = Join-Path $root 'PowerShell\7\pwsh.exe' + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + + # Windows PowerShell always exists at this fixed path, and these steps run on 5.1. + return (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') +} + # Quote the script path because Start-Process joins arguments with spaces without adding quotes. function Get-DevConfigRelaunchArguments { param( diff --git a/src/windows-dev-config/steps/_reboot-resume.ps1 b/src/windows-dev-config/steps/_reboot-resume.ps1 index e4c01b2..5d33dd9 100644 --- a/src/windows-dev-config/steps/_reboot-resume.ps1 +++ b/src/windows-dev-config/steps/_reboot-resume.ps1 @@ -18,7 +18,7 @@ function Suspend-DevConfigForReboot { [Parameter(Mandatory)] [string] $ScriptPath ) - $shell = Get-DevConfigShellExe + $shell = Get-DevConfigTaskShellExe $wrapperPath = Join-Path $PSScriptRoot '_resume-wrapper.ps1' # The wrapper handles output capture so the resumed run stays visible on screen. @@ -47,18 +47,22 @@ function Suspend-DevConfigForReboot { Start-Sleep -Seconds 10 # The resume task is already registered, so a manual restart continues from the same point. - try { - Restart-Computer -Force - } catch { + # shutdown.exe is used instead of Restart-Computer because the latter goes through WMI even + # for the local machine, and that call can time out and report failure mid-restart. + $shutdown = Join-Path $env:SystemRoot 'System32\shutdown.exe' + $result = Invoke-DevConfigNativeCommand -FilePath $shutdown -Arguments @('/r', '/t', '0', '/f') + + # 1115 means a restart is already under way, which is the outcome this wants either way. + if ($result.ExitCode -ne 0 -and $result.ExitCode -ne 1115) { Write-Host '' - Write-Host "Windows would not let setup restart this machine ($($_.Exception.Message))." -ForegroundColor Yellow + Write-Host "Windows would not let setup restart this machine (shutdown.exe returned $($result.ExitCode))." -ForegroundColor Yellow Write-Host 'Restart when convenient -- setup carries on by itself once you log back in.' -ForegroundColor Yellow # Keep the window open so the remaining manual restart instruction is visible. Wait-DevConfigKeyPress exit 0 } - # Restart-Computer can return before reboot begins, so pause before any fall-through code. + # The restart request returns straight away, so pause before any fall-through code. Start-Sleep -Seconds 60 exit 0 } diff --git a/src/windows-dev-config/steps/_resume-wrapper.ps1 b/src/windows-dev-config/steps/_resume-wrapper.ps1 index cd0fc52..f236f1c 100644 --- a/src/windows-dev-config/steps/_resume-wrapper.ps1 +++ b/src/windows-dev-config/steps/_resume-wrapper.ps1 @@ -28,7 +28,7 @@ $innerOut = Join-Path $logDir 'resume-inner-stdout.log' $innerErr = Join-Path $logDir 'resume-inner-stderr.log' Remove-Item $masterLog, $innerOut, $innerErr -ErrorAction SilentlyContinue -$shell = Get-DevConfigShellExe +$shell = Get-DevConfigTaskShellExe $proc = Start-Process -FilePath $shell ` -ArgumentList (Get-DevConfigRelaunchArguments -ScriptPath $ScriptPath -Resumed) ` -RedirectStandardOutput $innerOut -RedirectStandardError $innerErr -NoNewWindow -PassThru diff --git a/windows-dev-config/README.md b/windows-dev-config/README.md index 5f72de3..f202901 100644 --- a/windows-dev-config/README.md +++ b/windows-dev-config/README.md @@ -278,8 +278,6 @@ $url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/ ## Security -**Read it before you run it.** Piping a remote script into your shell is a real trust decision, and this one asks for Administrator. Everything it does is in this folder — [`bootstrap.ps1`](./bootstrap.ps1) is under 200 lines, [`dev-config.ps1`](./dev-config.ps1) is the orchestrator, and every change lives in a named file under [`steps/`](./steps). To read the exact copy you'd be running, use `-NoLaunch` above, or pass `-Ref` with a commit SHA to pin it. - **What runs elevated.** The whole setup, after the single UAC prompt. It needs Administrator for the `HKLM` settings, the WSL Windows features, and machine-wide package installs. **What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. @@ -436,7 +434,7 @@ A phase is just a file plus an entry in the `$phases` list. Files prefixed with | Area | Detail | | ---- | ------ | -| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `Restart-Computer -Force`. Save your work before you begin. | +| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `shutdown /r`. Save your work before you begin. | | **Ubuntu's first launch is still manual** | You have to open Ubuntu once to create a Linux username and password. | | **Package versions move** | Packages are installed at whatever winget currently publishes, so two machines set up on different days can differ. `Microsoft.DotNet.SDK.10` and `Python.Python.3.14` pin a major version and will need bumping as those age. | | **The font release is pinned** | Cascadia Code `2407.24`, verified by hash. Newer releases need both the version and the hash updated in `steps/fonts.ps1`. | From 868d80d47e9be62f2c55fd4938940213e3d85b1b Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:45:35 -0700 Subject: [PATCH 12/19] Ref fix --- src/windows-dev-config/bootstrap.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index 9b8a75a..d54df8a 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -30,6 +30,16 @@ Set-StrictMode -Version Latest $repo = 'microsoft/WindowsDeveloperConfig' +# The ref goes straight into the download URL, and '..' in it would redirect to another repository. +if ($Ref -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $Ref.Contains('..')) { + throw "'$Ref' is not a valid branch, tag or commit name. Use letters, digits, and . _ - / only." +} + +# A UNC install root would put the files the elevated setup loads on a remote share. +if ($InstallRoot -and ($InstallRoot.StartsWith('\\') -or $InstallRoot.StartsWith('//'))) { + throw '-InstallRoot must be a local path, not a network share.' +} + if (-not $InstallRoot) { # Per-user and outside the roaming profile: it has to still be there after the reboot. $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:TEMP } From 066b471390431e97fdc3aed9cf7f5ee693f25410 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:03:13 -0700 Subject: [PATCH 13/19] Load files --- src/windows-dev-config/dev-config.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/windows-dev-config/dev-config.ps1 b/src/windows-dev-config/dev-config.ps1 index 7e011c4..914e9f4 100644 --- a/src/windows-dev-config/dev-config.ps1 +++ b/src/windows-dev-config/dev-config.ps1 @@ -84,22 +84,28 @@ $phases = @( $failure = $null try { - $phaseIndex = 0 + # Every phase file is loaded before any of them runs, so the elevated process is not still reading new code off disk minutes in. + $loadedPhases = @() foreach ($phase in $phases) { - $phaseIndex++ $path = Join-Path $stepsDir $phase.File if (-not (Test-Path -LiteralPath $path)) { Write-Host "-- $($phase.File) not written yet, skipping" -ForegroundColor DarkGray continue } + . $path + $loadedPhases += $phase + } + + $phaseIndex = 0 + foreach ($phase in $loadedPhases) { + $phaseIndex++ # Script-scoped phase metadata avoids passing header state through every phase file. $Script:DevConfigPhaseIndex = $phaseIndex - $Script:DevConfigPhaseTotal = $phases.Count + $Script:DevConfigPhaseTotal = $loadedPhases.Count $Script:DevConfigPhaseTitle = $phase.Title $Script:DevConfigPhaseHeaderShown = $false - . $path if ($phase.File -eq 'wsl.ps1') { # The WSL phase registers resume using this orchestrator path. Invoke-WslPhase -OrchestratorPath $PSCommandPath From e3443d863ac193f1a7ee2d569e35679586194711 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:33:22 -0700 Subject: [PATCH 14/19] Fix font --- src/windows-dev-config/README.md | 2 +- src/windows-dev-config/steps/fonts.ps1 | 42 ++++++++++++++++++++------ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index f202901..fd69ba9 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -175,7 +175,7 @@ Widgets are turned off through the OS policy value because the per-user taskbar ### Fonts, Terminal and prompt -- **Cascadia Code NF** and **Cascadia Mono NF** are downloaded from the pinned [`microsoft/cascadia-code`](https://github.com/microsoft/cascadia-code/releases) release `2407.24`, verified against a known SHA-256, and installed **per-user** under `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. +- **Cascadia Code NF** and **Cascadia Mono NF** are downloaded from the pinned [`microsoft/cascadia-code`](https://github.com/microsoft/cascadia-code/releases) release `2407.24`, verified against a known SHA-256, and installed **for all users** under `%SystemRoot%\Fonts`. An earlier per-user copy left by a previous run is removed. - **Windows Terminal** gets Cascadia Mono NF as its default font face and PowerShell 7 as its default profile. `settings.json` is backed up to `settings.json.bak` before either change. - **Oh My Posh** is initialized from your PowerShell 7 `$PROFILE`. If an `oh-my-posh init` line is already there, nothing is added. - A **GitHub Copilot** profile is added to Windows Terminal as a settings fragment in `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`, so it appears in the dropdown without editing your settings file. diff --git a/src/windows-dev-config/steps/fonts.ps1 b/src/windows-dev-config/steps/fonts.ps1 index fee0d4d..691350b 100644 --- a/src/windows-dev-config/steps/fonts.ps1 +++ b/src/windows-dev-config/steps/fonts.ps1 @@ -11,20 +11,36 @@ $Script:CascadiaFontVersion = '2407.24' $Script:CascadiaWantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') $Script:CascadiaZipSha256 = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' $Script:CascadiaDefaultFontFace = 'Cascadia Mono NF' +$Script:CascadiaFontRegPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' +$Script:CascadiaUserFontRegPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' function Test-DevConfigCascadiaFontsInstalled { - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + $fontsDir = Join-Path $env:SystemRoot 'Fonts' $regValues = @( - (Get-ItemProperty $regPath -ErrorAction SilentlyContinue).PSObject.Properties | + (Get-ItemProperty $Script:CascadiaFontRegPath -ErrorAction SilentlyContinue).PSObject.Properties | Where-Object Name -notin 'PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider' | Select-Object -ExpandProperty Value ) $filesOk = -not ($Script:CascadiaWantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($Script:CascadiaWantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) + $regOk = -not ($Script:CascadiaWantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -eq $fn }) }) return ($filesOk -and $regOk) } +function Remove-DevConfigStalePerUserFont { + param( + [Parameter(Mandatory)] [string] $FileName, + [Parameter(Mandatory)] [string] $RegName + ) + $userReg = $Script:CascadiaUserFontRegPath + $userFile = Join-Path (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts') $FileName + try { + Remove-ItemProperty -Path $userReg -Name $RegName -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $userFile -Force -ErrorAction SilentlyContinue + } catch { + Write-Verbose "Could not remove the per-user copy of ${FileName}: $($_.Exception.Message)" + } +} + function Install-DevConfigCascadiaFonts { $version = $Script:CascadiaFontVersion $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$version/CascadiaCode-$version.zip" @@ -32,9 +48,7 @@ function Install-DevConfigCascadiaFonts { $zipPath = Join-Path $workDir 'CascadiaCode.zip' New-Item -ItemType Directory -Path $workDir -Force | Out-Null - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null + $fontsDir = Join-Path $env:SystemRoot 'Fonts' Write-Host "Downloading $zipUrl ..." Write-Host ' (About 10 MB from GitHub. This usually takes a few seconds.)' -ForegroundColor DarkGray @@ -64,7 +78,13 @@ function Install-DevConfigCascadiaFonts { $dest = Join-Path $fontsDir $name Write-Host "Installing $name -> $dest" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + try { + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + } catch { + # A font the system has already loaded can't be overwritten, and it is the same version. + if (-not (Test-Path $dest)) { throw } + Write-Host ' (keeping the copy already in place)' -ForegroundColor DarkGray + } $pfc = New-Object System.Drawing.Text.PrivateFontCollection try { @@ -75,7 +95,9 @@ function Install-DevConfigCascadiaFonts { } $regName = "$family (TrueType)" - New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null + # Machine-wide entries hold the file name; the system resolves it under the Fonts folder. + New-ItemProperty -Path $Script:CascadiaFontRegPath -Name $regName -Value $name -PropertyType String -Force | Out-Null + Remove-DevConfigStalePerUserFont -FileName $name -RegName $regName Write-Host " registered as '$regName'" } } finally { @@ -83,7 +105,7 @@ function Install-DevConfigCascadiaFonts { } Remove-Item $zipPath -Force - Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." + Write-Host "`nDone." } function Test-DevConfigCascadiaDefaultFont { From b4d95fa28d1c894770dad937373475ab89708be1 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:28:04 -0700 Subject: [PATCH 15/19] Added AllowUnsigned --- src/docs/development.md | 2 +- src/windows-dev-config/README.md | 16 +++++++++++--- src/windows-dev-config/bootstrap.ps1 | 31 +++++++++++----------------- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/docs/development.md b/src/docs/development.md index 69806af..8fa3134 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -128,7 +128,7 @@ This repo carries **two parallel copies** of every flow: | `src/docs/development.md` | Contributor docs (CI, validation, how to add a language). | **Yes** | n/a | | `src/tests/` | Hello-world programs + expected stdout used by the CI harness. | **Yes** | CI only | -**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** wherever those copies exist, so on a Windows box you don't need to know `src/` exists. The one exception is Calm OS: its `bootstrap.ps1` is new and hasn't been through a sign cycle yet, so the README's one-liner points at `src/windows-dev-config/bootstrap.ps1`. That's deliberate — the bootstrap still installs the *signed* payload when the ref it downloads has one, so the address you fetch the bootstrap from doesn't change what gets run. Repoint the README at the top-level copy once it lands. +**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** wherever those copies exist, so on a Windows box you don't need to know `src/` exists. The one exception is Calm OS: its `bootstrap.ps1` is new and hasn't been through a sign cycle yet, so the README's one-liner points at `src/windows-dev-config/bootstrap.ps1`. That's deliberate — the bootstrap requires the *signed* payload from the repository root by default, regardless of where the bootstrap itself was fetched. Contributors can explicitly select `src/windows-dev-config/` with `-AllowUnsigned` while testing a ref before its signed copy exists. Repoint the README at the top-level copy once it lands. **Contributors**: edit `src/`. The top-level paths are **regenerated** by [`.pipelines/OneBranch.SignAndPackage.yml`](../../.pipelines/OneBranch.SignAndPackage.yml), which Authenticode-signs every `src/**/*.ps1` and ships them (plus the `.winget` configs and the manifest) as the release artifact. The signed copies were merged into `main` from the `signed` branch in [PR #6](https://github.com/microsoft/WindowsDeveloperConfig/pull/6). A change to a `src/` script becomes a new signed top-level copy on the next sign cycle, not at PR merge, so the two can briefly disagree on a script's body until that cycle runs. diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index fd69ba9..18ae779 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -42,10 +42,13 @@ That's the whole thing. You'll get one UAC prompt, and the machine will restart `irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text and `iex` (`Invoke-Expression`) runs it. The bootstrap then: 1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. -2. Copies the setup — [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder — into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. +2. Selects the signed setup from the repository-root `windows-dev-config/` folder. +3. Copies [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. +The bootstrap never falls back to unsigned source automatically. Contributors testing a ref before its signed copy exists must explicitly pass `-AllowUnsigned`. + ## What to expect @@ -265,6 +268,13 @@ $url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/ & ([scriptblock]::Create((irm $url))) -Ref 'v1.2.3' ``` +**Test an unsigned branch.** `-AllowUnsigned` selects `src/windows-dev-config/` instead of the signed repository-root copy: + +```powershell +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -Ref 'my-branch' -AllowUnsigned +``` + **Download it but don't run it**, so you can read it first: ```powershell @@ -282,7 +292,7 @@ $url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/ **What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. -**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. Whichever address you fetch the bootstrap from, it installs the signed copy of the setup when the ref you asked for has one, and tells you in its output when it falls back to the source copy instead. +**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. The bootstrap requires that signed copy by default and does not silently fall back to source. Running the unsigned `src/windows-dev-config/` payload requires the explicit `-AllowUnsigned` switch. **What it does not do.** It doesn't collect or send telemetry, doesn't sign you in to anything, doesn't change credentials or Windows Defender settings, and doesn't touch files in your user profile beyond the PowerShell profile and Windows Terminal settings described above. @@ -451,7 +461,7 @@ Source of truth for this flow is `src/windows-dev-config/`. The copy at the repo | File | What it is | | ---- | ---------- | -| `bootstrap.ps1` | The remote entry point. Downloads, resolves signed-versus-source, installs, launches. | +| `bootstrap.ps1` | The remote entry point. Downloads, requires signed files by default, optionally selects source with `-AllowUnsigned`, installs, launches. | | `dev-config.ps1` | The orchestrator. Elevation, PowerShell 7, run lock, logging, the phase list, the summary. | | `steps/_step-runner.ps1` | The check/apply/verify engine, the tally, and the flag reporting. | | `steps/_*.ps1` | Shared helpers: elevation, reboot and resume, winget, registry, Terminal settings, retry, process execution, console. | diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index d54df8a..50c7d03 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -10,8 +10,8 @@ The setup cannot run from a piped-in string: it loads two dozen files from its own folder, relaunches itself elevated, and resumes after a reboot. This puts it somewhere real first. - The files it installs come from the signed release copy when the ref has one, and from - src/ otherwise, whichever address this script itself was fetched from. + By default, the files it installs must come from the signed release copy at the repository + root. Pass -AllowUnsigned to explicitly use the source copy under src/ instead. To pick a branch or pin a tag, run it as a script block instead: @@ -22,6 +22,7 @@ param( [string] $Ref = 'main', [string] $InstallRoot, + [switch] $AllowUnsigned, [switch] $NoLaunch ) @@ -109,7 +110,6 @@ try { $expanded = Join-Path $work 'expanded' Expand-Archive -LiteralPath $zip -DestinationPath $expanded -Force - # The signed copy the release pipeline publishes, then the source it was built from. $top = Get-ChildItem -LiteralPath $expanded -Directory | Select-Object -First 1 if (-not $top) { throw "The download from '$Ref' was empty. Check that the branch or tag name is right." @@ -118,25 +118,18 @@ try { $signed = Join-Path $top.FullName 'windows-dev-config' $source = Join-Path (Join-Path $top.FullName 'src') 'windows-dev-config' - $setupDir = $null - foreach ($candidate in @($signed, $source)) { - if ((Test-Path (Join-Path $candidate 'dev-config.ps1')) -and (Test-Path (Join-Path $candidate 'steps'))) { - $setupDir = $candidate - break + $setupDir = if ($AllowUnsigned) { $source } else { $signed } + if (-not ((Test-Path (Join-Path $setupDir 'dev-config.ps1')) -and (Test-Path (Join-Path $setupDir 'steps')))) { + if ($AllowUnsigned) { + throw "The download from '$Ref' doesn't contain the unsigned setup under src/windows-dev-config. Check that the branch or tag name is right." } + throw "'$Ref' doesn't contain a signed Calm OS setup under windows-dev-config. Pass -AllowUnsigned only if you intend to run the unsigned source copy." } - if (-not $setupDir) { - # A folder having moved is not on its own a reason to give up. - $found = Get-ChildItem -LiteralPath $expanded -Recurse -Filter 'dev-config.ps1' -File | - Where-Object { Test-Path (Join-Path $_.DirectoryName 'steps') } | - Select-Object -First 1 - if (-not $found) { - throw "The download from '$Ref' doesn't contain the setup files. Check that the branch or tag name is right." - } - $setupDir = $found.DirectoryName - } elseif ($setupDir -eq $source) { - Write-Host " '$Ref' has no signed copy yet, so its source files are being used." -ForegroundColor DarkGray + if ($AllowUnsigned) { + Write-Host ' Using the unsigned source copy because -AllowUnsigned was passed.' -ForegroundColor Yellow + } else { + Write-Host ' Using the signed release copy.' -ForegroundColor DarkGray } New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null From 24401d4594cfe03cec139d880c3be65ca9c2eaf0 Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:30:22 -0700 Subject: [PATCH 16/19] Assert validation --- src/docs/development.md | 2 +- src/windows-dev-config/README.md | 5 ++-- src/windows-dev-config/bootstrap.ps1 | 43 +++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/docs/development.md b/src/docs/development.md index 8fa3134..b7d7315 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -128,7 +128,7 @@ This repo carries **two parallel copies** of every flow: | `src/docs/development.md` | Contributor docs (CI, validation, how to add a language). | **Yes** | n/a | | `src/tests/` | Hello-world programs + expected stdout used by the CI harness. | **Yes** | CI only | -**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** wherever those copies exist, so on a Windows box you don't need to know `src/` exists. The one exception is Calm OS: its `bootstrap.ps1` is new and hasn't been through a sign cycle yet, so the README's one-liner points at `src/windows-dev-config/bootstrap.ps1`. That's deliberate — the bootstrap requires the *signed* payload from the repository root by default, regardless of where the bootstrap itself was fetched. Contributors can explicitly select `src/windows-dev-config/` with `-AllowUnsigned` while testing a ref before its signed copy exists. Repoint the README at the top-level copy once it lands. +**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** wherever those copies exist, so on a Windows box you don't need to know `src/` exists. The one exception is Calm OS: its `bootstrap.ps1` is new and hasn't been through a sign cycle yet, so the README's one-liner points at `src/windows-dev-config/bootstrap.ps1`. That's deliberate — the bootstrap requires the *signed* payload from the repository root by default, verifies every payload `.ps1` has a valid Microsoft Corporation Authenticode signature, and stops before installation if any check fails. Contributors can explicitly select `src/windows-dev-config/` and bypass signature validation with `-AllowUnsigned` while testing a ref before its signed copy exists. Repoint the README at the top-level copy once it lands. **Contributors**: edit `src/`. The top-level paths are **regenerated** by [`.pipelines/OneBranch.SignAndPackage.yml`](../../.pipelines/OneBranch.SignAndPackage.yml), which Authenticode-signs every `src/**/*.ps1` and ships them (plus the `.winget` configs and the manifest) as the release artifact. The signed copies were merged into `main` from the `signed` branch in [PR #6](https://github.com/microsoft/WindowsDeveloperConfig/pull/6). A change to a `src/` script becomes a new signed top-level copy on the next sign cycle, not at PR merge, so the two can briefly disagree on a script's body until that cycle runs. diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index 18ae779..9ca745d 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -43,7 +43,8 @@ That's the whole thing. You'll get one UAC prompt, and the machine will restart 1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. 2. Selects the signed setup from the repository-root `windows-dev-config/` folder. -3. Copies [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. +3. Verifies that every PowerShell file has a valid Microsoft Corporation Authenticode signature. +4. Copies [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. @@ -292,7 +293,7 @@ $url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/ **What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. -**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. The bootstrap requires that signed copy by default and does not silently fall back to source. Running the unsigned `src/windows-dev-config/` payload requires the explicit `-AllowUnsigned` switch. +**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. The bootstrap requires that signed copy by default and does not silently fall back to source. Before it copies or runs the payload, it requires every `.ps1` to have a `Valid` Authenticode signature whose signer is Microsoft Corporation; one missing, invalid, or unexpected signature stops the run. Running the unsigned `src/windows-dev-config/` payload skips these checks and requires the explicit `-AllowUnsigned` switch. **What it does not do.** It doesn't collect or send telemetry, doesn't sign you in to anything, doesn't change credentials or Windows Defender settings, and doesn't touch files in your user profile beyond the PowerShell profile and Windows Terminal settings described above. diff --git a/src/windows-dev-config/bootstrap.ps1 b/src/windows-dev-config/bootstrap.ps1 index 50c7d03..7a58f56 100644 --- a/src/windows-dev-config/bootstrap.ps1 +++ b/src/windows-dev-config/bootstrap.ps1 @@ -11,7 +11,8 @@ relaunches itself elevated, and resumes after a reboot. This puts it somewhere real first. By default, the files it installs must come from the signed release copy at the repository - root. Pass -AllowUnsigned to explicitly use the source copy under src/ instead. + root, and every PowerShell file must have a valid Microsoft signature. Pass -AllowUnsigned + to explicitly use the source copy under src/ without signature validation instead. To pick a branch or pin a tag, run it as a script block instead: @@ -30,6 +31,7 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $repo = 'microsoft/WindowsDeveloperConfig' +$microsoftSignerSubject = 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US' # The ref goes straight into the download URL, and '..' in it would redirect to another repository. if ($Ref -notmatch '^[A-Za-z0-9][A-Za-z0-9._/-]*$' -or $Ref.Contains('..')) { @@ -96,6 +98,44 @@ function Save-CalmOsArchive { throw "Could not download '$Ref' from $repo ($($lastError.Exception.Message)). Check your internet connection or proxy settings, then run this again." } +function Assert-CalmOsMicrosoftSigned { + param( + [Parameter(Mandatory)] [string] $Directory + ) + + $scripts = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter '*.ps1') + if ($scripts.Count -eq 0) { + throw "The signed Calm OS copy under windows-dev-config contains no PowerShell files." + } + + $failures = @() + foreach ($script in $scripts) { + $signature = Get-AuthenticodeSignature -LiteralPath $script.FullName + $relativePath = $script.FullName.Substring($Directory.Length).TrimStart([char]'\') + + if ($signature.Status -ne 'Valid') { + $failures += "$relativePath [$($signature.Status)]" + continue + } + + $subject = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + '' + } + if ($subject -ne $microsoftSignerSubject) { + $failures += "$relativePath [unexpected signer: $subject]" + } + } + + if ($failures.Count -gt 0) { + $details = ($failures | ForEach-Object { " $_" }) -join [Environment]::NewLine + throw "The signed Calm OS payload failed Microsoft signature verification:$([Environment]::NewLine)$details$([Environment]::NewLine)Nothing was installed. Use -AllowUnsigned only when you intentionally want to run the source copy." + } + + Write-Host " Verified $($scripts.Count) Microsoft-signed PowerShell files." -ForegroundColor DarkGray +} + Write-Host '' Write-Host 'Calm OS setup' -ForegroundColor Cyan Write-Host " Fetching '$Ref' from $repo..." -ForegroundColor DarkGray @@ -130,6 +170,7 @@ try { Write-Host ' Using the unsigned source copy because -AllowUnsigned was passed.' -ForegroundColor Yellow } else { Write-Host ' Using the signed release copy.' -ForegroundColor DarkGray + Assert-CalmOsMicrosoftSigned -Directory $setupDir } New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null From 9c0e1ea0998393ec6221ed19bf58dabe1128c73d Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:49:18 -0700 Subject: [PATCH 17/19] Leave the signed windows-dev-config copy unchanged The release-copy updates move to a separate PR, so this branch no longer touches windows-dev-config/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fa27b7a2-0eb1-4970-927c-340739ecbdc1 --- windows-dev-config/README.md | 646 ++++++---------- windows-dev-config/dev-config.winget | 1055 ++++++++++++++++++++++++++ windows-dev-config/install.ps1 | 242 ++++++ 3 files changed, 1531 insertions(+), 412 deletions(-) create mode 100644 windows-dev-config/dev-config.winget create mode 100644 windows-dev-config/install.ps1 diff --git a/windows-dev-config/README.md b/windows-dev-config/README.md index f202901..b475d35 100644 --- a/windows-dev-config/README.md +++ b/windows-dev-config/README.md @@ -1,460 +1,282 @@ -# Windows Dev Config - -*Turns a fresh Windows 11 machine into a clean, distraction-free developer workstation in one command.* - -This flow installs the tools you'd install anyway, applies the Windows settings you'd change anyway, and sets up WSL + Ubuntu including the reboot in the middle. It is a set of PowerShell scripts: no configuration file to point at, no repo to clone, nothing to install first. - -It is **idempotent** — every change is checked before it's made, so re-running it only fixes what has drifted. It is also **resumable** — if it fails, or you close the window, running it again picks up where it left off. - -> **Original design and curation:** Hamza Usmani. - -## Table of contents - -- [Quick start](#quick-start) -- [What to expect](#what-to-expect) -- [Requirements](#requirements) -- [Before you run this](#before-you-run-this) -- [What it changes](#what-it-changes) -- [How it works](#how-it-works) -- [Running it other ways](#running-it-other-ways) -- [Security](#security) -- [Troubleshooting](#troubleshooting) -- [Undoing it](#undoing-it) -- [Customizing it](#customizing-it) -- [Known limitations](#known-limitations) -- [For contributors](#for-contributors) +# Dev Configuration + +A WinGet Configuration (DSC) file that sets up a clean, lightweight, distraction-free developer workstation. The goal is a PC state that devs actually love using: no clutter, no noise, just the tools you need. + +This mirrors the curated environment currently provided by Cloud PC, so developers get a consistent experience regardless of device. + +The flow is a single DSC document (`dev-config.winget`) that handles everything end-to-end: elevation, the OS tweaks, the apps, the fonts, the shell prompt, and the WSL platform + Ubuntu install (including the reboot dance). + +> **Author:** Hamza Usmani. + +## Table of Contents + +- [Goals](#goals) +- [Prerequisites](#prerequisites) +- [Usage](#usage) +- [What this configures](#what-this-configures) +- [Configuration details](#configuration-details) + - [Phase resources (elevation + WSL)](#phase-resources-elevation--wsl) + - [Apps](#apps) + - [Theme and OS](#theme-and-os) + - [File Explorer](#file-explorer) + - [Taskbar](#taskbar) + - [Start, Search, Notifications](#start-search-notifications) + - [Services and features](#services-and-features) + - [Edge](#edge) + - [Fonts](#fonts) + - [Windows Terminal](#windows-terminal) + - [PowerShell profile](#powershell-profile) +- [Customization](#customization) +- [Design decisions](#design-decisions) +- [Known caveats](#known-caveats) --- -## Quick start - -Open **any** PowerShell window — Windows PowerShell or PowerShell 7, elevated or not — and run: - -```powershell -irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex -``` - -That's the whole thing. You'll get one UAC prompt, and the machine will restart once. - -
-What that command actually does - -`irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text and `iex` (`Invoke-Expression`) runs it. The bootstrap then: - -1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. -2. Copies the setup — [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder — into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. - -The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. - -
- -## What to expect - -Roughly **30 minutes** on a clean machine with a good connection, most of it spent downloading Visual Studio Code, the .NET SDK, PowerToys, and Ubuntu. - -| # | What happens | Your involvement | -| - | ------------ | ---------------- | -| 1 | A UAC prompt appears | **Accept it.** Most of the settings are machine-wide and need Administrator. | -| 2 | PowerShell 7 is installed if it isn't already, and the setup restarts itself on it | None | -| 3 | Ten phases run: packages, Windows settings, fonts, Terminal, prompt, Copilot | None. Long silent stretches during big downloads are normal — a "still working" note prints every minute | -| 4 | WSL is installed. The machine warns you and **restarts after 10 seconds** | **Save your work before you start.** | -| 5 | You sign back in; a window opens by itself and finishes the run | None | -| 6 | A summary prints: how many things changed, how many were already fine | Press a key to close, or leave it — it closes itself after 15 minutes | - -Afterwards, open **Ubuntu** from the Start menu once to create your Linux username and password. Some Explorer and taskbar changes appear after you sign out and back in. - -## Requirements - -- **Windows 11.** Built and tested against current Windows 11 releases. A few of the settings only exist on newer builds; on older ones those steps are skipped rather than failing the run. Windows 10 is not supported. -- **Administrator rights** on the machine, and the ability to accept a UAC prompt. -- **Internet access** to `github.com`, `raw.githubusercontent.com`, the PowerShell Gallery, and the winget package sources. Behind a proxy, the run needs your proxy configured for WinHTTP and for `winget`. -- **Hardware virtualization available to the OS** — WSL cannot install without it. On a physical machine that means VT-x / AMD-V enabled in BIOS/UEFI. In a VM it means the host has exposed nested virtualization to the guest. Everything except WSL still works without it; see [Troubleshooting](#troubleshooting). -- **About 15 GB of free disk space** for the full package set. - -You do **not** need Git, a repository clone, `winget configure`, the Visual C++ Redistributable, or PowerShell 7 beforehand. The flow handles all of those. - -## Before you run this - -This flow is opinionated, and a few of its choices are worth knowing about up front rather than discovering later. - -| Change | Why it might matter to you | -| ------ | -------------------------- | -| **Remote Desktop is enabled** | `fDenyTSConnections` is set to `0`, which allows incoming RDP sessions. The Windows Firewall rule is *not* opened, so this alone doesn't expose the machine to your network — but it is a real change to the machine's posture. | -| **Two Edge settings are applied as policy** | They're written under `HKLM\SOFTWARE\Policies\Microsoft\Edge`, so Edge will report "managed by your organization" and grey those two settings out in its UI. | -| **All notifications are turned off** | Do Not Disturb is enabled globally, not just for a quiet-hours window. Teams, Outlook, and everything else stop raising toasts until you turn it back on. | -| **Both Node.js LTS and nvm-windows are installed** | They are two different ways to manage Node. If you plan to use nvm, uninstall Node.js first so nvm owns the PATH entry. | -| **Windows Terminal's `settings.json` is rewritten** | A `settings.json.bak` is written next to it first, but any comments in your settings file are lost, because the file is round-tripped through JSON. If the file can't be parsed the run stops and leaves it untouched. | -| **There's no uninstall** | Nothing that gets applied is reverted automatically. [Undoing it](#undoing-it) lists the manual reversals. | - -Every one of these is listed in full detail in [What it changes](#what-it-changes). - -## What it changes - -51 individual steps across 11 phases. Each one is checked first and skipped if the machine is already in that state. - -### Packages - -Installed with winget from the `winget` source, silently, with agreements accepted: - -| Package | winget id | -| ------- | --------- | -| Windows Terminal | `Microsoft.WindowsTerminal` | -| PowerShell 7 | `Microsoft.PowerShell` | -| Git | `Git.Git` | -| GitHub CLI | `GitHub.cli` | -| GitHub Copilot CLI | `GitHub.Copilot` | -| Visual Studio Code | `Microsoft.VisualStudioCode` | -| .NET SDK 10 | `Microsoft.DotNet.SDK.10` | -| Python 3.14 | `Python.Python.3.14` | -| uv | `astral-sh.uv` | -| Node.js LTS | `OpenJS.NodeJS.LTS` | -| nvm for Windows | `CoreyButler.NVMforWindows` | -| Coreutils for Windows | `Microsoft.Coreutils` | -| Oh My Posh | `JanDeDobbeleer.OhMyPosh` | -| Windows App CLI | `Microsoft.WinAppCli` | -| PowerToys | `Microsoft.PowerToys` | - -A package counts as done only when winget reports it installed **and** current, so a re-run also picks up available updates. - -
-Windows settings — all 25 registry values - -**System** (`HKLM`, requires Administrator) - -| Setting | Key | Value | -| ------- | --- | ----- | -| Sudo, inline mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo\Enabled` | `3` | -| Developer Mode | `SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | `1` | -| Win32 long paths | `SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` | `1` | -| Remote Desktop allowed | `SYSTEM\CurrentControlSet\Control\Terminal Server\fDenyTSConnections` | `0` | - -**File Explorer** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer`) - -| Setting | Value name | Value | -| ------- | ---------- | ----- | -| Show file extensions | `Advanced\HideFileExt` | `0` | -| Show hidden files | `Advanced\Hidden` | `1` | -| Full path in the title bar | `Advanced\FullPathAddress` | `1` | -| Open Explorer to This PC | `Advanced\LaunchTo` | `1` | -| No frequent folders in Quick Access | `Advanced\ShowFrequent` | `0` | -| No recent files in Quick Access | `ShowRecent` | `0` | -| No recommended or cloud files | `ShowCloudFilesInQuickAccess` | `0` | -| Git status columns in Explorer | `Advanced\NavPaneShowVersionControl` | `1` | -| No sync-provider tips | `Advanced\ShowSyncProviderNotifications` | `0` | - -**Taskbar, Start, search and notifications** - -| Setting | Key | Value | -| ------- | --- | ----- | -| Do Not Disturb (all toasts off) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | `0` | -| Hide the Bluetooth tray icon | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | `0` | -| "End Task" on taskbar right-click | `HKCU\...\Explorer\Advanced\TaskbarEndTask` | `1` | -| No web results in search | `HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer\DisableSearchBoxSuggestions` | `1` | -| No search highlights | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | `0` | -| No Start menu recommendations | `HKCU\...\Explorer\Advanced\Start_IrisRecommendations` | `0` | -| Widgets off | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | `0` | -| No PowerToys always-on-top toasts | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | `0` | - -Widgets are turned off through the OS policy value because the per-user taskbar icon value no longer takes effect on Windows 11 24H2 and later. - -**Microsoft Edge** (`HKLM\SOFTWARE\Policies\Microsoft\Edge`) - -| Setting | Value name | Value | -| ------- | ---------- | ----- | -| Blank new tab page | `NewTabPageLocation` | `about:blank` | -| Skip the first-run experience | `HideFirstRunExperience` | `1` | - -**Theme** (`HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`) - -| Setting | Value name | Value | -| ------- | ---------- | ----- | -| Dark mode for apps | `AppsUseLightTheme` | `0` | -| Dark mode for the system | `SystemUsesLightTheme` | `0` | - -
- -### Fonts, Terminal and prompt - -- **Cascadia Code NF** and **Cascadia Mono NF** are downloaded from the pinned [`microsoft/cascadia-code`](https://github.com/microsoft/cascadia-code/releases) release `2407.24`, verified against a known SHA-256, and installed **per-user** under `%LOCALAPPDATA%\Microsoft\Windows\Fonts`. -- **Windows Terminal** gets Cascadia Mono NF as its default font face and PowerShell 7 as its default profile. `settings.json` is backed up to `settings.json.bak` before either change. -- **Oh My Posh** is initialized from your PowerShell 7 `$PROFILE`. If an `oh-my-posh init` line is already there, nothing is added. -- A **GitHub Copilot** profile is added to Windows Terminal as a settings fragment in `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`, so it appears in the dropdown without editing your settings file. - -### Developer extras - -These are **best-effort**: they need the network and a PATH that has just been updated, so a failure is flagged in the summary rather than stopping the run. - -- The **WinUI templates** for `dotnet new` (`Microsoft.WindowsAppSDK.WinUI.CSharp.Templates`). -- The **`microsoft/win-dev-skills`** marketplace and its **WinUI plugin**, registered with the GitHub Copilot CLI. - -### WSL - -- The WSL platform components, via `wsl --install --no-distribution`. If that isn't available, the `VirtualMachinePlatform` and `Microsoft-Windows-Subsystem-Linux` Windows features are enabled directly with `dism.exe` instead. -- A restart, if one is needed — see [Reboot and resume](#reboot-and-resume). -- **Ubuntu**, via `wsl --install -d Ubuntu --no-launch`, falling back to `--web-download` if the Microsoft Store route doesn't complete. The distro's first-run welcome screen is suppressed; open Ubuntu from the Start menu to create your Linux user. - -Nothing *inside* the distro is configured by this flow. For that, see [WSL Comfort](../wsl-comfort/readme.md). - -## How it works - -### The phases - -| # | Phase | Notes | -| - | ----- | ----- | -| 1 | Getting ready | Confirms PowerShell 7 and a winget new enough to drive non-interactively (1.6.0+), repairing winget if not | -| 2 | Packages | The 15 packages above, plus the PowerToys notification setting | -| 3 | System settings | Sudo, Developer Mode, long paths, Remote Desktop | -| 4 | File Explorer tweaks | | -| 5 | Taskbar, search & start tweaks | | -| 6 | Microsoft Edge tweaks | | -| 7 | Fonts | | -| 8 | Windows Terminal | | -| 9 | PowerShell profile | | -| 10 | GitHub Copilot | The Terminal profile, WinUI templates, and the Copilot CLI plugin — all best-effort | -| 11 | WSL + Ubuntu | Last on purpose, so its restart happens after everything else is done | - -### Check, apply, verify - -Every step is a triple: a check, an apply, and the same check again. - -- If the check passes first time, the step prints `already OK` and nothing runs. -- If the apply runs but the check still fails afterwards, that's an error — not a silent success. -- Steps that aren't worth stopping the whole run for are marked **best-effort**. If one of those fails it's reported as **flagged**, the run continues, and the summary names it at the end so it doesn't scroll past you. - -That's why the totals in the summary can add up to more than 51: the tally is saved across the reboot and carried into the resumed run, which re-checks every step it already did. Steps counted before the restart are counted again when they're confirmed after it. - -### Elevation and PowerShell 7 - -The setup relaunches itself twice before doing any work: - -1. **Elevated**, via UAC, if it wasn't already. Declining the prompt stops the run cleanly without changing anything. -2. **On PowerShell 7**, installing it first if necessary. The WinGet PowerShell module behaves more consistently there than on Windows PowerShell 5.1. If PowerShell 7 can't be installed the run continues on Windows PowerShell and says so. - -A machine-wide lock (`Global\WindowsDevConfigSetup`) means a second copy won't start while one is running — it tells you to switch windows instead of letting two runs fight over the same installs. - -### Reboot and resume - -Enabling the WSL platform requires a restart. When one is needed, the setup: - -1. Registers a scheduled task named **`WindowsDevConfigResume`** that runs at your next logon, as you, elevated, after a 30-second delay. -2. Saves its progress so far to `devconfig-tally.json`. -3. Prints a warning and restarts after **10 seconds**. - -After you sign in, the task opens a window, finishes the run, prints the combined summary for both halves, and removes itself. If Windows refuses the restart, the setup tells you and leaves the task registered — restart whenever you like and it still resumes. - -Only one restart is ever performed. If WSL still isn't usable after it, the run stops and explains why rather than rebooting again. - -### Logs - -A full transcript is written to **`devconfig-log.txt`** next to `dev-config.ps1` — so `%LOCALAPPDATA%\CalmOS\devconfig-log.txt` for the one-liner. The path is printed at the end of every run. - -The transcript is more verbose than the console on purpose: it records handled errors and raw command output that are deliberately kept off screen. Text in the log that isn't on your console is usually something the run recovered from. - -## Running it other ways - -**From a clone, with the repo already on disk:** - -```powershell -.\src\windows-dev-config\dev-config.ps1 -``` - -**Pin a tag, or try a branch.** `-Ref` takes a branch, tag, or commit SHA. Passing arguments needs the script-block form rather than `| iex`: +## Goals + +- **A PC devs actually want to use.** Clean Explorer, dark theme, no pop-ups, no recommendations, no widgets. Just your code and your tools. +- **Cloud PC parity.** Same tooling, OS settings, and policies as the current Cloud PC image. +- **One command.** `winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity` takes a fresh Windows machine to fully ready, including WSL + Ubuntu (with an auto-resume across the required reboot). +- **Idempotent.** Safe to re-run on existing machines to apply updates or fix drift. Every resource has a `testScript` or DSC-native idempotency. + +## Prerequisites + +- Windows 11 (latest). +- `winget` with the DSC v3 processor available (the file uses `Microsoft.WinGet/Package`, `Microsoft.Windows/Registry`, and `Microsoft.DSC.Transitional/*`). +- Administrator rights — the `ElevationCheck` resource will auto-relaunch winget elevated via `Start-Process -Verb RunAs` if you started in an unelevated session, but you'll need to consent at the UAC prompt. +- The Microsoft Visual C++ Redistributable when invoking `winget` from a non-elevated environment. Without it, `winget configure` fails with an internal error. See [aka.ms/vcredist](https://aka.ms/vcredist) or install via winget (see the Usage callout below). +- The repo on disk. `winget configure` reads a local file path, and the bootstrap is what installs Git, so on a fresh machine you'll either `git clone` (if Git is already installed) or download the repo as a ZIP from GitHub and extract it before running. +- **Hardware virtualization must be available to the OS** before WSL can install. On bare metal, this means virtualization (VT-x / AMD-V) is enabled in BIOS/UEFI. Inside a VM, it means the host has exposed nested virtualization to the guest. See the Usage callout below. + +## Usage + +> [!IMPORTANT] +> If `winget` is being invoked from a **non-elevated** environment, the Microsoft Visual C++ Redistributable ([aka.ms/vcredist](https://aka.ms/vcredist)) must also be installed — without it `winget configure` fails with an internal error. Install it once with the command for your machine's architecture: +> +> ```powershell +> # x64: +> winget install Microsoft.VCRedist.2015+.x64 +> +> # ARM64: +> winget install Microsoft.VCRedist.2015+.arm64 +> ``` + +> [!IMPORTANT] +> **WSL needs hardware virtualization.** If virtualization isn't available to the OS, the `InstallUbuntu` step fails with `wsl --install ... failed with exit code -1`. +> +> - **On bare metal:** enable virtualization (VT-x / AMD-V) in your BIOS/UEFI. The exact label varies by vendor — check your motherboard or laptop manufacturer's documentation if you can't find it. Reboot into firmware settings, toggle it on, save, and reboot back into Windows. +> - **Inside a VM:** the host must expose nested virtualization to the guest. For a Hyper-V host, run this from an elevated PowerShell session **on the host** (with the guest VM powered off): +> +> ```powershell +> Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true +> ``` +> +> Other hypervisors have their own equivalent settings — check your hypervisor's documentation. + +**Get the files first** (skip if you already have the repo locally): ```powershell -$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' -& ([scriptblock]::Create((irm $url))) -Ref 'v1.2.3' +# Git already installed: +git clone https://github.com/microsoft/WindowsDeveloperConfig.git +cd WindowsDeveloperConfig\windows-dev-config + +# Otherwise, download and extract the ZIP: +Invoke-WebRequest -Uri https://github.com/microsoft/WindowsDeveloperConfig/archive/refs/heads/main.zip -OutFile WindowsDeveloperConfig.zip +Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . +cd .\WindowsDeveloperConfig-main\windows-dev-config ``` -**Download it but don't run it**, so you can read it first: +**Full setup (recommended):** ```powershell -$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' -& ([scriptblock]::Create((irm $url))) -NoLaunch +winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity ``` -**Install somewhere else:** `-InstallRoot 'D:\tools\devconfig'`. The location has to survive the reboot, so avoid `%TEMP%`. - -**Already elevated and want it to stay that way:** `dev-config.ps1 -NoElevate` fails fast instead of prompting. - -## Security - -**What runs elevated.** The whole setup, after the single UAC prompt. It needs Administrator for the `HKLM` settings, the WSL Windows features, and machine-wide package installs. - -**What it downloads, and from where.** GitHub (this repository, and the pinned Cascadia Code release, which is checked against a SHA-256), the PowerShell Gallery (the `Microsoft.WinGet.Client` module), the winget package sources, and the GitHub favicon used as the Copilot profile icon. Failing to fetch the icon is not treated as an error. - -**Code signing.** The release pipeline Authenticode-signs every `.ps1` in this repository with a Microsoft certificate and publishes the signed copies at the repository root. Whichever address you fetch the bootstrap from, it installs the signed copy of the setup when the ref you asked for has one, and tells you in its output when it falls back to the source copy instead. - -**What it does not do.** It doesn't collect or send telemetry, doesn't sign you in to anything, doesn't change credentials or Windows Defender settings, and doesn't touch files in your user profile beyond the PowerShell profile and Windows Terminal settings described above. - -## Troubleshooting - -
-The run stopped and said it needs Administrator - -The UAC prompt was declined. Nothing was changed. Run the command again and accept it, or start from a terminal that's already elevated. - -
- -
-"Calm OS setup is already running in another window" - -Exactly what it says — switch to the other window. Two copies would fight over the same installs. If you're sure nothing is running, the previous process didn't exit cleanly; sign out and back in, or restart, and try again. +This is the canonical invocation documented in the header of `dev-config.winget`. -
+**What to expect:** -
-Some steps came back "flagged" +1. The first phase applies all OS tweaks, installs apps, installs Cascadia Code/Mono Nerd Fonts, and configures Windows Terminal and the PowerShell profile. +2. WSL platform components install; the DSC reboots the machine and registers a `RunOnce` resume. +3. After login, winget configure resumes automatically and installs the default Ubuntu distro. +4. Open Ubuntu from the Start menu to complete its first-launch setup (create a UNIX username and password). -Flagged means best-effort work that couldn't be completed or confirmed. The run finishes and names them in the summary. Everything else was applied. +The configuration is idempotent, so it is safe to re-run after reboot or at any later point. -The most common cause is a step that needs a package that hasn't finished registering yet — the WinUI templates need the .NET SDK on `PATH`, and the Copilot plugin steps need the GitHub Copilot CLI. **Run the command again**: the steps that already succeeded are skipped in seconds and only the flagged ones are retried. +## What this configures -
+- **14 apps** via winget (PowerShell 7, Git, GitHub CLI, GitHub Copilot CLI, VS Code, .NET SDK 10, Python 3.14, UV, Node.js LTS, NVM for Windows, Coreutils for Windows, Windows Application CLI, plus optional Oh My Posh and PowerToys). +- **WSL + Ubuntu**, installed via 3 transitional script resources that bracket a reboot (Phase 2/3/4 below). +- **~24 registry settings** for theme/OS, Explorer, Taskbar, Search, Start, Notifications, Edge, Sudo, and the Widget service. +- **Cascadia Code & Cascadia Mono Nerd Fonts** downloaded from the `microsoft/cascadia-code` GitHub release and registered per-user. +- **5 script resources** beyond the WSL phases: + - `ElevationCheck` — re-launches winget elevated if not already admin. + - `darkTheme` — applies the built-in `dark.theme` to switch to dark mode. + - `InstallCascadiaCodeNerdFonts` — downloads and installs the Nerd Font variants of Cascadia Code/Mono. + - `SetCascadiaNfAsDefault` — sets `Cascadia Mono NF` as the default font face in Windows Terminal's `settings.json`. + - `ps7default` — sets PowerShell 7 as Windows Terminal's default profile. + - `ohMyPoshProfileSet` — adds `oh-my-posh init pwsh | Invoke-Expression` to `$PROFILE` and dot-sources it. -
-WSL fails, or Ubuntu doesn't install +--- -Almost always hardware virtualization not being available to the OS. +## Configuration details -- **Physical machine:** enable virtualization (VT-x / AMD-V) in BIOS/UEFI. The label varies by vendor — check your manufacturer's documentation. Reboot into firmware settings, turn it on, save, and boot back into Windows. -- **Virtual machine:** the host has to expose nested virtualization to the guest. On a Hyper-V host, with the guest powered off: +All resources are dscv3 (`$schema: .../DSC/main/schemas/2023/08/config/document.json`, `metadata.winget.processor.identifier: dscv3`). Every resource that touches HKLM or runs elevated tools depends on `ElevationCheck`. - ```powershell - Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true - ``` +Package resources use `Microsoft.WinGet/Package` with `source: winget` and `useLatest: true` (except `Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, and `OpenJS.NodeJS.LTS`, which are pinned by id). - Other hypervisors have their own equivalent. +### Phase resources (elevation + WSL) -Then run the setup again. Everything else stays applied; only the WSL steps are retried. +| Name | Type | What it does | +|------|------|--------------| +| `ElevationCheck` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` checks `IsInRole(Administrator)`. If false, `setScript` re-invokes `winget configure --file --accept-configuration-agreements --disable-interactivity --wait` via `Start-Process -Verb RunAs`, then throws so the unelevated session ends cleanly. | +| `InstallWslComponents` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` probes for the `vmcompute` service (presence ⇒ Virtual Machine Platform is active). `setScript` runs `wsl --install --no-distribution`. | +| `RebootForVmp` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | Same `vmcompute` test. `setScript` registers `HKCU:\...\RunOnce\DSCConfigureResume` with the same `winget configure --file --accept-configuration-agreements` command, then `Restart-Computer -Force` and throws so DSC stops the current run. | +| `InstallUbuntu` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` runs `wsl --list --quiet` and returns true if any distro is already registered. `setScript` runs `wsl --install -d Ubuntu --no-launch`. | -If virtualization is definitely on and WSL still won't activate after the restart, the run says so and stops rather than rebooting in a loop. The other likely cause is that the machine couldn't reach the WSL download. +All app resources that need WSL present depend on `InstallUbuntu` so the OS work happens before the reboot — but the WSL install is still part of the same `winget configure` invocation thanks to the RunOnce resume. -
+### Apps -
-"WinGet is older than 1.6.0" or winget can't be updated +| Resource name | Package id | Notes | +|---------------|-----------|-------| +| `PowerShell` | `Microsoft.PowerShell` | Direct dependency on `ElevationCheck`. | +| `Git` | `Git.Git` | Depends on `ElevationCheck` + `InstallUbuntu`. | +| `GitHubCLI` | `GitHub.Cli` | Depends on `Git` + `InstallUbuntu`. | +| `GitHubCopilot` | `GitHub.Copilot` | Depends on `Git` + `InstallUbuntu`. | +| `VSCode` | `Microsoft.VisualStudioCode` | | +| `DotnetSdk` | `Microsoft.dotnet.SDK.10` | Pinned to v10. | +| `Python` | `Python.Python.3.14` | Pinned to 3.14. | +| `UV` | `astral-sh.uv` | | +| `NodeJS` | `OpenJS.NodeJS.LTS` | Pinned to the LTS line (currently Node 24 LTS). | +| `nvmForNode` | `CoreyButler.NVMforWindows` | Node version manager for Windows. | +| `Coreutils` | `Microsoft.Coreutils` | Microsoft-maintained Coreutils for Windows. Command integration is handled by the package itself after install. | +| `OhMyPosh` | `JanDeDobbeleer.OhMyPosh` | Marked Optional in the comments. Triggers `ohMyPoshProfileSet`. | +| `winappCli` | `Microsoft.winappcli` | Windows Application CLI. | +| `PowerToys` | `Microsoft.PowerToys` | Marked Optional. Followed by `PowerToysAOT` which disables AOT notifications via registry. | -The setup needs a winget that supports non-interactive installs, and tries to repair or update it. If it can't — usually because the built-in `winget` command is being used and the PowerShell module isn't reachable — update **App Installer** from the Microsoft Store, or install the latest release from [microsoft/winget-cli](https://github.com/microsoft/winget-cli/releases/latest), then run the setup again. +### Theme and OS -
+Dark theme is applied via a `RunCommandOnSet` resource named `darkTheme` (not via registry): -
-Nothing happened after the restart +| Resource | Type | What it does | +|----------|------|--------------| +| `darkTheme` | `Microsoft.DSC.Transitional/RunCommandOnSet` | `Start-Process` on `C:\Windows\Resources\Themes\dark.theme`, sleeps 2 s, then stops `SystemSettings` so the Settings window doesn't linger. Depends on `PowerShell`. | -The resume task waits 30 seconds after logon before starting, and the first thing it does is re-check what's already done, which is quiet. Give it a couple of minutes. +The remaining theme/OS entries below are `Microsoft.Windows/Registry`. -If nothing appears at all, check the task exists: +| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| Sudo enabled (inline mode) | `HKLM\...\Sudo\Enabled` | DWord `3` | +| Developer Mode | `HKLM\...\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | DWord `1` | +| Long path support | `HKLM\...\FileSystem\LongPathsEnabled` | DWord `1` | +| Remote Desktop on | `HKLM\...\Terminal Server\fDenyTSConnections` | DWord `0` | -```powershell -Get-ScheduledTask -TaskName WindowsDevConfigResume -``` +### File Explorer -Either way, running the original command again is safe and picks up exactly where it left off. +| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| Show file extensions | `HKCU\...\Advanced\HideFileExt` | DWord `0` | +| Show hidden files | `HKCU\...\Advanced\Hidden` | DWord `1` | +| Full path in titlebar | `HKCU\...\Advanced\FullPathAddress` | DWord `1` | +| Open to This PC | `HKCU\...\Advanced\LaunchTo` | DWord `1` | +| Frequent folders off | `HKCU\...\Advanced\ShowFrequent` | DWord `0` | +| Frequent files off | `HKCU\...\Explorer\ShowRecent` | DWord `0` | +| Recommended/cloud files off | `HKCU\...\Explorer\ShowCloudFilesInQuickAccess` | DWord `0` | +| Git integration in Explorer | `HKCU\...\Advanced\NavPaneShowVersionControl` | DWord `1` | +| Tips/sync-provider notifications off | `HKCU\...\Advanced\ShowSyncProviderNotifications` | DWord `0` | -
+### Taskbar -
-"Windows Terminal's settings file couldn't be read as JSON" +| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| Widgets button hidden | `HKCU\...\Advanced\TaskbarDa` | DWord `0` | +| Bluetooth notification icon off | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | DWord `0` | +| End Task on right-click | `HKCU\...\Advanced\TaskbarEndTask` | DWord `1` | -Your `settings.json` has a syntax error, so the setup stopped rather than overwrite a file it couldn't understand. Fix or rename the file named in the message, then run the setup again. +### Start, Search, Notifications -
+| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| Web search suggestions off | `HKCU\...\Policies\Explorer\DisableSearchBoxSuggestions` | DWord `1` | +| Search highlights off | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | DWord `0` | +| Start menu recommendations off | `HKCU\...\Advanced\Start_Layout` | DWord `1` | +| Toast notifications off (Do Not Disturb) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | DWord `0` | -
-Downloads fail or time out +### Services and features -The setup retries with backoff and raises TLS 1.2 for you, so this is usually a proxy. `winget` and WinHTTP each need to know about it: +| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| Widget service off (HKLM policy) | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | DWord `0` | +| PowerToys AOT notifications off | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | DWord `0` | -```powershell -netsh winhttp show proxy -``` - -Configure your proxy for both, then run the setup again. +### Edge -
+HKLM policies, applied via `Microsoft.Windows/Registry`: -
-Where do I look when none of the above fits? +| Item | Hive\Key\Value | Value | +|------|----------------|-------| +| New tab blank | `HKLM\SOFTWARE\Policies\Microsoft\Edge\NewTabPageLocation` | String `about:blank` | +| First-run experience off | `HKLM\SOFTWARE\Policies\Microsoft\Edge\HideFirstRunExperience` | DWord `1` | -`devconfig-log.txt`, in the same folder as `dev-config.ps1` (`%LOCALAPPDATA%\CalmOS` when you used the one-liner). The path is printed at the end of every run. +### Fonts -Then please [open an issue](https://github.com/microsoft/WindowsDeveloperConfig/issues) with your Windows build (`winver`), the command you ran, and the relevant part of that log. Setup that fails on a real machine is a bug worth fixing. +| Resource | Type | What it does | +|----------|------|--------------| +| `InstallCascadiaCodeNerdFonts` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Downloads `CascadiaCode-2407.24.zip` from `microsoft/cascadia-code` GitHub Releases, extracts `CascadiaCodeNF.ttf` and `CascadiaMonoNF.ttf` to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, and registers each under `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`. Per-user install — no admin required for this step. Depends on `PowerShell`. | -
+### Windows Terminal + +| Resource | Type | What it does | +|----------|------|--------------| +| `SetCascadiaNfAsDefault` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Locates Windows Terminal's `settings.json` (Store or unpackaged install), backs it up to `settings.json.bak`, and sets `profiles.defaults.font.face = "Cascadia Mono NF"`. Depends on `InstallCascadiaCodeNerdFonts`. | +| `ps7default` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Invokes `pwsh.exe -NoProfile -NoLogo -Command ...` which reads `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, finds the PowerShell 7 profile, and sets it as `defaultProfile`. Depends on `PowerShell`. | -## Undoing it +### PowerShell profile -There is no automatic undo, and the setup never removes anything on its own. The reversals below are the ones most people ask about. Registry changes under `HKLM` need an elevated prompt. +| Resource | Type | What it does | +|----------|------|--------------| +| `ohMyPoshProfileSet` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Creates `$PROFILE` if missing and appends `oh-my-posh init pwsh | Invoke-Expression` (idempotent — uses `Select-String` to check first), then dot-sources `$PROFILE`. Depends on `OhMyPosh`. | -```powershell -# Remote Desktop off again -Set-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' fDenyTSConnections 1 - -# Drop the two Edge policies (removes "managed by your organization" for them) -Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Edge' NewTabPageLocation, HideFirstRunExperience - -# Notifications back on -Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings' NOC_GLOBAL_SETTING_TOASTS_ENABLED 1 - -# Widgets back on -Remove-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Dsh' AllowNewsAndInterests - -# Back to light mode -Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' AppsUseLightTheme 1 -Set-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' SystemUsesLightTheme 1 -``` +--- -Everything else: - -- **Packages:** `winget uninstall --id ` using the ids in [Packages](#packages). -- **Explorer, Start and search settings:** all of them are also in Settings and Explorer's Options dialog. Sign out and back in for them to take effect. -- **Windows Terminal:** restore the `settings.json.bak` written next to `settings.json`. -- **The Copilot Terminal profile:** delete `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig`. -- **The Oh My Posh prompt:** remove the `oh-my-posh init` block from your PowerShell 7 `$PROFILE`. -- **Ubuntu:** `wsl --unregister Ubuntu`. This permanently deletes the distro's file system. -- **The setup itself:** delete `%LOCALAPPDATA%\CalmOS`. - -## Customizing it - -Everything lives in a named file under [`steps/`](./steps), so changing what runs is a local edit rather than a fork of a large document. Take a copy of the repository, edit, and run `dev-config.ps1` directly. - -| To... | Edit | -| ----- | ---- | -| Add or remove a package | The `$packages` list in [`steps/packages.ps1`](./steps/packages.ps1) | -| Change or drop a Windows setting | The `$tweaks` list in the matching `steps/registry-*.ps1` | -| Skip the Edge policies entirely | Remove `edge.ps1` from the `$phases` list in [`dev-config.ps1`](./dev-config.ps1) | -| Keep Remote Desktop off | Delete the `RemoteDesktop` entry in [`steps/registry-system.ps1`](./steps/registry-system.ps1) | -| Change the terminal font | `$Script:CascadiaDefaultFontFace` in [`steps/fonts.ps1`](./steps/fonts.ps1) | -| Install a different distro | The `wsl --install -d Ubuntu` arguments in [`steps/wsl.ps1`](./steps/wsl.ps1) | -| Add something new | Copy the shape of any phase file: build steps with `New-DevConfigStep` and pass them to `Invoke-DevConfigSteps` | - -A phase is just a file plus an entry in the `$phases` list. Files prefixed with `_` are shared helpers, not phases. - -## Known limitations - -| Area | Detail | -| ---- | ------ | -| **One restart, always visible** | The WSL platform genuinely requires it. The setup warns you for 10 seconds and then restarts with `shutdown /r`. Save your work before you begin. | -| **Ubuntu's first launch is still manual** | You have to open Ubuntu once to create a Linux username and password. | -| **Package versions move** | Packages are installed at whatever winget currently publishes, so two machines set up on different days can differ. `Microsoft.DotNet.SDK.10` and `Python.Python.3.14` pin a major version and will need bumping as those age. | -| **The font release is pinned** | Cascadia Code `2407.24`, verified by hash. Newer releases need both the version and the hash updated in `steps/fonts.ps1`. | -| **Terminal settings lose their comments** | `settings.json` is round-tripped through JSON, so comments don't survive. A `.bak` is written first. | -| **No package selection at run time** | It's the full set or a local edit. There's no `-Skip` switch and no prompt. | -| **No dry run** | There's no `-WhatIf`. The `already OK` output tells you what a re-run *would* skip, but only after the fact. | -| **Git and GitHub CLI are installed, not configured** | No `git config user.name`, no `gh auth login`. | -| **`%LOCALAPPDATA%\CalmOS` stays behind** | The installed copy and its log are left in place so a resumed or repeated run works. Delete it when you're done. | -| **Some changes need a sign-out** | Several Explorer and taskbar values are read by Explorer at logon. | - -## For contributors - -Source of truth for this flow is `src/windows-dev-config/`. The copy at the repository root is the Authenticode-signed release copy, regenerated by the sign pipeline — don't edit it directly. See [`src/docs/development.md`](https://github.com/microsoft/WindowsDeveloperConfig/blob/main/src/docs/development.md#repo-layout-signed-vs-source). - -| File | What it is | -| ---- | ---------- | -| `bootstrap.ps1` | The remote entry point. Downloads, resolves signed-versus-source, installs, launches. | -| `dev-config.ps1` | The orchestrator. Elevation, PowerShell 7, run lock, logging, the phase list, the summary. | -| `steps/_step-runner.ps1` | The check/apply/verify engine, the tally, and the flag reporting. | -| `steps/_*.ps1` | Shared helpers: elevation, reboot and resume, winget, registry, Terminal settings, retry, process execution, console. | -| `steps/.ps1` | One file per phase, each exporting a single `Invoke-Phase` function. | - -Adding a phase means adding one file and one line in the `$phases` list. Adding a step to an existing phase means one `New-DevConfigStep` call. Keep every step's check cheap and side-effect free — it runs on every invocation, including the fast path where nothing needs doing. +## Customization + +- **Pick and choose packages.** Comment out any `Microsoft.WinGet/Package` block to skip that install — most have no `dependsOn` chain beyond `InstallUbuntu` (exceptions: `GitHubCLI` and `GitHubCopilot` depend on `Git`; `PowerToysAOT` depends on `PowerToys`; `ohMyPoshProfileSet` depends on `OhMyPosh`). +- **Pin or unpin versions.** Switch `id: Python.Python.3.14` (pinned) to `id: Python.Python.3` if you want to drift forward, or switch `OpenJS.NodeJS.LTS` to `OpenJS.NodeJS` for current. Vice versa for the unpinned packages. +- **Toggle registry values.** Most settings are `DWord: 0` or `DWord: 1`; flip the value to invert the behavior. +- **Re-enable commented-out tweaks.** `HideDesktopIcons` ships commented out (it over-fires on some user setups). Uncomment to enable. +- **Change the WSL distro.** Edit the `wsl --install -d Ubuntu --no-launch` line inside the `InstallUbuntu` resource. +- **Change the terminal font.** Edit `$fontFace = 'Cascadia Mono NF'` inside `SetCascadiaNfAsDefault`, or change the `$WantedFonts` array in `InstallCascadiaCodeNerdFonts` to install a different Cascadia variant. +- **Skip the dark theme step.** Comment out the `darkTheme` resource if you prefer light mode (or want to set it manually). + +## Design decisions + +| Decision | Rationale | +|----------|-----------| +| Single dscv3 document, no modules | Easier to reason about and easier to re-run. The whole flow is one `winget configure` call. | +| `Microsoft.Windows/Registry` everywhere instead of `Microsoft.Windows.Developer/*` or `Microsoft.Windows.Settings/WindowsSettings` | Direct registry control is reliable across Windows 11 builds and avoids dependencies on legacy resource modules. | +| `Microsoft.DSC.Transitional/WindowsPowerShellScript` (not `PSDscResources/Script`) | The dscv3 transitional resource is the supported equivalent under the new processor. | +| Self-relaunch elevated from `ElevationCheck` | A user can double-click into an unelevated shell and the DSC will UAC-prompt itself rather than failing. | +| Reboot + RunOnce inside the DSC | The DSC owns the reboot and the resume, so the user only invokes `winget configure` once. The throw after `Restart-Computer -Force` is required because `Restart-Computer` returns immediately after signalling shutdown; without the throw DSC would treat the resource as succeeded and continue. | +| `useLatest: true` on most packages | Cloud PC parity tracks "current" tools. Pinned ids (`Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, `OpenJS.NodeJS.LTS`) are used where a major-version line matters. | +| Dark theme via `dark.theme` file (not registry) | Applying the shipped `.theme` file flips both `AppsUseLightTheme` and `SystemUsesLightTheme` *and* applies the matching color scheme/cursors atomically, which the broadcast-message dance you'd otherwise need from a registry-only approach often misses. | +| Per-user font install | Avoids requiring admin for the font step and keeps the font registration under `HKCU`, which is what modern Windows + Terminal expect. | +| `RunCommandOnSet` to mutate `settings.json` | Windows Terminal's settings are JSON-based and not registry-mapped; a small pwsh fragment is the cleanest way. | + +## Known caveats + +| Area | Caveat | +|------|--------| +| **`acceptAgreements` not on packages** | None of the `Microsoft.WinGet/Package` resources set `acceptAgreements: true`. The header comment compensates by passing `--accept-configuration-agreements` on the command line. | +| **WSL reboot** | `RebootForVmp` will hard-reboot the machine via `Restart-Computer -Force`. Save your work before running. The RunOnce key resumes the config on next login. | +| **Ubuntu first-launch** | After `InstallUbuntu`, you still need to open Ubuntu from the Start menu once to create a UNIX user. Nothing inside the distro is configured by this flow. | +| **`useLatest: true`** | Each run grabs the latest available version. Builds may differ between machines applying the config on different days. | +| **HKLM registry keys** | Sudo, the Widget service policy, Edge policies, Remote Desktop, Long Paths, and Developer Mode all live in HKLM. The `ElevationCheck` gate guarantees the run is elevated; without it these would silently fail. | +| **PowerToys AOT path** | `HKCU\...\Notifications\Settings\PowerToys\Enabled` targets a specific registry path that may change across PowerToys versions. | +| **Idempotency of WSL phases** | `InstallWslComponents` and `RebootForVmp` both test for `vmcompute`. Re-running after the reboot is a no-op for those resources. `InstallUbuntu` queries `wsl --list --quiet`, so it skips once any distro is registered. | +| **Pinned font release** | `InstallCascadiaCodeNerdFonts` hard-codes Cascadia Code release `2407.24` from `microsoft/cascadia-code`. Bump `$Version` to pick up newer releases. | +| **Windows Terminal settings overwrite** | `SetCascadiaNfAsDefault` and `ps7default` rewrite `settings.json` via `ConvertTo-Json`. `SetCascadiaNfAsDefault` writes a `settings.json.bak` first; `ps7default` does not. JSON comments will not survive the round-trip. | +| **`ohMyPoshProfileSet` runs `. $PROFILE`** | Dot-sourcing the profile inside `pwsh -NoProfile` can surface errors from the user's existing profile during DSC apply. | +| **`darkTheme` opens Settings briefly** | Applying `dark.theme` pops the Settings app open; the script kills it after 2 seconds. On slow machines the window may flash visibly. | +| **Currently commented out** | The `HideDesktopIcons` block lives in the file but is commented out. Uncomment to hide desktop icons. | diff --git a/windows-dev-config/dev-config.winget b/windows-dev-config/dev-config.winget new file mode 100644 index 0000000..fd56b5b --- /dev/null +++ b/windows-dev-config/dev-config.winget @@ -0,0 +1,1055 @@ +# Dev-Config — Developer Workstation Setup +# Apply with: winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity + +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: + identifier: dscv3 +resources: + +# --------------------------------------------------------------------------- +# Phase 0 — Elevation check (runs on initial invoke AND post-reboot resume) +# --------------------------------------------------------------------------- +# +# - name: ElevationCheck +# type: Microsoft.DSC.Transitional/WindowsPowerShellScript +# properties: +# getScript: | +# $id = [Security.Principal.WindowsIdentity]::GetCurrent() +# $p = [Security.Principal.WindowsPrincipal] $id +# return @{ elevated = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +# testScript: | +# $id = [Security.Principal.WindowsIdentity]::GetCurrent() +# $p = [Security.Principal.WindowsPrincipal] $id +# return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +# setScript: | +# $configFile = "${WinGetConfigRoot}\dev-config.winget" +# +# if (-not (Test-Path $configFile)) { +# $hint = "winget configure --file `"$configFile`" --accept-configuration-agreements --disable-interactivity" +# throw "ElevationCheck: not elevated and cannot locate config file to re-launch automatically. Please re-run in an elevated terminal: $hint" +# } +# +# Write-Host "ElevationCheck: not elevated — re-launching winget configure as Administrator..." +# +# $wingetArgs = @( +# 'configure', +# '--file', "`"$configFile`"", +# '--accept-configuration-agreements', +# '--disable-interactivity', +# '--wait' +# ) +# Start-Process winget -ArgumentList $wingetArgs -Verb RunAs +# +# throw "ElevationCheck: re-launched elevated successfully. This unelevated session is now complete — check the elevated window for results." + +# --------------------------------------------------------------------------- +# WSL Phase 1 — Enable WSL optional components via wsl --install +# (enables Virtual Machine Platform; reboot required) +# --------------------------------------------------------------------------- + +- name: InstallWslComponents + type: Microsoft.DSC.Transitional/WindowsPowerShellScript + properties: + getScript: | + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" + return @{ vmcomputePresent = [bool]$svc } + testScript: | + # If vmcompute is present, VMP is active — components already installed. + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" + return [bool]$svc + setScript: | + Write-Host "InstallWslComponents: running wsl --install --no-distribution..." + # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* + # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which + # wsl's install bootstrap requires. The dscv3 host has no console to + # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also + # suppresses the new console) makes wsl fail with "The Windows Subsystem + # for Linux is not installed". With no -Redirect* flags PowerShell never + # captures wsl's output — it goes to that console, not PowerShell's error + # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','--no-distribution' -Wait -PassThru + if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { + Write-Host "WSL installed successfully, but a system reboot is required." + } + elseif ($p.ExitCode -ne 0) { + throw "InstallWslComponents: wsl --install failed with exit code $($p.ExitCode)" + } + metadata: + securityContext: elevated + +# --------------------------------------------------------------------------- +# WSL Phase 2 — Reboot so VMP takes effect +# --------------------------------------------------------------------------- + +- name: RebootForVmp + type: Microsoft.DSC.Transitional/WindowsPowerShellScript + dependsOn: + - InstallWslComponents + properties: + getScript: | + # Get-CimInstance returns $null without error when the service doesn't + # exist (unlike Get-Service, which sets HadErrors at the hosting layer + # even with -ErrorAction Ignore or try/catch). + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" + return @{ vmcomputePresent = [bool]$svc } + testScript: | + # vmcompute (Hyper-V Host Compute Service) is registered once Virtual + # Machine Platform is active post-reboot. Presence alone is sufficient + # — no need to check if it is running. + $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" + return [bool]$svc + setScript: | + # ${WinGetConfigRoot} is set by winget configure to the directory + # containing the config file being applied. + $configFile = "${WinGetConfigRoot}\dev-config.winget" + $resumeCmd = "winget configure --file `"$configFile`" --accept-configuration-agreements" + + # RunOnce entries are deleted by Windows automatically before they + # are executed, so no cleanup step is needed in this config. + $runOncePath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' + Set-ItemProperty -Path $runOncePath -Name 'DSCConfigureResume' -Value $resumeCmd -Force + + Write-Host "RebootForVmp: registered RunOnce resume key." + Write-Host " HKCU:\...\RunOnce\DSCConfigureResume = $resumeCmd" + Write-Host "RebootForVmp: rebooting now to activate Virtual Machine Platform..." + Restart-Computer -Force + + # Restart-Computer -Force returns immediately after signalling the OS + # to reboot — it does not block until the machine goes down. Without + # an explicit failure here DSC would consider this resource succeeded + # and attempt to run InstallUbuntu before the reboot happens. + # Throwing forces DSC to mark the current run as failed; the RunOnce + # key handles resuming on the next login. + Start-Sleep -Seconds 60 # give the OS time to initiate shutdown + throw "Reboot initiated to activate Virtual Machine Platform. DSC will resume via RunOnce on next login." + metadata: + securityContext: elevated + + +# --------------------------------------------------------------------------- +# WSL Phase 3 — Install default Ubuntu distro (VMP now active post-reboot) +# --------------------------------------------------------------------------- + +- name: InstallUbuntu + type: Microsoft.DSC.Transitional/WindowsPowerShellScript + dependsOn: + - RebootForVmp + properties: + getScript: | + # Run wsl --list via Start-Process with redirected output. Calling wsl.exe + # directly leaks its non-zero exit code (when no distro is installed) into + # $LASTEXITCODE and routes its stderr into PowerShell's error stream — both + # of which the dscv3 PowerShell adapter treats as a resource failure. + # Start-Process isolates the native call: stderr never reaches the error + # stream and $LASTEXITCODE is untouched. + $env:WSL_UTF8 = '1' + $distros = @() + $out = [System.IO.Path]::GetTempFileName() + $err = [System.IO.Path]::GetTempFileName() + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` + -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if ($p.ExitCode -eq 0) { + $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | + ForEach-Object { ($_ -replace "`0", '').Trim() } | + Where-Object { $_ }) + } + Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue + return @{ distroCount = $distros.Count; distros = ($distros -join ',') } + testScript: | + $env:WSL_UTF8 = '1' + $out = [System.IO.Path]::GetTempFileName() + $err = [System.IO.Path]::GetTempFileName() + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` + -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + if ($p.ExitCode -ne 0) { + Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue + return $false + } + $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | + ForEach-Object { ($_ -replace "`0", '').Trim() } | + Where-Object { $_ }) + Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue + return $distros.Count -gt 0 + setScript: | + # Suppress the "Welcome to WSL" first-run GUI/OOBE + $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + New-Item -Path $lxssPath -Force | Out-Null + Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force + + Write-Host "InstallUbuntu: running wsl --install -d Ubuntu --no-launch..." + # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* + # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which + # wsl's install bootstrap requires. The dscv3 host has no console to + # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also + # suppresses the new console) makes wsl fail with "The Windows Subsystem + # for Linux is not installed". With no -Redirect* flags PowerShell never + # captures wsl's output — it goes to that console, not PowerShell's error + # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. + $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','-d','Ubuntu','--no-launch' -Wait -PassThru + if ($p.ExitCode -ne 0) { + throw "InstallUbuntu: wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" + } + metadata: + securityContext: elevated + +# ============================================================================= +# Terminal and PowerShell 7 +# ============================================================================= + +- type: Microsoft.WinGet/Package + name: Terminal + properties: + id: Microsoft.WindowsTerminal + source: winget + useLatest: true + installMode: silent + metadata: + description: Install Windows Terminal + +- type: Microsoft.WinGet/Package + name: PowerShell + properties: + id: Microsoft.PowerShell + source: winget + useLatest: true + installMode: silent + metadata: + description: Install PowerShell 7 + +# ============================================================================= +# Force dark theme +# Uses app and system theme registry values to detect dark theme. +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: darkTheme + dependsOn: + - PowerShell + properties: + getScript: | + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue + $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue + return @{ AppsUseLightTheme = [int]$apps; SystemUsesLightTheme = [int]$system } + testScript: | + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue + $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue + return ($apps -eq 0 -and $system -eq 0) + setScript: | + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' + Set-ItemProperty -Path $regPath -Name "AppsUseLightTheme" -Value 0 + Set-ItemProperty -Path $regPath -Name "SystemUsesLightTheme" -Value 0 + metadata: + description: Sets dark theme + +# ============================================================================= +# Install Cascadia Code Nerd Fonts +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: InstallCascadiaCodeNerdFonts + dependsOn: + - PowerShell + properties: + getScript: | + $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + $regValues = @( + (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | + Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | + Select-Object -ExpandProperty Value + ) + $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) + $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) + return @{ filesInstalled = $filesOk; registryEntries = $regOk } + testScript: | + $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + $regValues = @( + (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | + Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | + Select-Object -ExpandProperty Value + ) + $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) + $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) + return ($filesOk -and $regOk) + setScript: | + $ErrorActionPreference = 'Stop' + + $Version = '2407.24' + $WantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') + $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$Version/CascadiaCode-$Version.zip" + $workDir = Join-Path $env:TEMP "CascadiaCode-$Version" + $zipPath = Join-Path $workDir 'CascadiaCode.zip' + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + + $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null + + Write-Host "Downloading $zipUrl ..." + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing + + $expectedHash = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' + $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { + Remove-Item $zipPath -Force + throw "Hash mismatch for CascadiaCode-$Version.zip: expected $expectedHash, got $actualHash" + } + + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.Drawing + + $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + foreach ($name in $WantedFonts) { + $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 + if (-not $entry) { Write-Warning "Not found in archive: $name"; continue } + + $dest = Join-Path $fontsDir $name + Write-Host "Installing $name -> $dest" + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) + + $pfc = New-Object System.Drawing.Text.PrivateFontCollection + try { + $pfc.AddFontFile($dest) + $family = $pfc.Families[0].Name + } finally { $pfc.Dispose() } + + $regName = "$family (TrueType)" + New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null + Write-Host " registered as '$regName'" + } + } + finally { + $zip.Dispose() + } + + Remove-Item $zipPath -Force + Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." + metadata: + description: Install Cascadia Code Nerd Fonts + +# ============================================================================= +# Set Cascadia Mono NF as default Windows Terminal font +# NOTE: This cannot use a fragment. Fragments support adding profiles and color +# schemes, but not profiles.defaults (which applies settings across all profiles). +# Direct settings.json modification is the only available approach here. +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: SetCascadiaNfAsDefault + dependsOn: + - PowerShell + - InstallCascadiaCodeNerdFonts + properties: + getScript: | + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { return @{ fontFace = $null } } + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $json = $clean | ConvertFrom-Json + return @{ fontFace = $json.profiles.defaults.font.face } + testScript: | + $fontFace = 'Cascadia Mono NF' + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { return $true } + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $json = $clean | ConvertFrom-Json + return ($json.profiles.defaults.font.face -eq $fontFace) + setScript: | + $fontFace = 'Cascadia Mono NF' + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { throw 'Windows Terminal settings.json not found.' } + Write-Host "Using: $settingsPath" + + Copy-Item $settingsPath "$settingsPath.bak" -Force + + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $json = $clean | ConvertFrom-Json -AsHashtable + + if (-not $json.profiles) { $json.profiles = [ordered]@{} } + if (-not $json.profiles.defaults) { $json.profiles.defaults = [ordered]@{} } + if (-not $json.profiles.defaults.font) { $json.profiles.defaults.font = [ordered]@{} } + $json.profiles.defaults.font.face = $fontFace + + $json | ConvertTo-Json -Depth 32 | Set-Content $settingsPath -Encoding utf8 + Write-Host "Set font to '$fontFace' (backup: $settingsPath.bak)" + metadata: + description: Making Cascadia fonts default + +# ============================================================================= +# Set PowerShell 7 as the default Windows Terminal profile +# NOTE: This cannot use a fragment. Fragments support adding profiles and color +# schemes, but not the top-level defaultProfile setting in settings.json. +# Direct settings.json modification is the only available approach here. +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: ps7default + dependsOn: + - PowerShell + properties: + getScript: | + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { return @{ defaultProfile = $null; ps7ProfileGuid = $null } } + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $settings = $clean | ConvertFrom-Json + $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 + return @{ defaultProfile = $settings.defaultProfile; ps7ProfileGuid = $ps7.guid } + testScript: | + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { return $true } + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $settings = $clean | ConvertFrom-Json + $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 + if (-not $ps7) { return $true } + return ($settings.defaultProfile -eq $ps7.guid) + setScript: | + $settingsPath = @( + Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | + ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $settingsPath) { return } + $raw = Get-Content $settingsPath -Raw + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $settings = $clean | ConvertFrom-Json + $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 + if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { + $settings.defaultProfile = $ps7.guid + $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath -Encoding UTF8 + } + metadata: + description: Set PowerShell 7 as the default Windows Terminal profile + +# ============================================================================= +# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) +# ============================================================================= + + +# ============================================================================= +# Theme and OS +# ============================================================================= + +- type: Microsoft.Windows/Registry + name: Sudo + properties: + keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo + valueName: Enabled + valueData: + DWord: 3 + metadata: + description: Enable Sudo in inline mode + securityContext: elevated + +# Developer Mode: AllowDevelopmentWithoutDevLicense=1 (replaces +# Microsoft.Windows.Settings/WindowsSettings DeveloperMode:true) +- type: Microsoft.Windows/Registry + name: DeveloperMode + properties: + keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock + valueName: AllowDevelopmentWithoutDevLicense + valueData: + DWord: 1 + metadata: + description: Enable Developer Mode (sideload + dev features) + securityContext: elevated + +# Long path support (replaces Microsoft.Windows.Developer/EnableLongPathSupport) +- type: Microsoft.Windows/Registry + name: LongPaths + properties: + keyPath: HKLM\SYSTEM\CurrentControlSet\Control\FileSystem + valueName: LongPathsEnabled + valueData: + DWord: 1 + metadata: + description: Enable Win32 long path support + securityContext: elevated + +# Remote Desktop (replaces Microsoft.Windows.Developer/EnableRemoteDesktop) +- type: Microsoft.Windows/Registry + name: RemoteDesktop + properties: + keyPath: HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server + valueName: fDenyTSConnections + valueData: + DWord: 0 + metadata: + description: Enable Remote Desktop (firewall rule still needs separate enable) + securityContext: elevated + +# ============================================================================= +# File Explorer and Desktop +# ============================================================================= + +#- type: Microsoft.Windows/Registry +# name: HideDesktopIcons +# dependsOn: +# - ElevationCheck +# properties: +# keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced +# valueName: HideIcons +# valueData: +# DWord: 1 +# metadata: +# description: Hide desktop icons + +# Show file extensions (replaces Microsoft.Windows.Developer/WindowsExplorer +# FileExtensions:Show) +- type: Microsoft.Windows/Registry + name: ShowFileExtensions + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: HideFileExt + valueData: + DWord: 0 + metadata: + description: Show file extensions in Explorer + securityContext: elevated + +# Show hidden files (replaces Microsoft.Windows.Developer/WindowsExplorer +# HiddenFiles:Show) +- type: Microsoft.Windows/Registry + name: ShowHiddenFiles + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: Hidden + valueData: + DWord: 1 + metadata: + description: Show hidden files in Explorer + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: FullPathTitlebar + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: FullPathAddress + valueData: + DWord: 1 + metadata: + description: Show full path in Explorer titlebar + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: OpenThisPC + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: LaunchTo + valueData: + DWord: 1 + metadata: + description: Open File Explorer to This PC + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: FrequentFolders + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: ShowFrequent + valueData: + DWord: 0 + metadata: + description: Disable frequent folders in Quick Access + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: FrequentFiles + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer + valueName: ShowRecent + valueData: + DWord: 0 + metadata: + description: Disable frequent files in Quick Access + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: RecommendedFiles + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer + valueName: ShowCloudFilesInQuickAccess + valueData: + DWord: 0 + metadata: + description: Disable recommended/cloud files in Quick Access + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: GitCodeFolders + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: NavPaneShowVersionControl + valueData: + DWord: 1 + metadata: + description: Enable Git integration in File Explorer + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: TipsOff + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: ShowSyncProviderNotifications + valueData: + DWord: 0 + metadata: + description: Disable sync provider notifications (tips) + securityContext: elevated + +# ============================================================================= +# Notifications and Lock Screen +# ============================================================================= + +- type: Microsoft.Windows/Registry + name: DoNotDisturb + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings + valueName: NOC_GLOBAL_SETTING_TOASTS_ENABLED + valueData: + DWord: 0 + metadata: + description: Enable Do Not Disturb (disable all notifications) + securityContext: elevated + +# ============================================================================= +# Taskbar +# ============================================================================= + +# Hide Widgets button (replaces Microsoft.Windows.Developer/Taskbar +# WidgetsButton:Hide). 0 = hidden. +- type: Microsoft.Windows/Registry + name: TaskbarHideWidgets + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: TaskbarDa + valueData: + DWord: 0 + metadata: + description: Hide Widgets button on the taskbar + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: BluetoothOff + properties: + keyPath: HKCU\Control Panel\Bluetooth + valueName: Notification Area Icon + valueData: + DWord: 0 + metadata: + description: Hide Bluetooth icon in taskbar notification area + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: EndTask + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: TaskbarEndTask + valueData: + DWord: 1 + metadata: + description: Enable "End Task" on right-click of taskbar icons + securityContext: elevated + +# ============================================================================= +# Start and Search +# ============================================================================= + +- type: Microsoft.Windows/Registry + name: WebSearchOff + properties: + keyPath: HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer + valueName: DisableSearchBoxSuggestions + valueData: + DWord: 1 + metadata: + description: Disable web search in Start/Search + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: SearchHightlightOff + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings + valueName: IsDynamicSearchBoxEnabled + valueData: + DWord: 0 + metadata: + description: Disable Show search highlights + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: StartRecommendations + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced + valueName: Start_IrisRecommendations + valueData: + DWord: 0 + metadata: + description: Disable Start menu recommendations + securityContext: elevated + +# ============================================================================= +# Services and Features +# ============================================================================= + +- type: Microsoft.Windows/Registry + name: WidgetServiceOff + properties: + keyPath: HKLM\SOFTWARE\Policies\Microsoft\Dsh + valueName: AllowNewsAndInterests + valueData: + DWord: 0 + metadata: + description: Disable Widget service + securityContext: elevated + + +# ============================================================================= +# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) +# +# Microsoft Edge policies — https://learn.microsoft.com/deployedge/microsoft-edge-policies#newtabpage +# ============================================================================= + +- type: Microsoft.Windows/Registry + name: EdgeNewTab + properties: + keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge + valueName: NewTabPageLocation + valueData: + String: about:blank + metadata: + description: Set Edge new tab to blank + securityContext: elevated + +- type: Microsoft.Windows/Registry + name: EdgeOOBE + properties: + keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge + valueName: HideFirstRunExperience + valueData: + DWord: 1 + metadata: + description: Disable Edge first-run experience + securityContext: elevated + +# ============================================================================= +# Software Installs +# ============================================================================= + +- type: Microsoft.WinGet/Package + name: Git + properties: + id: Git.Git + source: winget + useLatest: true + installMode: silent + metadata: + description: Install Git + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: GitHubCLI + dependsOn: + - Git + properties: + id: GitHub.Cli + source: winget + useLatest: true + installMode: silent + metadata: + description: Install GitHub CLI + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: GitHubCopilot + dependsOn: + - Git + properties: + id: GitHub.Copilot + source: winget + useLatest: true + installMode: silent + metadata: + description: Install GitHub Copilot + +- type: Microsoft.WinGet/Package + name: VSCode + properties: + id: Microsoft.VisualStudioCode + source: winget + useLatest: true + installMode: silent + metadata: + description: Install VS Code + +- type: Microsoft.WinGet/Package + name: DotnetSdk + properties: + id: Microsoft.dotnet.SDK.10 + source: winget + useLatest: true + installMode: silent + metadata: + description: Install dotnet SDK + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: Python + properties: + id: Python.Python.3.14 + source: winget + useLatest: true + installMode: silent + metadata: + description: Install Python 3.14 + +- type: Microsoft.WinGet/Package + name: UV + properties: + id: astral-sh.uv + source: winget + useLatest: true + installMode: silent + metadata: + description: Install UV (Python tool) + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: NodeJS + properties: + id: OpenJS.NodeJS.LTS + source: winget + useLatest: true + installMode: silent + metadata: + description: Install Node.js 24 LTS + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: nvmForNode + properties: + id: CoreyButler.NVMforWindows + source: winget + useLatest: true + installMode: silent + metadata: + description: Install NVM + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: Coreutils + properties: + id: Microsoft.Coreutils + source: winget + useLatest: true + metadata: + description: Install Coreutils for Windows + securityContext: elevated + +- type: Microsoft.WinGet/Package + name: OhMyPosh + properties: + id: JanDeDobbeleer.OhMyPosh + source: winget + useLatest: true + installMode: silent + metadata: + description: Install Oh My Posh (Optional) + +- type: Microsoft.WinGet/Package + name: winappCli + properties: + id: Microsoft.winappcli + source: winget + useLatest: true + installMode: silent + metadata: + description: Install winAppCLI + +- type: Microsoft.WinGet/Package + name: PowerToys + properties: + id: Microsoft.PowerToys + source: winget + useLatest: true + installMode: silent + metadata: + description: Install PowerToys (Optional) + +- type: Microsoft.Windows/Registry + name: PowerToysAOT + dependsOn: + - PowerToys + properties: + keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys + valueName: Enabled + valueData: + DWord: 0 + metadata: + description: Disable PowerToys AOT notifications + +# ============================================================================= +# Include Oh My Posh init in PowerShell 7 profile +# ============================================================================= +- type: OhMyPosh/Shell + name: ohMyPoshProfileSet + dependsOn: + - OhMyPosh + properties: + states: + - name: pwsh + command: | + $(if (Get-Command 'oh-my-posh' -ErrorAction SilentlyContinue) { + oh-my-posh init pwsh + # Set output encoding to UTF-8 + [Console]::OutputEncoding =[System.Text.Encoding]::UTF8 + # Set input encoding to UTF-8 (for reading user input with non-ASCII chars) + [Console]::InputEncoding =[System.Text.Encoding]::UTF8 + }) + skipExistingInit: true + metadata: + description: Setup OhMyPosh in PowerShell 7 + +# ============================================================================= +# Add GitHub Copilot profile to Windows Terminal +# Uses a fragment file so settings.json is never touched directly. +# Fragment file: %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: GitHubCopilotProfile + dependsOn: + - PowerShell + - GitHubCopilot + - Terminal + properties: + getScript: | + $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' + return @{ fragmentPresent = (Test-Path $fragmentPath) } + testScript: | + $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' + return (Test-Path $fragmentPath) + setScript: | + $fragmentsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig' + New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null + + # Download icon alongside the fragment file so the relative path "copilot.png" resolves correctly. + $iconPath = Join-Path $fragmentsDir 'copilot.png' + Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing + + $fragment = @{ + profiles = @( + @{ + guid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' + name = 'GitHub Copilot' + commandline = 'pwsh.exe -NoExit -Command "copilot"' + icon = 'copilot.png' + startingDirectory = '%USERPROFILE%' + hidden = $false + tabTitle = 'Copilot' + } + ) + } + + $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' + $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 + + # Touch settings.json to trigger WT's hot-reload (re-scans Fragments\*.json). + @( + "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", + "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", + "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" + ) | Where-Object { Test-Path $_ } | ForEach-Object { + try { (Get-Item -LiteralPath $_).LastWriteTime = Get-Date } catch {} + } + + Write-Host "GitHub Copilot profile fragment written to $fragmentFile" -ForegroundColor Green + Write-Host "Open Windows Terminal: the 'GitHub Copilot' profile is available in the dropdown." -ForegroundColor Cyan + metadata: + description: Create Github Copilot Profile + +# ============================================================================= +# Install Win Skills +# ============================================================================= +- type: Microsoft.DSC.Transitional/PowerShellScript + name: InstallWinUITemplates + dependsOn: + - PowerShell + - DotnetSdk + properties: + getScript: | + $installed = [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) + return @{ installed = $installed } + testScript: | + return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) + setScript: | + dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates + metadata: + description: Install WinUI dotnet new templates + +- type: Microsoft.DSC.Transitional/PowerShellScript + name: AddWinSkillsMarketplace + dependsOn: + - PowerShell + - GitHubCopilot + properties: + getScript: | + $present = [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') + return @{ present = $present } + testScript: | + return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') + setScript: | + copilot plugin marketplace add microsoft/win-dev-skills + metadata: + description: Add win-dev-skills to the Copilot plugin marketplace + +- type: Microsoft.DSC.Transitional/PowerShellScript + name: InstallWinUIPlugin + dependsOn: + - PowerShell + - AddWinSkillsMarketplace + properties: + getScript: | + $installed = [bool](copilot plugin list 2>&1 | Select-String 'winui') + return @{ installed = $installed } + testScript: | + return [bool](copilot plugin list 2>&1 | Select-String 'winui') + setScript: | + copilot plugin install winui@win-dev-skills + metadata: + description: Install the WinUI Copilot plugin from win-dev-skills diff --git a/windows-dev-config/install.ps1 b/windows-dev-config/install.ps1 new file mode 100644 index 0000000..7e2c26c --- /dev/null +++ b/windows-dev-config/install.ps1 @@ -0,0 +1,242 @@ +<# +.SYNOPSIS + Apply the Calm OS user-experience configuration on Windows. + +.DESCRIPTION + Thin CI/dev shim around `dev-config.winget`, a winget DSC configuration + that sets up the full Calm OS developer workstation (apps, distraction- + free desktop, taskbar polish, Recall off, dark theme, WSL + Ubuntu). + + The shim only: + * applies the DSC config with retry, + * rehydrates PATH in the current session, + * emits `INSTALL_OK: calm-os` for the test harness. + + RequireCommands lists `git` because the master config installs it + unconditionally; asserting it on PATH catches a clean-install failure + early. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\Workloads\_common\apply-configuration.ps1') ` + -Id 'calm-os' ` + -ConfigFile (Join-Path $PSScriptRoot 'dev-config.winget') ` + -RequireCommands @('git') + +# SIG # Begin signature block +# MIInSQYJKoZIhvcNAQcCoIInOjCCJzYCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDQFhDGnQSmZ/Yc +# GalqrJZknbCuanj0Z60K23TbV/riYaCCDLowggX1MIID3aADAgECAhMzAAACHU0Z +# yE7XD1dIAAAAAAIdMA0GCSqGSIb3DQEBCwUAMFcxCzAJBgNVBAYTAlVTMR4wHAYD +# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBD +# b2RlIFNpZ25pbmcgUENBIDIwMjQwHhcNMjYwNDE2MTg1OTQzWhcNMjcwNDE1MTg1 +# OTQzWjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE +# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYD +# VQQDExVNaWNyb3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IB +# DwAwggEKAoIBAQDQvewXxx9gZZFC6Ys1WBay8BJ8kGA4JQnH5CMafqOASlTpK9H8 +# o5ZXTXt0caVQTNMUPt445wXYD+dFtaKWTwDn1I52oUSrC9vJin1Gsqt+zyKJL5Dg +# 3eQXbQNR61DmMy20GLTIO3SFed9Rfi/ophgCLGFLDR3r0KvHjwMb/jYWS0celV/4 +# Lz27LfAekm8v9E5IXaeiXbAUYZKK090n4CVl3JBtbN+9DtI9SNu/yjvozW52/u7R +# X/Ttpa/KDlpuokZ+Zcbvmtd9ur9gFLvZzh41o9MsE/clQtdaFWGvuo6Jua/ntpgk +# ey3E5/vBFe+MJPG6phdnuo6r57ZudCudiI1bAgMBAAGjggGbMIIBlzAOBgNVHQ8B +# Af8EBAMCB4AwHwYDVR0lBBgwFgYKKwYBBAGCN0wIAQYIKwYBBQUHAwMwHQYDVR0O +# BBYEFH6QuMwqcPG0hQlQ6c5jCtTTLrVeMEUGA1UdEQQ+MDykOjA4MR4wHAYDVQQL +# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xFjAUBgNVBAUTDTIzMDAxMis1MDc1NTkw +# HwYDVR0jBBgwFoAUf1k/VCHarU/vBeXmo9ctBpQSCDEwYAYDVR0fBFkwVzBVoFOg +# UYZPaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jcmwvTWljcm9zb2Z0 +# JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNybDBtBggrBgEFBQcBAQRh +# MF8wXQYIKwYBBQUHMAKGUWh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMv +# Y2VydHMvTWljcm9zb2Z0JTIwQ29kZSUyMFNpZ25pbmclMjBQQ0ElMjAyMDI0LmNy +# dDAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQBKTbYOjzwTG/DXGaz9 +# s6+fQeaTtDcFmMY+5UyVFCyj7Pv+5i37qfX8lSL/tBIfYQfWsMuBQlfZurJD6r4H +# VJ2CeH+1fgiq8dcHdVKoZ3Sa2qXoX3cq9iS8cVb06B7+5/XJ7I0OxHH9fDsvJ3T3 +# w5V/ZtAIFmLrl+P0CtG+92uzRsn0nTbdFjOkLMLWPLAU3THohKRlSEMgFJpPkm5n +# 5UAZ35xX6FWCrDLsSKb555bTifwa8mJBwdlof0bmfYidH+dxZ1FdDxvLnNl9zeKs +# A4kejaaIqqIPguhwAti5Ql7BlTNoJNwxCvBmqW2MQLnCkYN/VVUsR3V2x/rcTNzo +# Bf/Z/SpROvdaA2ZOOd1uioXJt3tdLQ7vHpqpib0KfWr/FWXW10q38VxfCnRQBqzb +# SuztR7nEMuzX7Ck+B/XaPDXd1qh72+QYyB0Z2VzWmO9zsnb9Uq/dwu8LGeQqnyu6 +# 7SDGACvnXii2fb9+US492VTnXSnFKyqwgzUyFMtZK1/sHYTv6bG4TtQUygQxTN+Z +# V+aJIlKO2MqZ7bKrAnOzS9m6NgoTdWOq11bTOZwKlIEV/EhV9SWkDmdpR/hPPT2v +# 6TEj4F8PT/zHjRezIU5c/DGlt/VhY/pK0XkJtEyMmmS1BMtjU/rqBZVMIm3dnxQs +# /TBByr+Cf8Z1r7aifQVQ+WSqzjCCBr0wggSloAMCAQICEzMAAAA5O7Y3Gb8GHWcA +# AAAAADkwDQYJKoZIhvcNAQEMBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX +# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg +# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl +# IEF1dGhvcml0eSAyMDExMB4XDTI0MDgwODIwNTQxOFoXDTM2MDMyMjIyMTMwNFow +# VzELMAkGA1UEBhMCVVMxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEo +# MCYGA1UEAxMfTWljcm9zb2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAyNDCCAiIwDQYJ +# KoZIhvcNAQEBBQADggIPADCCAgoCggIBANgBnB7jOMeqlRYHNa265v4IY9fH8TKh +# emHfPINe1gpLaV3dhg324WwH06LcHbpnsBukCDNitryo0dtS/EW6I/yEL/bLSY8h +# KpbfQuWusBPr9qazYcDxCW/qnjb5JsI1s8bNOg3bVATvQVL4tcf03aTycsz8QeCd +# M0l/yHRObJ9QqazM1r6VPEOJ7LL+uEEb73w6QCuhs89a1uv1zerOYMnsneRRwCbp +# yW11IcggU0cRKDDq1pjVJzIbIF6+oiXXbReOsgeI8zu1FyQfK0fVkaya8SmVHQ/t +# Of23mZ4W9k0Ri22QW9p3UgSC5OUDktKxxcCmGL6tXLfOGSWHIIV4YrTJTT6PNty5 +# REojHJuZHArkF9VnHTERWoTjAzfI3kP+5b4alUdhgAZ7ttOu1bVnXfHaqPYl2rPs +# 20ji03LOVWsh/radgE17es5hL+t6lV0eVHrVhsssROWJuz2MXMCt7iw7lFPG9LXK +# Gjsmonn2gotGdHIuEg5JnJMJVmixd5LRlkmgYRZKzhxSCwyoGIq0PhaA7Y+VPct5 +# pCHkijcIIDm0nlkK+0KyepolcqGm0T/GYQRMhHJlGOOmVQop36wUVUYklUy++vDW +# eEgEo4s7hxN6mIbf2MSIQ/iIfMZgJxC69oukMUXCrOC3SkE/xIkgpfl22MM1itkZ +# 35nNXkMolU1lAgMBAAGjggFOMIIBSjAOBgNVHQ8BAf8EBAMCAYYwEAYJKwYBBAGC +# NxUBBAMCAQAwHQYDVR0OBBYEFH9ZP1Qh2q1P7wXl5qPXLQaUEggxMBkGCSsGAQQB +# gjcUAgQMHgoAUwB1AGIAQwBBMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU +# ci06AjGQQ7kUBU7h6qfHMdEjiTQwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDovL2Ny +# bC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljUm9vQ2VyQXV0MjAx +# MV8yMDExXzAzXzIyLmNybDBeBggrBgEFBQcBAQRSMFAwTgYIKwYBBQUHMAKGQmh0 +# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljUm9vQ2VyQXV0MjAx +# MV8yMDExXzAzXzIyLmNydDANBgkqhkiG9w0BAQwFAAOCAgEAFJQfOChP7onn6fLI +# MKrSlN1WYKwDFgAddymOUO3FrM8d7B/W/iQ6DxXsDn7D5W4wMwYeLystcEqfkjz4 +# NURRgazyMu5yRzQh4LqjA4tStTcJh1opExo7nn5PuPBYnbu0+THSuVHTe0VTTPVh +# ily/piFrDo3axQ9P4C+Ol5yet+2gTfekICS5xS+cYfSIvgn0JksVBVMYVI5QFu/q +# hnLhsEFEUzG8fvv0hjgkO+lkpV9ty6GkN4vdnd7ya6Q6aR9y34aiM1qmxaxBi6OU +# nyNl6fkuun/diTFnYDLTppOkr/mg5WSfCiDVMNCxtj4wPKC5OmHm1DQIt/MNokbb +# H3UGsFP1QbzsLocuSqLCvH09Io3fDPTmscR9Y75G4qX7RTX8AdBPo0I6OEojf39z +# uFZt0qOHm65YWQE69cZM2ueE1MB05dNNgHK9gTE7zKvK/fg8B2qjW88MT/WF5V5u +# vZGtqa9FSL2RazArA+rDPuf6JGYz4HpgMZHB4S6szWSKYBv0VisCzfxgeU+dquXW +# 9bd0auYlOB58DPcOYKdc3Se94g+xL4pcEhbB54JOgAkwYTu/9dLeH2pDqeJZAABV +# DWRQCaXfO5LgyKwKCLYXpigrZYCjUSBcr+Ve8PFWMhVTQl0v4q8J/AUmQN5W4n10 +# 1cY2L4A7GTQG1h32HHAvfQESWP0xghnlMIIZ4QIBATBuMFcxCzAJBgNVBAYTAlVT +# MR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jv +# c29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMjQCEzMAAAIdTRnITtcPV0gAAAAAAh0w +# DQYJYIZIAWUDBAIBBQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYK +# KwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIOKpePvQ +# SXhM7PKXvy4CGaIjjRTFiawCPXoTF+ktXLqxMEIGCisGAQQBgjcCAQwxNDAyoBSA +# EgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20w +# DQYJKoZIhvcNAQEBBQAEggEAcF2NQcxNvU1I2FUYH8+h+KqDmXWkbDWvYT8HyC6M +# OVwcPHtJL/ynvINM9Chf/5LHeIP/gKxpekDauBlfJgHeDG2AgMlPbneK19Il5dBo +# 6hC96Puj6uJiMJptksSv4NJGe5A13C3CKuFN5ia0Xl3pI75isohOYp7W48pSW7eO +# /v6Tt9NMvuYYy1TZOdYFtVjvlWQs2PXfViQXWZm6oQx2T/B5XMpw+RqX2L+s0x9k +# 31uEQmwMgWvGVs+2FZGe/aaAxQQMgTDyx6Fr1H+ufxZb6hOrLT50Eqw5qPqjiAni +# JnSodfhyKSq3t3y513+9hD+hApAdILfUMvfjOELmth6TGKGCF5cwgheTBgorBgEE +# AYI3AwMBMYIXgzCCF38GCSqGSIb3DQEHAqCCF3AwghdsAgEDMQ8wDQYJYIZIAWUD +# BAIBBQAwggFSBgsqhkiG9w0BCRABBKCCAUEEggE9MIIBOQIBAQYKKwYBBAGEWQoD +# ATAxMA0GCWCGSAFlAwQCAQUABCB+BGKjf5jlRtBU4taa56dqtAzJUFcH/S6yELq3 +# 1F/XwwIGakfudnlyGBMyMDI2MDcwOTIzMTA0OS43NDhaMASAAgH0oIHRpIHOMIHL +# MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVk +# bW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxN +# aWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRT +# UyBFU046OTIwMC0wNUUwLUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0 +# YW1wIFNlcnZpY2WgghHtMIIHIDCCBQigAwIBAgITMwAAAiNP2WAkU8/+KwABAAAC +# IzANBgkqhkiG9w0BAQsFADB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGlu +# Z3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv +# cmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDAe +# Fw0yNjAyMTkxOTM5NTdaFw0yNzA1MTcxOTM5NTdaMIHLMQswCQYDVQQGEwJVUzET +# MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV +# TWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQLExxNaWNyb3NvZnQgQW1lcmlj +# YSBPcGVyYXRpb25zMScwJQYDVQQLEx5uU2hpZWxkIFRTUyBFU046OTIwMC0wNUUw +# LUQ5NDcxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggIi +# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCK6Q2nk5WUdKzSCSafp+UjUARs +# WxHKS63rJhFC/zSabFumTBuaJ0QNrmqevub5Db7fSj5qtwwKnjIO92+HXF67192f +# ujL7DFot5WEj/AtEZ/XrzFHimKlN1h6gEQwP5I67wizaPW5ZzSBNpaLBg5oHvASP +# OZtwdNUoZ+DQKF3hJl1KZuoIlVK+qi7cLjgak6s5oOZcRCMrKnuC3aoVa6wRDbYv +# KUuj7rkFx9KO0PsHJ/k+LnZMggRheh4AVdawyh+oOzKPjlQGUNfSeWUgym2U9CLa +# 8tt0mQX4DxDz6+ram50gj1oAfyQ6TQ7r96PADFOKBgaU7+cpHnaZG89dTegQ6ydB +# RGIycOw1dRX2eKDRRzziK3cn0WaIm/7OeGsyQKjIzEQuUTDv0Jj/9zQ7truLOOpJ +# D98BJVOK7je84Sz2hb3HvUST7j1j2N8peD6olkpFHR/1Z8Jz4F+mkrUF7MmPAirY +# HRzunbIg3HrDMNwFYN7yBkDA4/VMo9CY0y9oGUoq2yjbCwTibz9VYl93nB3QQiTC +# T9nW3M+TOWB+PMrZpExq1BSHmKPzIqehKqrUDoM33PK+dEKwpYLET6uXq4HuQRMX +# WT//sPubUnQAaaUMfQhAZSy23HtxwtN3eK9+T4wCav2wQFt57eUOwUW5/DCzMF9t +# ua5He1hNvgcAXaiG1wIDAQABo4IBSTCCAUUwHQYDVR0OBBYEFNbAh89v29nPY9bw +# Qb1QYCzxVgeXMB8GA1UdIwQYMBaAFJ+nFV0AXmJdg/Tl0mWnG1M1GelyMF8GA1Ud +# HwRYMFYwVKBSoFCGTmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvY3Js +# L01pY3Jvc29mdCUyMFRpbWUtU3RhbXAlMjBQQ0ElMjAyMDEwKDEpLmNybDBsBggr +# BgEFBQcBAQRgMF4wXAYIKwYBBQUHMAKGUGh0dHA6Ly93d3cubWljcm9zb2Z0LmNv +# bS9wa2lvcHMvY2VydHMvTWljcm9zb2Z0JTIwVGltZS1TdGFtcCUyMFBDQSUyMDIw +# MTAoMSkuY3J0MAwGA1UdEwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgw +# DgYDVR0PAQH/BAQDAgeAMA0GCSqGSIb3DQEBCwUAA4ICAQCHQwe7z5tp4NZwAf1c +# B+4c9J4svw3P6WqGBMxtqznS6DdzUzStXHCaPZhM41g1iKHNnmcnLjwLujOEaNjh +# SnUDiAZqQjW5ZapOBxgc7Egghh9k+r78qWAe3rJ4QohBbhSGdZtKivTRaeRqmnhy +# 8+ThrKhzCeEwaarXJimZwSpdQQUDbheWHeyAxASqultd5KO0m/UFvO03tfepqGXA +# 4tCg/WGECwKqOjJzpRAfPIB6y1HyVrk+vmL5rpEbTwwLOtX7WxFGG8+cYLk9HjaD +# kxraA/HYlKQRx1sdza+w/gulLwgOnByRJKF2rr8M7FNIlwoi6ywFpaNc8A7HewaG +# jgw/tfcE260I1XekGluANI9HnONOYWlI7BKBQbWE2teo6vsQ1Vg8B8rTZSePVdmX +# L1PPqqs3KVdFKM5kYocPCDM+6VL32IV96sESf2T7DjxanpCg2D2UYj4Z1i7cy8U1 +# LLDGg55KWs4af2RRBjH2MulHgAmW5obKxiZCDQjRaroJ2XElXUhigE9BzvhCFbT/ +# HDY2vpVpl5HnSpcCSxmL5i5lIT/xbAQMI7Luh75Xrm+IslfFWOGOGMlCp+24qEJE +# glXEP7xwsolNdBNndXihhyIefVGlI1DR7xGELiJrk8ifVWYo9XEbEXv/lbvp6F2R +# 2UsnweWckvq0y1HWnLHDqH6dPjCCB3EwggVZoAMCAQICEzMAAAAVxedrngKbSZkA +# AAAAABUwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpX +# YXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg +# Q29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRpZmljYXRl +# IEF1dGhvcml0eSAyMDEwMB4XDTIxMDkzMDE4MjIyNVoXDTMwMDkzMDE4MzIyNVow +# fDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1Jl +# ZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMd +# TWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwggIiMA0GCSqGSIb3DQEBAQUA +# A4ICDwAwggIKAoICAQDk4aZM57RyIQt5osvXJHm9DtWC0/3unAcH0qlsTnXIyjVX +# 9gF/bErg4r25PhdgM/9cT8dm95VTcVrifkpa/rg2Z4VGIwy1jRPPdzLAEBjoYH1q +# UoNEt6aORmsHFPPFdvWGUNzBRMhxXFExN6AKOG6N7dcP2CZTfDlhAnrEqv1yaa8d +# q6z2Nr41JmTamDu6GnszrYBbfowQHJ1S/rboYiXcag/PXfT+jlPP1uyFVk3v3byN +# pOORj7I5LFGc6XBpDco2LXCOMcg1KL3jtIckw+DJj361VI/c+gVVmG1oO5pGve2k +# rnopN6zL64NF50ZuyjLVwIYwXE8s4mKyzbnijYjklqwBSru+cakXW2dg3viSkR4d +# Pf0gz3N9QZpGdc3EXzTdEonW/aUgfX782Z5F37ZyL9t9X4C626p+Nuw2TPYrbqgS +# Uei/BQOj0XOmTTd0lBw0gg/wEPK3Rxjtp+iZfD9M269ewvPV2HM9Q07BMzlMjgK8 +# QmguEOqEUUbi0b1qGFphAXPKZ6Je1yh2AuIzGHLXpyDwwvoSCtdjbwzJNmSLW6Cm +# gyFdXzB0kZSU2LlQ+QuJYfM2BjUYhEfb3BvR/bLUHMVr9lxSUV0S2yW6r1AFemzF +# ER1y7435UsSFF5PAPBXbGjfHCBUYP3irRbb1Hode2o+eFnJpxq57t7c+auIurQID +# AQABo4IB3TCCAdkwEgYJKwYBBAGCNxUBBAUCAwEAATAjBgkrBgEEAYI3FQIEFgQU +# KqdS/mTEmr6CkTxGNSnPEP8vBO4wHQYDVR0OBBYEFJ+nFV0AXmJdg/Tl0mWnG1M1 +# GelyMFwGA1UdIARVMFMwUQYMKwYBBAGCN0yDfQEBMEEwPwYIKwYBBQUHAgEWM2h0 +# dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2lvcHMvRG9jcy9SZXBvc2l0b3J5Lmh0 +# bTATBgNVHSUEDDAKBggrBgEFBQcDCDAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMA +# QTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbL +# j+iiXGJo0T2UkFvXzpoYxDBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1p +# Y3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0w +# Ni0yMy5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3 +# Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIz +# LmNydDANBgkqhkiG9w0BAQsFAAOCAgEAnVV9/Cqt4SwfZwExJFvhnnJL/Klv6lwU +# tj5OR2R4sQaTlz0xM7U518JxNj/aZGx80HU5bbsPMeTCj/ts0aGUGCLu6WZnOlNN +# 3Zi6th542DYunKmCVgADsAW+iehp4LoJ7nvfam++Kctu2D9IdQHZGN5tggz1bSNU +# 5HhTdSRXud2f8449xvNo32X2pFaq95W2KFUn0CS9QKC/GbYSEhFdPSfgQJY4rPf5 +# KYnDvBewVIVCs/wMnosZiefwC2qBwoEZQhlSdYo2wh3DYXMuLGt7bj8sCXgU6ZGy +# qVvfSaN0DLzskYDSPeZKPmY7T7uG+jIa2Zb0j/aRAfbOxnT99kxybxCrdTDFNLB6 +# 2FD+CljdQDzHVG2dY3RILLFORy3BFARxv2T5JL5zbcqOCb2zAVdJVGTZc9d/HltE +# AY5aGZFrDZ+kKNxnGSgkujhLmm77IVRrakURR6nxt67I6IleT53S0Ex2tVdUCbFp +# AUR+fKFhbHP+CrvsQWY9af3LwUFJfn6Tvsv4O+S3Fb+0zj6lMVGEvL8CwYKiexcd +# FYmNcP7ntdAoGokLjzbaukz5m/8K6TT4JDVnK+ANuOaMmdbhIurwJ0I9JZTmdHRb +# atGePu1+oDEzfbzL6Xu/OHBE0ZDxyKs6ijoIYn/ZcGNTTY3ugm2lBRDBcQZqELQd +# VTNYs6FwZvKhggNQMIICOAIBATCB+aGB0aSBzjCByzELMAkGA1UEBhMCVVMxEzAR +# BgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1p +# Y3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJpY2Eg +# T3BlcmF0aW9uczEnMCUGA1UECxMeblNoaWVsZCBUU1MgRVNOOjkyMDAtMDVFMC1E +# OTQ3MSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNloiMKAQEw +# BwYFKw4DAhoDFQA4RWFs+kTiZnoZiAj1BtYj8zCNaqCBgzCBgKR+MHwxCzAJBgNV +# BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4w +# HAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29m +# dCBUaW1lLVN0YW1wIFBDQSAyMDEwMA0GCSqGSIb3DQEBCwUAAgUA7fpVNzAiGA8y +# MDI2MDcwOTE3MTMyN1oYDzIwMjYwNzEwMTcxMzI3WjB3MD0GCisGAQQBhFkKBAEx +# LzAtMAoCBQDt+lU3AgEAMAoCAQACAgOqAgH/MAcCAQACAhK4MAoCBQDt+6a3AgEA +# MDYGCisGAQQBhFkKBAIxKDAmMAwGCisGAQQBhFkKAwKgCjAIAgEAAgMHoSChCjAI +# AgEAAgMBhqAwDQYJKoZIhvcNAQELBQADggEBAHlZmSRaw7pzDwCEp8iA/skt0HaL +# KJRGRQvW30895bSUuo+vE1UTIjO7SxKh1CVMqvUI6CwljFT+cPcgpYFKS21SKU9J +# UQmiLS/vwHvqHUDmjs+arJVMKXXoUxbO8IWaJVNR03001Kd006WQO/JHvUg3PLmJ +# qiTbpIWly4R2qS7+CXsTPi1l7ByE1tsyZxo4Vy+oVptJmkPEWAyAXpA3rX4mLb+s +# lPuRKbaoX/Raq295Mai6eFNm3FVIdHAJ27WXdMQoQJ/vEHtD1Pw0FADuz/yFY5m1 +# sYd7/Cv7lBd6vwU0kOEp2ds4Usu3ffMP7j6XF+lPo+THyYrokFK7VtThQAMxggQN +# MIIECQIBATCBkzB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQ +# MA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +# MSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAiNP +# 2WAkU8/+KwABAAACIzANBglghkgBZQMEAgEFAKCCAUowGgYJKoZIhvcNAQkDMQ0G +# CyqGSIb3DQEJEAEEMC8GCSqGSIb3DQEJBDEiBCAtVuKKUgZVUDNHPora5xDOjnMN +# UMkvZl9P4qMoaKb0OjCB+gYLKoZIhvcNAQkQAi8xgeowgecwgeQwgb0EIJbwMywR +# bvcGiynjnwjAqcaD47yYvebKZRAvtEAR5u6zMIGYMIGApH4wfDELMAkGA1UEBhMC +# VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV +# BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp +# bWUtU3RhbXAgUENBIDIwMTACEzMAAAIjT9lgJFPP/isAAQAAAiMwIgQgA+lhR3SR +# u/WLXPFwgLDbiBnkJZ7WzC5ZZfzMi+dRQ1owDQYJKoZIhvcNAQELBQAEggIAVytW +# nTZHLT/XxBeWDJQfigYEubi/eGA11IQ/b/WPqE0csWSi0fKha4xevjl5mtdGz6oq +# f3wXGo0tXK3Ho/CDZDeYpbtp1Kvl7aOUCrFH/K51OhCAOD8fAciYx9SBuBXI+Kan +# uTIpPX1Rs6iUBhrlG+4thjkL0MmKeRf3maBtwbNfRAXYR6w2vzprE7Mtvsa8dZA+ +# mOMKLOZwcLm8Z5+zn+srwghmApEclfREllPyjQMRj3odaVhGuw7nRUvYreJB5k4I +# 1GKZjTPgG7Gg45P6FK9W8epa6U0RjWANYnqtzQZpieuKAJRHGi/kMr16UyCqdjEK +# zpsD2FENOe8ZPIu4ilHg1OUR9TcGks1qoLzavQeun90k4uGo8tX5XcJOuITJS0fk +# OgMVxYCItiB/hCTa2OX08xOt4yDLWZkcu+lWDlmpbQJcuc7wDoVC8VIeKXjEycuY +# gUXJBJRndg3/9UKEJURw/O/g0Gw2l/l1bIez+X4FTf2QnZAOAFOWreszSqMKsBEE +# hHK4p00vS/1PgO51NYi+P6DDn2hZO0ckqd7J6+R5yi9qXJLmsfaKxUdFmdNIzvJ9 +# bwmcU4jAOiTgXkpEWIYR/bKX3Z8WBhWZE8kX8xuuBjuKiYgv097OVDvHn79ocu6k +# OCS1M4L26J/z0jyylWy5Xkx79XVzd84d6JC2bu4= +# SIG # End signature block From cf2cf014ac1cd9274689f10203f56044b106200d Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:55:43 -0700 Subject: [PATCH 18/19] Show the quick start as a script block --- README.md | 5 ++++- src/windows-dev-config/README.md | 11 +++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f505441..be06188 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,14 @@ A set of PowerShell scripts that installs dev tools, applies opinionated Windows Open any PowerShell window — elevated or not — and run: ```powershell -irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -AllowUnsigned ``` You'll get one UAC prompt. Expect about 30 minutes on a clean machine. +> `-AllowUnsigned` runs the source copy under `src/` instead of the signed copy at the repository root. + > ⚠️ **It will restart your machine, once.** Enabling WSL needs a Windows optional feature that requires a restart. You get a 10-second warning, and a scheduled task finishes the run automatically after you sign back in. **Save your work before you start.**
diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index 9ca745d..28ee961 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -31,19 +31,22 @@ It is **idempotent** — every change is checked before it's made, so re-running Open **any** PowerShell window — Windows PowerShell or PowerShell 7, elevated or not — and run: ```powershell -irm https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1 | iex +$url = 'https://raw.githubusercontent.com/microsoft/WindowsDeveloperConfig/main/src/windows-dev-config/bootstrap.ps1' +& ([scriptblock]::Create((irm $url))) -AllowUnsigned ``` That's the whole thing. You'll get one UAC prompt, and the machine will restart once. +> `-AllowUnsigned` runs the source copy under `src/` instead of the signed copy at the repository root. +
What that command actually does -`irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text and `iex` (`Invoke-Expression`) runs it. The bootstrap then: +`irm` (`Invoke-RestMethod`) downloads [`bootstrap.ps1`](./bootstrap.ps1) as text, and running it as a script block lets you pass switches to it. The bootstrap then: 1. Downloads the repository as a ZIP from `github.com/microsoft/WindowsDeveloperConfig`. -2. Selects the signed setup from the repository-root `windows-dev-config/` folder. -3. Verifies that every PowerShell file has a valid Microsoft Corporation Authenticode signature. +2. Selects the setup: the signed repository-root `windows-dev-config/` folder, or `src/windows-dev-config/` with `-AllowUnsigned`. +3. Verifies that every PowerShell file has a valid Microsoft Corporation Authenticode signature — skipped under `-AllowUnsigned`. 4. Copies [`dev-config.ps1`](./dev-config.ps1) plus the [`steps/`](./steps) folder into `%LOCALAPPDATA%\CalmOS`, deletes its temporary download folder, and starts the setup from there. The setup is installed to disk rather than run from the pipe because it loads two dozen files from its own folder, relaunches itself elevated, and has to survive a reboot — none of which a piped-in string can do. From 7ec7186c05a67c9af74211b86fe65140f8cfdd4d Mon Sep 17 00:00:00 2001 From: AmirMS <104940545+AmelBawa-msft@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:57:15 -0700 Subject: [PATCH 19/19] Detect winget upgrades in CLI mode --- src/windows-dev-config/steps/_winget.ps1 | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/windows-dev-config/steps/_winget.ps1 b/src/windows-dev-config/steps/_winget.ps1 index c8cdbcd..3f56804 100644 --- a/src/windows-dev-config/steps/_winget.ps1 +++ b/src/windows-dev-config/steps/_winget.ps1 @@ -174,8 +174,8 @@ function Test-DevConfigWingetPackageInstalled { if ($listed.ExitCode -ne 0) { throw "winget list $Id failed with exit code $($listed.ExitCode)" } - # winget list --upgrade-available exits 0 for any installed package, with or without an upgrade. - return $true + # useLatest requires the package to be current, not only installed, so match the module path. + return -not (Test-DevConfigWingetUpgradeAvailable -Id $Id) } # EqualsCaseInsensitive avoids ambiguous substring matches. @@ -188,6 +188,21 @@ function Test-DevConfigWingetPackageInstalled { return -not $pkg.IsUpdateAvailable } +# winget list exits 0 whether or not an upgrade exists, and every message it prints is localized. +# The package id is the one token in that output that is never translated, so it is what gets matched. +function Test-DevConfigWingetUpgradeAvailable { + param( + [Parameter(Mandatory)] [string] $Id + ) + $upgrade = Invoke-DevConfigWingetCli -Arguments @('list', '--id', $Id, '--exact', '--upgrade-available', '--accept-source-agreements') + if ($upgrade.ExitCode -ne 0) { + # No listing means nothing to upgrade to; a broken query must not force an endless reinstall. + return $false + } + # @() keeps the count valid when nothing matches; under Set-StrictMode a bare $null has no Count. + return @($upgrade.Output -split '\r?\n' | Where-Object { $_ -match ('(^|\s)' + [regex]::Escape($Id) + '(\s|$)') }).Count -gt 0 +} + function Install-DevConfigWingetPackage { param( [Parameter(Mandatory)] [string] $Id