diff --git a/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1 similarity index 65% rename from home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl rename to home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1 index d55bac56..9b890f52 100644 --- a/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1.tmpl +++ b/home/.chezmoiscripts/windows/run_onchange_set-windows-terminal-powershell-args.ps1 @@ -1,4 +1,4 @@ -# {{ if eq .chezmoi.os "windows" }} +#!/usr/bin/env pwsh # Start the Windows Terminal PowerShell profile quietly. # # Two lines of noise appear before anything useful: @@ -18,9 +18,25 @@ # As with the sibling default-profile script, Windows Terminal owns # settings.json and every machine has a different, hand-tuned config, so only # this one profile's commandline is touched. +# +# Deliberately NOT a .tmpl: the signing workflow skips *.ps1.tmpl (a rendered +# template no longer matches its signature), and this script needs no template +# input. The OS guard the sibling templates carry is redundant here because +# .chezmoiignore already drops .chezmoiscripts/windows/** on non-Windows, and +# the module path comes from CHEZMOI_SOURCE_DIR, which chezmoi exports to every +# script it runs. $ErrorActionPreference = "Stop" -$modulePath = "{{ .chezmoi.sourceDir }}\dot_config\powershell\modules\DotfilesHelpers" +# CHEZMOI_SOURCE_DIR arrives with forward slashes (and sometimes in 8.3 form), +# so build the path with Join-Path rather than string concatenation. Falling +# back to the applied tree keeps the script usable when run by hand. +if ($env:CHEZMOI_SOURCE_DIR) { + $modulePath = Join-Path $env:CHEZMOI_SOURCE_DIR "dot_config/powershell/modules/DotfilesHelpers" +} +else { + $modulePath = Join-Path $HOME ".config/powershell/modules/DotfilesHelpers" +} + if (Test-Path $modulePath) { Import-Module $modulePath -Force -DisableNameChecking } @@ -40,4 +56,3 @@ if (-not $results) { elseif (-not ($results | Where-Object { $_.Status -in @('Updated', 'AlreadySet') })) { Write-Host "[SKIP] PowerShell Core profile not found; leaving commandline unchanged" -ForegroundColor Yellow } -# {{ end }} diff --git a/home/dot_config/fastfetch/status.ps1 b/home/dot_config/fastfetch/status.ps1 index eda48224..3b3f9b80 100644 --- a/home/dot_config/fastfetch/status.ps1 +++ b/home/dot_config/fastfetch/status.ps1 @@ -226,10 +226,16 @@ function Write-StatusSection { # UTF-8 without BOM: `cmd /c type` copies the bytes straight through to # fastfetch, so a BOM would show up as stray characters in the banner. + # The temp name is unique so a concurrent writer cannot claim it. $encoding = New-Object System.Text.UTF8Encoding($false) - $tmp = "$file.tmp" - [System.IO.File]::WriteAllText($tmp, ($Value + "`r`n"), $encoding) - Move-Item -LiteralPath $tmp -Destination $file -Force + $tmp = '{0}.{1}.tmp' -f $file, [System.IO.Path]::GetRandomFileName() + try { + [System.IO.File]::WriteAllText($tmp, ($Value + "`r`n"), $encoding) + Move-Item -LiteralPath $tmp -Destination $file -Force + } + finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } } function Invoke-StatusRender { diff --git a/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 index 9c3853e1..8d3ce294 100644 --- a/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 +++ b/home/dot_config/powershell/modules/DotfilesHelpers/Public/FastfetchStatus.ps1 @@ -208,8 +208,14 @@ function Update-FastfetchStatusCache { } # Drop rendered files whose source is gone (section no longer applies). + # Scoped deliberately narrowly: '.tmp' files belong to an in-flight write in + # this or another shell, and deleting one would break its Move-Item. foreach ($stale in @(Get-ChildItem -LiteralPath $CacheDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notlike '*.src' -and $_.Name -notlike '.*' })) { + Where-Object { + $_.Name -notlike '*.src' -and + $_.Name -notlike '*.tmp' -and + $_.Name -notlike '.*' + })) { if (-not (Test-Path -LiteralPath "$($stale.FullName).src")) { if ($PSCmdlet.ShouldProcess($stale.FullName, 'Remove orphaned fastfetch status section')) { Remove-Item -LiteralPath $stale.FullName -Force -ErrorAction SilentlyContinue @@ -344,9 +350,17 @@ function Write-FastfetchStatusFile { $encoding = New-Object System.Text.UTF8Encoding($false) $content = ($Lines -join "`r`n") + "`r`n" - $tmp = "$Path.tmp" - [System.IO.File]::WriteAllText($tmp, $content, $encoding) - Move-Item -LiteralPath $tmp -Destination $Path -Force + # Unique temp name: two shells starting at the same moment both render the + # same section, and a shared '.tmp' would let one process's Move-Item + # pull the file out from under the other's. + $tmp = '{0}.{1}.tmp' -f $Path, [System.IO.Path]::GetRandomFileName() + try { + [System.IO.File]::WriteAllText($tmp, $content, $encoding) + Move-Item -LiteralPath $tmp -Destination $Path -Force + } + finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } } # SIG # Begin signature block diff --git a/home/dot_config/powershell/profile.ps1 b/home/dot_config/powershell/profile.ps1 index 8d1fa66e..3220edfb 100644 --- a/home/dot_config/powershell/profile.ps1 +++ b/home/dot_config/powershell/profile.ps1 @@ -149,7 +149,10 @@ if ([Environment]::UserInteractive -and -not $env:CHEZMOI_SOURCE_DIR) { if (-not $fastfetchProc.WaitForExit($fastfetchTimeoutMs)) { try { $fastfetchProc.Kill() } catch { } Write-Host "`n(fastfetch timed out after $([math]::Round($fastfetchTimeoutMs / 1000, 1))s; skipping)" -ForegroundColor DarkYellow - } else { + } elseif ($fastfetchProc.ExitCode -eq 0) { + # Only a clean run actually painted a banner. A non-zero exit + # (bad config, for instance) leaves the screen empty, so the + # welcome lines below stay as the one sign of life. $script:_fastfetchShown = $true } } catch { diff --git a/tests/powershell/FastfetchStatus.Tests.ps1 b/tests/powershell/FastfetchStatus.Tests.ps1 index 90a041ee..c32412a6 100644 --- a/tests/powershell/FastfetchStatus.Tests.ps1 +++ b/tests/powershell/FastfetchStatus.Tests.ps1 @@ -477,6 +477,29 @@ Describe "fastfetch status module helpers" { Join-Path $script:TestCacheDir 'updates' | Should -Not -Exist } + It "should not delete an in-flight temp file from a concurrent writer" { + # Cleanup used to remove any non-.src file, which could yank a + # '..tmp' out from under another shell's Move-Item. + $inFlight = Join-Path $script:TestCacheDir 'updates.abc12345.tmp' + Set-Content -LiteralPath $inFlight -Value 'half-written' + Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' + + Update-FastfetchStatusCache -SkipRefresh + + $inFlight | Should -Exist + } + + It "should give each write a unique temp name" { + # Two shells starting together both render the same section; a + # shared '.tmp' would make one of the writes fail. + $target = Join-Path $script:TestCacheDir 'concurrent' + Write-FastfetchStatusFile -Path $target -Lines 'one' + Write-FastfetchStatusFile -Path $target -Lines 'two' + + (Get-Content -LiteralPath $target -Raw).Trim() | Should -Be 'two' + @(Get-ChildItem -LiteralPath $script:TestCacheDir -Filter '*.tmp') | Should -BeNullOrEmpty + } + It "should leave the stamp alone" { Set-Content -LiteralPath (Join-Path $script:TestCacheDir 'updates.src') -Value 'line' Update-FastfetchStatusCache -SkipRefresh diff --git a/tests/powershell/Profile.Tests.ps1 b/tests/powershell/Profile.Tests.ps1 index a40385c8..b212f36c 100644 --- a/tests/powershell/Profile.Tests.ps1 +++ b/tests/powershell/Profile.Tests.ps1 @@ -486,6 +486,12 @@ Describe "Profile startup noise" { $script:ProfileContent | Should -Match 'if \(-not \$fastfetchProc\.WaitForExit' } + It "Profile should not mark fastfetch as shown when it exited non-zero" { + # Exiting quickly is not the same as succeeding: a bad config leaves the + # screen blank, and suppressing the welcome lines would look like a hang. + $script:ProfileContent | Should -Match '\$fastfetchProc\.ExitCode -eq 0' + } + It "Profile should keep the welcome lines for installs without fastfetch" { $script:ProfileContent | Should -Match 'PowerShell Profile Loaded' $script:ProfileContent | Should -Match "Type 'aliases' to see available aliases" diff --git a/tests/powershell/WindowsTerminal.Tests.ps1 b/tests/powershell/WindowsTerminal.Tests.ps1 index e4a97f3a..3afd66e1 100644 --- a/tests/powershell/WindowsTerminal.Tests.ps1 +++ b/tests/powershell/WindowsTerminal.Tests.ps1 @@ -484,6 +484,46 @@ Describe "Set-WindowsTerminalProfileCommandLine Function" -Tag "Unit" { } } +Describe "Windows Terminal PowerShell args script" -Tag "Unit" { + BeforeAll { + $script:ArgsScript = Join-Path $script:RepoRoot ` + "home\.chezmoiscripts\windows\run_onchange_set-windows-terminal-powershell-args.ps1" + } + + It "Should exist as a plain .ps1 so the signing workflow can sign it" { + # sign-powershell.yml signs **.ps1 but excludes **.ps1.tmpl, because a + # rendered template no longer matches its signature. + $script:ArgsScript | Should -Exist + "$script:ArgsScript.tmpl" | Should -Not -Exist + } + + It "Should have valid PowerShell syntax" { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script:ArgsScript, [ref]$null, [ref]$errors) | Out-Null + $errors | Should -BeNullOrEmpty + } + + It "Should not contain chezmoi template syntax" { + $content = Get-Content $script:ArgsScript -Raw + $content | Should -Not -Match '\{\{' + } + + It "Should resolve the module through CHEZMOI_SOURCE_DIR" { + $content = Get-Content $script:ArgsScript -Raw + $content | Should -Match 'CHEZMOI_SOURCE_DIR' + # Forward slashes and 8.3 paths arrive in that variable, so the path has + # to be composed rather than concatenated. + $content | Should -Match 'Join-Path' + } + + It "Should quieten both pwsh startup lines" { + $content = Get-Content $script:ArgsScript -Raw + $content | Should -Match '-NoLogo' + $content | Should -Match '-NoProfileLoadTime' + } +} + Describe "Set-WindowsTerminalCopilotProfile Function" -Tag "Unit" { BeforeEach { $script:settingsPath = Join-Path $script:TestDir.FullName "settings-$(Get-Random).json"