From dab9b40447e4942166a8ac7fd1659d2948abe84d Mon Sep 17 00:00:00 2001 From: 26zl <143036376+26zl@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:51:29 +0200 Subject: [PATCH] Read config files as UTF-8 and keep durable profile backups On Windows PowerShell 5.1 a bare Get-Content -Raw decodes with the system ANSI code page, so non-ASCII (e.g. aao) in settings was corrupted and then written back. Profile backups also rotated by time keeping five, so the user's original profile was lost after five installs and Uninstall-Profile restored nothing. - setup.ps1: add -Encoding UTF8 to the Get-Content -Raw reads of user-settings.json, Windows Terminal settings.json, theme.json, terminal-config.json, the OMP theme, and the wizard state file. - setup.ps1: on first install, save a one-time profile.original.ps1 that the oldprofile* rotation never deletes. - Uninstall-Profile: restore profile.original.ps1 instead of just removing the profile. - Update-Profile: back up the current profile before overwriting (rotated, latest five; the immutable original is never matched). - sed: read UTF-8 (BOM-aware) via Get-Utf8FileText and keep a .bak. - README: make the review-first hash flow the recommended default in the install-wizard section; -SkipHashCheck is the unverified fallback. --- Microsoft.PowerShell_profile.ps1 | 31 ++++++++++++++++++++++++++++--- README.md | 11 ++++++++++- setup.ps1 | 23 +++++++++++++++-------- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/Microsoft.PowerShell_profile.ps1 b/Microsoft.PowerShell_profile.ps1 index 3c63221..26fceb6 100644 --- a/Microsoft.PowerShell_profile.ps1 +++ b/Microsoft.PowerShell_profile.ps1 @@ -900,6 +900,18 @@ function Update-Profile { foreach ($dir in $profileDirs) { $target = Join-Path $dir "Microsoft.PowerShell_profile.ps1" if (Test-Path $dir) { + # Back up the current profile before overwriting it, keeping the latest five + # (profile.original.ps1 is never matched, so the first-install copy survives). + if (Test-Path -Path $target -PathType Leaf) { + try { + $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' + Copy-Item -Path $target -Destination (Join-Path $dir "oldprofile.$backupStamp.ps1") -Force -ErrorAction Stop + Get-ChildItem -Path $dir -Filter 'oldprofile*.ps1' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | + ForEach-Object { Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue } + } + catch { Write-Warning "Could not back up $target before update: $_" } + } try { Copy-Item -Path $tempProfile -Destination $target -Force -ErrorAction Stop $copySuccess++ @@ -1991,10 +2003,14 @@ function sed($file, $find, $replace) { } if ($null -eq $find -or $find -eq '') { Write-Error "Usage: sed "; return } if ($null -eq $replace) { $replace = '' } - $content = Get-Content -LiteralPath $file -Raw + $resolved = (Resolve-Path -LiteralPath $file).Path + # Read as UTF-8 (BOM-aware) so non-ASCII content survives the rewrite on Windows PowerShell 5.1. + $content = Get-Utf8FileText $resolved if ($null -eq $content -or $content.Length -eq 0) { Write-Warning "File is empty: $file"; return } $utf8NoBom = [System.Text.UTF8Encoding]::new($false) - [System.IO.File]::WriteAllText((Resolve-Path -LiteralPath $file).Path, $content.replace("$find", $replace), $utf8NoBom) + # Keep a single .bak of the pre-edit content, like sed -i.bak. + Copy-Item -LiteralPath $resolved -Destination "$resolved.bak" -Force -ErrorAction SilentlyContinue + [System.IO.File]::WriteAllText($resolved, $content.replace("$find", $replace), $utf8NoBom) } # Show the full path of a command @@ -3575,7 +3591,16 @@ function Uninstall-Profile { ) foreach ($dir in $profileDirs) { $mainProfile = Join-Path $dir 'Microsoft.PowerShell_profile.ps1' - if (Test-Path $mainProfile) { + $originalBackup = Join-Path $dir 'profile.original.ps1' + if (Test-Path $originalBackup) { + # Restore the pre-install profile saved on first install instead of leaving none behind. + if ($PSCmdlet.ShouldProcess($mainProfile, 'Restore original profile saved before first install')) { + Copy-Item -Path $originalBackup -Destination $mainProfile -Force -ErrorAction SilentlyContinue + Remove-Item $originalBackup -Force -ErrorAction SilentlyContinue + Write-Host " Restored original profile to $mainProfile" -ForegroundColor Green + } + } + elseif (Test-Path $mainProfile) { if ($PSCmdlet.ShouldProcess($mainProfile, 'Remove profile file')) { Remove-Item $mainProfile -Force -ErrorAction SilentlyContinue Write-Host " Removed $mainProfile" -ForegroundColor DarkGray diff --git a/README.md b/README.md index 4d263ab..1bf3b6a 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,15 @@ The **install wizard runs by default** — pick theme / scheme / font / features Inspired by [powerlevel10k](https://github.com/romkatv/powerlevel10k)'s `p10k configure`. **Runs automatically on every interactive install** — it's the default experience, not an opt-in. Non-interactive environments skip it. ```powershell -# Default trusted-download flow — wizard runs as part of installation +# Recommended (review first): run with no hash flag. setup.ps1 downloads the bundle, +# prints its SHA-256 hashes, and stops WITHOUT installing. Review them (ideally against +# the commit list at https://github.com/26zl/PowerShellPerfect/commits/main), then re-run +# pinning the combined hash for a verified install (the wizard runs during install): $setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" +& ([scriptblock]::Create($setup)) # prints hashes, then stops +& ([scriptblock]::Create($setup)) -ExpectedSha256 '' # verified install + +# Unverified fallback: skip the integrity check and install whatever was just downloaded. & ([scriptblock]::Create($setup)) -SkipHashCheck # Bypass the wizard and apply repo defaults (Tokyo Night + CascadiaCode): @@ -63,6 +70,8 @@ $setup = irm "https://github.com/26zl/PowerShellPerfect/raw/main/setup.ps1" Reconfigure-Profile ``` +> Same guarantee as the Quick Install above: the printed hash proves download integrity, not upstream authenticity (see the note there). A signed-release or tag-pinning model would add authenticity and is left as a maintainer follow-up. + **Steps** (all accept Enter to keep the default / skip): 1. **Quick start** — one-key preset: Tokyo Night scheme, CascadiaCode Nerd Font, VS Code editor, dark chrome, default features. Answer yes to jump straight to the summary. diff --git a/setup.ps1 b/setup.ps1 index 30ee655..0e4b804 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -299,7 +299,7 @@ function Save-WizardChoices { ) $utf8 = [System.Text.UTF8Encoding]::new($false) $s = if (Test-Path $UserSettingsPath) { - try { (Get-Content $UserSettingsPath -Raw | ConvertFrom-Json -ErrorAction Stop) } + try { (Get-Content $UserSettingsPath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop) } catch { [PSCustomObject]@{} } } else { [PSCustomObject]@{} } @@ -426,7 +426,7 @@ function Start-InstallWizard { # Resume from state file if caller passed one that exists if ($StatePath -and (Test-Path $StatePath)) { try { - $prev = Get-Content $StatePath -Raw | ConvertFrom-Json + $prev = Get-Content $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json $prevSchema = if ($prev.PSObject.Properties['schemaVersion']) { [int]$prev.schemaVersion } else { 1 } if ($prevSchema -ne $WIZARD_STATE_SCHEMA) { Write-Host '' @@ -1107,7 +1107,7 @@ if ($wantWizard) { try { $configTmp = Join-Path $env:TEMP ("psp-theme-" + [System.IO.Path]::GetRandomFileName() + ".json") Copy-Item (Resolve-SetupSourcePath -Kind 'theme') $configTmp -Force -ErrorAction Stop - $profileConfig = Get-Content $configTmp -Raw | ConvertFrom-Json + $profileConfig = Get-Content $configTmp -Raw -Encoding UTF8 | ConvertFrom-Json Copy-Item $configTmp (Join-Path $configCachePath "theme.json") -Force Remove-Item $configTmp -ErrorAction SilentlyContinue } @@ -1120,7 +1120,7 @@ $terminalConfig = $null try { $terminalConfigTmp = Join-Path $env:TEMP ("psp-terminal-" + [System.IO.Path]::GetRandomFileName() + ".json") Copy-Item (Resolve-SetupSourcePath -Kind 'terminal') $terminalConfigTmp -Force -ErrorAction Stop - $terminalConfig = Get-Content $terminalConfigTmp -Raw | ConvertFrom-Json + $terminalConfig = Get-Content $terminalConfigTmp -Raw -Encoding UTF8 | ConvertFrom-Json Copy-Item $terminalConfigTmp (Join-Path $configCachePath "terminal-config.json") -Force Remove-Item $terminalConfigTmp -ErrorAction SilentlyContinue } @@ -1153,7 +1153,7 @@ $script:WtManaged = $true $userSettingsPath = Join-Path $configCachePath "user-settings.json" if (Test-Path $userSettingsPath) { try { - $userSettings = Get-Content $userSettingsPath -Raw | ConvertFrom-Json + $userSettings = Get-Content $userSettingsPath -Raw -Encoding UTF8 | ConvertFrom-Json # windowsTerminal.manage = false hands settings.json to another tool, such as a dotfiles repo. if ($userSettings.windowsTerminal -and $userSettings.windowsTerminal.PSObject.Properties['manage'] -and $userSettings.windowsTerminal.manage -eq $false) { $script:WtManaged = $false } if ($profileConfig -and $userSettings.theme) { @@ -1217,6 +1217,13 @@ foreach ($dir in $profileDirs) { $tempDownload = Join-Path $env:TEMP ("psp-profile_download_" + (Split-Path $dir -Leaf) + "_" + [System.IO.Path]::GetRandomFileName() + ".ps1") Copy-Item (Resolve-SetupSourcePath -Kind 'profile') $tempDownload -Force -ErrorAction Stop if (Test-Path -Path $targetProfile -PathType Leaf) { + # Preserve the pre-install profile once as an immutable copy that rotation never deletes + # and Uninstall-Profile restores; oldprofile* rotation below never matches this name. + $originalBackup = Join-Path $dir 'profile.original.ps1' + if (-not (Test-Path -Path $originalBackup)) { + Copy-Item -Path $targetProfile -Destination $originalBackup -Force + Write-Host " Original profile saved to [$originalBackup]" -ForegroundColor DarkGray + } # Keep the latest five timestamped profile backups. $backupStamp = Get-Date -Format 'yyyyMMdd-HHmmss' $backupPath = Join-Path $dir ("oldprofile.$backupStamp.ps1") @@ -1402,13 +1409,13 @@ function Install-OhMyPoshTheme { $alreadyValid = $false if (Test-Path -LiteralPath $themeFilePath -PathType Leaf) { try { - $null = Get-Content $themeFilePath -Raw -ErrorAction Stop | ConvertFrom-Json + $null = Get-Content $themeFilePath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json $alreadyValid = $true } catch { $null = $_ } } if (-not $alreadyValid) { Invoke-DownloadWithRetry -Uri $ThemeUrl -OutFile $themeFilePath - $null = Get-Content $themeFilePath -Raw | ConvertFrom-Json + $null = Get-Content $themeFilePath -Raw -Encoding UTF8 | ConvertFrom-Json Write-Host " Theme '$ThemeName' downloaded." -ForegroundColor Green } else { @@ -1606,7 +1613,7 @@ foreach ($wtSettingsPath in $wtSettingsPaths) { $wt = $null for ($wtAttempt = 1; $wtAttempt -le 2; $wtAttempt++) { try { - $wtRaw = (Get-Content $wtSettingsPath -Raw) -replace $jsoncCommentPattern, '' + $wtRaw = (Get-Content $wtSettingsPath -Raw -Encoding UTF8) -replace $jsoncCommentPattern, '' $wt = $wtRaw | ConvertFrom-Json break }