Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Default: let Git normalize line endings based on file content detection.
* text=auto

# PowerShell scripts: CRLF on checkout matches Windows tooling and avoids PS5 parser
# quirks that can surface when source files land in $env:USERPROFILE\Documents\.
*.ps1 text eol=crlf
*.psm1 text eol=crlf
*.psd1 text eol=crlf

# JSON / Markdown / YAML / config: LF is standard and works everywhere.
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.gitignore text eol=lf
*.gitattributes text eol=lf

# Binary assets: tell Git not to attempt diff or EOL conversion.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.7z binary
*.tar binary
*.gz binary
*.ttf binary
*.otf binary
*.woff binary
*.woff2 binary

# GitHub Linguist: test harnesses shouldn't inflate language stats or drown out
# the profile in the language bar.
tests/** linguist-detectable=false
116 changes: 98 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ jobs:
'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.
) | Where-Object { $_.ScriptName -notin @('ci-functional.ps1', 'test.ps1', 'rawhunt.ps1', 'locallab.ps1', 'lint.ps1') }
# tests/ is excluded: those files intentionally call profile aliases (gc, ls, cat, uptime,
# eventlog) by their short names to exercise the profile's own function definitions, and
# contain patterns that trip secrets/path scanners on their own test data.
$results | Format-Table -AutoSize
if ($results | Where-Object Severity -in 'Error','Warning') {
Write-Error "PSScriptAnalyzer found warnings or errors"
Expand All @@ -47,12 +48,23 @@ jobs:
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)"
# $LASTEXITCODE alone misses non-terminating errors (a profile can emit errors and still
# exit 0). Capture the child's streams and fail on any ErrorRecord. Warnings are suppressed
# in the child because user-file/plugin load failures are non-fatal by design.
$smoke = pwsh -NonInteractive -NoProfile -Command "`$WarningPreference='SilentlyContinue'; . '${{ github.workspace }}/Microsoft.PowerShell_profile.ps1'" 2>&1
$code = $LASTEXITCODE
$smokeErrors = @($smoke | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] })
if ($code -ne 0) {
$smokeErrors | ForEach-Object { Write-Host $_ -ForegroundColor Red }
Write-Error "Profile failed to load non-interactive (exit code: $code)"
exit 1
}
Write-Host "Profile loaded successfully in non-interactive mode." -ForegroundColor Green
if ($smokeErrors.Count -gt 0) {
$smokeErrors | ForEach-Object { Write-Host $_ -ForegroundColor Red }
Write-Error "Profile loaded but emitted $($smokeErrors.Count) error(s) during load (non-terminating errors are still failures)"
exit 1
}
Write-Host "Profile loaded successfully in non-interactive mode (no errors)." -ForegroundColor Green

- name: PS5 parse-check (all .ps1 files)
shell: powershell
Expand Down Expand Up @@ -86,7 +98,9 @@ jobs:
'/home/'
'\\\\Users\\\\'
)
$files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' }
# tests/test.ps1 replicates this same check locally, so it contains the regex patterns
# we are searching for; excluded by name to avoid self-matching.
$files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne 'test.ps1' }
$found = @()
foreach ($file in $files) {
$lines = Get-Content $file.FullName -ErrorAction SilentlyContinue
Expand Down Expand Up @@ -154,7 +168,8 @@ jobs:
- name: Check for Set-Content -Encoding UTF8
shell: pwsh
run: |
$files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' }
# tests/test.ps1 replicates this scan and contains the regex itself; excluded by name.
$files = Get-ChildItem -Recurse -Include *.ps1 | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.Name -ne 'test.ps1' }
$found = @()
foreach ($file in $files) {
$lines = Get-Content $file.FullName -ErrorAction SilentlyContinue
Expand Down Expand Up @@ -187,7 +202,8 @@ jobs:
'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\*' }
# tests/test.ps1 contains secret-detection regex patterns locally; excluded by name.
$files = Get-ChildItem -Recurse -Include *.ps1,*.md,*.yml,*.json | Where-Object { $_.FullName -notlike '*\.git\*' -and $_.FullName -notlike '*\.github\workflows\*' -and $_.Name -ne 'test.ps1' }
$found = @()
foreach ($file in $files) {
$content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue
Expand Down Expand Up @@ -254,6 +270,67 @@ jobs:
}
Write-Host "Config schema valid." -ForegroundColor Green

- name: Validate wizard curated data (CuratedSchemes + CuratedFonts)
shell: pwsh
run: |
# Extract $script:CuratedSchemes and $script:CuratedFonts from setup.ps1 via AST
# and assert each entry has all required fields. Avoids running setup.ps1 itself.
$setup = Join-Path $PWD 'setup.ps1'
$tokens = $null; $parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($setup, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) { Write-Error "setup.ps1 has parse errors"; exit 1 }

$assignments = $ast.FindAll({
param($n)
$n -is [System.Management.Automation.Language.AssignmentStatementAst] -and
($n.Left.Extent.Text -in @('$script:CuratedSchemes', '$script:CuratedFonts'))
}, $true)

$found = @{}
foreach ($a in $assignments) {
$name = $a.Left.Extent.Text.TrimStart('$').Replace('script:','')
Invoke-Expression $a.Extent.Text
$found[$name] = Get-Variable -Name $name -Scope Local -ValueOnly
}

$errors = 0
# Schemes: expect 7 entries, each with Name, Desc, and a nested Scheme with name + background
$schemes = $found['CuratedSchemes']
if (-not $schemes) { Write-Host "FAIL: CuratedSchemes not found" -ForegroundColor Red; exit 1 }
if ($schemes.Count -ne 7) { Write-Host "FAIL: expected 7 curated schemes, got $($schemes.Count)" -ForegroundColor Red; $errors++ }
$schemeReq = @('Name','Desc','Scheme')
$schemeInnerReq = @('name','background','foreground','black','red','green','yellow','blue','purple','cyan','white')
foreach ($s in $schemes) {
foreach ($k in $schemeReq) {
if (-not $s.ContainsKey($k)) { Write-Host "FAIL: scheme '$($s.Name)' missing '$k'" -ForegroundColor Red; $errors++ }
}
if ($s.Scheme) {
foreach ($k in $schemeInnerReq) {
if (-not $s.Scheme.ContainsKey($k)) { Write-Host "FAIL: scheme '$($s.Name)' missing Scheme.$k" -ForegroundColor Red; $errors++ }
}
if ($s.Scheme.background -and $s.Scheme.background -notmatch '^#[0-9a-fA-F]{6}$') {
Write-Host "FAIL: scheme '$($s.Name)' background not a #RRGGBB hex: $($s.Scheme.background)" -ForegroundColor Red; $errors++
}
}
}
Write-Host "OK: $($schemes.Count) curated schemes validated" -ForegroundColor Green

# Fonts: expect 6 entries, each with Asset, DisplayName, Desc
$fonts = $found['CuratedFonts']
if (-not $fonts) { Write-Host "FAIL: CuratedFonts not found" -ForegroundColor Red; exit 1 }
if ($fonts.Count -ne 6) { Write-Host "FAIL: expected 6 curated fonts, got $($fonts.Count)" -ForegroundColor Red; $errors++ }
$fontReq = @('Asset','DisplayName','Desc')
foreach ($f in $fonts) {
foreach ($k in $fontReq) {
if (-not $f.ContainsKey($k) -or [string]::IsNullOrWhiteSpace([string]$f[$k])) {
Write-Host "FAIL: font '$($f.Asset)' missing '$k'" -ForegroundColor Red; $errors++
}
}
}
Write-Host "OK: $($fonts.Count) curated fonts validated" -ForegroundColor Green

if ($errors -gt 0) { exit 1 }

- name: Dry-run setup.ps1 (parse + function definitions)
shell: pwsh
run: |
Expand All @@ -262,7 +339,7 @@ jobs:
$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')
$requiredFunctions = @('Test-InternetConnection', 'Install-NerdFonts', 'Install-OhMyPoshTheme', 'Install-WingetPackage', 'Merge-JsonObject', 'Select-PreferredEditor', 'Invoke-DownloadWithRetry', 'Remove-SafeTempDirectory', 'Update-SessionPathFromRegistry')
$errors = 0
foreach ($fn in $requiredFunctions) {
if ($content -notmatch "function\s+$fn\b") {
Expand All @@ -279,7 +356,8 @@ jobs:
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 }
if ($null -eq $override) { return }
if ($null -eq $base) { throw 'Merge-JsonObject: $base cannot be null (caller must pass an object to merge into).' }
foreach ($prop in $override.PSObject.Properties) {
$baseVal = $base.PSObject.Properties[$prop.Name]
if ($baseVal -and $baseVal.Value -is [PSCustomObject] -and $prop.Value -is [PSCustomObject]) {
Expand Down Expand Up @@ -320,11 +398,13 @@ jobs:
}
Write-Host "OK: object replacement" -ForegroundColor Green

# Test 4: null base or override returns without throwing (matches production guard)
# Test 4: null override is a no-op; null base throws (caller-bug surfacing)
$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 }
if ($base.a -ne 1) { Write-Error "Null override should not modify base"; exit 1 }
$threw = $false
try { Merge-JsonObject $null ([PSCustomObject]@{ a = 1 }) } catch { $threw = $true }
if (-not $threw) { Write-Error "Null base should throw"; exit 1 }
Write-Host "OK: null guard" -ForegroundColor Green

- name: Test WT settings merge (mock)
Expand Down Expand Up @@ -379,8 +459,8 @@ jobs:

if ($errors -gt 0) { exit 1 }

# Verify JSON roundtrip
$json = $mockWt | ConvertTo-Json -Depth 10
# Verify JSON roundtrip (depth matches production: profile/setup write WT settings with -Depth 100)
$json = $mockWt | ConvertTo-Json -Depth 100
$null = $json | ConvertFrom-Json -ErrorAction Stop
Write-Host "OK: WT settings merge + JSON roundtrip" -ForegroundColor Green

Expand All @@ -398,4 +478,4 @@ jobs:
- name: Run functional profile tests
shell: pwsh
run: |
pwsh -NoProfile -File '${{ github.workspace }}/ci-functional.ps1'
pwsh -NoProfile -File '${{ github.workspace }}/tests/ci-functional.ps1'
6 changes: 2 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,5 @@ profile_user.ps1
.tmpcmd.ps1
LastExecutionTime.txt

# Local test/lint scripts
lint2.ps1
test.ps1
rawhunt.ps1
# Experimental / scratch files (underscore-prefixed stay untracked)
_*.ps1
19 changes: 18 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ All code must work under both PowerShell 5.1 and 7+. Key differences:
Also set `UpgradeStrategy` (`winget` for normal tools, `preserve-direct` only when a direct/MSI install must not be pushed back through winget).
2. Add a numbered install step in `setup.ps1` (it cannot read `ProfileTools`)

### Adding a New User-Facing Command

1. Write the function in `Microsoft.PowerShell_profile.ps1`. Prefer approved PowerShell verbs for `Verb-Noun` names; short non-verb names are acceptable for Unix-style utilities (`grep`, `journal`, `nscan`).
2. Seed the command registry so it shows up in `Get-ProfileCommand` and `Start-ProfileTour`. Add an entry to the `$script:_seedCommands` array near the end of the profile:

```powershell
@{ Name = 'mycmd'; Category = 'Developer'; Synopsis = 'One-line description' }
```

3. Add an `Invoke-CommandProbe` entry in `tests/ci-functional.ps1` - either executing real code or `-SkipReason '...'` for destructive/interactive/tool-dependent commands. CI enforces 100% coverage, so missing a probe fails the `functional` job.
4. If the command has nested internal helpers (e.g. `Write-JournalLine` inside `journal`), add them to `$internalOnly` in the coverage audit so they are not flagged as missing public commands.
5. Update the appropriate section in `Show-Help` and `README.md`.

### Adding an Argument Completer

For new commands with parameters that benefit from Tab-complete (ports, log names, modes with descriptions, etc.), add a `Register-ArgumentCompleter` block near the top of the Sysadmin section. Use `$null = $commandName, $parameterName, $commandAst, $fakeBoundParameters` to mark unused `param()` entries as intentional and satisfy `PSReviewUnusedParameter`.

## Running the Linter

CI uses PSScriptAnalyzer and fails on both warnings and errors. Run it locally before pushing:
Expand Down Expand Up @@ -84,6 +101,6 @@ CI runs on push/PR to `main` with three jobs:

- **lint**: PSScriptAnalyzer, smoke test, PS5 parse, hardcoded-path check, non-ASCII/BOM/secrets checks
- **install-flow**: JSON config validation, schema checks, Merge-JsonObject tests, WT merge mock, required function checks (`Test-InternetConnection`, `Install-NerdFonts`, `Install-OhMyPoshTheme`, `Install-WingetPackage`, `Merge-JsonObject`, `Select-PreferredEditor`, `Invoke-DownloadWithRetry`)
- **functional**: Runs `ci-functional.ps1` (elevated): full install flow, sandbox install/execute/uninstall, and 100% command-probe coverage
- **functional**: Runs `tests/ci-functional.ps1` (elevated): full install flow, sandbox install/execute/uninstall, and 100% command-probe coverage

All three jobs must pass. CI also fails on hardcoded user paths and embedded secrets.
Loading
Loading