feat: add functions for oh-my-posh command invocation and prompt cont… #18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| jobs: | |
| lint: | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Install PSScriptAnalyzer | |
| shell: pwsh | |
| run: Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.24.0 -Force -Scope CurrentUser | |
| - name: Run PSScriptAnalyzer | |
| shell: pwsh | |
| run: | | |
| $results = Invoke-ScriptAnalyzer -Path . -Recurse -ExcludeRule @( | |
| 'PSAvoidUsingWriteHost' # Profile needs colored console output | |
| 'PSAvoidUsingWMICmdlet' # PS5 compatibility path in uptime | |
| 'PSUseShouldProcessForStateChangingFunctions' # Overkill for a profile | |
| 'PSUseBOMForUnicodeEncodedFile' # Not needed | |
| 'PSReviewUnusedParameter' # Scriptblock params required by completer API | |
| 'PSUseSingularNouns' # Style preference | |
| ) | Where-Object { $_.ScriptName -ne 'ci-functional.ps1' } | |
| # ci-functional.ps1 is excluded: it intentionally calls profile aliases (gc, ls, cat, uptime, | |
| # eventlog) by their short names to exercise the profile's own function definitions. | |
| $results | Format-Table -AutoSize | |
| if ($results | Where-Object Severity -in 'Error','Warning') { | |
| Write-Error "PSScriptAnalyzer found warnings or errors" | |
| exit 1 | |
| } | |
| - name: Smoke-test profile (non-interactive) | |
| shell: pwsh | |
| run: | | |
| $env:CI = 'true' | |
| pwsh -NonInteractive -NoProfile -Command ". '${{ github.workspace }}/Microsoft.PowerShell_profile.ps1'" | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Profile failed to load non-interactive (exit code: $LASTEXITCODE)" | |
| exit 1 | |
| } | |
| Write-Host "Profile loaded successfully in non-interactive mode." -ForegroundColor Green | |
| - name: PS5 parse-check (all .ps1 files) | |
| shell: powershell | |
| run: | | |
| Write-Host "PS5 version: $($PSVersionTable.PSVersion)" -ForegroundColor Cyan | |
| $errors = 0 | |
| $files = Get-ChildItem -Path '${{ github.workspace }}' -Filter *.ps1 -Recurse | |
| foreach ($file in $files) { | |
| $tokens = $null; $parseErrors = $null | |
| [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors) | Out-Null | |
| if ($parseErrors.Count -gt 0) { | |
| Write-Host "FAIL: $($file.FullName)" -ForegroundColor Red | |
| foreach ($pe in $parseErrors) { | |
| Write-Host " Line $($pe.Extent.StartLineNumber) Col $($pe.Extent.StartColumnNumber): $($pe.Message)" -ForegroundColor Red | |
| Write-Host " Text: $($pe.Extent.Text)" -ForegroundColor Yellow | |
| } | |
| $errors++ | |
| } else { | |
| Write-Host "OK: $($file.Name)" -ForegroundColor Green | |
| } | |
| } | |
| if ($errors -gt 0) { exit 1 } | |
| - name: Check for hardcoded paths | |
| shell: pwsh | |
| run: | | |
| # Regex patterns for matching hardcoded user paths (used directly with -match, no extra escaping) | |
| $patterns = @( | |
| 'C:\\Users\\' | |
| 'C:/Users/' | |
| '/home/' | |
| '\\\\Users\\\\' | |
| ) | |
| $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' } | |
| $found = @() | |
| foreach ($file in $files) { | |
| $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue | |
| for ($i = 0; $i -lt $lines.Count; $i++) { | |
| foreach ($pattern in $patterns) { | |
| if ($lines[$i] -match $pattern) { | |
| $found += [PSCustomObject]@{ | |
| File = $file.Name | |
| Line = $i + 1 | |
| Match = $lines[$i].Trim() | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if ($found) { | |
| $found | Format-Table -AutoSize | |
| Write-Error "Hardcoded user paths found" | |
| exit 1 | |
| } | |
| Write-Host "No hardcoded paths found." -ForegroundColor Green | |
| - name: Check for non-ASCII characters in scripts | |
| shell: pwsh | |
| run: | | |
| $files = Get-ChildItem -Path '${{ github.workspace }}' -Filter *.ps1 -Recurse | |
| $found = @() | |
| foreach ($file in $files) { | |
| $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue | |
| for ($i = 0; $i -lt $lines.Count; $i++) { | |
| if ($lines[$i] -match '[^\x00-\x7E]') { | |
| $found += [PSCustomObject]@{ | |
| File = $file.Name | |
| Line = $i + 1 | |
| Match = $lines[$i].Trim() | |
| } | |
| } | |
| } | |
| } | |
| if ($found) { | |
| $found | Format-Table -AutoSize | |
| Write-Error "Non-ASCII characters found in .ps1 files (em dashes, smart quotes, etc. break PS5)" | |
| exit 1 | |
| } | |
| Write-Host "No non-ASCII characters found." -ForegroundColor Green | |
| - name: Check for UTF-8 BOM | |
| shell: pwsh | |
| run: | | |
| $files = Get-ChildItem -Recurse -Include *.ps1,*.json | Where-Object { $_.FullName -notlike '*\.git\*' } | |
| $found = @() | |
| foreach ($file in $files) { | |
| $bytes = [System.IO.File]::ReadAllBytes($file.FullName) | |
| if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { | |
| $found += $file.Name | |
| } | |
| } | |
| if ($found) { | |
| $found | ForEach-Object { Write-Host "BOM found: $_" -ForegroundColor Red } | |
| Write-Error "UTF-8 BOM detected. Use [System.IO.File]::WriteAllText() with UTF8Encoding(false)" | |
| exit 1 | |
| } | |
| Write-Host "No UTF-8 BOM found." -ForegroundColor Green | |
| - name: Check for Set-Content -Encoding UTF8 | |
| shell: pwsh | |
| run: | | |
| $files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' } | |
| $found = @() | |
| foreach ($file in $files) { | |
| $lines = Get-Content $file.FullName -ErrorAction SilentlyContinue | |
| for ($i = 0; $i -lt $lines.Count; $i++) { | |
| if ($lines[$i] -match 'Set-Content\s.*-Encoding\s+UTF8' -and $lines[$i] -notmatch '^\s*#') { | |
| $found += [PSCustomObject]@{ | |
| File = $file.Name | |
| Line = $i + 1 | |
| Match = $lines[$i].Trim() | |
| } | |
| } | |
| } | |
| } | |
| if ($found) { | |
| $found | Format-Table -AutoSize | |
| Write-Error "Set-Content -Encoding UTF8 writes BOM on PS5. Use [System.IO.File]::WriteAllText() instead" | |
| exit 1 | |
| } | |
| Write-Host "No Set-Content -Encoding UTF8 found." -ForegroundColor Green | |
| - name: Check for secrets | |
| shell: pwsh | |
| run: | | |
| $patterns = @( | |
| '(?i)(api[_-]?key|apikey)\s*[:=]\s*[''"][A-Za-z0-9+/=]{16,}[''"]' | |
| '(?i)(secret|token|password)\s*[:=]\s*[''"][^''"]{8,}[''"]' | |
| '(?i)(aws_access_key_id|aws_secret_access_key)\s*[:=]' | |
| 'ghp_[A-Za-z0-9]{36}' | |
| 'github_pat_[A-Za-z0-9_]{82}' | |
| 'sk-[A-Za-z0-9]{32,}' | |
| '(?i)connectionstring\s*[:=]\s*[''"]Server=' | |
| ) | |
| $files = Get-ChildItem -Recurse -Include *.ps1,*.md,*.yml,*.json | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.FullName -notlike '*\.github\workflows\*' } | |
| $found = @() | |
| foreach ($file in $files) { | |
| $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue | |
| if (-not $content) { continue } | |
| foreach ($pattern in $patterns) { | |
| if ($content -match $pattern) { | |
| $found += [PSCustomObject]@{ | |
| File = $file.Name | |
| Pattern = $pattern.Substring(0, [Math]::Min(40, $pattern.Length)) + '...' | |
| } | |
| } | |
| } | |
| } | |
| if ($found) { | |
| $found | Format-Table -AutoSize | |
| Write-Error "Potential secrets found" | |
| exit 1 | |
| } | |
| Write-Host "No secrets found." -ForegroundColor Green | |
| install-flow: | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Validate JSON configs parse correctly | |
| shell: pwsh | |
| run: | | |
| $errors = 0 | |
| foreach ($file in @('theme.json', 'terminal-config.json')) { | |
| try { | |
| $null = Get-Content $file -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop | |
| Write-Host "OK: $file" -ForegroundColor Green | |
| } catch { | |
| Write-Host "FAIL: $file - $_" -ForegroundColor Red | |
| $errors++ | |
| } | |
| } | |
| if ($errors -gt 0) { exit 1 } | |
| - name: Validate config schema (required keys) | |
| shell: pwsh | |
| run: | | |
| $theme = Get-Content 'theme.json' -Raw | ConvertFrom-Json | |
| $terminal = Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json | |
| $errors = @() | |
| # theme.json must have theme.name and theme.url | |
| if (-not $theme.theme.name) { $errors += 'theme.json: missing theme.name' } | |
| if (-not $theme.theme.url) { $errors += 'theme.json: missing theme.url' } | |
| # terminal-config.json must have defaults and fontInstall | |
| if (-not $terminal.defaults) { $errors += 'terminal-config.json: missing defaults' } | |
| if (-not $terminal.fontInstall) { $errors += 'terminal-config.json: missing fontInstall' } | |
| if (-not $terminal.fontInstall.name) { $errors += 'terminal-config.json: missing fontInstall.name' } | |
| if (-not $terminal.fontInstall.displayName) { $errors += 'terminal-config.json: missing fontInstall.displayName' } | |
| if (-not $terminal.fontInstall.version) { $errors += 'terminal-config.json: missing fontInstall.version' } | |
| if ($errors.Count -gt 0) { | |
| $errors | ForEach-Object { Write-Host "FAIL: $_" -ForegroundColor Red } | |
| exit 1 | |
| } | |
| Write-Host "Config schema valid." -ForegroundColor Green | |
| - name: Dry-run setup.ps1 (parse + function definitions) | |
| shell: pwsh | |
| run: | | |
| # Source setup.ps1 functions without running the main install flow | |
| # by extracting and testing the helper functions in isolation | |
| $content = Get-Content 'setup.ps1' -Raw | |
| # Verify required functions are defined in setup.ps1 | |
| $requiredFunctions = @('Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', 'Invoke-DownloadWithRetry') | |
| $errors = 0 | |
| foreach ($fn in $requiredFunctions) { | |
| if ($content -notmatch "function\s+$fn\b") { | |
| Write-Host "FAIL: setup.ps1 missing function $fn" -ForegroundColor Red | |
| $errors++ | |
| } else { | |
| Write-Host "OK: $fn defined" -ForegroundColor Green | |
| } | |
| } | |
| if ($errors -gt 0) { exit 1 } | |
| - name: Test Merge-JsonObject logic | |
| shell: pwsh | |
| run: | | |
| # Extract Merge-JsonObject from setup.ps1 and test it (with null guard like production) | |
| function Merge-JsonObject($base, $override) { | |
| if (-not $base -or -not $override) { return } | |
| foreach ($prop in $override.PSObject.Properties) { | |
| $baseVal = $base.PSObject.Properties[$prop.Name] | |
| if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) { | |
| Merge-JsonObject $baseVal.Value $prop.Value | |
| } else { | |
| $base | Add-Member -NotePropertyName $prop.Name -NotePropertyValue $prop.Value -Force | |
| } | |
| } | |
| } | |
| # Test 1: flat merge | |
| $base = [PSCustomObject]@{ a = 1; b = 2 } | |
| $override = [PSCustomObject]@{ b = 99; c = 3 } | |
| Merge-JsonObject $base $override | |
| if ($base.a -ne 1 -or $base.b -ne 99 -or $base.c -ne 3) { | |
| Write-Error "Flat merge failed: $($base | ConvertTo-Json -Compress)" | |
| exit 1 | |
| } | |
| Write-Host "OK: flat merge" -ForegroundColor Green | |
| # Test 2: deep merge preserves nested keys | |
| $base = [PSCustomObject]@{ font = [PSCustomObject]@{ face = "Consolas"; size = 11 }; opacity = 75 } | |
| $override = [PSCustomObject]@{ font = [PSCustomObject]@{ size = 14 } } | |
| Merge-JsonObject $base $override | |
| if ($base.font.face -ne "Consolas" -or $base.font.size -ne 14 -or $base.opacity -ne 75) { | |
| Write-Error "Deep merge failed: $($base | ConvertTo-Json -Compress)" | |
| exit 1 | |
| } | |
| Write-Host "OK: deep merge" -ForegroundColor Green | |
| # Test 3: override replaces non-object with object | |
| $base = [PSCustomObject]@{ theme = "simple" } | |
| $override = [PSCustomObject]@{ theme = [PSCustomObject]@{ name = "pure" } } | |
| Merge-JsonObject $base $override | |
| if ($base.theme.name -ne "pure") { | |
| Write-Error "Object replacement failed: $($base | ConvertTo-Json -Compress)" | |
| exit 1 | |
| } | |
| Write-Host "OK: object replacement" -ForegroundColor Green | |
| # Test 4: null base or override returns without throwing (matches production guard) | |
| $base = [PSCustomObject]@{ a = 1 } | |
| Merge-JsonObject $null $base | |
| Merge-JsonObject $base $null | |
| if ($base.a -ne 1) { Write-Error "Null guard should not modify base"; exit 1 } | |
| Write-Host "OK: null guard" -ForegroundColor Green | |
| - name: Test WT settings merge (mock) | |
| shell: pwsh | |
| run: | | |
| # Simulate the WT settings update flow with mock data | |
| $mockWt = [PSCustomObject]@{ | |
| profiles = [PSCustomObject]@{ | |
| defaults = [PSCustomObject]@{ font = [PSCustomObject]@{ face = "Consolas"; size = 10 } } | |
| list = @() | |
| } | |
| schemes = @() | |
| actions = @() | |
| } | |
| $theme = Get-Content 'theme.json' -Raw | ConvertFrom-Json | |
| $terminal = Get-Content 'terminal-config.json' -Raw | ConvertFrom-Json | |
| $defaults = $mockWt.profiles.defaults | |
| # Apply terminal defaults | |
| $terminal.defaults.PSObject.Properties | ForEach-Object { | |
| $defaults | Add-Member -NotePropertyName $_.Name -NotePropertyValue $_.Value -Force | |
| } | |
| # Apply theme colors | |
| if ($theme.windowsTerminal.colorScheme) { | |
| $defaults | Add-Member -NotePropertyName "colorScheme" -NotePropertyValue $theme.windowsTerminal.colorScheme -Force | |
| } | |
| # Verify results (read expected values from config, not hardcoded) | |
| $errors = 0 | |
| if ($defaults.opacity -ne $terminal.defaults.opacity) { Write-Host "FAIL: opacity=$($defaults.opacity), expected $($terminal.defaults.opacity)" -ForegroundColor Red; $errors++ } | |
| if ($defaults.colorScheme -ne $theme.windowsTerminal.colorScheme) { Write-Host "FAIL: colorScheme=$($defaults.colorScheme), expected $($theme.windowsTerminal.colorScheme)" -ForegroundColor Red; $errors++ } | |
| if ($defaults.font.face -ne $terminal.defaults.font.face) { Write-Host "FAIL: font.face=$($defaults.font.face), expected $($terminal.defaults.font.face)" -ForegroundColor Red; $errors++ } | |
| # Verify scheme upsert | |
| $schemeDef = $theme.windowsTerminal.scheme | |
| $mockWt.schemes = @(@($mockWt.schemes | Where-Object { $_ -and $_.name -ne $schemeDef.name }) + ([PSCustomObject]$schemeDef)) | |
| if ($mockWt.schemes.Count -ne 1 -or $mockWt.schemes[0].name -ne $schemeDef.name) { | |
| Write-Host "FAIL: scheme upsert" -ForegroundColor Red; $errors++ | |
| } | |
| # Verify keybinding upsert | |
| foreach ($kb in $terminal.keybindings) { | |
| $bindingId = "User.profile.$($kb.keys -replace '[^a-zA-Z0-9]', '')" | |
| $mockWt.actions = @($mockWt.actions) + ([PSCustomObject]@{ keys = $kb.keys; command = $kb.command }) | |
| } | |
| $firstKb = @($terminal.keybindings)[0] | |
| $found = $mockWt.actions | Where-Object { $_.keys -eq $firstKb.keys } | |
| if (-not $found -or $found.command -ne $firstKb.command) { | |
| Write-Host "FAIL: keybinding upsert (expected keys=$($firstKb.keys) command=$($firstKb.command))" -ForegroundColor Red; $errors++ | |
| } | |
| if ($errors -gt 0) { exit 1 } | |
| # Verify JSON roundtrip | |
| $json = $mockWt | ConvertTo-Json -Depth 10 | |
| $null = $json | ConvertFrom-Json -ErrorAction Stop | |
| Write-Host "OK: WT settings merge + JSON roundtrip" -ForegroundColor Green | |
| functional: | |
| runs-on: windows-latest | |
| needs: [lint, install-flow] | |
| steps: | |
| - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| - name: Run functional profile tests | |
| shell: pwsh | |
| run: | | |
| pwsh -NoProfile -File '${{ github.workspace }}/ci-functional.ps1' |