Skip to content

Commit d6937b6

Browse files
committed
fix: preserve MSI oh-my-posh installs and harden tool resolution
1 parent 8a4f945 commit d6937b6

5 files changed

Lines changed: 198 additions & 43 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ All code must work under both PowerShell 5.1 and 7+. Key differences:
4040
### Adding a New Tool
4141

4242
1. Add an entry to the `$script:ProfileTools` array in `Microsoft.PowerShell_profile.ps1` with `Name`, `Id` (winget), `Cmd`, `Cache`, and `VerCmd`
43+
Also set `UpgradeStrategy` (`winget` for normal tools, `preserve-direct` only when a direct/MSI install must not be pushed back through winget).
4344
2. Add a numbered install step in `setup.ps1` (it cannot read `ProfileTools`)
4445

4546
## Running the Linter

Microsoft.PowerShell_profile.ps1

Lines changed: 138 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@ if ($isAdmin -and -not [System.Environment]::GetEnvironmentVariable('POWERSHELL_
3838
# Cache: init-script filename in $cacheDir that must be deleted when the tool is upgraded (or $null).
3939
# VerCmd: argument(s) to get the tool version for pre/post-upgrade display.
4040
$script:ProfileTools = @(
41-
@{ Name = "Oh My Posh"; Id = "JanDeDobbeleer.OhMyPosh"; Cmd = "oh-my-posh"; Cache = $null; VerCmd = "version" }
42-
@{ Name = "eza"; Id = "eza-community.eza"; Cmd = "eza"; Cache = $null; VerCmd = "--version" }
43-
@{ Name = "zoxide"; Id = "ajeetdsouza.zoxide"; Cmd = "zoxide"; Cache = "zoxide-init.ps1"; VerCmd = "--version" }
44-
@{ Name = "fzf"; Id = "junegunn.fzf"; Cmd = "fzf"; Cache = $null; VerCmd = "--version" }
45-
@{ Name = "bat"; Id = "sharkdp.bat"; Cmd = "bat"; Cache = $null; VerCmd = "--version" }
46-
@{ Name = "ripgrep"; Id = "BurntSushi.ripgrep.MSVC"; Cmd = "rg"; Cache = $null; VerCmd = "--version" }
41+
@{ Name = "Oh My Posh"; Id = "JanDeDobbeleer.OhMyPosh"; Cmd = "oh-my-posh"; Cache = $null; VerCmd = "version"; UpgradeStrategy = "preserve-direct" }
42+
@{ Name = "eza"; Id = "eza-community.eza"; Cmd = "eza"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" }
43+
@{ Name = "zoxide"; Id = "ajeetdsouza.zoxide"; Cmd = "zoxide"; Cache = "zoxide-init.ps1"; VerCmd = "--version"; UpgradeStrategy = "winget" }
44+
@{ Name = "fzf"; Id = "junegunn.fzf"; Cmd = "fzf"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" }
45+
@{ Name = "bat"; Id = "sharkdp.bat"; Cmd = "bat"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" }
46+
@{ Name = "ripgrep"; Id = "BurntSushi.ripgrep.MSVC"; Cmd = "rg"; Cache = $null; VerCmd = "--version"; UpgradeStrategy = "winget" }
4747
)
4848

4949
# Run a scriptblock in a job with timeout; returns result or $null on timeout/failure.
@@ -59,11 +59,10 @@ function Invoke-WithTimeout {
5959
try {
6060
$job = Start-Job -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
6161
$null = Wait-Job $job -Timeout $TimeoutSec
62-
if ($job.State -eq 'Running') {
63-
Stop-Job $job -ErrorAction SilentlyContinue
62+
if ($job.State -ne 'Completed') {
63+
if ($job.State -eq 'Running') { Stop-Job $job -ErrorAction SilentlyContinue }
6464
return $null
6565
}
66-
if ($job.State -eq 'Failed') { return $null }
6766
Receive-Job $job
6867
}
6968
catch { return $null }
@@ -120,6 +119,25 @@ function Get-ExternalCommandPath {
120119
return $null
121120
}
122121

122+
# Merge PSCustomObject overrides recursively so nested user/theme/terminal keys are preserved.
123+
function Merge-JsonObject {
124+
param(
125+
$base,
126+
$override
127+
)
128+
129+
if (-not $base -or -not $override) { return }
130+
foreach ($prop in $override.PSObject.Properties) {
131+
$baseVal = $base.PSObject.Properties[$prop.Name]
132+
if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) {
133+
Merge-JsonObject $baseVal.Value $prop.Value
134+
}
135+
else {
136+
$base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
137+
}
138+
}
139+
}
140+
123141
# Specific helper to get the path to oh-my-posh executable for cache clearing (since it has a built-in cache clear command instead of a file-based cache)
124142
function Get-OhMyPoshExecutablePath {
125143
$resolvedPath = Get-ExternalCommandPath -CommandName 'oh-my-posh'
@@ -153,6 +171,70 @@ function Get-OhMyPoshExecutablePath {
153171
return $null
154172
}
155173

174+
# Return OMP install path and kind (windowsapps vs direct) for upgrade logic
175+
function Get-OhMyPoshInstallInfo {
176+
$path = Get-OhMyPoshExecutablePath
177+
if (-not $path) {
178+
return [PSCustomObject]@{
179+
Path = $null
180+
InstallKind = 'missing'
181+
}
182+
}
183+
184+
$windowsAppsRoot = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps'
185+
$installKind = if ($path -like "$windowsAppsRoot*") { 'windowsapps' } else { 'direct' }
186+
return [PSCustomObject]@{
187+
Path = $path
188+
InstallKind = $installKind
189+
}
190+
}
191+
192+
# Resolve executable path for a profile tool (OMP uses Get-OhMyPoshInstallInfo, others use Get-Command)
193+
function Get-ProfileToolExecutablePath {
194+
param(
195+
[Parameter(Mandatory)]
196+
[hashtable]$Tool
197+
)
198+
199+
if ($Tool.Cmd -eq 'oh-my-posh') {
200+
return (Get-OhMyPoshInstallInfo).Path
201+
}
202+
203+
return Get-ExternalCommandPath -CommandName $Tool.Cmd
204+
}
205+
206+
# Get version string for a profile tool by running its VerCmd
207+
function Get-ProfileToolVersionText {
208+
param(
209+
[Parameter(Mandatory)]
210+
[hashtable]$Tool,
211+
[Parameter(Mandatory)]
212+
[string]$ExecutablePath
213+
)
214+
215+
$versionArgs = @()
216+
if ($Tool.VerCmd -is [System.Array]) {
217+
$versionArgs = @($Tool.VerCmd)
218+
}
219+
elseif (-not [string]::IsNullOrWhiteSpace([string]$Tool.VerCmd)) {
220+
$versionArgs = @([string]$Tool.VerCmd)
221+
}
222+
223+
try {
224+
$versionLine = & $ExecutablePath @versionArgs 2>$null |
225+
Where-Object { $_ -match '\d+\.\d+' } |
226+
Select-Object -First 1
227+
if ($versionLine) {
228+
return $versionLine.Trim()
229+
}
230+
}
231+
catch {
232+
return $null
233+
}
234+
235+
return $null
236+
}
237+
156238
# Invoke oh-my-posh with explicit UTF-8 stdio and explicit arguments so prompt rendering
157239
# never depends on opaque internal init/cache state.
158240
function Invoke-OhMyPoshCommand {
@@ -216,7 +298,7 @@ function Invoke-OhMyPoshCommand {
216298
}
217299
}
218300

219-
# Gather context for oh-my-posh prompt rendering, including error code, execution time, stack count, terminal width, and non-filesystem working directory.
301+
# Gather context for oh-my-posh prompt rendering, including error code, execution time, stack count, terminal width, and non-filesystem working directory.
220302
# This is used to provide consistent context to oh-my-posh for prompt rendering without relying on opaque internal state or caches.
221303
function Get-OhMyPoshPromptContext {
222304
param(
@@ -305,12 +387,12 @@ function Get-OhMyPoshPromptContext {
305387
return [PSCustomObject]$context
306388
}
307389

308-
# Get the prompt text from oh-my-posh by invoking the executable with explicit arguments and context.
390+
# Get the prompt text from oh-my-posh by invoking the executable with explicit arguments and context.
309391
# This avoids relying on opaque internal state or caches for prompt rendering, and allows consistent prompts even in non-interactive contexts (like SSH or CI) where init scripts may not run.
310392
function Get-OhMyPoshPromptText {
311393
param(
312394
[Parameter(Mandatory)]
313-
[ValidateSet('primary', 'secondary', 'transient', 'debug', 'right', 'tooltip', 'valid', 'error', 'preview')]
395+
[ValidateSet('primary', 'secondary')]
314396
[string]$Type,
315397
[Parameter(Mandatory)]
316398
[string]$ExecutablePath,
@@ -334,7 +416,7 @@ function Get-OhMyPoshPromptText {
334416
"--shell-version=$($PSVersionTable.PSVersion.ToString())"
335417
)
336418

337-
if ($Type -eq 'primary' -or $Type -eq 'debug' -or $Type -eq 'transient') {
419+
if ($Type -eq 'primary') {
338420
$context = Get-OhMyPoshPromptContext -OriginalSuccess:$OriginalSuccess -OriginalLastExitCode $OriginalLastExitCode
339421
$arguments += @(
340422
"--status=$($context.ErrorCode)"
@@ -606,20 +688,6 @@ function Update-Profile {
606688
}
607689
}
608690

609-
# Merge helper - deep-merges PSCustomObjects so nested keys are preserved
610-
function Merge-JsonObject($base, $override) {
611-
if (-not $base -or -not $override) { return }
612-
foreach ($prop in $override.PSObject.Properties) {
613-
$baseVal = $base.PSObject.Properties[$prop.Name]
614-
if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) {
615-
Merge-JsonObject $baseVal.Value $prop.Value
616-
}
617-
else {
618-
$base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force
619-
}
620-
}
621-
}
622-
623691
# Apply user-settings.json overrides (never downloaded, never overwritten)
624692
if (Test-Path $userSettingsPath) {
625693
try {
@@ -903,7 +971,7 @@ function Update-Profile {
903971

904972
# Phase 7: Install missing tools
905973
if (Get-Command winget -ErrorAction SilentlyContinue) {
906-
$missing = $script:ProfileTools | Where-Object { -not (Get-Command $_.Cmd -ErrorAction SilentlyContinue) }
974+
$missing = $script:ProfileTools | Where-Object { -not (Get-ProfileToolExecutablePath -Tool $_) }
907975
if ($missing) {
908976
Write-Host "Installing missing tools..." -ForegroundColor Cyan
909977
$installedTools = @()
@@ -1031,24 +1099,52 @@ function Update-PowerShell {
10311099
}
10321100
}
10331101
}
1034-
# Update installed profile tools via winget (skips tools not present on this machine)
1102+
# Update installed profile tools via winget, while preserving direct/MSI Oh My Posh installs.
10351103
function Update-Tools {
1036-
$installed = $script:ProfileTools | Where-Object { Get-Command $_.Cmd -ErrorAction SilentlyContinue }
1104+
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
1105+
Write-Warning "winget not found. Update-Tools only supports winget-managed upgrades."
1106+
return
1107+
}
1108+
1109+
$installed = foreach ($tool in $script:ProfileTools) {
1110+
$toolPath = Get-ProfileToolExecutablePath -Tool $tool
1111+
if ($toolPath) {
1112+
[PSCustomObject]@{
1113+
Tool = $tool
1114+
ExecutablePath = $toolPath
1115+
}
1116+
}
1117+
}
1118+
10371119
if (-not $installed) {
10381120
Write-Host "No profile tools detected. Run Update-Profile to install them." -ForegroundColor Yellow
10391121
return
10401122
}
10411123
$upgraded = 0
10421124
$failed = 0
1043-
foreach ($tool in $installed) {
1125+
$preserved = 0
1126+
foreach ($toolEntry in $installed) {
1127+
$tool = $toolEntry.Tool
1128+
$toolPath = $toolEntry.ExecutablePath
1129+
1130+
if ($tool.UpgradeStrategy -eq 'preserve-direct' -and $tool.Cmd -eq 'oh-my-posh') {
1131+
$ompInstall = Get-OhMyPoshInstallInfo
1132+
if ($ompInstall.InstallKind -eq 'direct') {
1133+
Write-Host "Skipping $($tool.Name) update to preserve direct/MSI install at $($ompInstall.Path)." -ForegroundColor DarkGray
1134+
$preserved++
1135+
continue
1136+
}
1137+
}
1138+
10441139
# Capture pre-upgrade version
1045-
$oldVer = try { (& $tool.Cmd $tool.VerCmd 2>$null | Where-Object { $_ -match '\d+\.\d+' } | Select-Object -First 1).Trim() } catch { $null }
1140+
$oldVer = Get-ProfileToolVersionText -Tool $tool -ExecutablePath $toolPath
10461141
Write-Host "Updating $($tool.Name)..." -ForegroundColor Cyan
10471142
winget upgrade --id $tool.Id --accept-source-agreements --accept-package-agreements
10481143
if ($LASTEXITCODE -eq 0) {
10491144
# Refresh PATH so the new binary is found for version check
10501145
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('PATH', 'User')
1051-
$newVer = try { (& $tool.Cmd $tool.VerCmd 2>$null | Where-Object { $_ -match '\d+\.\d+' } | Select-Object -First 1).Trim() } catch { $null }
1146+
$newToolPath = Get-ProfileToolExecutablePath -Tool $tool
1147+
$newVer = if ($newToolPath) { Get-ProfileToolVersionText -Tool $tool -ExecutablePath $newToolPath } else { $null }
10521148
if ($newVer -and $oldVer -and $newVer -ne $oldVer) {
10531149
Write-Host " $($tool.Name): $oldVer -> $newVer" -ForegroundColor Green
10541150
if ($tool.Cache) {
@@ -1069,7 +1165,12 @@ function Update-Tools {
10691165
Write-Warning "$failed tool(s) failed to update. Check the output above."
10701166
}
10711167
if ($upgraded -eq 0 -and $failed -eq 0) {
1072-
Write-Host "All tools are up to date." -ForegroundColor Green
1168+
if ($preserved -gt 0) {
1169+
Write-Host "All winget-managed tools are up to date. $preserved tool(s) were preserved." -ForegroundColor Green
1170+
}
1171+
else {
1172+
Write-Host "All tools are up to date." -ForegroundColor Green
1173+
}
10731174
}
10741175
}
10751176

@@ -2949,6 +3050,7 @@ $scriptblock = {
29493050
'deno' = @('run', 'compile', 'test', 'lint', 'fmt', 'cache', 'info', 'doc', 'upgrade')
29503051
}
29513052

3053+
if (-not $commandAst.CommandElements -or $commandAst.CommandElements.Count -eq 0) { return }
29523054
$command = $commandAst.CommandElements[0].Value
29533055
if ($customCompletions.ContainsKey($command)) {
29543056
$customCompletions[$command] | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
@@ -3095,7 +3197,7 @@ if ($isInteractive) {
30953197
}
30963198
}
30973199
else {
3098-
Write-Warning "oh-my-posh not found. Install it with: winget install JanDeDobbeleer.OhMyPosh"
3200+
Write-Warning "oh-my-posh not found. Install the MSI build or use: winget install JanDeDobbeleer.OhMyPosh"
30993201
}
31003202
}
31013203

@@ -3187,7 +3289,7 @@ ${g}Edit-Profile${r} / ${g}ep${r} - Open profile in preferred editor.
31873289
${g}edit${r} <file> - Open file in preferred editor.
31883290
${g}Update-Profile${r} - Sync profile, theme, caches, and WT settings. Use -Force to re-apply.
31893291
${g}Update-PowerShell${r} - Check for new PowerShell releases.
3190-
${g}Update-Tools${r} - Update Oh My Posh, eza, zoxide, fzf, bat, and ripgrep.
3292+
${g}Update-Tools${r} - Update winget-managed tools; direct/MSI Oh My Posh installs are preserved.
31913293
${g}Show-Help${r} - Show this help message.
31923294
${g}reload${r} - Reload the PowerShell profile.
31933295
${g}Clear-ProfileCache${r} - Reset profile caches plus OMP internal caches.

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ A PowerShell profile made to make a CLI nerd's life easier. Brings the Linux ter
1111
- **AI/CI sandbox safe** - detects non-interactive environments and suppresses network calls and UI setup automatically
1212
- **PS5 + PS7** - installs to both profile directories and handles every API difference between editions
1313
- **Hardened** - sensitive commands filtered from PSReadLine history; no secrets in source
14-
- **Fast startup** - init scripts cached to disk
14+
- **Fast startup** - prompt renders directly from a local Oh My Posh theme; only zoxide init is cached
1515
- **Survives updates** - personal overrides in `profile_user.ps1` and `user-settings.json` are never touched
1616

1717
Originally forked and inspired by [ChrisTitusTech/powershell-profile](https://github.com/ChrisTitusTech/powershell-profile).
@@ -26,6 +26,10 @@ irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" | iex
2626

2727
The terminal restarts automatically when setup finishes (new tab in Windows Terminal, or new window otherwise). For the best experience use [PowerShell 7](https://github.com/PowerShell/PowerShell).
2828

29+
> **Recommended for Oh My Posh:** Install the x64 MSI release manually instead of relying on `winget`/Store (`WindowsApps`/MSIX). This repo preserves a direct MSI install and avoids the WindowsApps path when possible.
30+
31+
If Oh My Posh is already installed directly via MSI, setup preserves that install instead of forcing it back through the WindowsApps/MSIX path.
32+
2933
### Manual Setup
3034

3135
```powershell
@@ -53,7 +57,7 @@ When running locally you can override terminal defaults (not available via `irm
5357
```powershell
5458
Update-Profile # Sync profile, theme, caches, and Windows Terminal settings
5559
Update-PowerShell # Check for new PowerShell 7 releases
56-
Update-Tools # Update all managed tools (Oh My Posh, eza, zoxide, fzf, bat, ripgrep)
60+
Update-Tools # Update winget-managed tools; direct/MSI Oh My Posh installs are preserved
5761
```
5862
5963
`Update-Profile` requires hash verification by default. Confirm with `-ExpectedSha256 '<hash>'`, or use `-SkipHashCheck` to bypass. Use `-Force` to re-apply settings even when nothing changed upstream.
@@ -108,7 +112,7 @@ Run `Show-Help` in your terminal for a colored version of this list.
108112
| `Edit-Profile` / `ep` | Open profile in preferred editor |
109113
| `Update-Profile` | Sync profile, theme, caches, and WT settings |
110114
| `Update-PowerShell` | Check for new PowerShell 7 releases |
111-
| `Update-Tools` | Update Oh My Posh, eza, zoxide, fzf, bat, and ripgrep |
115+
| `Update-Tools` | Update winget-managed tools; direct/MSI Oh My Posh installs are preserved |
112116
| `reload` | Reload the PowerShell profile |
113117
| `Show-Help` | Show help in terminal |
114118
| `Uninstall-Profile` | Remove profile, caches, and WT changes (`-All` for everything) |

ci-functional.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,10 @@ Invoke-TestCase -Name 'Coverage audit against profile exports' -Code {
779779
# Internal helper functions that are not direct end-user commands
780780
$internalOnly = @(
781781
'Get-ExternalCommandPath'
782+
'Get-OhMyPoshInstallInfo'
782783
'Get-OhMyPoshExecutablePath'
784+
'Get-ProfileToolExecutablePath'
785+
'Get-ProfileToolVersionText'
783786
'Invoke-OhMyPoshCommand'
784787
'Get-OhMyPoshPromptContext'
785788
'Get-OhMyPoshPromptText'

0 commit comments

Comments
 (0)