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 }