From 9e87458ffe938ce37f7322dfadaa6637ef72c7db Mon Sep 17 00:00:00 2001 From: KGreen <297745393+XKush@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:12:59 +0200 Subject: [PATCH 1/2] fix(release): v3.0.1 health resilience and offline stress matrix Gracefully handle corrupt baseline/history JSON, structured verify -Json errors, SECURITY.md for 3.0.x, and CI resilience/stress smoke tests. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 22 +++- README.md | 4 +- README.ru.md | 4 +- SECURITY.md | 44 ++++--- docs/ROADMAP.md | 2 +- install.ps1 | 4 +- lib/DevShellHealth.ps1 | 37 +++++- modules/KGreen.Workstation.psd1 | 4 +- scripts/README.md | 3 +- .../invoke/Invoke-DevShellVerify.ps1 | 10 +- .../test/Test-DevShellStressMatrix.ps1 | 111 ++++++++++++++++++ .../maintainer/test/Test-HealthResilience.ps1 | 63 ++++++++++ 13 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 scripts/maintainer/test/Test-DevShellStressMatrix.ps1 create mode 100644 scripts/maintainer/test/Test-HealthResilience.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d61ab4a..52ab812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,8 @@ jobs: shell: pwsh run: | pwsh -NoProfile -File scripts/maintainer/test/Test-HealthSmoke.ps1 + pwsh -NoProfile -File scripts/maintainer/test/Test-HealthResilience.ps1 + pwsh -NoProfile -File scripts/maintainer/test/Test-DevShellStressMatrix.ps1 release-assets: if: startsWith(github.ref, 'refs/tags/v') diff --git a/CHANGELOG.md b/CHANGELOG.md index 5168a1f..3507fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ See [docs/ROADMAP.md](docs/ROADMAP.md) for the stabilization contract. --- +## [3.0.1] - 2026-06-28 + +### Fixed + +- **`Compare-DevShellHealthBaseline`** — graceful handling of missing or corrupt `baseline.json` +- **`Show-DevShellHealthHistory`** — skip invalid jsonl lines instead of failing +- **`devshell verify -Json`** — structured errors `no_baseline` and `baseline_invalid` + +### Added + +- **`Test-HealthResilience.ps1`**, **`Test-DevShellStressMatrix.ps1`** — offline CI resilience matrix +- CI: resilience + stress tests in **health-smoke** job + +### Changed + +- **`SECURITY.md`** — supported versions updated for 3.0.x + +--- + ## [3.0.0] - 2026-06-29 **Unified health & API freeze** — platform spec `1.0.0` LOCKED. @@ -286,7 +305,8 @@ Pre–HomeBase DevShell iterations. See git history before public OSS rename. --- -[Unreleased]: https://github.com/XKush/homebase-devshell/compare/v3.0.0...HEAD +[Unreleased]: https://github.com/XKush/homebase-devshell/compare/v3.0.1...HEAD +[3.0.1]: https://github.com/XKush/homebase-devshell/compare/v3.0.0...v3.0.1 [3.0.0]: https://github.com/XKush/homebase-devshell/releases/tag/v3.0.0 [2.3.0]: https://github.com/XKush/homebase-devshell/releases/tag/v2.3.0 [2.2.2]: https://github.com/XKush/homebase-devshell/releases/tag/v2.2.2 diff --git a/README.md b/README.md index 465e388..dca0ca1 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ A workstation **readiness and privacy configuration auditing toolkit** for Windo ![DevReady — install, devshell health, Ready to work](docs/assets/devready-demo.gif) -**Inspect before run:** [`install.ps1` @ v3.0.0](https://github.com/XKush/homebase-devshell/blob/v3.0.0/install.ps1) · `devshell init` (dry-run, no changes) · [zip + SHA256](packaging/README.md) +**Inspect before run:** [`install.ps1` @ v3.0.1](https://github.com/XKush/homebase-devshell/blob/v3.0.1/install.ps1) · `devshell init` (dry-run, no changes) · [zip + SHA256](packaging/README.md) --- ## 30-second start ```powershell -irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.0/install.ps1 | iex +irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.1/install.ps1 | iex ``` Close the terminal. Open a new one. Run: diff --git a/README.ru.md b/README.ru.md index 6143722..97ab64c 100644 --- a/README.ru.md +++ b/README.ru.md @@ -12,14 +12,14 @@ ![DevReady — install, devshell health, Ready to work](docs/assets/devready-demo.gif) -**Проверьте до запуска:** [`install.ps1` @ v3.0.0](https://github.com/XKush/homebase-devshell/blob/v3.0.0/install.ps1) · `devshell init` (dry-run) · [zip + SHA256](packaging/README.md) +**Проверьте до запуска:** [`install.ps1` @ v3.0.1](https://github.com/XKush/homebase-devshell/blob/v3.0.1/install.ps1) · `devshell init` (dry-run) · [zip + SHA256](packaging/README.md) --- ## Старт за 30 секунд ```powershell -irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.0/install.ps1 | iex +irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.1/install.ps1 | iex ``` Закройте терминал. Откройте снова: diff --git a/SECURITY.md b/SECURITY.md index 55c386a..36e3ab5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,24 +4,21 @@ | Version | Supported | |---------|-----------| -| 2.0.x | ✅ | -| < 2.0 | ❌ | +| **3.0.x** | ✅ Current | +| 2.x | Best effort (no new features) | +| < 2.0 | ❌ | ## Reporting a vulnerability -If you discover a security issue in HOME BASE: +If you discover a security issue in **HomeBase DevShell** / DevReady: 1. **Do not** open a public issue for exploitable vulnerabilities. -2. Report privately with: +2. Use **[GitHub Private vulnerability reporting](https://github.com/XKush/homebase-devshell/security/advisories/new)** (preferred). +3. Include: - description and impact; - steps to reproduce; - affected commands or scripts; - - HOME BASE version (`ModuleVersion` from `modules/KGreen.Workstation.psd1`). -3. Allow reasonable time for a fix before public disclosure. - -When the repository is published, prefer **GitHub Security Advisories** (Private vulnerability reporting). - -Until then, contact the maintainer directly through your established private channel. + - version from `devshell version` or `modules/KGreen.Workstation.psd1` `ModuleVersion`. ## Response expectations @@ -35,29 +32,28 @@ Until then, contact the maintainer directly through your established private cha **In scope:** -- Destructive operations (`Remove-Item`, backup rotation, restore) +- Product CLI: `install`, `health`, `doctor`, `privacy`, `repair` (`-Fix`) +- Destructive module commands (`restoreconfig`, `cleanup`, backup rotation) - Profile / terminal deployment scripts - PGP key handling (`pgp-*`) -- Firewall and privacy hardening scripts -- Trust system integrity (`trustcheck`, SelfCheck) +- Privacy repair scripts (registry/DNS) - Path / module load issues leading to privilege or data loss **Out of scope:** -- Tor network anonymity guarantees (operational security is user responsibility) -- Third-party tools installed via winget (not bundled in this repo) -- Misuse of security scripts without authorization +- Tor network anonymity guarantees +- Third-party tools installed via winget +- Misuse on systems you are not authorized to manage ## Safe use -HOME BASE includes security-related automation intended for **authorized lab use** on systems you own or are permitted to manage. - -- Users are responsible for compliance with local laws. -- Microsoft Defender AV is **intentionally not enabled** by this project design. -- Always run `backupconfig` before mutating operations. -- Use `-WhatIf` on `cleanup` and similar commands before execution. +- Intended for **systems you own or may manage**. +- Microsoft Defender AV is **never enabled** by this project. +- Run `backupconfig` before destructive module operations. +- Use `-WhatIf` on `cleanup` where supported. ## Related documentation -- [internal-docs/charter/SECURITY-POLICY.md](internal-docs/charter/SECURITY-POLICY.md) — operational security chain -- [internal-docs/charter/BACKUP-POLICY.md](internal-docs/charter/BACKUP-POLICY.md) — backup and rollback +- [MANIFESTO](docs/MANIFESTO.md) — trust boundaries +- [PROJECT-PRINCIPLES](docs/PROJECT-PRINCIPLES.md) — repair and privacy rules +- [RELEASE-CRITERIA](docs/RELEASE-CRITERIA.md) — release gates diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 166f5db..cb63349 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ This is a **contract** with users — not a wishlist. Items move only after community signal or maintainer review. -**Current product version:** 3.0.0 · **Platform spec:** 1.0.0 LOCKED +**Current product version:** 3.0.1 · **Platform spec:** 1.0.0 LOCKED --- diff --git a/install.ps1 b/install.ps1 index 287d9c9..fb2aeda 100644 --- a/install.ps1 +++ b/install.ps1 @@ -3,7 +3,7 @@ .SYNOPSIS HomeBase DevShell one-line bootstrap installer. .EXAMPLE - irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.0/install.ps1 | iex + irm https://raw.githubusercontent.com/XKush/homebase-devshell/v3.0.1/install.ps1 | iex .EXAMPLE pwsh -File install.ps1 .EXAMPLE @@ -21,7 +21,7 @@ param( ) $ErrorActionPreference = 'Stop' -$script:DevShellReleaseTag = 'v3.0.0' +$script:DevShellReleaseTag = 'v3.0.1' function Test-DevShellRepo { param([string]$Path) diff --git a/lib/DevShellHealth.ps1 b/lib/DevShellHealth.ps1 index e1c90ef..ac2c694 100644 --- a/lib/DevShellHealth.ps1 +++ b/lib/DevShellHealth.ps1 @@ -191,11 +191,23 @@ function Show-DevShellHealthHistory { Write-Host 'No health history yet. Run: devshell health' -ForegroundColor DarkGray return } - $rows = Get-Content $HistoryPath -Encoding UTF8 | ForEach-Object { $_ | ConvertFrom-Json } + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($line in (Get-Content $HistoryPath -Encoding UTF8)) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + try { + $rows.Add(($line | ConvertFrom-Json)) + } catch { + Write-Verbose "Skipping invalid history line: $line" + } + } + if ($rows.Count -eq 0) { + Write-Host 'No readable health history entries.' -ForegroundColor DarkGray + return + } Write-Host '' Write-Host 'Health history' -ForegroundColor Cyan Write-Host '' - $slice = if ($rows.Count -gt $Last) { $rows[($rows.Count - $Last)..($rows.Count - 1)] } else { $rows } + $slice = if ($rows.Count -gt $Last) { @($rows)[($rows.Count - $Last)..($rows.Count - 1)] } else { @($rows) } foreach ($r in $slice) { $d = ([datetime]$r.timestamp).ToString('MMM dd') Write-Host ("{0,-8} Privacy {1,3}% Developer {2,-4} Ready {3}" -f $d, $r.privacy, $r.developer, $(if ($r.ready) { 'yes' } else { 'no' })) -ForegroundColor DarkGray @@ -227,9 +239,26 @@ function Compare-DevShellHealthBaseline { } if (-not (Test-Path $BaselinePath)) { Write-Host 'No baseline. Run: devshell baseline' -ForegroundColor Yellow - return $null + return [PSCustomObject]@{ + baselineTimestamp = $null + currentTimestamp = $Current.timestamp + changes = @() + driftDetected = $false + noBaseline = $true + } + } + try { + $base = Get-Content $BaselinePath -Raw -Encoding UTF8 | ConvertFrom-Json + } catch { + Write-Host 'Baseline file is invalid JSON. Run: devshell baseline' -ForegroundColor Yellow + return [PSCustomObject]@{ + baselineTimestamp = $null + currentTimestamp = $Current.timestamp + changes = @('Baseline file is unreadable or corrupt') + driftDetected = $true + baselineInvalid = $true + } } - $base = Get-Content $BaselinePath -Raw | ConvertFrom-Json $changes = [System.Collections.Generic.List[string]]::new() foreach ($key in $Current.sections.Keys) { diff --git a/modules/KGreen.Workstation.psd1 b/modules/KGreen.Workstation.psd1 index dce395c..ab6bbcb 100644 --- a/modules/KGreen.Workstation.psd1 +++ b/modules/KGreen.Workstation.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'KGreen.Workstation.psm1' - ModuleVersion = '3.0.0' + ModuleVersion = '3.0.1' GUID = '7f3a9c2e-4b81-4d5f-9e0a-1c8b6d4e2f90' Author = 'KGreen' CompanyName = 'KGreen' @@ -42,7 +42,7 @@ Tags = @('DevReady', 'HomeBase', 'DevShell', 'PowerShell', 'Windows', 'health-check', 'developer-experience', 'workstation-setup') LicenseUri = 'https://opensource.org/licenses/MIT' ProjectUri = 'https://github.com/XKush/homebase-devshell' - ReleaseNotes = 'DevReady v3.0.0 — unified health dashboard, API freeze, baseline/verify/history.' + ReleaseNotes = 'DevReady v3.0.1 — health resilience (corrupt baseline/history), stress matrix tests.' } } } diff --git a/scripts/README.md b/scripts/README.md index 89ee9fd..d7a2480 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,7 +11,7 @@ Runtime scripts for **HomeBase DevShell** (public name: **DevReady**). | Script | Command | Purpose | |--------|---------|---------| | [`install.ps1`](../install.ps1) | `irm … \| iex` | Clone, bootstrap, optional winget tools | -| [`devshell.ps1`](../devshell.ps1) | `devshell …` | `init` · `install` · `doctor` · `status` | +| [`devshell.ps1`](../devshell.ps1) | `devshell …` | `health` · `install` · `doctor` · `init` · `status` | PATH shims: `devready.cmd` (doctor Core) · `devshell.cmd` (full CLI) @@ -48,6 +48,7 @@ PATH shims: `devready.cmd` (doctor Core) · `devshell.cmd` (full CLI) |--------|--------|---------| | `Test-ReleaseVersion.ps1` | release-version | psd1 + install pin + CHANGELOG + tag | | `Test-HealthSmoke.ps1` | health-smoke | `health -Json` contract | +| `Test-HealthResilience.ps1` | manual / CI optional | corrupt baseline/history handling | | `Test-DoctorSmoke.ps1` | doctor-smoke | `doctor` Core JSON | | `Test-PrivacyAuditSmoke.ps1` | privacy-smoke | Privacy audits + doctor `-Privacy` | | `Test-WorkstationCommands.ps1` | command-health | 72 commands + command-health.json | diff --git a/scripts/maintainer/invoke/Invoke-DevShellVerify.ps1 b/scripts/maintainer/invoke/Invoke-DevShellVerify.ps1 index 58222f6..c83299d 100644 --- a/scripts/maintainer/invoke/Invoke-DevShellVerify.ps1 +++ b/scripts/maintainer/invoke/Invoke-DevShellVerify.ps1 @@ -18,13 +18,21 @@ $current = Get-DevShellHealthReport -RepoRoot $repoRoot -Tier Core -ProductVersi $diff = Compare-DevShellHealthBaseline -Current $current if ($Json) { + if ($diff.noBaseline) { + @{ error = 'no_baseline'; message = 'Run: devshell baseline' } | ConvertTo-Json + exit 1 + } + if ($diff.baselineInvalid) { + @{ error = 'baseline_invalid'; message = 'Baseline file is unreadable or corrupt'; changes = @($diff.changes) } | ConvertTo-Json + exit 1 + } $diff | ConvertTo-Json -Depth 5 exit $(if ($diff.driftDetected) { 1 } else { 0 }) } Write-Host '' Write-Host 'Baseline verify' -ForegroundColor Cyan -if (-not $diff) { exit 1 } +if ($diff.noBaseline) { exit 1 } Write-Host " Baseline: $($diff.baselineTimestamp)" -ForegroundColor DarkGray Write-Host " Current: $($diff.currentTimestamp)" -ForegroundColor DarkGray Write-Host '' diff --git a/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 b/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 new file mode 100644 index 0000000..0fd9986 --- /dev/null +++ b/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 @@ -0,0 +1,111 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Offline stress scenarios — isolated temp dirs, no network, no admin required. +.DESCRIPTION + Safe CI matrix for missing/corrupt paths. Does not modify user profile or ~/.homebase. +#> +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\_Resolve-RepoRoot.ps1') +$Root = Resolve-WorkstationRepoRoot -Start $PSScriptRoot +. (Join-Path $Root 'lib\WorkstationCommon.ps1') +. (Join-Path $Root 'lib\DevShellHealth.ps1') +. (Join-Path $Root 'lib\PrivacyAudit.ps1') + +$pass = 0 +function Assert-Stress { + param([string]$Name, [scriptblock]$Test) + try { + & $Test + Write-Host " [PASS] $Name" -ForegroundColor Green + $script:pass++ + } catch { + Write-Host " [FAIL] $Name — $_" -ForegroundColor Red + throw + } +} + +Write-Host 'DevShell stress matrix (offline)' -ForegroundColor Cyan + +$product = '3.0.0' +$psd1 = Join-Path $Root 'modules\KGreen.Workstation.psd1' +if (Test-Path $psd1) { $product = [string](Import-PowerShellDataFile $psd1).ModuleVersion } + +$sampleReport = [PSCustomObject]@{ + timestamp = (Get-Date).ToString('o') + sections = [ordered]@{ + developer = @{ label = 'Developer'; status = 'PASS' } + privacyConfiguration = @{ label = 'Privacy Configuration'; status = 'PASS'; score = 90 } + } + privacyReport = @{ checks = @(@{ id = 'doh'; status = 'Pass' }) } +} + +Assert-Stress 'missing baseline path' { + $p = Join-Path $env:TEMP "devshell-stress-missing-$([guid]::NewGuid().ToString('N')).json" + $r = Compare-DevShellHealthBaseline -Current $sampleReport -BaselinePath $p + if (-not $r.noBaseline) { throw 'expected noBaseline' } +} + +Assert-Stress 'corrupt baseline JSON' { + $p = Join-Path $env:TEMP "devshell-stress-bad-$([guid]::NewGuid().ToString('N')).json" + try { + Set-Content $p '{ corrupt' -Encoding UTF8 + $r = Compare-DevShellHealthBaseline -Current $sampleReport -BaselinePath $p + if (-not $r.baselineInvalid) { throw 'expected baselineInvalid' } + } finally { + Remove-Item $p -Force -ErrorAction SilentlyContinue + } +} + +Assert-Stress 'corrupt history jsonl line skipped' { + $hist = Join-Path $env:TEMP "devshell-stress-hist-$([guid]::NewGuid().ToString('N')).jsonl" + try { + @('{"timestamp":"2026-01-01T00:00:00","privacy":80,"developer":"PASS","ready":true}', 'BAD', '{"timestamp":"2026-01-02T00:00:00","privacy":90,"developer":"PASS","ready":true}') | + Set-Content $hist -Encoding UTF8 + $out = pwsh -NoProfile -Command @" +. '$Root\lib\DevShellHealth.ps1' +Show-DevShellHealthHistory -HistoryPath '$hist' -Last 5 +"@ 2>&1 | Out-String + if ($out -notmatch 'Privacy') { throw 'valid rows not shown' } + } finally { + Remove-Item $hist -Force -ErrorAction SilentlyContinue + } +} + +Assert-Stress 'privacy audit without user config (defaults only)' { + $r = Get-PrivacyAuditReport -Scope System -RepoRoot $Root -ProductVersion $product + if ($null -eq $r.Score) { throw 'no score' } + if ($r.Checks.Count -lt 1) { throw 'no checks' } +} + +Assert-Stress 'verify -Json no_baseline envelope' { + $env:HOMEBASE_DEVSHELL_ROOT = $Root + $fakeBase = Join-Path $env:TEMP "devshell-stress-nobase-$([guid]::NewGuid().ToString('N')).json" + $out = pwsh -NoProfile -Command @" +`$env:HOMEBASE_DEVSHELL_ROOT = '$Root' +. '$Root\lib\DevShellHealth.ps1' +. '$Root\lib\WorkstationCommon.ps1' +. '$Root\scripts\maintainer\invoke\_PrivacyInvokeCommon.ps1' +`$repo = '$Root' +`$product = '$product' +`$current = Get-DevShellHealthReport -RepoRoot `$repo -Tier Core -ProductVersion `$product +`$diff = Compare-DevShellHealthBaseline -Current `$current -BaselinePath '$fakeBase' +if (`$diff.noBaseline) { @{ error = 'no_baseline'; message = 'Run: devshell baseline' } | ConvertTo-Json } else { throw 'expected no baseline' } +"@ 2>&1 | Out-String + if ($out -notmatch 'no_baseline') { throw "bad envelope: $out" } +} + +Assert-Stress 'empty logs dir — doctor json path tolerant' { + $tempLogs = Join-Path $env:TEMP "devshell-stress-logs-$([guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Force -Path $tempLogs | Out-Null + try { + $r = Invoke-DevShellDoctorJson -RepoRoot $Root -Tier Core + if ($null -ne $r -and $r.Passed -eq $null -and $r.Failed -eq $null) { throw 'unexpected doctor shape' } + } finally { + Remove-Item $tempLogs -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Write-Host '' +Write-Host "DevShell stress matrix — ALL PASS ($pass checks)" -ForegroundColor Green +exit 0 diff --git a/scripts/maintainer/test/Test-HealthResilience.ps1 b/scripts/maintainer/test/Test-HealthResilience.ps1 new file mode 100644 index 0000000..a1c449a --- /dev/null +++ b/scripts/maintainer/test/Test-HealthResilience.ps1 @@ -0,0 +1,63 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Resilience checks for health baseline/history (corrupt JSON, missing files). +#> +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\_Resolve-RepoRoot.ps1') +$Root = Resolve-WorkstationRepoRoot -Start $PSScriptRoot +. (Join-Path $Root 'lib\WorkstationCommon.ps1') +. (Join-Path $Root 'lib\DevShellHealth.ps1') + +Write-Host 'Health resilience smoke' -ForegroundColor Cyan + +$product = '3.0.0' +$psd1 = Join-Path $Root 'modules\KGreen.Workstation.psd1' +if (Test-Path $psd1) { $product = [string](Import-PowerShellDataFile $psd1).ModuleVersion } + +$report = [PSCustomObject]@{ + timestamp = (Get-Date).ToString('o') + sections = [ordered]@{ developer = @{ label = 'Developer'; status = 'PASS' } } + privacyReport = @{ checks = @(@{ id = 'doh'; status = 'Pass' }) } +} + +$tempBase = Join-Path $env:TEMP "devshell-baseline-test-$([guid]::NewGuid().ToString('N').Substring(0,8)).json" +try { + Set-Content -Path $tempBase -Value '{ not valid json' -Encoding UTF8 + $bad = Compare-DevShellHealthBaseline -Current $report -BaselinePath $tempBase + if (-not $bad.baselineInvalid) { throw 'corrupt baseline should set baselineInvalid' } + Write-Host ' [PASS] corrupt baseline handled' -ForegroundColor Green + + $badJson = $bad | ConvertTo-Json -Depth 3 + if ($badJson -notmatch 'baselineInvalid') { throw 'baselineInvalid should serialize' } + + $missing = Join-Path $env:TEMP "devshell-missing-baseline-$([guid]::NewGuid().ToString('N')).json" + $none = Compare-DevShellHealthBaseline -Current $report -BaselinePath $missing + if (-not $none.noBaseline) { throw 'missing baseline should set noBaseline' } + Write-Host ' [PASS] missing baseline handled' -ForegroundColor Green +} +finally { + if (Test-Path $tempBase) { Remove-Item $tempBase -Force -ErrorAction SilentlyContinue } +} + +$tempHist = Join-Path $env:TEMP "devshell-history-test-$([guid]::NewGuid().ToString('N').Substring(0,8)).jsonl" +try { + @( + '{"timestamp":"2026-01-01T00:00:00","privacy":80,"developer":"PASS","ready":true}' + 'not-json' + '{"timestamp":"2026-01-02T00:00:00","privacy":90,"developer":"PASS","ready":true}' + ) | Set-Content $tempHist -Encoding UTF8 + $out = pwsh -NoProfile -Command @" +. '$Root\lib\DevShellHealth.ps1' +Show-DevShellHealthHistory -HistoryPath '$tempHist' -Last 5 +"@ 2>&1 | Out-String + if ($out -notmatch 'Privacy') { throw 'history should show valid rows despite corrupt line' } + Write-Host ' [PASS] corrupt history line skipped' -ForegroundColor Green +} +finally { + if (Test-Path $tempHist) { Remove-Item $tempHist -Force -ErrorAction SilentlyContinue } +} + +Write-Host '' +Write-Host 'Health resilience smoke — ALL PASS' -ForegroundColor Green +exit 0 From 218cef38cff718929860ccc8c7b3cf3fa4e1b414 Mon Sep 17 00:00:00 2001 From: KGreen <297745393+XKush@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:19:35 +0200 Subject: [PATCH 2/2] feat(quality): health -Sections, Pester and PSScriptAnalyzer CI Optional health section filter for faster CI checks. Baseline Pester tests, script analysis gate, profiling helper, and stress matrix coverage for -Sections. Co-authored-by: Cursor --- .PSScriptAnalyzerSettings.psd1 | 11 +++ .github/workflows/ci.yml | 30 ++++++- CHANGELOG.md | 7 ++ devshell.ps1 | 3 + docs/API-STABILITY.md | 1 + docs/ROADMAP.md | 2 +- lib/DevShellHealth.ps1 | 88 +++++++++++++++---- modules/KGreen.Workstation.psd1 | 2 +- .../invoke/Invoke-DevShellHealth.ps1 | 3 +- .../test/Measure-DevShellHealthProfile.ps1 | 29 ++++++ .../test/Test-DevShellStressMatrix.ps1 | 9 ++ tests/pester/Health.Baseline.Tests.ps1 | 54 ++++++++++++ 12 files changed, 217 insertions(+), 22 deletions(-) create mode 100644 .PSScriptAnalyzerSettings.psd1 create mode 100644 scripts/maintainer/test/Measure-DevShellHealthProfile.ps1 create mode 100644 tests/pester/Health.Baseline.Tests.ps1 diff --git a/.PSScriptAnalyzerSettings.psd1 b/.PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..02d7ecf --- /dev/null +++ b/.PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,11 @@ +@{ + IncludeDefaultRules = $true + ExcludeRules = @( + 'PSAvoidUsingWriteHost' + 'PSUseShouldProcessForStateChangingFunctions' + 'PSReviewUnusedParameter' + 'PSUseDeclaredVarsMoreThanAssignments' + 'PSAvoidGlobalVars' + 'PSUseSingularNouns' + ) +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52ab812..41f062f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,9 +113,37 @@ jobs: pwsh -NoProfile -File scripts/maintainer/test/Test-HealthResilience.ps1 pwsh -NoProfile -File scripts/maintainer/test/Test-DevShellStressMatrix.ps1 + pester: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install Pester + shell: pwsh + run: Install-Module Pester -Force -Scope CurrentUser -MinimumVersion 5.5.0 -SkipPublisherCheck + - name: Pester unit tests + shell: pwsh + run: Invoke-Pester -Path tests/pester -Output Detailed -CI + + script-analysis: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Install PSScriptAnalyzer + shell: pwsh + run: Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -MinimumVersion 1.21.0 + - name: PSScriptAnalyzer (lib + maintainer) + shell: pwsh + run: | + $paths = @('lib', 'scripts/maintainer/invoke', 'scripts/maintainer/test', 'devshell.ps1', 'install.ps1') + $errors = Invoke-ScriptAnalyzer -Path $paths -Recurse -Settings .PSScriptAnalyzerSettings.psd1 -Severity Error + if ($errors) { + $errors | Format-Table RuleName, ScriptName, Line, Message -AutoSize + throw "PSScriptAnalyzer errors: $($errors.Count)" + } + release-assets: if: startsWith(github.ref, 'refs/tags/v') - needs: [release-version, command-health, platform-hardening, install-smoke, init-smoke, privacy-smoke, doctor-smoke, health-smoke] + needs: [release-version, command-health, platform-hardening, install-smoke, init-smoke, privacy-smoke, doctor-smoke, health-smoke, pester, script-analysis] runs-on: windows-latest permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 3507fd9..cf24d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: ## [Unreleased] +### Added + +- **`devshell health -Sections`** — optional subset (`developer`, `privacy`, `browser`, `network`) +- **Pester** unit tests (`tests/pester/Health.Baseline.Tests.ps1`) +- **PSScriptAnalyzer** CI gate (`.PSScriptAnalyzerSettings.psd1`) +- **`Measure-DevShellHealthProfile.ps1`** — maintainer timing helper + See [docs/ROADMAP.md](docs/ROADMAP.md) for the stabilization contract. --- diff --git a/devshell.ps1 b/devshell.ps1 index 3fb1718..8cb09d7 100644 --- a/devshell.ps1 +++ b/devshell.ps1 @@ -22,6 +22,7 @@ param( [switch]$Json, [ValidateSet('html')] [string]$Export, + [string[]]$Sections, [ValidateSet('Chrome', 'Edge', 'Firefox', 'All')] [string]$Browser = 'All', [int]$Last = 20 @@ -55,6 +56,7 @@ HomeBase DevShell — workstation readiness & privacy auditing devshell health Unified dashboard (developer + privacy + browser + network) devshell health -Json Machine-readable report + devshell health -Sections developer,privacy Subset of sections (faster) devshell health -Export html HTML report in Logs folder devshell history Privacy/configuration score trend devshell baseline Save configuration baseline @@ -95,6 +97,7 @@ switch ($Command) { $healthArgs = @{ Tier = $Tier } if ($Json) { $healthArgs['Json'] = $true } if ($Export) { $healthArgs['Export'] = $Export } + if ($Sections) { $healthArgs['SectionFilter'] = $Sections } if ($Argument) { $healthArgs['OutFile'] = $Argument } & (Join-Path $repoRoot 'scripts\maintainer\invoke\Invoke-DevShellHealth.ps1') @healthArgs exit $LASTEXITCODE diff --git a/docs/API-STABILITY.md b/docs/API-STABILITY.md index f50c3bb..d9a1ee4 100644 --- a/docs/API-STABILITY.md +++ b/docs/API-STABILITY.md @@ -21,6 +21,7 @@ Frozen **product CLI** commands — semver **major** bump required to break beha - `-Json` / `-JsonOnly` on `doctor`, `privacy`, `health`, `verify` - `-Fix` on `doctor`, `privacy` - `-Tier Core|Full` on `doctor`, `health` +- `-Sections developer,privacy,browser,network` on `health` (optional subset; comma-separated) - `-Export html` on `health` ## JSON schemas (versioned) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cb63349..2b80ba3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ This is a **contract** with users — not a wishlist. Items move only after community signal or maintainer review. -**Current product version:** 3.0.1 · **Platform spec:** 1.0.0 LOCKED +**Current product version:** 3.0.1 · **Next minor:** 3.1.0 (in development) · **Platform spec:** 1.0.0 LOCKED --- diff --git a/lib/DevShellHealth.ps1 b/lib/DevShellHealth.ps1 index ac2c694..28c95c2 100644 --- a/lib/DevShellHealth.ps1 +++ b/lib/DevShellHealth.ps1 @@ -48,29 +48,72 @@ function Invoke-DevShellDoctorJson { try { return Get-Content $latest.FullName -Raw | ConvertFrom-Json } catch { return $null } } +function Resolve-DevShellHealthSectionKeys { + param([string[]]$Sections) + $all = @('developer', 'privacyConfiguration', 'browserConfiguration', 'network') + if (-not $Sections -or $Sections.Count -eq 0) { return $all } + $map = @{ + developer = 'developer' + dev = 'developer' + privacy = 'privacyConfiguration' + privacyConfiguration = 'privacyConfiguration' + browser = 'browserConfiguration' + browserConfiguration = 'browserConfiguration' + network = 'network' + net = 'network' + } + $selected = [System.Collections.Generic.List[string]]::new() + foreach ($s in $Sections) { + foreach ($part in ($s -split '[,;]')) { + $key = $part.Trim() + if ([string]::IsNullOrWhiteSpace($key)) { continue } + $resolved = $map[$key] + if (-not $resolved) { + $lower = $key.ToLowerInvariant() + foreach ($entry in $map.GetEnumerator()) { + if ($entry.Key -eq $lower) { $resolved = $entry.Value; break } + } + } + if ($resolved -and -not $selected.Contains($resolved)) { $selected.Add($resolved) } + } + } + if ($selected.Count -eq 0) { return $all } + return @($selected) +} + function Get-DevShellHealthReport { param( [Parameter(Mandatory)][string]$RepoRoot, [ValidateSet('Core', 'Full')] [string]$Tier = 'Core', - [string]$ProductVersion = '3.0.0' + [string]$ProductVersion = '3.0.0', + [string[]]$SectionFilter ) - . (Join-Path $RepoRoot 'lib\PrivacyAudit.ps1') + $want = Resolve-DevShellHealthSectionKeys -Sections $SectionFilter + $runDeveloper = $want -contains 'developer' + $runPrivacy = $want -contains 'privacyConfiguration' + $runBrowser = $want -contains 'browserConfiguration' + $runNetwork = $want -contains 'network' - $doctor = Invoke-DevShellDoctorJson -RepoRoot $RepoRoot -Tier $Tier - $privacy = Get-PrivacyAuditReport -Scope System -RepoRoot $RepoRoot -ProductVersion $ProductVersion - $browser = Get-PrivacyAuditReport -Scope Browser -RepoRoot $RepoRoot -ProductVersion $ProductVersion - $network = Get-PrivacyAuditReport -Scope Vpn -RepoRoot $RepoRoot -ProductVersion $ProductVersion + if ($runPrivacy -or $runBrowser -or $runNetwork) { + . (Join-Path $RepoRoot 'lib\PrivacyAudit.ps1') + } + + $doctor = if ($runDeveloper) { Invoke-DevShellDoctorJson -RepoRoot $RepoRoot -Tier $Tier } else { $null } + $privacy = if ($runPrivacy) { Get-PrivacyAuditReport -Scope System -RepoRoot $RepoRoot -ProductVersion $ProductVersion } else { $null } + $browser = if ($runBrowser) { Get-PrivacyAuditReport -Scope Browser -RepoRoot $RepoRoot -ProductVersion $ProductVersion } else { $null } + $network = if ($runNetwork) { Get-PrivacyAuditReport -Scope Vpn -RepoRoot $RepoRoot -ProductVersion $ProductVersion } else { $null } - $devFail = if ($doctor) { @($doctor.Failed).Count } else { 1 } - $devWarn = if ($doctor) { @($doctor.Warnings).Count } else { 0 } - $devPass = if ($doctor) { @($doctor.Passed).Count } else { 0 } + $devFail = if ($runDeveloper) { if ($doctor) { @($doctor.Failed).Count } else { 1 } } else { 0 } + $devWarn = if ($runDeveloper -and $doctor) { @($doctor.Warnings).Count } else { 0 } + $devPass = if ($runDeveloper -and $doctor) { @($doctor.Passed).Count } else { 0 } - $privacyDoc = ConvertTo-PrivacyReportDocument -Report $privacy -ProductVersion $ProductVersion -Context $privacy.Context + $privacyDoc = if ($runPrivacy) { ConvertTo-PrivacyReportDocument -Report $privacy -ProductVersion $ProductVersion -Context $privacy.Context } else { $null } - $sections = [ordered]@{ - developer = [ordered]@{ + $sections = [ordered]@{} + if ($runDeveloper) { + $sections['developer'] = [ordered]@{ id = 'developer' label = 'Developer' status = Get-DevShellSectionStatus -FailCount $devFail -WarnCount $devWarn @@ -80,7 +123,9 @@ function Get-DevShellHealthReport { tier = $Tier detail = if ($devFail -eq 0) { 'Shell, tools, profile checks' } else { (@($doctor.Failed) | Select-Object -First 2) -join '; ' } } - privacyConfiguration = [ordered]@{ + } + if ($runPrivacy) { + $sections['privacyConfiguration'] = [ordered]@{ id = 'privacyConfiguration' label = 'Privacy Configuration' status = Get-DevShellSectionStatus -FailCount $privacy.FailCount -WarnCount $privacy.WarnCount -ScoreLabel "$($privacy.Score)%" @@ -91,7 +136,9 @@ function Get-DevShellHealthReport { warnings = $privacy.WarnCount failed = $privacy.FailCount } - browserConfiguration = [ordered]@{ + } + if ($runBrowser) { + $sections['browserConfiguration'] = [ordered]@{ id = 'browserConfiguration' label = 'Browser Configuration' status = Get-DevShellSectionStatus -FailCount $browser.FailCount -WarnCount $browser.WarnCount -ScoreLabel "$($browser.Score)%" @@ -99,7 +146,9 @@ function Get-DevShellHealthReport { warnings = $browser.WarnCount detail = 'Policy and prefs audit — not a full browser security review.' } - network = [ordered]@{ + } + if ($runNetwork) { + $sections['network'] = [ordered]@{ id = 'network' label = 'Network' status = Get-DevShellSectionStatus -FailCount $network.FailCount -WarnCount $network.WarnCount @@ -109,7 +158,9 @@ function Get-DevShellHealthReport { } } - $ready = ($devFail -eq 0) -and ($privacy.FailCount -eq 0) + $ready = $true + if ($runDeveloper -and $devFail -gt 0) { $ready = $false } + if ($runPrivacy -and $privacy.FailCount -gt 0) { $ready = $false } $message = if ($ready) { 'Ready to work.' } else { 'Not ready yet.' } [PSCustomObject]@{ @@ -118,10 +169,11 @@ function Get-DevShellHealthReport { timestamp = (Get-Date).ToString('o') philosophy = 'HomeBase DevShell prepares, verifies and maintains professional Windows workstations.' tier = $Tier + sectionsRequested = @($want) sections = $sections privacyReport = $privacyDoc - browserReport = (ConvertTo-PrivacyReportDocument -Report $browser -ProductVersion $ProductVersion -Context $browser.Context) - networkReport = (ConvertTo-PrivacyReportDocument -Report $network -ProductVersion $ProductVersion -Context $network.Context) + browserReport = if ($runBrowser) { ConvertTo-PrivacyReportDocument -Report $browser -ProductVersion $ProductVersion -Context $browser.Context } else { $null } + networkReport = if ($runNetwork) { ConvertTo-PrivacyReportDocument -Report $network -ProductVersion $ProductVersion -Context $network.Context } else { $null } doctorReport = $doctor summary = [ordered]@{ ready = $ready diff --git a/modules/KGreen.Workstation.psd1 b/modules/KGreen.Workstation.psd1 index ab6bbcb..8adafc0 100644 --- a/modules/KGreen.Workstation.psd1 +++ b/modules/KGreen.Workstation.psd1 @@ -42,7 +42,7 @@ Tags = @('DevReady', 'HomeBase', 'DevShell', 'PowerShell', 'Windows', 'health-check', 'developer-experience', 'workstation-setup') LicenseUri = 'https://opensource.org/licenses/MIT' ProjectUri = 'https://github.com/XKush/homebase-devshell' - ReleaseNotes = 'DevReady v3.0.1 — health resilience (corrupt baseline/history), stress matrix tests.' + ReleaseNotes = 'DevReady v3.0.1+dev — health -Sections, Pester + PSScriptAnalyzer CI (3.1.0 prep).' } } } diff --git a/scripts/maintainer/invoke/Invoke-DevShellHealth.ps1 b/scripts/maintainer/invoke/Invoke-DevShellHealth.ps1 index 08adf44..3dd91a1 100644 --- a/scripts/maintainer/invoke/Invoke-DevShellHealth.ps1 +++ b/scripts/maintainer/invoke/Invoke-DevShellHealth.ps1 @@ -10,6 +10,7 @@ param( [switch]$Json, [ValidateSet('html')] [string]$Export, + [string[]]$SectionFilter, [string]$OutFile ) @@ -21,7 +22,7 @@ $repoRoot = Resolve-WorkstationRepoRoot -Start $PSScriptRoot . (Join-Path $PSScriptRoot '_PrivacyInvokeCommon.ps1') $product = Get-DevShellProductVersionFromRoot -RepoRoot $repoRoot -$report = Get-DevShellHealthReport -RepoRoot $repoRoot -Tier $Tier -ProductVersion $product +$report = Get-DevShellHealthReport -RepoRoot $repoRoot -Tier $Tier -ProductVersion $product -SectionFilter $SectionFilter $paths = Get-DevShellHealthPaths -RepoRoot $repoRoot Save-DevShellHealthHistory -Report $report -HistoryPath $paths.History diff --git a/scripts/maintainer/test/Measure-DevShellHealthProfile.ps1 b/scripts/maintainer/test/Measure-DevShellHealthProfile.ps1 new file mode 100644 index 0000000..fe4d3dd --- /dev/null +++ b/scripts/maintainer/test/Measure-DevShellHealthProfile.ps1 @@ -0,0 +1,29 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Measure devshell doctor vs health JSON timing (maintainer profiling). +#> +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot '..\_Resolve-RepoRoot.ps1') +$Root = Resolve-WorkstationRepoRoot -Start $PSScriptRoot +$env:HOMEBASE_DEVSHELL_ROOT = $Root + +Write-Host 'DevShell health profiling' -ForegroundColor Cyan +Write-Host " Root: $Root" -ForegroundColor DarkGray +Write-Host '' + +$doctor = Measure-Command { + pwsh -NoProfile -File (Join-Path $Root 'devshell.ps1') doctor -Json *> $null +} +$health = Measure-Command { + pwsh -NoProfile -File (Join-Path $Root 'devshell.ps1') health -Json *> $null +} +$healthDev = Measure-Command { + pwsh -NoProfile -File (Join-Path $Root 'devshell.ps1') health -Json -Sections developer *> $null +} + +Write-Host (" doctor -Json: {0:N2}s" -f $doctor.TotalSeconds) +Write-Host (" health -Json (full): {0:N2}s" -f $health.TotalSeconds) +Write-Host (" health -Json -Sections dev:{0:N2}s" -f $healthDev.TotalSeconds) +Write-Host '' +Write-Host 'Registry/CIM: run privacy with -Verbose and count Get-ItemProperty lines in log.' -ForegroundColor DarkGray diff --git a/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 b/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 index 0fd9986..aa2750f 100644 --- a/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 +++ b/scripts/maintainer/test/Test-DevShellStressMatrix.ps1 @@ -95,6 +95,15 @@ if (`$diff.noBaseline) { @{ error = 'no_baseline'; message = 'Run: devshell base if ($out -notmatch 'no_baseline') { throw "bad envelope: $out" } } +Assert-Stress 'health -Sections developer only' { + $env:HOMEBASE_DEVSHELL_ROOT = $Root + $out = pwsh -NoProfile -File (Join-Path $Root 'devshell.ps1') health -Json -Sections developer 2>&1 | Out-String + $doc = $out.Trim() | ConvertFrom-Json + if (-not $doc.sections.developer) { throw 'missing developer section' } + if ($doc.sections.privacyConfiguration) { throw 'privacy should be omitted' } + if ($doc.sectionsRequested -notcontains 'developer') { throw 'sectionsRequested missing developer' } +} + Assert-Stress 'empty logs dir — doctor json path tolerant' { $tempLogs = Join-Path $env:TEMP "devshell-stress-logs-$([guid]::NewGuid().ToString('N'))" New-Item -ItemType Directory -Force -Path $tempLogs | Out-Null diff --git a/tests/pester/Health.Baseline.Tests.ps1 b/tests/pester/Health.Baseline.Tests.ps1 new file mode 100644 index 0000000..a76426f --- /dev/null +++ b/tests/pester/Health.Baseline.Tests.ps1 @@ -0,0 +1,54 @@ +#Requires -Version 7.0 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + $script:Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + . (Join-Path $script:Root 'lib\DevShellHealth.ps1') + $script:Sample = [PSCustomObject]@{ + timestamp = (Get-Date).ToString('o') + sections = [ordered]@{ + developer = @{ label = 'Developer'; status = 'PASS' } + privacyConfiguration = @{ label = 'Privacy Configuration'; status = 'PASS'; score = 90 } + } + privacyReport = @{ checks = @(@{ id = 'doh'; status = 'Pass' }) } + } +} + +Describe 'Compare-DevShellHealthBaseline' { + It 'returns noBaseline when file missing' { + $path = Join-Path $env:TEMP "pester-nobase-$([guid]::NewGuid().ToString('N')).json" + $r = Compare-DevShellHealthBaseline -Current $script:Sample -BaselinePath $path + $r.noBaseline | Should -Be $true + $r.driftDetected | Should -Be $false + } + + It 'returns baselineInvalid on corrupt JSON' { + $path = Join-Path $env:TEMP "pester-badbase-$([guid]::NewGuid().ToString('N')).json" + try { + Set-Content $path '{ not-json' -Encoding UTF8 + $r = Compare-DevShellHealthBaseline -Current $script:Sample -BaselinePath $path + $r.baselineInvalid | Should -Be $true + $r.driftDetected | Should -Be $true + } finally { + Remove-Item $path -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Resolve-DevShellHealthSectionKeys' { + It 'defaults to all sections when empty' { + $keys = Resolve-DevShellHealthSectionKeys -Sections @() + $keys.Count | Should -Be 4 + } + + It 'resolves developer alias' { + $keys = Resolve-DevShellHealthSectionKeys -Sections @('developer') + $keys | Should -Be @('developer') + } + + It 'resolves comma-separated names' { + $keys = Resolve-DevShellHealthSectionKeys -Sections @('developer,privacy') + $keys | Should -Contain 'developer' + $keys | Should -Contain 'privacyConfiguration' + } +}