From 1b5da32447d2efbf9f4a57ded8f14766994b1217 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Thu, 22 Oct 2026 09:18:59 +0200 Subject: [PATCH 1/4] feat(windows): install fixed-path PowerShell 7 MSI and guard MSIX migration --- .github/workflows/shell-quality.yml | 6 +- README.md | 5 +- docs/windows.md | 43 +++- install.ps1 | 27 ++- tests/windows-powershell-msi-tests.ps1 | 58 ++++++ tests/windows-smoke.ps1 | 12 +- windows/Dotfiles.WindowsPowerShell.psm1 | 258 ++++++++++++++++++++++++ windows/Install-PowerShellMsi.ps1 | 18 ++ windows/packages.psd1 | 1 + 9 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 tests/windows-powershell-msi-tests.ps1 create mode 100644 windows/Dotfiles.WindowsPowerShell.psm1 create mode 100644 windows/Install-PowerShellMsi.ps1 diff --git a/.github/workflows/shell-quality.yml b/.github/workflows/shell-quality.yml index 9ae27a0..e96f71a 100644 --- a/.github/workflows/shell-quality.yml +++ b/.github/workflows/shell-quality.yml @@ -243,10 +243,14 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Parse PowerShell sources and run the minimal dry-run + - name: Parse PowerShell sources and run the minimal dry-run (PowerShell 7) shell: pwsh run: ./tests/windows-smoke.ps1 + - name: Parse and smoke-test from Windows PowerShell 5.1 + shell: powershell + run: ./tests/windows-smoke.ps1 + full-install: if: github.event_name == 'workflow_dispatch' && github.event.inputs.full_install == 'true' runs-on: ubuntu-latest diff --git a/README.md b/README.md index 429b0a5..43df486 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Open a regular PowerShell window. WinGet may request elevation for individual pa 5. Open a new PowerShell or Windows Terminal session to load the installed profile. -See the [Windows guide](docs/windows.md) for WinGet recovery, package selection, changed files, local overlays, backup behavior, and known limitations. +See the [Windows guide](docs/windows.md) for WinGet recovery, package selection, changed files, local overlays, backup behavior, and known limitations. PowerShell 7 is installed through the fixed-path **MSI** variant (instead of WinGet's default MSIX) to support SSH sessions; existing Store/MSIX installations require explicit migration consent. ### macOS @@ -162,7 +162,8 @@ Run `./install.sh --help` for the complete option list. macOS-specific options s | `.\install.ps1 -DryRun` | Preview file, Git, and package operations without changing the machine | | `.\install.ps1 -Minimal` | Apply managed configuration but skip package installation | | `.\install.ps1 -Sync` | Reconcile profiles, copied configuration, and Git defaults without installing packages | -| `.\install.ps1 -Force` or `-y` | Answer yes to all package prompts; this includes optional and non-empty private manifests | +| `.\install.ps1 -Force` or `-y` | Answer yes to all package-group prompts; never authorize removal of Store PowerShell | +| `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicitly opt into replacing current-user PowerShell MSIX with a fixed-path 7.6 MSI, when not running inside MSIX | | `.\install.ps1 -CleanBackups` | Inspect the currently supported Windows backup-cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore managed Git values when they have not changed since installation | diff --git a/docs/windows.md b/docs/windows.md index 85aa7ce..ab06176 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -120,7 +120,8 @@ Windows Terminal and TrafficMonitor configuration are currently copied during no | `.\install.ps1 -DryRun` | Preview files, Git changes, manifest selection, and missing packages without modifying the machine | | `.\install.ps1 -Minimal` | Apply managed files and Git defaults but skip package installation | | `.\install.ps1 -Sync` | Reconcile profiles, copied files, manifests, and Git defaults without installing packages | -| `.\install.ps1 -Force` | Answer yes to core, optional, and non-empty private package prompts | +| `.\install.ps1 -Force` | Answer yes to package-group prompts, but not to removing PowerShell MSIX | +| `.\install.ps1 -Force -MigratePowerShellToMsi` | Explicit opt-in to replace current-user PowerShell MSIX with fixed-path MSI, only from a non-MSIX shell | | `.\install.ps1 -y` | Alias for `-Force` | | `.\install.ps1 -CleanBackups` | List backups in the currently supported cleanup scope and ask before deletion | | `.\install.ps1 -RestoreGitDefaults` | Restore installer-managed Git values when they still match the values applied by the installer | @@ -130,7 +131,8 @@ Important mode rules: - `-Sync` cannot be combined with `-Minimal`, `-Force`, or `-CleanBackups`. - `-RestoreGitDefaults` is a dedicated operation and cannot be combined with installation options. - `-DryRun` can be combined with `-Force` or `-Sync` for a non-destructive full preview. -- `-Force` is a yes-to-all switch, not simply an overwrite switch. It opts into optional and local private packages as well. +- `-Force` opts into package groups, including optional and private ones, but never authorizes removal of PowerShell MSIX. Use the additional `-MigratePowerShellToMsi` flag or an interactive confirmation. +- `-MigratePowerShellToMsi` is incompatible with `-Minimal`, `-Sync`, `-RestoreGitDefaults`, and `-CleanBackups`. ## Package manifests @@ -148,6 +150,43 @@ Both `Winget` and `NpmGlobal` sections are supported. The public `NpmGlobal` sec A WinGet entry can be either a package ID string or a structured entry with an explicit source. The installer uses exact package IDs and installs only packages it does not detect as already present. +### PowerShell 7: fixed-path MSI for SSH + +The core manifest still lists `Microsoft.PowerShell`, but the installer treats it specially. Starting with PowerShell 7.6, a generic WinGet install selects **MSIX** by default, which exposes `pwsh.exe` through an App Execution Alias in `%LOCALAPPDATA%\Microsoft\WindowsApps`. That alias can fail from Windows OpenSSH or other service logon contexts. For this bootstrap we therefore require the **MSI (wix)** installer, with a stable real executable at `C:\Program Files\PowerShell\7\pwsh.exe`. See [Microsoft's PowerShell installation guide](https://learn.microsoft.com/powershell/scripting/install/install-powershell-on-windows) and [OpenSSH App Execution Alias issue](https://github.com/PowerShell/Win32-OpenSSH/issues/2449). + +The current bootstrap targets **7.6.7 MSI** with `--installer-type wix --version 7.6.7.0`. Microsoft has announced that **7.7 and later will not ship MSI installers**, so do not silently switch this path to MSIX during a future upgrade; reassess a fixed-path ZIP/launcher approach for 7.7+. The installed PowerShell 7.6 MSI continues to use the usual supported servicing policy. + +Behavior: + +- **No existing PowerShell 7:** install and verify MSI at the stable Program Files path. Built-in Windows PowerShell 5.1 is never removed. +- **Fixed-path PowerShell 7 already working:** leave it in place and avoid reinstalling. +- **Store/MSIX is present:** ask whether to replace it (default **No**). `-Force` is not permission to remove it. An explicit `-MigratePowerShellToMsi` or Yes answer installs/verifies MSI first, then removes only the *current user's* `Microsoft.PowerShell` AppX registration. The shared WinGet ID does not identify the two installer technologies, so uninstalling blindly by `winget uninstall Microsoft.PowerShell` is unsafe. +- **The script itself is running from PowerShell MSIX:** never uninstall the running package. Show a command to **close that session** and relaunch only this migration from Windows PowerShell 5.1, which has its own system executable and stays installed. +- **Other or broken `pwsh.exe` installations or an unavailable AppX query:** warn and skip automatic changes rather than guessing. + +For the typical migration from an active MSIX terminal, first close the existing PowerShell 7 window, then start Windows PowerShell 5.1 and run the narrow helper: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\windows\Install-PowerShellMsi.ps1 -Migrate +``` + +The helper changes only PowerShell, not your Git configuration, profiles, Node or other packages. Alternatively, from a Windows PowerShell 5.1 session, use the normal package bootstrap with `.\install.ps1 -Force -MigratePowerShellToMsi`. Both paths require explicit migration consent; ordinary `-Force` leaves existing MSIX untouched. Preview operations with `.\windows\Install-PowerShellMsi.ps1 -DryRun` (from the repo root) or `.\install.ps1 -DryRun -Force`. + +To verify an MSI installation from **a new Windows/SSH session**: + +```powershell +where.exe pwsh +Get-Command pwsh -All | Select-Object Source +& "$env:ProgramFiles\PowerShell\7\pwsh.exe" -NoProfile -Command '$PSHOME' +Get-AppxPackage -Name Microsoft.PowerShell +``` + +The MSI invocation should report `C:\Program Files\PowerShell\7`. After migration the current-user MSIX package should no longer be registered. Restart old shells to refresh PATH. If the old Store alias persists, inspect **Settings → Apps → Advanced app settings → App execution aliases**, rather than deleting anything under WindowsApps manually. + +**OpenSSH default shell is configured separately:** installation alone does not change `HKLM:\SOFTWARE\OpenSSH` or restart `sshd`. If you explicitly want PowerShell 7 as the SSH default shell, inspect your OpenSSH configuration and set `DefaultShell` to the full Program Files executable path from an elevated session; keep the original registry value for rollback. This repo deliberately does not change service configuration. + +Windows PowerShell **5.1 is supported as a bootstrap/recovery host**. The Windows smoke checks parse and exercise the dotfiles under both `powershell.exe` (5.1) and `pwsh.exe` (7), without installing or removing anything. If the MSI install or MSIX removal fails, the bootstrap reports failure, preserves Windows PowerShell 5.1, and does not automatically delete the other installation. + ## Local configuration ### PowerShell overlays diff --git a/install.ps1 b/install.ps1 index aca98e2..2675553 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,6 +4,7 @@ param( [Alias('y')] [switch]$Force, [switch]$Minimal, + [switch]$MigratePowerShellToMsi, [switch]$CleanBackups, [switch]$Sync, [switch]$RestoreGitDefaults @@ -15,6 +16,8 @@ $ProgressPreference = 'SilentlyContinue' $windowsPackageModule = Join-Path $PSScriptRoot 'windows\Dotfiles.WindowsPackages.psm1' Import-Module -Name $windowsPackageModule -Force +$windowsPowerShellModule = Join-Path $PSScriptRoot 'windows\Dotfiles.WindowsPowerShell.psm1' +Import-Module -Name $windowsPowerShellModule -Force function Write-Section { param([string]$Message) @@ -541,7 +544,13 @@ function Get-WindowsPackageComparison { $installed = $false switch ($section.Manager) { - 'Winget' { $installed = Test-WingetPackageInstalled -PackageId $package.Id -Source $package.Source } + 'Winget' { + if ($package.Id -eq 'Microsoft.PowerShell') { + $installed = Test-DotfilesPwshMsiPresent + } else { + $installed = Test-WingetPackageInstalled -PackageId $package.Id -Source $package.Source + } + } 'NpmGlobal' { $installed = Test-NpmGlobalPackageInstalled -PackageName $package.Name } } @@ -597,10 +606,20 @@ function Install-WindowsPackageBaseline { Write-Info " $($section.Manager):" foreach ($packageEntry in $section.Packages) { $package = Resolve-WindowsPackageEntry -PackageEntry $packageEntry -Manager $section.Manager + if ($section.Manager -eq 'Winget' -and $package.Id -eq 'Microsoft.PowerShell') { + Invoke-DotfilesPowerShellBootstrap -RepoRoot $PSScriptRoot -DryRun:$DryRun -NonInteractive:$Force -ExplicitMigration:$MigratePowerShellToMsi + continue + } $installed = $false switch ($section.Manager) { - 'Winget' { $installed = Test-WingetPackageInstalled -PackageId $package.Id -Source $package.Source } + 'Winget' { + if ($package.Id -eq 'Microsoft.PowerShell') { + $installed = Test-DotfilesPwshMsiPresent + } else { + $installed = Test-WingetPackageInstalled -PackageId $package.Id -Source $package.Source + } + } 'NpmGlobal' { $installed = Test-NpmGlobalPackageInstalled -PackageName $package.Name } } @@ -721,6 +740,10 @@ if ($RestoreGitDefaults -and ($Sync -or $Minimal -or $Force -or $CleanBackups)) throw '-RestoreGitDefaults cannot be combined with installation options.' } +if ($MigratePowerShellToMsi -and ($Minimal -or $Sync -or $RestoreGitDefaults -or $CleanBackups)) { + throw '-MigratePowerShellToMsi requires a normal package run. Use the standalone Windows PowerShell 5.1 migration helper if needed.' +} + $RepoRoot = Split-Path -Parent $PSCommandPath $WindowsProfileSource = Join-Path $RepoRoot 'windows/profile.ps1' $WindowsPackageModuleSource = Join-Path $RepoRoot 'windows/Dotfiles.WindowsPackages.psm1' diff --git a/tests/windows-powershell-msi-tests.ps1 b/tests/windows-powershell-msi-tests.ps1 new file mode 100644 index 0000000..c069db2 --- /dev/null +++ b/tests/windows-powershell-msi-tests.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent $PSScriptRoot +Import-Module -Name (Join-Path $repoRoot 'windows/Dotfiles.WindowsPowerShell.psm1') -Force + +function New-FakePwshState { + param( + [switch]$Msi, + [switch]$BrokenMsi, + [switch]$Msix, + [switch]$RunningMsix, + [switch]$QueryFailed, + [string[]]$OtherPaths = @() + ) + + [pscustomobject]@{ + FixedPath = 'C:\Program Files\PowerShell\7\pwsh.exe' + MsiPresent = [bool]$Msi + MsiFilePresent = [bool]($Msi -or $BrokenMsi) + MsixPackages = if ($Msix) { @([pscustomobject]@{ Name = 'Microsoft.PowerShell'; PackageFullName = 'Microsoft.PowerShell_7.6.7.0_test' }) } else { @() } + MsixQuerySucceeded = -not [bool]$QueryFailed + RunningMsix = [bool]$RunningMsix + OtherPwshPaths = @($OtherPaths) + } +} + +function Assert-PwshPlan { + param( + [Parameter(Mandatory = $true)][psobject]$State, + [Parameter(Mandatory = $true)][string]$Expected, + [switch]$Migrate + ) + $plan = Get-DotfilesPowerShellPlan -State $State -ExplicitMigration:$Migrate + if ($plan -ne $Expected) { + throw "Expected PowerShell plan '$Expected', got '$plan'." + } +} + +Assert-PwshPlan -State (New-FakePwshState) -Expected Install +Assert-PwshPlan -State (New-FakePwshState -Msi) -Expected Existing +Assert-PwshPlan -State (New-FakePwshState -Msix) -Expected Prompt +Assert-PwshPlan -State (New-FakePwshState -Msix) -Migrate -Expected Migrate +Assert-PwshPlan -State (New-FakePwshState -Msix -Msi) -Expected Prompt +Assert-PwshPlan -State (New-FakePwshState -Msix -Msi) -Migrate -Expected Migrate +Assert-PwshPlan -State (New-FakePwshState -Msix -RunningMsix) -Expected Prompt +Assert-PwshPlan -State (New-FakePwshState -Msix -RunningMsix) -Migrate -Expected Relaunch +Assert-PwshPlan -State (New-FakePwshState -QueryFailed) -Expected UnknownAppx +Assert-PwshPlan -State (New-FakePwshState -QueryFailed -RunningMsix) -Migrate -Expected Relaunch +Assert-PwshPlan -State (New-FakePwshState -BrokenMsi) -Expected BrokenMsi +Assert-PwshPlan -State (New-FakePwshState -BrokenMsi -Msix) -Migrate -Expected BrokenMsi +Assert-PwshPlan -State (New-FakePwshState -OtherPaths @('D:\Tools\pwsh.exe')) -Expected OtherPwsh +Assert-PwshPlan -State (New-FakePwshState -OtherPaths @('D:\Tools\pwsh.exe')) -Migrate -Expected OtherPwsh + +Write-Host 'PowerShell MSI migration planning tests passed.' diff --git a/tests/windows-smoke.ps1 b/tests/windows-smoke.ps1 index 904a751..0c64398 100644 --- a/tests/windows-smoke.ps1 +++ b/tests/windows-smoke.ps1 @@ -9,6 +9,9 @@ $sources = @( 'install.ps1' 'windows/profile.ps1' 'windows/Dotfiles.WindowsPackages.psm1' + 'windows/Dotfiles.WindowsPowerShell.psm1' + 'windows/Install-PowerShellMsi.ps1' + 'tests/windows-powershell-msi-tests.ps1' 'windows/start-menu-shortcuts.ps1' ) @@ -46,13 +49,20 @@ foreach ($commandName in @('rld', 'npmupg')) { Import-PowerShellDataFile -Path (Join-Path $repoRoot 'windows/packages.psd1') | Out-Null Import-Module (Join-Path $repoRoot 'windows/Dotfiles.WindowsPackages.psm1') -Force +& (Join-Path $repoRoot 'tests/windows-powershell-msi-tests.ps1') + +$coreManifest = Import-PowerShellDataFile -Path (Join-Path $repoRoot 'windows/packages.psd1') +if (@($coreManifest.Winget) -notcontains 'Microsoft.PowerShell') { + throw 'The Windows core manifest must include Microsoft.PowerShell.' +} + $entry = Resolve-WindowsPackageEntry -PackageEntry 'eza-community.eza' -Manager Winget if ($entry.Source -ne 'winget' -or $entry.Id -ne 'eza-community.eza') { throw 'Shared package entry normalization failed.' } -if (-not $IsWindows) { +if ($env:OS -ne 'Windows_NT') { Write-Host 'PowerShell parsing and shared module checks passed; Windows lifecycle skipped on this platform.' return } diff --git a/windows/Dotfiles.WindowsPowerShell.psm1 b/windows/Dotfiles.WindowsPowerShell.psm1 new file mode 100644 index 0000000..3f78021 --- /dev/null +++ b/windows/Dotfiles.WindowsPowerShell.psm1 @@ -0,0 +1,258 @@ +# Installer-only PowerShell 7 MSI migration helpers. Import has no side effects. +# Explicitly target PowerShell 7.6: 7.7 and later are MSIX-only upstream. +Set-StrictMode -Version Latest + +$script:PwshPackageId = 'Microsoft.PowerShell' +$script:PwshMsiVersion = '7.6.7.0' + +function Get-DotfilesPwshMsiPath { + $programFiles = [Environment]::GetEnvironmentVariable('ProgramW6432') + if ([string]::IsNullOrWhiteSpace($programFiles)) { + $programFiles = $env:ProgramFiles + } + if ([string]::IsNullOrWhiteSpace($programFiles)) { + throw 'Cannot determine Program Files directory.' + } + return (Join-Path $programFiles 'PowerShell\7\pwsh.exe') +} + +function Test-DotfilesPwshMsiPresent { + $path = Get-DotfilesPwshMsiPath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + try { + $item = Get-Item -LiteralPath $path -ErrorAction Stop + if ($item.Length -le 0 -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + return $false + } + $major = @(& $path -NoLogo -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.Major' 2>$null) + return ($LASTEXITCODE -eq 0 -and $major.Count -eq 1 -and $major[0].ToString().Trim() -eq '7') + } catch { + return $false + } +} + +function Get-DotfilesPowerShellState { + $fixedPath = Get-DotfilesPwshMsiPath + $isMsiPresent = Test-DotfilesPwshMsiPresent + $isMsiFilePresent = Test-Path -LiteralPath $fixedPath -PathType Leaf + + $msixQuerySucceeded = $false + $msixPackages = @() + if (Get-Command Get-AppxPackage -ErrorAction SilentlyContinue) { + try { + $msixPackages = @( + Get-AppxPackage -Name 'Microsoft.PowerShell' -ErrorAction Stop | + Where-Object { $_.Name -eq 'Microsoft.PowerShell' -and $_.PackageFullName -like 'Microsoft.PowerShell_*' } + ) + $msixQuerySucceeded = $true + } catch { + # An incomplete AppX query must never authorize a destructive migration. + } + } + + $runningMsix = ( + $PSVersionTable.PSEdition -eq 'Core' -and + $PSHOME -match '[\\/]WindowsApps[\\/]' + ) + + $otherPaths = @() + foreach ($command in @(Get-Command pwsh -All -CommandType Application -ErrorAction SilentlyContinue)) { + $path = $command.Source + if ([string]::IsNullOrWhiteSpace($path)) { continue } + if ($path.Equals($fixedPath, [StringComparison]::OrdinalIgnoreCase)) { continue } + if ($path -match '[\\/]Microsoft[\\/]WindowsApps[\\/]pwsh\.exe$') { continue } + if ($path -match '[\\/]WindowsApps[\\/].+[\\/]pwsh\.exe$') { continue } + $otherPaths += $path + } + + [pscustomobject]@{ + FixedPath = $fixedPath + MsiPresent = [bool]$isMsiPresent + MsiFilePresent = [bool]$isMsiFilePresent + MsixPackages = @($msixPackages) + MsixQuerySucceeded = [bool]$msixQuerySucceeded + RunningMsix = [bool]$runningMsix + OtherPwshPaths = @($otherPaths | Select-Object -Unique) + } +} + +# Pure planning function; dotfiles -Force never supplies migration consent. +function Get-DotfilesPowerShellPlan { + param( + [Parameter(Mandatory = $true)][psobject]$State, + [switch]$ExplicitMigration + ) + + if (-not $State.MsixQuerySucceeded) { return 'UnknownAppx' } + if (@($State.OtherPwshPaths).Count -gt 0) { return 'OtherPwsh' } + if ($State.MsiFilePresent -and -not $State.MsiPresent) { return 'BrokenMsi' } + if (@($State.MsixPackages).Count -gt 0) { + if (-not $ExplicitMigration) { return 'Prompt' } + if ($State.RunningMsix) { return 'Relaunch' } + return 'Migrate' + } + if ($State.RunningMsix) { return 'UnknownAppx' } + if ($State.MsiPresent) { return 'Existing' } + return 'Install' +} + +function Install-DotfilesPwshMsi { + param([switch]$MsixAlreadyPresent) + + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + throw 'WinGet is not installed; cannot install PowerShell MSI.' + } + + $args = @( + 'install', '--id', $script:PwshPackageId, '--exact', + '--source', 'winget', '--version', $script:PwshMsiVersion, + '--installer-type', 'wix', '--scope', 'machine', + '--accept-package-agreements', '--accept-source-agreements', '--silent' + ) + + if ($MsixAlreadyPresent) { + # Both distributions have the same WinGet package ID. Without --force, + # WinGet may refuse to install MSI because it detects the MSIX package. + $args += '--force' + } + + Write-Host " Installing PowerShell $script:PwshMsiVersion with WinGet (MSI / wix)..." + & winget @args + if ($LASTEXITCODE -ne 0) { + throw "WinGet MSI installation failed with exit code $LASTEXITCODE. No MSIX package was removed." + } + + if (-not (Test-DotfilesPwshMsiPresent)) { + throw 'The MSI install did not create a working Program Files\PowerShell\7\pwsh.exe. MSIX has not been removed.' + } +} + +function Show-DotfilesPwshRelaunch { + param([Parameter(Mandatory = $true)][string]$RepoRoot) + $windowsPs = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' + $helper = Join-Path $RepoRoot 'windows\Install-PowerShellMsi.ps1' + Write-Warning 'This installer is running inside PowerShell MSIX. It will not uninstall its own executable.' + Write-Host ' Close this PowerShell 7 session and any other packaged pwsh.exe processes.' + Write-Host ' Then relaunch ONLY the PowerShell migration from Windows PowerShell 5.1:' + Write-Host (' & "{0}" -NoProfile -ExecutionPolicy Bypass -File "{1}" -Migrate' -f $windowsPs, $helper) +} + +function Invoke-DotfilesPowerShellBootstrap { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [switch]$DryRun, + [switch]$NonInteractive, + [switch]$ExplicitMigration + ) + + $state = Get-DotfilesPowerShellState + $plan = Get-DotfilesPowerShellPlan -State $state -ExplicitMigration:$ExplicitMigration + switch ($plan) { + 'UnknownAppx' { + Write-Warning 'Cannot reliably identify the current-user PowerShell MSIX installation. Skipping to avoid modifying an unknown PowerShell installation.' + if ($ExplicitMigration -and -not $DryRun) { + throw 'Cannot migrate without a reliable Get-AppxPackage result.' + } + return + } + 'OtherPwsh' { + Write-Warning "Another pwsh.exe is on PATH: $($state.OtherPwshPaths -join ', '). Resolve that installation first." + if ($ExplicitMigration -and -not $DryRun) { + throw 'Unexpected PowerShell executable on PATH.' + } + return + } + 'BrokenMsi' { + Write-Warning "A PowerShell executable exists at $($state.FixedPath) but cannot be validated. Repair it manually before continuing." + if ($ExplicitMigration -and -not $DryRun) { + throw 'Cannot migrate with a broken Program Files PowerShell installation.' + } + return + } + 'Existing' { + Write-Host " PowerShell 7 already runs from the fixed path: $($state.FixedPath)" + return + } + 'Prompt' { + if ($DryRun) { + Write-Host ' Would ask whether to replace PowerShell Store/MSIX with MSI (default No).' + return + } + if ($NonInteractive) { + Write-Warning 'PowerShell MSIX detected. -Force is not consent to remove it; leaving it installed. Use -MigratePowerShellToMsi explicitly.' + return + } + Write-Host ' PowerShell Store/MSIX is installed.' + Write-Host " MSI gives a fixed, SSH-friendly executable: $($state.FixedPath)" + Write-Host ' Migration installs/verifies MSI first, then removes MSIX for the current user.' + $reply = Read-Host ' Replace Store/MSIX PowerShell 7 with the MSI variant? [y/N]' + if ($reply -notmatch '^[Yy]$') { + Write-Host ' Keeping MSIX unchanged.' + return + } + $plan = Get-DotfilesPowerShellPlan -State $state -ExplicitMigration + } + } + + if ($plan -eq 'Relaunch') { + Show-DotfilesPwshRelaunch -RepoRoot $RepoRoot + if ($ExplicitMigration -and -not $DryRun) { + throw 'Relaunch from Windows PowerShell 5.1 before removing an active MSIX installation.' + } + return + } + + if ($DryRun) { + if ($plan -eq 'Migrate') { + Write-Host " Would verify MSI at $($state.FixedPath), installing $script:PwshMsiVersion via wix first if missing, then remove only current-user Microsoft.PowerShell MSIX." + } else { + Write-Host " Would install PowerShell $script:PwshMsiVersion via wix (MSI) at $($state.FixedPath)." + } + return + } + + try { + if (-not $state.MsiPresent) { + Install-DotfilesPwshMsi -MsixAlreadyPresent:($plan -eq 'Migrate') + } + + if (-not (Test-DotfilesPwshMsiPresent)) { + throw 'MSI executable verification failed. No PowerShell MSIX uninstall attempted.' + } + + if ($plan -eq 'Migrate') { + if ($state.RunningMsix) { + throw 'Refusing to remove the PowerShell MSIX executable currently running this script.' + } + foreach ($package in @($state.MsixPackages)) { + Write-Host " Removing current-user MSIX package: $($package.PackageFullName)" + Remove-AppxPackage -Package $package.PackageFullName -ErrorAction Stop + } + $remaining = @( + Get-AppxPackage -Name 'Microsoft.PowerShell' -ErrorAction Stop | + Where-Object { $_.Name -eq 'Microsoft.PowerShell' } + ) + if ($remaining.Count -gt 0) { + throw 'PowerShell MSIX is still registered. Close packaged pwsh.exe sessions and retry removal.' + } + Write-Host ' PowerShell MSI migration complete. Restart any SSH sessions before testing.' + } else { + Write-Host ' PowerShell MSI installation complete.' + } + + Write-Host (' Fixed executable: {0}' -f $state.FixedPath) + Write-Host ' In a new shell: where.exe pwsh; $PSHOME; pwsh -NoProfile -Command $PSHOME' + } catch { + Write-Warning "PowerShell MSI bootstrap incomplete: $($_.Exception.Message)" + Write-Warning 'No attempt was made to uninstall or overwrite Windows PowerShell 5.1.' + throw + } +} + +Export-ModuleMember -Function @( + 'Get-DotfilesPowerShellPlan' + 'Get-DotfilesPowerShellState' + 'Get-DotfilesPwshMsiPath' + 'Invoke-DotfilesPowerShellBootstrap' + 'Test-DotfilesPwshMsiPresent' +) diff --git a/windows/Install-PowerShellMsi.ps1 b/windows/Install-PowerShellMsi.ps1 new file mode 100644 index 0000000..3a17d76 --- /dev/null +++ b/windows/Install-PowerShellMsi.ps1 @@ -0,0 +1,18 @@ +[CmdletBinding()] +param( + # An explicit opt-in is required to uninstall the current-user MSIX variant. + [switch]$Migrate, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($env:OS -ne 'Windows_NT') { + throw 'This helper is intended for Windows.' +} + +$module = Join-Path $PSScriptRoot 'Dotfiles.WindowsPowerShell.psm1' +Import-Module -Name $module -Force + +Invoke-DotfilesPowerShellBootstrap -RepoRoot (Split-Path -Parent $PSScriptRoot) -DryRun:$DryRun -ExplicitMigration:$Migrate diff --git a/windows/packages.psd1 b/windows/packages.psd1 index b51f984..49bb9bb 100644 --- a/windows/packages.psd1 +++ b/windows/packages.psd1 @@ -7,6 +7,7 @@ 'GitHub.cli' 'JanDeDobbeleer.OhMyPosh' 'DEVCOM.JetBrainsMonoNerdFont' + # MSI (wix), not WinGet's default MSIX; handled by the installer-specific bootstrap. 'Microsoft.PowerShell' 'Microsoft.WindowsTerminal' 'GNU.Nano' From 96089f78a445fd568f2368c873d3b73e3b92d29f Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Thu, 22 Oct 2026 09:21:33 +0200 Subject: [PATCH 2/4] fix(windows): offer safe 5.1 recovery when packaged pwsh lacks AppX cmdlets --- tests/windows-powershell-msi-tests.ps1 | 11 ++++++++++- windows/Dotfiles.WindowsPowerShell.psm1 | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/windows-powershell-msi-tests.ps1 b/tests/windows-powershell-msi-tests.ps1 index c069db2..fb8bda7 100644 --- a/tests/windows-powershell-msi-tests.ps1 +++ b/tests/windows-powershell-msi-tests.ps1 @@ -17,11 +17,19 @@ function New-FakePwshState { [string[]]$OtherPaths = @() ) + $packages = @() + if ($Msix) { + $packages = @([pscustomobject]@{ + Name = 'Microsoft.PowerShell' + PackageFullName = 'Microsoft.PowerShell_7.6.7.0_test' + }) + } + [pscustomobject]@{ FixedPath = 'C:\Program Files\PowerShell\7\pwsh.exe' MsiPresent = [bool]$Msi MsiFilePresent = [bool]($Msi -or $BrokenMsi) - MsixPackages = if ($Msix) { @([pscustomobject]@{ Name = 'Microsoft.PowerShell'; PackageFullName = 'Microsoft.PowerShell_7.6.7.0_test' }) } else { @() } + MsixPackages = @($packages) MsixQuerySucceeded = -not [bool]$QueryFailed RunningMsix = [bool]$RunningMsix OtherPwshPaths = @($OtherPaths) @@ -49,6 +57,7 @@ Assert-PwshPlan -State (New-FakePwshState -Msix -Msi) -Migrate -Expected Migrate Assert-PwshPlan -State (New-FakePwshState -Msix -RunningMsix) -Expected Prompt Assert-PwshPlan -State (New-FakePwshState -Msix -RunningMsix) -Migrate -Expected Relaunch Assert-PwshPlan -State (New-FakePwshState -QueryFailed) -Expected UnknownAppx +Assert-PwshPlan -State (New-FakePwshState -QueryFailed -RunningMsix) -Expected Relaunch Assert-PwshPlan -State (New-FakePwshState -QueryFailed -RunningMsix) -Migrate -Expected Relaunch Assert-PwshPlan -State (New-FakePwshState -BrokenMsi) -Expected BrokenMsi Assert-PwshPlan -State (New-FakePwshState -BrokenMsi -Msix) -Migrate -Expected BrokenMsi diff --git a/windows/Dotfiles.WindowsPowerShell.psm1 b/windows/Dotfiles.WindowsPowerShell.psm1 index 3f78021..c52e7ac 100644 --- a/windows/Dotfiles.WindowsPowerShell.psm1 +++ b/windows/Dotfiles.WindowsPowerShell.psm1 @@ -83,7 +83,10 @@ function Get-DotfilesPowerShellPlan { [switch]$ExplicitMigration ) - if (-not $State.MsixQuerySucceeded) { return 'UnknownAppx' } + if (-not $State.MsixQuerySucceeded) { + if ($State.RunningMsix) { return 'Relaunch' } + return 'UnknownAppx' + } if (@($State.OtherPwshPaths).Count -gt 0) { return 'OtherPwsh' } if ($State.MsiFilePresent -and -not $State.MsiPresent) { return 'BrokenMsi' } if (@($State.MsixPackages).Count -gt 0) { From 3f3eab8944502d8fa4e331144b6aa1e69ce1be22 Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Thu, 22 Oct 2026 09:24:31 +0200 Subject: [PATCH 3/4] fix(windows): block MSIX removal while packaged pwsh processes are running --- docs/windows.md | 2 +- windows/Dotfiles.WindowsPowerShell.psm1 | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/windows.md b/docs/windows.md index ab06176..77bef6b 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -170,7 +170,7 @@ For the typical migration from an active MSIX terminal, first close the existing powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\windows\Install-PowerShellMsi.ps1 -Migrate ``` -The helper changes only PowerShell, not your Git configuration, profiles, Node or other packages. Alternatively, from a Windows PowerShell 5.1 session, use the normal package bootstrap with `.\install.ps1 -Force -MigratePowerShellToMsi`. Both paths require explicit migration consent; ordinary `-Force` leaves existing MSIX untouched. Preview operations with `.\windows\Install-PowerShellMsi.ps1 -DryRun` (from the repo root) or `.\install.ps1 -DryRun -Force`. +The helper changes only PowerShell, not your Git configuration, profiles, Node or other packages. Before uninstalling the MSIX package, it also checks for any remaining packaged `pwsh.exe` processes and aborts removal if they are still running. Alternatively, from a Windows PowerShell 5.1 session, use the normal package bootstrap with `.\install.ps1 -Force -MigratePowerShellToMsi`. Both paths require explicit migration consent; ordinary `-Force` leaves existing MSIX untouched. Preview operations with `.\windows\Install-PowerShellMsi.ps1 -DryRun` (from the repo root) or `.\install.ps1 -DryRun -Force`. To verify an MSI installation from **a new Windows/SSH session**: diff --git a/windows/Dotfiles.WindowsPowerShell.psm1 b/windows/Dotfiles.WindowsPowerShell.psm1 index c52e7ac..33853b6 100644 --- a/windows/Dotfiles.WindowsPowerShell.psm1 +++ b/windows/Dotfiles.WindowsPowerShell.psm1 @@ -130,6 +130,25 @@ function Install-DotfilesPwshMsi { } } +function Assert-DotfilesNoRunningPackagedPwsh { + if (-not (Get-Command Get-CimInstance -ErrorAction SilentlyContinue)) { + throw 'Cannot check running MSIX PowerShell processes. Use Windows PowerShell 5.1 with the CIM cmdlets available.' + } + + try { + $packaged = @( + Get-CimInstance -ClassName Win32_Process -Filter "Name = 'pwsh.exe'" -ErrorAction Stop | + Where-Object { $_.ExecutablePath -match '[\\/]WindowsApps[\\/]' } + ) + } catch { + throw 'Cannot inspect running PowerShell processes. MSIX was not removed.' + } + + if ($packaged.Count -gt 0) { + throw 'Another packaged pwsh.exe process is still running. Close Store/MSIX PowerShell windows and retry the migration.' + } +} + function Show-DotfilesPwshRelaunch { param([Parameter(Mandatory = $true)][string]$RepoRoot) $windowsPs = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe' @@ -227,6 +246,7 @@ function Invoke-DotfilesPowerShellBootstrap { if ($state.RunningMsix) { throw 'Refusing to remove the PowerShell MSIX executable currently running this script.' } + Assert-DotfilesNoRunningPackagedPwsh foreach ($package in @($state.MsixPackages)) { Write-Host " Removing current-user MSIX package: $($package.PackageFullName)" Remove-AppxPackage -Package $package.PackageFullName -ErrorAction Stop From bc6d0c1d3bfeae9c3bd85aa0e335fbd61508047c Mon Sep 17 00:00:00 2001 From: Alessandro Digilio Date: Sun, 8 Nov 2026 22:16:53 +0100 Subject: [PATCH 4/4] =?UTF-8?q?fix(windows):=20=F0=9F=90=9B=20update=20Pow?= =?UTF-8?q?erShell=20MSI=20to=207.6.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/windows.md | 2 +- windows/Dotfiles.WindowsPowerShell.psm1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/windows.md b/docs/windows.md index 77bef6b..982ee46 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -154,7 +154,7 @@ A WinGet entry can be either a package ID string or a structured entry with an e The core manifest still lists `Microsoft.PowerShell`, but the installer treats it specially. Starting with PowerShell 7.6, a generic WinGet install selects **MSIX** by default, which exposes `pwsh.exe` through an App Execution Alias in `%LOCALAPPDATA%\Microsoft\WindowsApps`. That alias can fail from Windows OpenSSH or other service logon contexts. For this bootstrap we therefore require the **MSI (wix)** installer, with a stable real executable at `C:\Program Files\PowerShell\7\pwsh.exe`. See [Microsoft's PowerShell installation guide](https://learn.microsoft.com/powershell/scripting/install/install-powershell-on-windows) and [OpenSSH App Execution Alias issue](https://github.com/PowerShell/Win32-OpenSSH/issues/2449). -The current bootstrap targets **7.6.7 MSI** with `--installer-type wix --version 7.6.7.0`. Microsoft has announced that **7.7 and later will not ship MSI installers**, so do not silently switch this path to MSIX during a future upgrade; reassess a fixed-path ZIP/launcher approach for 7.7+. The installed PowerShell 7.6 MSI continues to use the usual supported servicing policy. +The current bootstrap targets **7.6.9 MSI** with `--installer-type wix --version 7.6.9.0`. Microsoft has announced that **7.7 and later will not ship MSI installers**, so do not silently switch this path to MSIX during a future upgrade; reassess a fixed-path ZIP/launcher approach for 7.7+. The installed PowerShell 7.6 MSI continues to use the usual supported servicing policy. Behavior: diff --git a/windows/Dotfiles.WindowsPowerShell.psm1 b/windows/Dotfiles.WindowsPowerShell.psm1 index 33853b6..01c6ca8 100644 --- a/windows/Dotfiles.WindowsPowerShell.psm1 +++ b/windows/Dotfiles.WindowsPowerShell.psm1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest $script:PwshPackageId = 'Microsoft.PowerShell' -$script:PwshMsiVersion = '7.6.7.0' +$script:PwshMsiVersion = '7.6.9.0' function Get-DotfilesPwshMsiPath { $programFiles = [Environment]::GetEnvironmentVariable('ProgramW6432')