Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/shell-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand Down
43 changes: 41 additions & 2 deletions docs/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand All @@ -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.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:

- **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. 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**:

```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
Expand Down
27 changes: 25 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ param(
[Alias('y')]
[switch]$Force,
[switch]$Minimal,
[switch]$MigratePowerShellToMsi,
[switch]$CleanBackups,
[switch]$Sync,
[switch]$RestoreGitDefaults
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -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'
Expand Down
67 changes: 67 additions & 0 deletions tests/windows-powershell-msi-tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
[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 = @()
)

$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 = @($packages)
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) -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
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.'
12 changes: 11 additions & 1 deletion tests/windows-smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)

Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading