diff --git a/.github/actions/spelling/expect/generic_terms.txt b/.github/actions/spelling/expect/generic_terms.txt index 9cc94fa6..1269ebec 100644 --- a/.github/actions/spelling/expect/generic_terms.txt +++ b/.github/actions/spelling/expect/generic_terms.txt @@ -21,3 +21,12 @@ usr versioning VGpu vse +debian +euo +linux +pipefail +sudoer +tarball +utf +vendored +versioned diff --git a/.github/actions/spelling/expect/software.txt b/.github/actions/spelling/expect/software.txt index 6eac7d03..b2e2164d 100644 --- a/.github/actions/spelling/expect/software.txt +++ b/.github/actions/spelling/expect/software.txt @@ -18,3 +18,17 @@ tokio lldb Rustlang vadimcn +CPython +gofmt +gradlew +javac +Jdk +mojibake +mvnw +rustc +tsc +uvx +winappcli +winbuild +wincreate +winforms diff --git a/.github/actions/spelling/expect/windows_terms.txt b/.github/actions/spelling/expect/windows_terms.txt index 94e05c0e..55efcfc6 100644 --- a/.github/actions/spelling/expect/windows_terms.txt +++ b/.github/actions/spelling/expect/windows_terms.txt @@ -19,3 +19,11 @@ cursorindicator packagetype BSODs Audiosrv +ADMX +chcp +conhost +MDM +PATHEXT +Redist +sideloaded +UWP diff --git a/WindowsDevScripts/scripts/windows/_common/apply-configuration.ps1 b/WindowsDevScripts/scripts/windows/_common/apply-configuration.ps1 new file mode 100644 index 00000000..9f8b9494 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/apply-configuration.ps1 @@ -0,0 +1,123 @@ +<# +.SYNOPSIS + Apply a winget DSC configuration file with retry, refresh PATH in the current + session, verify a list of expected commands, and emit the CI sentinel. + +.DESCRIPTION + Flow-level `install.ps1` files are thin shims: the real install logic lives + in each flow's `configuration.winget`. This helper centralizes the glue + that CI needs around `winget configure`: + + 1. `winget configure --file ` with exponential-backoff retry + (shared helper; flaky network is common on hosted runners). Always + passes `--accept-configuration-agreements` and `--disable-interactivity`. + Note: `--accept-package-agreements` is NOT a valid flag on + `winget configure` (only on `winget install`). Package-agreement + consent for packages installed by DSC resources flows through + `--accept-configuration-agreements`. + 2. Re-read machine+user PATH from the registry into `$env:Path` so the + caller's *current* PowerShell session can see freshly installed + executables (winget updates the registry but not running processes). + 3. Assert each command in `-RequireCommands` resolves on PATH. + 4. Print `INSTALL_OK: ` as the final line; CI asserts on this. + +.PARAMETER Id + Flow id, only used in log prefixes and the final sentinel line. + +.PARAMETER ConfigFile + Path to the winget DSC YAML config for the flow. + +.PARAMETER RequireCommands + Commands that must resolve on PATH after configuration has been applied. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [string] $ConfigFile, + # AllowEmptyCollection: Windows PowerShell 5.1 rejects empty arrays + # bound to Mandatory parameters. Some flows (e.g. mac-comfort-shell) + # have no post-install CLI to verify - the DSC only installs a font + # and pwsh - so they legitimately pass @() here. + [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $RequireCommands +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Fix #15: force UTF-8 on this process's console + external-program +# pipes. Windows PowerShell 5.1 defaults to the ANSI code page (1252 on +# en-US) for `[Console]::OutputEncoding`, which mangles winget's +# braille-pattern spinner glyphs into scrolling mojibake. winget writes +# UTF-8; matching it up front lets the carriage-return overwrites in +# the spinner work as intended and gives readable progress output. +# Safe no-op on pwsh 7 where UTF-8 is already the default. +try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch { + # Some hosts (e.g. certain CI agents with redirected stdout) reject + # the assignment. Not worth failing the whole flow over cosmetics. + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" +} + +$common = Split-Path -Parent $PSCommandPath +. (Join-Path $common 'invoke-retry.ps1') + +# Hard-fail fast if `winget configure` isn't available on this host. Every +# flow in this repo -- and the CmdPal extension that launches them -- uses +# `winget configure` as its only install path, so this is a stop-the-world +# prerequisite, not a warn-and-continue diagnostic. +# +# Fix #16: if the assert fails on first try, auto-invoke the canonical +# remediation (`enable-winget-configure.ps1`) once and then re-assert. +# The remediation runs `winget configure --enable` and installs +# Microsoft.VCRedist.2015+.x64, which covers the two failure modes a +# fresh VM actually hits. The remediation script itself self-elevates +# via UAC when needed; when we're already elevated (the install.ps1 +# entry point in practice) it runs in-process and does not pause. +$assertScript = Join-Path $common 'assert-winget-configure.ps1' +$enableScript = Join-Path $common 'enable-winget-configure.ps1' +try { + & $assertScript +} +catch { + Write-Host '' + Write-Host "--- winget configure not available; auto-remediating via $enableScript ---" -ForegroundColor Yellow + Write-Host " (reason: $($_.Exception.Message.Split([Environment]::NewLine)[0]))" -ForegroundColor DarkGray + Write-Host '' + & $enableScript + # Re-assert; surface the original failure mode if remediation did + # not actually fix it. + & $assertScript +} + +if (-not (Test-Path -LiteralPath $ConfigFile)) { + throw "DSC config file not found: $ConfigFile" +} + +Write-Host "--- $Id flow: winget configure --file $ConfigFile ---" + +Invoke-Retry -Name "winget configure $Id" -ScriptBlock { + winget configure ` + --file $ConfigFile ` + --accept-configuration-agreements ` + --disable-interactivity + if ($LASTEXITCODE -ne 0) { + throw "winget configure failed with exit code $LASTEXITCODE" + } +} + +# winget updates the registry copy of PATH but not the PATH of this already +# running PowerShell process. Rehydrate so subsequent CI steps see new tools. +& (Join-Path $common 'refresh-path.ps1') + +foreach ($cmd in $RequireCommands) { + if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { + throw "$cmd not found on PATH after applying $ConfigFile" + } + Write-Host "$cmd : $(& $cmd --version 2>&1 | Select-Object -First 1)" +} + +Write-Host "INSTALL_OK: $Id" diff --git a/WindowsDevScripts/scripts/windows/_common/assert-winget-configure.ps1 b/WindowsDevScripts/scripts/windows/_common/assert-winget-configure.ps1 new file mode 100644 index 00000000..dd660d6d --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/assert-winget-configure.ps1 @@ -0,0 +1,154 @@ +<# +.SYNOPSIS + Hard-fail preflight: assert that `winget configure` is available on this + host. Every Windows flow in this repo -- and the CmdPal extension that + launches them -- uses `winget configure` as its only install path. If it + isn't wired up, there is nothing useful to do but bail with an + actionable message. + +.DESCRIPTION + Failure modes this catches, in order of likelihood: + + 1. winget (Microsoft.DesktopAppInstaller) is not installed at all, or + is too old / broken to expose the `configure` subcommand. + 2. The `configuration` experimental feature flag is turned off in + `winget settings` (`experimentalFeatures.configuration = false`). + Only relevant on winget < 1.6; harmless to check on newer builds. + 3. Group Policy / MDM has disabled configure via the ADMX policy + `EnableWindowsPackageManagerConfiguration` (registry value + `HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppInstaller\ + EnableWindowsPackageManagerConfiguration`, `0` = disabled). + 4. Running in a non-interactive context where the AppInstaller COM + server cannot spin up (headless service accounts, SSH sessions + without a desktop). We can't always detect this -- we just surface + it as a fallback hint when the other checks pass but configure + still errors. + + This script never "warns and continues" -- the whole point of this repo + is `winget configure`, so a failure here is a stop-the-world condition. + +.PARAMETER Quiet + Suppress the "OK" line on success. Error output is always emitted. +#> + +[CmdletBinding()] +param( + [switch] $Quiet +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# UTF-8 console encoding, matching winget's output. Without this, a +# `winget configure --help` probe under Windows PowerShell 5.1 prints +# garbled glyphs in the error message we surface. See issue #15. +try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch { + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" +} + +function Test-ConfigurePolicyAllowed { + # Returns $true if the GPO key is absent or set to anything other than 0. + # Returns $false ONLY when the key is explicitly 0 (disabled by policy). + $keyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppInstaller' + try { + $val = (Get-ItemProperty -Path $keyPath -Name 'EnableWindowsPackageManagerConfiguration' -ErrorAction Stop).EnableWindowsPackageManagerConfiguration + return [int]$val -ne 0 + } catch { + # Key or value absent => not policy-restricted. + return $true + } +} + +# 1. winget itself must be present. +$wingetCmd = Get-Command winget -ErrorAction SilentlyContinue +if (-not $wingetCmd) { + throw @" +winget is not installed or not on PATH. + +This repository's flows (and the CmdPal extension) require Windows Package +Manager (winget) with the `configure` subcommand. To fix: + + 1. Install / update 'App Installer' from the Microsoft Store, or + 2. Grab the latest MSIX from + https://github.com/microsoft/winget-cli/releases/latest + (look for Microsoft.DesktopAppInstaller_*.msixbundle). + +Then re-run your command. +"@ +} + +# 2. GPO / MDM check first -- cheapest, and its error message is the most +# actionable, so surface it before the subprocess call. +if (-not (Test-ConfigurePolicyAllowed)) { + throw @" +`winget configure` is disabled by Group Policy on this machine. + +Registry key: + HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppInstaller + EnableWindowsPackageManagerConfiguration = 0 + +This is ADMX policy 'Enable Windows Package Manager Configuration' +(Computer Configuration > Administrative Templates > Windows Components > +App Installer). If this is your box, set the policy to 'Enabled' (or +delete the value) and reboot. If the box is domain-managed, file a +ticket with IT -- every flow in this repo depends on configure being +allowed. +"@ +} + +# 3. Probe the configure subcommand itself. `--help` is a pure no-op that +# exits 0 iff the subcommand is wired up and the experimental flag +# (if required) is on. +$helpOutput = & winget configure --help 2>&1 +$helpExit = $LASTEXITCODE + +$looksRecognized = ($helpExit -eq 0) -and ($helpOutput -join "`n") -match '(?i)configuration|configure' + +if (-not $looksRecognized) { + throw @" +`winget configure --help` did not succeed on this machine +(exit=$helpExit). winget itself is present ($($wingetCmd.Source)) but the +`configure` subcommand is not wired up. + +Output from `winget configure --help`: +-------- +$($helpOutput -join [Environment]::NewLine) +-------- + +To fix, run the canonical remediation script (elevates via UAC): + + scripts\windows\_common\enable-winget-configure.ps1 + +It runs `winget configure --enable` and installs the required +Microsoft.VCRedist.2015+.x64 dependency, then re-verifies. The CmdPal +extension's red banner launches the same script. If you prefer to run +the steps by hand: + + winget configure --enable + winget install -s winget --id Microsoft.VCRedist.2015+.x64 `` + --accept-package-agreements --accept-source-agreements + +Still failing after the script? Likely causes: + + 1. App Installer itself is too old to know `--enable`. Update it: + winget source update + winget upgrade --id Microsoft.AppInstaller --accept-source-agreements --accept-package-agreements + or install the latest MSIX from + https://github.com/microsoft/winget-cli/releases/latest + + 2. You are running from a non-interactive session (SSH, Scheduled + Task 'run whether user is logged on or not', headless service + account). winget's configure path needs the AppInstaller COM + server, which requires an interactive desktop. Re-run from a + foreground PowerShell / Windows Terminal window. +"@ +} + +if (-not $Quiet) { + $ver = (& winget --version) 2>$null + Write-Host "winget configure: available ($ver)" +} diff --git a/WindowsDevScripts/scripts/windows/_common/enable-winget-configure.ps1 b/WindowsDevScripts/scripts/windows/_common/enable-winget-configure.ps1 new file mode 100644 index 00000000..387292c2 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/enable-winget-configure.ps1 @@ -0,0 +1,167 @@ +<# +.SYNOPSIS + One-shot "fix it" script: turn on `winget configure` on a machine where + it isn't working yet. + +.DESCRIPTION + This is the single remediation path for the three failure modes that + `assert-winget-configure.ps1` detects. The CmdPal extension's red + "winget configure is unavailable" banner launches this script; humans + can also run it by hand. Keeping the logic here (not duplicated in C#) + means any future tweak -- e.g. dropping the VCRedist install once + AppInstaller ships it transitively -- only has to happen in one place. + + What it does, in order: + + 1. Self-elevates via `Start-Process -Verb RunAs` if not already admin. + `winget configure --enable` flips a machine-wide flag and needs + elevation; `Microsoft.VCRedist.2015+.x64` likewise. + 2. Runs `winget configure --enable` -- the supported first-party way + to turn the `configure` subcommand on. Ignores "already enabled" + errors so re-runs are a safe no-op. + 3. Installs `Microsoft.VCRedist.2015+.x64` -- the PackageManager + configure path transitively depends on the 2015+ x64 redistributable + (AppInstaller does not always pull it in on its own). Skipped when + already present. + 4. Re-runs the assert to confirm the fix took. + +.PARAMETER NoElevate + Internal switch used by the self-elevation path to avoid infinite + re-elevation loops. Do not set by hand. + +.PARAMETER SkipVCRedist + Skip step (3). Useful once Microsoft ships a configure path that no + longer needs VCRedist -- flip this on and we keep the rest of the + remediation. + +.EXAMPLE + # From a normal PowerShell -- triggers a UAC prompt, then runs. + .\enable-winget-configure.ps1 + +.EXAMPLE + # From an already-elevated PowerShell (e.g. inside a VM bootstrap). + .\enable-winget-configure.ps1 -NoElevate +#> + +[CmdletBinding()] +param( + [switch] $NoElevate, + [switch] $SkipVCRedist, + + # Internal: set only by the self-elevation path below so the exit + # pause fires only when we're running in a fresh window that would + # otherwise close. Not part of the public surface; users running the + # script themselves (elevated or not) should not pass this. + [switch] $FromRelaunch +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Force UTF-8 on the console + external pipe encodings. Windows +# PowerShell 5.1 defaults to the ANSI code page (1252) which mangles +# winget's braille-pattern spinner glyphs into scrolling mojibake. +# Safe no-op on pwsh 7. See issue #15. +try { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [Console]::OutputEncoding = $utf8NoBom + $OutputEncoding = $utf8NoBom +} catch { + Write-Verbose "Could not force UTF-8 console encoding: $($_.Exception.Message)" +} + +# Also force the OS-level console code page to 65001 (UTF-8) via chcp. +# [Console]::OutputEncoding alone is not always sufficient under Windows +# PowerShell 5.1 -- particularly in a freshly-spawned elevated conhost, +# where winget's own stdout goes through the OS console code page (1252 +# by default on en-US). That causes the VCRedist download progress bar's +# block glyphs (U+2588) to render as "ûÆ" mojibake. See issue #22. +try { + $null = cmd /c 'chcp 65001 >nul 2>&1' +} catch { } + +function Test-IsAdmin { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($id) + return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +if (-not (Test-IsAdmin)) { + if ($NoElevate) { + throw 'Not running as Administrator and -NoElevate was passed. Re-launch from an elevated PowerShell.' + } + + Write-Host '' + Write-Host 'This fix needs to run elevated (UAC prompt will appear).' -ForegroundColor Yellow + Write-Host 'Launching an elevated PowerShell...' -ForegroundColor Yellow + Write-Host '' + + $forwardedArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath, '-NoElevate', '-FromRelaunch') + if ($SkipVCRedist) { $forwardedArgs += '-SkipVCRedist' } + + try { + Start-Process -FilePath 'pwsh.exe' -ArgumentList $forwardedArgs -Verb RunAs -Wait + } catch { + # Fall back to Windows PowerShell 5.1 if pwsh isn't installed. + Start-Process -FilePath 'powershell.exe' -ArgumentList $forwardedArgs -Verb RunAs -Wait + } + return +} + +Write-Host '' +Write-Host '=== enable-winget-configure ===' -ForegroundColor Cyan +Write-Host '' + +# --- Step 1: winget configure --enable ---------------------------------- +Write-Host 'Step 1/3: winget configure --enable' -ForegroundColor Cyan +try { + & winget configure --enable --disable-interactivity --accept-source-agreements 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + # Some winget builds return non-zero on "already enabled" -- inspect + # stderr instead of hard-failing on the exit code alone. + Write-Host " (exit=$LASTEXITCODE -- if already enabled this is benign)" -ForegroundColor DarkYellow + } +} catch { + Write-Warning "winget configure --enable raised: $($_.Exception.Message)" +} + +# --- Step 2: VCRedist 2015+ x64 ----------------------------------------- +if ($SkipVCRedist) { + Write-Host '' + Write-Host 'Step 2/3: SKIPPED (via -SkipVCRedist)' -ForegroundColor DarkYellow +} else { + Write-Host '' + Write-Host 'Step 2/3: winget install Microsoft.VCRedist.2015+.x64' -ForegroundColor Cyan + & winget install ` + --source winget ` + --id 'Microsoft.VCRedist.2015+.x64' ` + --accept-package-agreements ` + --accept-source-agreements ` + --disable-interactivity 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Host " (exit=$LASTEXITCODE -- if already installed this is benign)" -ForegroundColor DarkYellow + } +} + +# --- Step 3: re-run the assert to confirm the fix took ------------------ +Write-Host '' +Write-Host 'Step 3/3: verifying winget configure is now available' -ForegroundColor Cyan +$assert = Join-Path $PSScriptRoot 'assert-winget-configure.ps1' +if (Test-Path -LiteralPath $assert) { + & $assert +} else { + Write-Warning "assert-winget-configure.ps1 not found next to this script; skipping verify." +} + +Write-Host '' +Write-Host 'All done. You can close this window.' -ForegroundColor Green +Write-Host '' + +# Pause only if we self-elevated into a fresh window that would +# otherwise close before the user could read the output. When invoked +# directly from the user's own shell (elevated or not), the window is +# under the user's control and the pause is pure friction. +if ($FromRelaunch -and $Host.Name -eq 'ConsoleHost') { + Write-Host 'Press any key to exit...' -ForegroundColor DarkGray + try { [void][System.Console]::ReadKey($true) } catch { Start-Sleep -Seconds 5 } +} diff --git a/WindowsDevScripts/scripts/windows/_common/invoke-retry.ps1 b/WindowsDevScripts/scripts/windows/_common/invoke-retry.ps1 new file mode 100644 index 00000000..6e089ad7 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/invoke-retry.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS + Retry a script block with exponential backoff. Intended for flaky network + operations such as `winget install` or package-registry pulls. + +.EXAMPLE + . "$PSScriptRoot/invoke-retry.ps1" + Invoke-Retry -Name 'winget install Node.js' -ScriptBlock { + winget install --id OpenJS.NodeJS.LTS --silent ` + --accept-package-agreements --accept-source-agreements + if ($LASTEXITCODE -ne 0) { throw "winget exited $LASTEXITCODE" } + } +#> + +$ErrorActionPreference = 'Stop' + +function Invoke-Retry { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [scriptblock] $ScriptBlock, + [string] $Name = 'operation', + [int] $MaxAttempts = 3, + [int] $InitialDelaySeconds = 5 + ) + + $attempt = 0 + $delay = $InitialDelaySeconds + while ($true) { + $attempt++ + try { + Write-Host "[invoke-retry] ${Name}: attempt $attempt/$MaxAttempts" + & $ScriptBlock + Write-Host "[invoke-retry] ${Name}: success on attempt $attempt" + return + } catch { + if ($attempt -ge $MaxAttempts) { + Write-Host "[invoke-retry] ${Name}: giving up after $attempt attempts" + throw + } + Write-Warning "[invoke-retry] ${Name}: attempt $attempt failed: $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + $delay = $delay * 2 + } + } +} diff --git a/WindowsDevScripts/scripts/windows/_common/preflight.ps1 b/WindowsDevScripts/scripts/windows/_common/preflight.ps1 new file mode 100644 index 00000000..66cade7c --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/preflight.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Log the runner's state before a flow runs. Pure diagnostics; never fails CI. +#> + +$ErrorActionPreference = 'Continue' + +Write-Host '==== preflight ====' +Write-Host "Date (UTC): $((Get-Date).ToUniversalTime().ToString('o'))" +Write-Host "Host: $env:COMPUTERNAME" +Write-Host "User: $env:USERNAME" +Write-Host "PowerShell: $($PSVersionTable.PSVersion)" +try { + $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop + Write-Host "OS: $($os.Caption) $($os.Version) build $($os.BuildNumber)" +} catch { + Write-Host "OS: (Get-CimInstance failed: $($_.Exception.Message))" +} + +try { + $wv = (winget --version) 2>$null + Write-Host "winget: $wv" +} catch { + Write-Host "winget: not available" +} + +try { + $drive = Get-PSDrive -Name C -ErrorAction Stop + $freeGb = [math]::Round($drive.Free / 1GB, 2) + $usedGb = [math]::Round($drive.Used / 1GB, 2) + Write-Host "Disk C: free=${freeGb}GB used=${usedGb}GB" +} catch { + Write-Host "Disk: (query failed: $($_.Exception.Message))" +} + +Write-Host '==== /preflight ====' diff --git a/WindowsDevScripts/scripts/windows/_common/refresh-path.ps1 b/WindowsDevScripts/scripts/windows/_common/refresh-path.ps1 new file mode 100644 index 00000000..f229c5c3 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/_common/refresh-path.ps1 @@ -0,0 +1,52 @@ +<# +.SYNOPSIS + Refresh $env:PATH (and a few related vars) in the current PowerShell session + by re-reading Machine + User environment from the registry. + +.DESCRIPTION + Windows installers (including winget packages) update the registry copy of + PATH but do not update the PATH of already-running processes. Without this, + a script that installs Node.js will not see `node.exe` in the same session. +#> + +$ErrorActionPreference = 'Stop' + +function Get-EnvFromRegistry { + param( + [Parameter(Mandatory)] [ValidateSet('Machine', 'User')] [string] $Scope, + [Parameter(Mandatory)] [string] $Name + ) + if ($Scope -eq 'Machine') { + $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + } else { + $key = 'HKCU:\Environment' + } + try { + return (Get-ItemProperty -Path $key -Name $Name -ErrorAction Stop).$Name + } catch { + return $null + } +} + +$machinePath = Get-EnvFromRegistry -Scope Machine -Name 'Path' +$userPath = Get-EnvFromRegistry -Scope User -Name 'Path' + +$combined = @($machinePath, $userPath) | + Where-Object { $_ } | + ForEach-Object { $_.TrimEnd(';') } | + Where-Object { $_ } | + ForEach-Object { $_ -split ';' } | + Where-Object { $_ -and $_.Trim() } | + Select-Object -Unique + +$env:Path = ($combined -join ';') + +# Some tools read these instead of (or in addition to) PATH. +foreach ($n in @('PATHEXT', 'PSModulePath')) { + $m = Get-EnvFromRegistry -Scope Machine -Name $n + $u = Get-EnvFromRegistry -Scope User -Name $n + $v = @($m, $u) | Where-Object { $_ } | ForEach-Object { $_.TrimEnd(';') } | Where-Object { $_ } + if ($v) { Set-Item -Path "Env:$n" -Value ($v -join ';') } +} + +Write-Host "[refresh-path] PATH rehydrated ($($combined.Count) entries)" diff --git a/WindowsDevScripts/scripts/windows/dotnet/configuration.winget b/WindowsDevScripts/scripts/windows/dotnet/configuration.winget new file mode 100644 index 00000000..2afd433a --- /dev/null +++ b/WindowsDevScripts/scripts/windows/dotnet/configuration.winget @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/dotnet/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: DotNetSdk + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + properties: + id: Microsoft.DotNet.SDK.10 + source: winget + acceptAgreements: true diff --git a/WindowsDevScripts/scripts/windows/dotnet/install.ps1 b/WindowsDevScripts/scripts/windows/dotnet/install.ps1 new file mode 100644 index 00000000..be6a87d5 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/dotnet/install.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Apply the .NET winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the .NET flow is + `configuration.winget` in this directory - a winget DSC configuration that + declaratively installs the .NET 10 SDK via winget. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `dotnet`, + * verify `dotnet` resolves, and + * emit `INSTALL_OK: dotnet` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'dotnet' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('dotnet') diff --git a/WindowsDevScripts/scripts/windows/go/configuration.winget b/WindowsDevScripts/scripts/windows/go/configuration.winget new file mode 100644 index 00000000..3b7d5131 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/go/configuration.winget @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/go/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: Go + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + properties: + id: GoLang.Go + source: winget + acceptAgreements: true diff --git a/WindowsDevScripts/scripts/windows/go/install.ps1 b/WindowsDevScripts/scripts/windows/go/install.ps1 new file mode 100644 index 00000000..e9414362 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/go/install.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Apply the Go winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the Go flow is + `configuration.winget` in this directory - a winget DSC configuration that + declaratively installs the Go toolchain via winget. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `go`, + * verify `go` resolves, and + * emit `INSTALL_OK: go` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'go' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('go', 'gofmt') diff --git a/WindowsDevScripts/scripts/windows/java/configuration.winget b/WindowsDevScripts/scripts/windows/java/configuration.winget new file mode 100644 index 00000000..055d140b --- /dev/null +++ b/WindowsDevScripts/scripts/windows/java/configuration.winget @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/java/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: OpenJdk + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + properties: + id: Microsoft.OpenJDK.21 + source: winget + acceptAgreements: true diff --git a/WindowsDevScripts/scripts/windows/java/install.ps1 b/WindowsDevScripts/scripts/windows/java/install.ps1 new file mode 100644 index 00000000..e6cdef0a --- /dev/null +++ b/WindowsDevScripts/scripts/windows/java/install.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Apply the Java winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the Java flow is + `configuration.winget` in this directory - a winget DSC configuration that + declaratively installs the Microsoft Build of OpenJDK 21 (LTS) via winget. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `java`, + * verify `java` and `javac` resolve, and + * emit `INSTALL_OK: java` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'java' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('java', 'javac') diff --git a/WindowsDevScripts/scripts/windows/php/configuration.winget b/WindowsDevScripts/scripts/windows/php/configuration.winget new file mode 100644 index 00000000..46ff4f16 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/php/configuration.winget @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/php/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: Php + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + properties: + id: PHP.PHP.8.5 + source: winget + acceptAgreements: true \ No newline at end of file diff --git a/WindowsDevScripts/scripts/windows/php/install.ps1 b/WindowsDevScripts/scripts/windows/php/install.ps1 new file mode 100644 index 00000000..69cadbd3 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/php/install.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Apply the PHP winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the PHP flow is + `configuration.winget` in this directory — a winget DSC configuration that + declaratively installs PHP via winget. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `php`, + * verify `php` resolves, and + * emit `INSTALL_OK: php` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'php' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('php') diff --git a/WindowsDevScripts/scripts/windows/python/configuration.winget b/WindowsDevScripts/scripts/windows/python/configuration.winget new file mode 100644 index 00000000..73bef0e6 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/python/configuration.winget @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/python/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: Python + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + properties: + id: Python.Python.3.13 + source: winget + acceptAgreements: true diff --git a/WindowsDevScripts/scripts/windows/python/install.ps1 b/WindowsDevScripts/scripts/windows/python/install.ps1 new file mode 100644 index 00000000..38864ee6 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/python/install.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Apply the Python winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the Python flow is + `configuration.winget` in this directory — a winget DSC configuration that + declaratively installs CPython 3.13 and uv via winget. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `python`, + `pip`, and `uv`, + * verify those commands resolve, and + * emit `INSTALL_OK: python` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'python' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('python', 'pip', 'uv') diff --git a/WindowsDevScripts/scripts/windows/rust/configuration.winget b/WindowsDevScripts/scripts/windows/rust/configuration.winget new file mode 100644 index 00000000..24fde793 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/rust/configuration.winget @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/rust/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - type: Microsoft.WinGet/Package + name: Rustup + properties: + id: Rustlang.Rustup + source: winget + acceptAgreements: true + metadata: + description: Install the rustup toolchain manager + winget: + securityContext: elevated + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: InstallStableToolchain + dependsOn: + - Rustup + properties: + executable: powershell + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": $machine = [Environment]::GetEnvironmentVariable('Path','Machine'); $user = [Environment]::GetEnvironmentVariable('Path','User'); $env:Path = (($machine, $user) | Where-Object { $_ }) -join ';'; if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { throw 'rustup not found on PATH after Rustup install; cannot install toolchain.' }; & rustup default stable; if ($LASTEXITCODE -ne 0) { throw "rustup default stable failed with exit code $LASTEXITCODE" } + treatAsArray: true + metadata: + description: Install and default the stable Rust toolchain via rustup + diff --git a/WindowsDevScripts/scripts/windows/rust/install.ps1 b/WindowsDevScripts/scripts/windows/rust/install.ps1 new file mode 100644 index 00000000..60ce0269 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/rust/install.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Apply the Rust winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the Rust flow is + `configuration.winget` in this directory - a winget DSC configuration that + installs rustup via winget and then runs `rustup default stable` to bring + in the stable Rust toolchain (rustc, cargo, ...). + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `cargo`, + * verify `rustc` and `cargo` resolve, and + * emit `INSTALL_OK: rust` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'rust' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('rustc', 'cargo') diff --git a/WindowsDevScripts/scripts/windows/typescript/configuration.winget b/WindowsDevScripts/scripts/windows/typescript/configuration.winget new file mode 100644 index 00000000..38e1dc7f --- /dev/null +++ b/WindowsDevScripts/scripts/windows/typescript/configuration.winget @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/typescript/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - name: Node + type: Microsoft.WinGet/Package + metadata: + securityContext: elevated + description: Install Node.js LTS (provides node + npm) + properties: + id: OpenJS.NodeJS.LTS + source: winget + acceptAgreements: true + + - name: InstallTypeScript + type: Microsoft.DSC.Transitional/RunCommandOnSet + dependsOn: + - Node + metadata: + description: Install the TypeScript compiler globally via npm + properties: + executable: powershell + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": $machine = [Environment]::GetEnvironmentVariable('Path','Machine'); $user = [Environment]::GetEnvironmentVariable('Path','User'); $env:Path = (($machine, $user) | Where-Object { $_ }) -join ';'; if (Get-Command tsc -ErrorAction SilentlyContinue) { exit 0 }; if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { throw 'npm not found on PATH after Node install; cannot install typescript.' }; & npm install --global --no-fund --no-audit typescript; if ($LASTEXITCODE -ne 0) { throw "npm install --global typescript failed with exit code $LASTEXITCODE" } + treatAsArray: true diff --git a/WindowsDevScripts/scripts/windows/typescript/install.ps1 b/WindowsDevScripts/scripts/windows/typescript/install.ps1 new file mode 100644 index 00000000..4ac4f305 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/typescript/install.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Apply the TypeScript winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the TypeScript flow + is `configuration.winget` in this directory — a winget DSC configuration + that declaratively installs Node.js LTS and, via a PSDscResources/Script + resource, globally installs the TypeScript compiler. + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see new tools, + * verify the expected commands resolve, and + * emit `INSTALL_OK: typescript` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'typescript' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('node', 'npm', 'tsc') diff --git a/WindowsDevScripts/scripts/windows/winforms/configuration.winget b/WindowsDevScripts/scripts/windows/winforms/configuration.winget new file mode 100644 index 00000000..db518193 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/winforms/configuration.winget @@ -0,0 +1,113 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/winforms/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - type: Microsoft.WinGet/Package + name: PowerShell7 + properties: + id: Microsoft.PowerShell + source: winget + acceptAgreements: true + metadata: + description: Install PowerShell 7 + winget: + securityContext: elevated + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: Microsoft.Windows.Developer.Module + dependsOn: + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": if (-not (Get-Module -ListAvailable -Name Microsoft.Windows.Developer)) { Install-Module -Name Microsoft.Windows.Developer -Confirm:$False -Force -AllowPrerelease -AllowClobber } + treatAsArray: true + metadata: + description: Ensure Microsoft.Windows.Developer module is installed + + - type: Microsoft.Windows.Developer/OsVersion + name: OsVersion + dependsOn: + - Microsoft.Windows.Developer.Module + properties: + MinVersion: '10.0.17763' + metadata: + description: Verify min OS version (Windows 10 1809+) + allowPrerelease: true + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: Microsoft.Windows.Settings.Module + dependsOn: + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": if (-not (Get-Module -ListAvailable -Name Microsoft.Windows.Settings)) { Install-Module -Name Microsoft.Windows.Settings -Confirm:$False -Force -AllowPrerelease -AllowClobber } + treatAsArray: true + metadata: + description: Ensure Microsoft.Windows.Settings module is installed + + - type: Microsoft.Windows.Settings/WindowsSettings + name: DeveloperMode + dependsOn: + - Microsoft.Windows.Settings.Module + properties: + DeveloperMode: true + metadata: + description: Enable Developer Mode + allowPrerelease: true + winget: + securityContext: elevated + + - name: DotNetSdk + type: Microsoft.WinGet/Package + metadata: + description: Install .NET SDK 10 + winget: + securityContext: elevated + properties: + id: Microsoft.DotNet.SDK.10 + source: winget + acceptAgreements: true + + - name: VisualStudioCommunity + type: Microsoft.WinGet/Package + metadata: + description: Install Visual Studio Community Edition for WinForms development + winget: + securityContext: elevated + properties: + id: Microsoft.VisualStudio.Community + source: winget + acceptAgreements: true + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: VSWorkloads + dependsOn: + - VisualStudioCommunity + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'; if (-not (Test-Path $vswhere)) { throw 'vswhere.exe not found.' }; $setup = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\setup.exe'; if (-not (Test-Path $setup)) { throw 'Visual Studio setup.exe not found.' }; $installPath = & $vswhere -latest -products * -property installationPath; if (-not $installPath) { throw 'Visual Studio installation path could not be determined.' }; & $setup modify --installPath $installPath --channelId VisualStudio.18.Release --productId Microsoft.VisualStudio.Product.Community --add Microsoft.VisualStudio.Workload.ManagedDesktop --quiet --norestart --wait; if ($LASTEXITCODE -ne 0) { throw "Visual Studio workload installation failed with exit code $LASTEXITCODE." } + treatAsArray: true + metadata: + description: Install required VS workloads for WinForms development + winget: + securityContext: elevated diff --git a/WindowsDevScripts/scripts/windows/winforms/install.ps1 b/WindowsDevScripts/scripts/windows/winforms/install.ps1 new file mode 100644 index 00000000..dab2fc61 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/winforms/install.ps1 @@ -0,0 +1,27 @@ +<# +.SYNOPSIS + Apply the WinForms winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the WinForms flow + is `configuration.winget` in this directory — a winget DSC configuration + that declaratively installs the .NET 10 SDK (which includes the Windows + Desktop targeting pack used by `UseWindowsForms=true` projects). + + The shim exists only to: + * apply the DSC config with retry (hosted-runner networks are flaky), + * rehydrate PATH in the current session so later CI steps see `dotnet`, + * verify `dotnet` resolves, and + * emit `INSTALL_OK: winforms` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'winforms' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('dotnet') diff --git a/WindowsDevScripts/scripts/windows/winui/configuration.winget b/WindowsDevScripts/scripts/windows/winui/configuration.winget new file mode 100644 index 00000000..2d315512 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/winui/configuration.winget @@ -0,0 +1,113 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +# +# winget configure --file scripts/windows/winui/configuration.winget ` +# --accept-configuration-agreements ` +# --disable-interactivity +# +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +metadata: + winget: + processor: dscv3 +resources: + - type: Microsoft.WinGet/Package + name: PowerShell7 + properties: + id: Microsoft.PowerShell + source: winget + acceptAgreements: true + metadata: + description: Install PowerShell 7 + winget: + securityContext: elevated + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: Microsoft.Windows.Developer.Module + dependsOn: + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": if (-not (Get-Module -ListAvailable -Name Microsoft.Windows.Developer)) { Install-Module -Name Microsoft.Windows.Developer -Confirm:$False -Force -AllowPrerelease -AllowClobber } + treatAsArray: true + metadata: + description: Ensure Microsoft.Windows.Developer module is installed + + - type: Microsoft.Windows.Developer/OsVersion + name: OsVersion + dependsOn: + - Microsoft.Windows.Developer.Module + properties: + MinVersion: '10.0.17763' + metadata: + description: Verify min OS version (Windows 10 1809+) + allowPrerelease: true + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: Microsoft.Windows.Settings.Module + dependsOn: + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": if (-not (Get-Module -ListAvailable -Name Microsoft.Windows.Settings)) { Install-Module -Name Microsoft.Windows.Settings -Confirm:$False -Force -AllowPrerelease -AllowClobber } + treatAsArray: true + metadata: + description: Ensure Microsoft.Windows.Settings module is installed + + - type: Microsoft.Windows.Settings/WindowsSettings + name: DeveloperMode + dependsOn: + - Microsoft.Windows.Settings.Module + properties: + DeveloperMode: true + metadata: + description: Enable Developer Mode + allowPrerelease: true + winget: + securityContext: elevated + + - type: Microsoft.WinGet/Package + name: VisualStudio + properties: + id: Microsoft.VisualStudio.Community + source: winget + acceptAgreements: true + metadata: + description: Install Visual Studio 2026 Community + winget: + securityContext: elevated + + - type: Microsoft.WinGet/Package + name: WinAppCLI + properties: + id: Microsoft.WinAppCLI + source: winget + acceptAgreements: true + metadata: + description: Install Windows App SDK CLI (winappcli) + winget: + securityContext: elevated + + - type: Microsoft.DSC.Transitional/RunCommandOnSet + name: VSWorkloads + dependsOn: + - VisualStudio + - PowerShell7 + properties: + executable: pwsh + arguments: + "0": -NoProfile + "1": -NoLogo + "2": -Command + "3": $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'; if (-not (Test-Path $vswhere)) { throw 'vswhere.exe not found.' }; $setup = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\setup.exe'; if (-not (Test-Path $setup)) { throw 'Visual Studio setup.exe not found.' }; $installPath = & $vswhere -latest -products * -property installationPath; if (-not $installPath) { throw 'Visual Studio installation path could not be determined.' }; & $setup modify --installPath $installPath --channelId VisualStudio.18.Release --productId Microsoft.VisualStudio.Product.Community --add Microsoft.VisualStudio.Workload.ManagedDesktop --add Microsoft.VisualStudio.Workload.Universal --add Microsoft.VisualStudio.ComponentGroup.WindowsAppSDK.Cs --quiet --norestart --wait; if ($LASTEXITCODE -ne 0) { throw "Visual Studio workload installation failed with exit code $LASTEXITCODE." } + treatAsArray: true + metadata: + description: Install required VS workloads with Visual Studio Installer + winget: + securityContext: elevated diff --git a/WindowsDevScripts/scripts/windows/winui/install.ps1 b/WindowsDevScripts/scripts/windows/winui/install.ps1 new file mode 100644 index 00000000..59fbe856 --- /dev/null +++ b/WindowsDevScripts/scripts/windows/winui/install.ps1 @@ -0,0 +1,33 @@ +<# +.SYNOPSIS + Apply the WinUI 3 winget DSC configuration on Windows. + +.DESCRIPTION + This script is a thin CI/dev shim. The core artifact for the WinUI 3 flow + is `configuration.winget` in this directory — a dscv3 winget DSC config + that mirrors the canonical Microsoft Learn onboarding + (https://learn.microsoft.com/windows/apps/get-started/start-here): + * asserts minimum OS version, + * enables Developer Mode, + * installs Visual Studio 2026 Community, and + * adds the .NET Desktop, UWP, and Windows App SDK C# workloads/components. + + The shim exists only to: + * apply the DSC config with retry via `_common/apply-configuration.ps1` + (which passes `--accept-configuration-agreements` and + `--disable-interactivity`), + * rehydrate PATH in the current session so later CI steps see `dotnet`, + * verify `dotnet` resolves, and + * emit `INSTALL_OK: winui` for the test harness. +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +& (Join-Path $PSScriptRoot '..\_common\apply-configuration.ps1') ` + -Id 'winui' ` + -ConfigFile (Join-Path $PSScriptRoot 'configuration.winget') ` + -RequireCommands @('dotnet') diff --git a/WindowsDevScripts/tests/_harness/run-flow.ps1 b/WindowsDevScripts/tests/_harness/run-flow.ps1 new file mode 100644 index 00000000..f3eabef4 --- /dev/null +++ b/WindowsDevScripts/tests/_harness/run-flow.ps1 @@ -0,0 +1,109 @@ +<# +.SYNOPSIS + Build + run a hello-world and diff its stdout against an expected file. + +.DESCRIPTION + Invoked by CI after a flow's install script has run. Keeps the "does the + install actually produce a working toolchain?" question down to a single + assertion per flow. + +.PARAMETER Id + Flow id, used only for log prefixes. + +.PARAMETER Build + Shell command to build the hello-world. Empty string skips the build step + (useful for interpreted languages). + +.PARAMETER Run + Shell command whose stdout is compared against -Expected. + +.PARAMETER Expected + Path to a file containing the exact expected stdout. + +.NOTES + Commands run with the repository root as the working directory (the harness + does not change it). Output comparison normalizes CRLF->LF and trims trailing + whitespace on each line plus trailing blank lines. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Id, + [Parameter()] [string] $Build = '', + [Parameter(Mandatory)] [string] $Run, + [Parameter(Mandatory)] [string] $Expected +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Write-Section { + param([string] $Text) + Write-Host "" + Write-Host "==== [$Id] $Text ====" +} + +function Normalize-Output { + param([string] $Text) + if ($null -eq $Text) { return '' } + $Text = $Text -replace "`r`n", "`n" + $Text = $Text -replace "`r", "`n" + $lines = $Text -split "`n" + $lines = $lines | ForEach-Object { $_.TrimEnd() } + # Trim trailing blank lines. + $i = $lines.Count - 1 + while ($i -ge 0 -and [string]::IsNullOrEmpty($lines[$i])) { $i-- } + if ($i -lt 0) { return '' } + return ($lines[0..$i] -join "`n") +} + +function Invoke-Shell { + param( + [Parameter(Mandatory)] [string] $Command, + [Parameter(Mandatory)] [string] $Label + ) + Write-Host "> $Command" + if ($IsWindows -or ($null -eq $IsWindows)) { + # Windows PowerShell 5.1 doesn't define $IsWindows but is always Windows. + $output = & cmd.exe /d /c $Command 2>&1 | Out-String + } else { + # Useful for local testing on non-Windows hosts. + $output = & bash -c $Command 2>&1 | Out-String + } + $exit = $LASTEXITCODE + Write-Host $output + if ($exit -ne 0) { + throw "$Label failed with exit code $exit" + } + return $output +} + +if (-not (Test-Path -LiteralPath $Expected)) { + throw "Expected-output file not found: $Expected" +} +$expectedText = Normalize-Output (Get-Content -LiteralPath $Expected -Raw) + +if (-not [string]::IsNullOrWhiteSpace($Build)) { + Write-Section 'build' + [void](Invoke-Shell -Command $Build -Label 'build') +} else { + Write-Section 'build (skipped)' +} + +Write-Section 'run' +$runOutput = Invoke-Shell -Command $Run -Label 'run' +$actualText = Normalize-Output $runOutput + +Write-Section 'assert' +if ($actualText -ceq $expectedText) { + Write-Host "OK: stdout matches $Expected" + Write-Host "FLOW_OK: $Id" + exit 0 +} + +Write-Host '--- expected ---' +Write-Host $expectedText +Write-Host '--- actual ---' +Write-Host $actualText +Write-Host '--- end ---' +throw "Flow '$Id' stdout did not match expected output in $Expected" diff --git a/WindowsDevScripts/tests/_harness/run-server.ps1 b/WindowsDevScripts/tests/_harness/run-server.ps1 new file mode 100644 index 00000000..b3eb045c --- /dev/null +++ b/WindowsDevScripts/tests/_harness/run-server.ps1 @@ -0,0 +1,211 @@ +<# +.SYNOPSIS + Build a server scenario, start it in the background, hit an endpoint, + and persist the response body to a file. + +.DESCRIPTION + Companion to tests/_harness/run-flow.ps1 for scenario flows that ship a + real Web API as their hello-world (web-api-csharp, web-api-ts, + web-api-python, web-api-java). + + Designed to be the manifest's `build` step. The matching `run` step is + a simple `type ` (or `cmd /c type ...`) that emits the + persisted body for run-flow.ps1 to diff against expected.txt. + + Why the split: when pwsh runs as `pwsh -File ...` under cmd.exe, its + Write-Host / "host display" output is folded into the process's stdout + (there is no interactive console host to capture it separately). The + outer harness merges stderr into stdout via `2>&1` and diffs the lot. + So we cannot route diagnostics around the diff at runtime. Persisting + the body to disk and emitting only the file contents in the run step + keeps the diff clean and lets diagnostics flow freely through the + build step's normal stdout. + + Lifecycle on each invocation: + 1. (Optional) run a synchronous build command (e.g. `dotnet build` or + `mvn package`). + 2. Start the server command in a hidden child process. Server stdout + and stderr are redirected to log files in a per-run temp dir. + 3. Poll HealthUrl until any 2xx/3xx response, or HealthTimeoutSeconds + elapses. If the server process exits during polling the script + fails immediately and surfaces the exit code. + 4. Issue a GET to RequestUrl (defaults to HealthUrl) and capture the + response body. + 5. Always: kill the server process tree via `taskkill /F /T /PID`. + This handles `dotnet run`, `mvnw spring-boot:run`, `node`, and + `uvicorn`, which all spawn child processes that Stop-Process + alone leaves running. + 6. Write the response body verbatim to OutputFile. + 7. On any failure, dump the captured server log files to stdout so + they show up in the harness's build-step output. + +.PARAMETER Id + Flow id, used in log prefixes only. + +.PARAMETER Build + Optional shell command (run via cmd.exe /d /c) to build the scenario + before starting the server. Empty string skips the build step. + +.PARAMETER Start + Shell command (run via cmd.exe /d /c) that starts the server in the + foreground. Wrapped in a hidden child process by this script. + +.PARAMETER HealthUrl + URL polled until the server is ready. Any 2xx or 3xx response is + considered ready. Polled at 500ms intervals. + +.PARAMETER RequestUrl + Optional URL whose response body is persisted. Defaults to HealthUrl. + Use a separate URL when the health endpoint is not the endpoint you + want to diff. + +.PARAMETER OutputFile + Path where the response body is written verbatim. Parent directory is + created if missing. The manifest's `run` step is expected to be + `type ` so run-flow.ps1 sees only the body. + +.PARAMETER HealthTimeoutSeconds + Maximum time to wait for HealthUrl readiness before failing. Default 60. + +.PARAMETER ShutdownGraceSeconds + Maximum time to wait for the server process tree to exit after taskkill. + Default 10. + +.NOTES + Commands run with the repository root as the working directory, matching + the run-flow.ps1 contract. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Id, + [Parameter()] [string] $Build = '', + [Parameter(Mandatory)] [string] $Start, + [Parameter(Mandatory)] [string] $HealthUrl, + [Parameter()] [string] $RequestUrl = '', + [Parameter(Mandatory)] [string] $OutputFile, + [Parameter()] [int] $HealthTimeoutSeconds = 60, + [Parameter()] [int] $ShutdownGraceSeconds = 10 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if ([string]::IsNullOrWhiteSpace($RequestUrl)) { + $RequestUrl = $HealthUrl +} + +function Write-Section { + param([string] $Text) + Write-Host "" + Write-Host "==== [$Id] $Text ====" +} + +function Stop-ServerTree { + param([System.Diagnostics.Process] $Proc, [int] $GraceSeconds) + if ($null -eq $Proc) { return } + if ($Proc.HasExited) { return } + # taskkill /T kills the full child tree; Stop-Process alone leaves the + # actual server (a child of cmd.exe / dotnet / mvnw / etc.) running. + & taskkill.exe /F /T /PID $Proc.Id 2>&1 | Out-Null + if (-not $Proc.HasExited) { + $Proc.WaitForExit($GraceSeconds * 1000) | Out-Null + } +} + +function Write-ServerLogs { + param([string] $StdoutPath, [string] $StderrPath) + Write-Host "--- server stdout ($StdoutPath) ---" + if (Test-Path -LiteralPath $StdoutPath) { + Get-Content -LiteralPath $StdoutPath | ForEach-Object { Write-Host $_ } + } else { + Write-Host "(no stdout log)" + } + Write-Host "--- server stderr ($StderrPath) ---" + if (Test-Path -LiteralPath $StderrPath) { + Get-Content -LiteralPath $StderrPath | ForEach-Object { Write-Host $_ } + } else { + Write-Host "(no stderr log)" + } + Write-Host "--- end server logs ---" +} + +if (-not [string]::IsNullOrWhiteSpace($Build)) { + Write-Section 'build' + Write-Host "> $Build" + & cmd.exe /d /c $Build + if ($LASTEXITCODE -ne 0) { + throw "Build failed with exit code $LASTEXITCODE" + } +} + +$logStamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$logDir = Join-Path ([System.IO.Path]::GetTempPath()) "wdss-$Id-$logStamp" +New-Item -ItemType Directory -Path $logDir -Force | Out-Null +$outLog = Join-Path $logDir 'server.out.log' +$errLog = Join-Path $logDir 'server.err.log' + +Write-Section 'start server' +Write-Host "> $Start" +Write-Host "logs: $logDir" + +$serverProc = Start-Process -FilePath cmd.exe ` + -ArgumentList '/d', '/c', $Start ` + -PassThru -WindowStyle Hidden ` + -RedirectStandardOutput $outLog ` + -RedirectStandardError $errLog +Write-Host "server pid: $($serverProc.Id)" + +try { + Write-Section "wait for $HealthUrl (timeout ${HealthTimeoutSeconds}s)" + $deadline = (Get-Date).AddSeconds($HealthTimeoutSeconds) + $ready = $false + $attempts = 0 + while ((Get-Date) -lt $deadline) { + $attempts++ + if ($serverProc.HasExited) { + throw "Server process exited prematurely with exit code $($serverProc.ExitCode) after $attempts health attempts" + } + try { + $resp = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($resp.StatusCode -ge 200 -and $resp.StatusCode -lt 400) { + $ready = $true + break + } + } catch { + # Server not ready yet (connection refused, 5xx, etc.); keep polling. + } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { + throw "Server at $HealthUrl did not become ready within ${HealthTimeoutSeconds}s ($attempts attempts)" + } + Write-Host "server ready after $attempts health attempt(s)" + + Write-Section "GET $RequestUrl" + $bodyResp = Invoke-WebRequest -Uri $RequestUrl -UseBasicParsing -TimeoutSec 30 + Write-Host "HTTP $($bodyResp.StatusCode) $($bodyResp.StatusDescription)" + + Write-Section "persist response body to $OutputFile" + $outputDir = Split-Path -Parent $OutputFile + if (-not [string]::IsNullOrWhiteSpace($outputDir) -and -not (Test-Path -LiteralPath $outputDir)) { + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + } + # WriteAllText leaves bytes verbatim; the harness normalizes CRLF on diff. + [System.IO.File]::WriteAllText($OutputFile, $bodyResp.Content) + Write-Host "wrote $($bodyResp.Content.Length) char(s)" +} +catch { + Write-ServerLogs -StdoutPath $outLog -StderrPath $errLog + throw +} +finally { + Write-Section 'stop server' + Stop-ServerTree -Proc $serverProc -GraceSeconds $ShutdownGraceSeconds + if ($null -ne $serverProc -and $serverProc.HasExited) { + Write-Host "server pid $($serverProc.Id) exited with code $($serverProc.ExitCode)" + } elseif ($null -ne $serverProc) { + Write-Host "warning: server pid $($serverProc.Id) did not exit cleanly within ${ShutdownGraceSeconds}s" + } +} + diff --git a/WindowsDevScripts/tests/dotnet/Program.cs b/WindowsDevScripts/tests/dotnet/Program.cs new file mode 100644 index 00000000..b98cbbb0 --- /dev/null +++ b/WindowsDevScripts/tests/dotnet/Program.cs @@ -0,0 +1,7 @@ +// Hello-world probe for the .NET flow. +// +// The simplest possible thing that proves the .NET 10 SDK installed and +// produced a working `dotnet` toolchain. Top-level statement; matches the +// `dotnet new console` template's idiomatic shape. + +Console.WriteLine("Hello, world!"); diff --git a/WindowsDevScripts/tests/dotnet/expected.txt b/WindowsDevScripts/tests/dotnet/expected.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/WindowsDevScripts/tests/dotnet/expected.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/WindowsDevScripts/tests/dotnet/hello.csproj b/WindowsDevScripts/tests/dotnet/hello.csproj new file mode 100644 index 00000000..4249fcb5 --- /dev/null +++ b/WindowsDevScripts/tests/dotnet/hello.csproj @@ -0,0 +1,22 @@ + + + + + + Exe + net10.0 + HelloDotNet + hello + enable + enable + false + + + diff --git a/WindowsDevScripts/tests/go/expected.txt b/WindowsDevScripts/tests/go/expected.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/WindowsDevScripts/tests/go/expected.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/WindowsDevScripts/tests/go/hello.go b/WindowsDevScripts/tests/go/hello.go new file mode 100644 index 00000000..7e6af991 --- /dev/null +++ b/WindowsDevScripts/tests/go/hello.go @@ -0,0 +1,14 @@ +// Hello-world probe for the Go flow. +// +// `go run` accepts a single .go file directly without a surrounding module, +// so this file is intentionally standalone (no go.mod). If the toolchain +// install was incomplete, `go run` would fail and the harness would flag +// the flow broken. + +package main + +import "fmt" + +func main() { + fmt.Println("Hello, world!") +} diff --git a/WindowsDevScripts/tests/java/Hello.java b/WindowsDevScripts/tests/java/Hello.java new file mode 100644 index 00000000..8491d186 --- /dev/null +++ b/WindowsDevScripts/tests/java/Hello.java @@ -0,0 +1,13 @@ +// Hello-world probe for the Java flow. +// +// Exercised via JDK 11+'s single-file source-code launcher (JEP 330): +// `java tests/java/Hello.java` compiles and runs this file in one step, +// so no explicit `javac` build is needed. If the JDK install was +// incomplete, the launcher would fail and the harness would flag the +// flow broken. + +public class Hello { + public static void main(String[] args) { + System.out.println("Hello, world!"); + } +} diff --git a/WindowsDevScripts/tests/java/expected.txt b/WindowsDevScripts/tests/java/expected.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/WindowsDevScripts/tests/java/expected.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/WindowsDevScripts/tests/php/expected.txt b/WindowsDevScripts/tests/php/expected.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/WindowsDevScripts/tests/php/expected.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/WindowsDevScripts/tests/php/hello.php b/WindowsDevScripts/tests/php/hello.php new file mode 100644 index 00000000..923530d7 --- /dev/null +++ b/WindowsDevScripts/tests/php/hello.php @@ -0,0 +1,2 @@ + + + + + + Exe + net10.0-windows + true + HelloWinForms + hello + enable + enable + false + + + diff --git a/WindowsDevScripts/tests/winui/Program.cs b/WindowsDevScripts/tests/winui/Program.cs new file mode 100644 index 00000000..af88d976 --- /dev/null +++ b/WindowsDevScripts/tests/winui/Program.cs @@ -0,0 +1,12 @@ +// Hello-world probe for the WinUI 3 flow. +// +// We don't show a window (CI runners are headless for interactive UI), but we +// *do* reference a WinUI type and read back its name — this forces the +// Microsoft.WinUI projection assembly (shipped by the Microsoft.WindowsAppSDK +// NuGet) to actually load. If the WinAppSDK restore was incomplete, the +// `typeof` below would fail and the harness would flag the flow broken. + +using System; + +var name = typeof(Microsoft.UI.Xaml.Application).Name; +Console.WriteLine($"WinUI: {name}"); diff --git a/WindowsDevScripts/tests/winui/expected.txt b/WindowsDevScripts/tests/winui/expected.txt new file mode 100644 index 00000000..64b5b631 --- /dev/null +++ b/WindowsDevScripts/tests/winui/expected.txt @@ -0,0 +1 @@ +WinUI: Application diff --git a/WindowsDevScripts/tests/winui/hello.csproj b/WindowsDevScripts/tests/winui/hello.csproj new file mode 100644 index 00000000..958ee01b --- /dev/null +++ b/WindowsDevScripts/tests/winui/hello.csproj @@ -0,0 +1,41 @@ + + + + + + Exe + net10.0-windows10.0.19041.0 + HelloWinUI + hello + enable + enable + false + None + false + + + + + + +