diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a6965f6..6d921ac 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,4 +4,4 @@ This is a PowerShell 3.0+ module for managing the ConnectWise Automate Windows a Key file locations: `source/Public/` (exported), `source/Private/` (internal), `Tests/`, `.build/`. -Before committing: `./Tests/test-local.ps1` +Before committing: run `./Scripts/Invoke-QuickTest.ps1 -IncludeAnalyzer -OutputFormat Structured` and verify `success` is `true`. Before pushing: `./Tests/test-local.ps1`. See AGENTS.md for full workflow. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98311ae..0275ee6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,14 @@ # semantic versioning. # # Branch strategy: -# develop push → build → test → analyze → publish prerelease → GitHub Release -# main push → build → test → analyze → publish stable → GitHub Release -# pull requests → build → test → analyze (no publish, no release) +# develop push → build → smoke-test → publish prerelease → GitHub Release +# main push → build → smoke-test → publish stable → GitHub Release +# pull requests → build → smoke-test (no publish, no release) +# +# Testing strategy: +# Full validation (build + PSScriptAnalyzer + Pester) runs LOCALLY before every +# commit via ./Tests/test-local.ps1. CI provides a safety-net smoke test only. +# See AGENTS.md for the mandatory local validation workflow. # # Versioning: # GitVersion calculates the version from git history and branch. @@ -95,8 +100,8 @@ jobs: path: output/RequiredModules retention-days: 1 - test: - name: Test + smoke-test: + name: Smoke Test needs: build runs-on: windows-latest steps: @@ -114,6 +119,19 @@ jobs: name: required-modules path: output/RequiredModules + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + $env:PSModulePath = "$(Resolve-Path output/RequiredModules)" + [IO.Path]::PathSeparator + $env:PSModulePath + Import-Module PSScriptAnalyzer + # Analyze .ps1/.psm1 only; .psd1 excluded due to PSScriptAnalyzer NullRef bug + $files = Get-ChildItem -Path source -Include '*.ps1','*.psm1' -Recurse -File + $results = $files | ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName -Settings .PSScriptAnalyzerSettings.psd1 } + if ($results) { + $results | Format-Table -AutoSize + throw "PSScriptAnalyzer found $($results.Count) issue(s)" + } + - name: Run Pester tests shell: pwsh run: | @@ -131,36 +149,12 @@ jobs: retention-days: 7 if-no-files-found: ignore - analyze: - name: PSScriptAnalyzer - needs: build - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Download required modules - uses: actions/download-artifact@v4 - with: - name: required-modules - path: output/RequiredModules - - - name: Run PSScriptAnalyzer - shell: pwsh - run: | - $env:PSModulePath = "$(Resolve-Path output/RequiredModules)" + [IO.Path]::PathSeparator + $env:PSModulePath - Import-Module PSScriptAnalyzer - $results = Invoke-ScriptAnalyzer -Path source -Recurse -Severity Error,Warning -Settings .PSScriptAnalyzerSettings.psd1 - if ($results) { - $results | Format-Table -AutoSize - throw "PSScriptAnalyzer found $($results.Count) issue(s)" - } - # ─── Publish jobs ─────────────────────────────────────────────────────── publish-prerelease: name: Publish Prerelease if: github.ref == 'refs/heads/develop' && github.event_name == 'push' - needs: [build, test, analyze] + needs: [build, smoke-test] runs-on: windows-latest environment: PSGallery steps: @@ -215,7 +209,7 @@ jobs: publish-stable: name: Publish Stable if: github.ref == 'refs/heads/main' && github.event_name == 'push' - needs: [build, test, analyze] + needs: [build, smoke-test] runs-on: windows-latest environment: PSGallery steps: @@ -299,8 +293,10 @@ jobs: Write-Host "Release $tag already exists. Skipping." "skip=true" >> $env:GITHUB_OUTPUT } else { + Write-Host "Release $tag not found. Will create." "skip=false" >> $env:GITHUB_OUTPUT } + exit 0 - name: Download release notes if: steps.check.outputs.skip != 'true' @@ -383,8 +379,10 @@ jobs: Write-Host "Release $tag already exists. Skipping." "skip=true" >> $env:GITHUB_OUTPUT } else { + Write-Host "Release $tag not found. Will create." "skip=false" >> $env:GITHUB_OUTPUT } + exit 0 - name: Download release notes if: steps.check.outputs.skip != 'true' diff --git a/AGENTS.md b/AGENTS.md index 215c738..48858da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ The build uses [Sampler](https://github.com/gaelcolas/Sampler) with ModuleBuilde ### CI/CD -CI is intentionally lightweight (smoke test, build, publish). Full testing is local. See the header comments in `.github/workflows/ci.yml` for branch strategy and gating rules. +CI runs build + a consolidated smoke test (PSScriptAnalyzer + Pester on one runner) + publish. It is a safety net -- local validation is the primary gate. See CLAUDE.md for the mandatory validation workflow and `.github/workflows/ci.yml` for branch strategy. ### Common Patterns @@ -175,6 +175,7 @@ See `Get-Help .\Tests\test-local.ps1` for flags: `-SkipBuild`, `-SkipTests`, `-S ### Key Rules +- **Local validation is mandatory before every commit.** Run `Invoke-QuickTest.ps1 -IncludeAnalyzer` during development and `test-local.ps1` before committing. CI is a safety net, not the primary gate. - No code is complete without passing tests. A function without a test is unfinished work. - PSScriptAnalyzer zero errors required. Run against `source/`. Always use `-IncludeAnalyzer` during development. - Dual-mode testing details: `Get-Help .\Tests\Invoke-AllTests.ps1` @@ -193,10 +194,19 @@ gh issue edit --add-label ai-in-progress --remove-label ai-ready 1. Claim the issue (above), create branch: `git checkout -b feature/-short-description` 2. Read the full issue body and acceptance criteria -3. Implement, add tests, iterate with `Invoke-QuickTest.ps1 -IncludeAnalyzer -OutputFormat Structured` -4. Run `./build.ps1 -Tasks build` then `./Tests/test-local.ps1` -- build + analyze + test must all pass -5. Commit referencing the issue: `Add feature X (fixes #123)` -6. Push, open PR, update label: `gh issue edit --add-label ai-review --remove-label ai-in-progress` +3. Implement changes, adding tests as you go +4. **After each meaningful change**, validate: + ```powershell + ./Scripts/Invoke-QuickTest.ps1 -IncludeAnalyzer -OutputFormat Structured + ``` + Parse the JSON `success` field. Fix all `failedTests` and `analyzerErrors` before proceeding. +5. **Before committing**, run the full pipeline: + ```powershell + ./Tests/test-local.ps1 + ``` + All three stages (build, analyze, test) must pass. Do not commit until they do. +6. Commit referencing the issue: `Add feature X (fixes #123)` +7. Push, open PR, update label: `gh issue edit --add-label ai-review --remove-label ai-in-progress` ### Guardrails diff --git a/CLAUDE.md b/CLAUDE.md index f99baf9..4c5960d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,26 @@ For project context, architecture, build commands, code conventions, and contribution workflow, read [AGENTS.md](AGENTS.md). That is the single source of truth for all AI agents. +## Mandatory Validation + +**After each meaningful change**, run: + +```powershell +./Scripts/Invoke-QuickTest.ps1 -IncludeAnalyzer -OutputFormat Structured +``` + +Parse the JSON output. If `success` is `false`, fix all `failedTests` and `analyzerErrors` before proceeding. + +**Before every commit**, run the full pipeline: + +```powershell +./Tests/test-local.ps1 +``` + +All three stages (build, analyze, test) must pass. Do not commit until they do. + +CI is a safety net only -- failures should never first appear there. + ## Session State Use `.claude/plan.md` as a personal scratchpad for the current session. It is gitignored and not shared across agents or users. diff --git a/Scripts/Invoke-QuickTest.ps1 b/Scripts/Invoke-QuickTest.ps1 index 5a89d66..fe585e4 100644 --- a/Scripts/Invoke-QuickTest.ps1 +++ b/Scripts/Invoke-QuickTest.ps1 @@ -171,12 +171,19 @@ if ($IncludeAnalyzer) { } if ($analyzePath) { - $analyzeParams = @{ Path = $analyzePath; Recurse = $true } + # Analyze .ps1/.psm1 only; .psd1 excluded due to PSScriptAnalyzer NullRef bug + $analyzeParams = @{} if (Test-Path $settingsFile) { $analyzeParams['Settings'] = $settingsFile } - $analyzeResults = Invoke-ScriptAnalyzer @analyzeParams + if (Test-Path $analyzePath -PathType Leaf) { + $analyzeResults = Invoke-ScriptAnalyzer -Path $analyzePath @analyzeParams + } + else { + $files = Get-ChildItem -Path $analyzePath -Include '*.ps1','*.psm1' -Recurse -File + $analyzeResults = $files | ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName @analyzeParams } + } if ($analyzeResults) { $analyzerErrors = @($analyzeResults | Where-Object Severity -eq 'Error' | ForEach-Object { diff --git a/Tests/test-local.ps1 b/Tests/test-local.ps1 index f83133e..e8cd4a8 100644 --- a/Tests/test-local.ps1 +++ b/Tests/test-local.ps1 @@ -72,27 +72,19 @@ if (-not $SkipAnalyze) { $sourcePath = Join-Path $ProjectRoot 'source' $settingsFile = Join-Path $ProjectRoot '.PSScriptAnalyzerSettings.psd1' - $analyzeParams = @{ - Path = $sourcePath - Recurse = $true - } + # Analyze .ps1/.psm1 only; .psd1 excluded due to PSScriptAnalyzer NullRef bug + $files = Get-ChildItem -Path $sourcePath -Include '*.ps1','*.psm1' -Recurse -File + $analyzeParams = @{} if (Test-Path $settingsFile) { $analyzeParams['Settings'] = $settingsFile } - $results = Invoke-ScriptAnalyzer @analyzeParams + $results = $files | ForEach-Object { Invoke-ScriptAnalyzer -Path $_.FullName @analyzeParams } if ($results) { $results | Format-Table -AutoSize - $errors = @($results | Where-Object Severity -eq 'Error') - - if ($errors.Count -gt 0) { - Write-Host "PSScriptAnalyzer found $($errors.Count) error(s)" -ForegroundColor Red - exit 1 - } - else { - Write-Host "PSScriptAnalyzer warnings found (but no errors)`n" -ForegroundColor Yellow - } + Write-Host "PSScriptAnalyzer found $($results.Count) issue(s)" -ForegroundColor Red + exit 1 } else { Write-Host "PSScriptAnalyzer PASSED - no issues found`n" -ForegroundColor Green diff --git a/source/ConnectWiseAutomateAgent.psd1 b/source/ConnectWiseAutomateAgent.psd1 index 854d81d..60d33d7 100644 --- a/source/ConnectWiseAutomateAgent.psd1 +++ b/source/ConnectWiseAutomateAgent.psd1 @@ -25,8 +25,8 @@ PowerShellVersion = '3.0' # Functions to export from this module - # Wildcard for development; ModuleBuilder writes explicit list at build time - FunctionsToExport = @('*') + # ModuleBuilder overwrites this list at build time + FunctionsToExport = @('ConvertFrom-CWAASecurity','ConvertTo-CWAASecurity','Get-CWAAError','Get-CWAAInfo','Get-CWAAInfoBackup','Get-CWAALogLevel','Get-CWAAProbeError','Get-CWAAProxy','Get-CWAASettings','Hide-CWAAAddRemove','Install-CWAA','Invoke-CWAACommand','New-CWAABackup','Redo-CWAA','Register-CWAAHealthCheckTask','Rename-CWAAAddRemove','Repair-CWAA','Reset-CWAA','Restart-CWAA','Set-CWAALogLevel','Set-CWAAProxy','Show-CWAAAddRemove','Start-CWAA','Stop-CWAA','Test-CWAAHealth','Test-CWAAPort','Test-CWAAServerConnectivity','Uninstall-CWAA','Unregister-CWAAHealthCheckTask','Update-CWAA') # Cmdlets to export from this module CmdletsToExport = @() @@ -35,8 +35,8 @@ VariablesToExport = @() # Aliases to export from this module - # Wildcard for development; ModuleBuilder discovers [Alias()] attributes at build time - AliasesToExport = @('*') + # ModuleBuilder discovers [Alias()] attributes at build time + AliasesToExport = @('ConvertFrom-LTSecurity','ConvertTo-LTSecurity','Get-LTErrors','Get-LTLogging','Get-LTProbeErrors','Get-LTProxy','Get-LTServiceInfo','Get-LTServiceInfoBackup','Get-LTServiceSettings','Hide-LTAddRemove','Install-LTService','Invoke-LTServiceCommand','New-LTServiceBackup','Redo-LTService','Register-LTHealthCheckTask','Reinstall-CWAA','Reinstall-LTService','Rename-LTAddRemove','Repair-LTService','Reset-LTService','Restart-LTService','Set-LTLogging','Set-LTProxy','Show-LTAddRemove','Start-LTService','Stop-LTService','Test-LTHealth','Test-LTPorts','Test-LTServerConnectivity','Uninstall-LTService','Unregister-LTHealthCheckTask','Update-LTService') # Private data to pass to the module specified in RootModule/ModuleToProcess PrivateData = @{ diff --git a/source/Public/InstallUninstall/Uninstall-CWAA.ps1 b/source/Public/InstallUninstall/Uninstall-CWAA.ps1 index 20e978e..6a47c03 100644 --- a/source/Public/InstallUninstall/Uninstall-CWAA.ps1 +++ b/source/Public/InstallUninstall/Uninstall-CWAA.ps1 @@ -1,4 +1,4 @@ -function Uninstall-CWAA { +function Uninstall-CWAA { <# .SYNOPSIS Completely uninstalls the ConnectWise Automate Agent from the local computer. @@ -208,7 +208,7 @@ function Uninstall-CWAA { if ($PSCmdlet.ShouldProcess("$uninstaller", 'DownloadFile')) { Write-Debug "Downloading Agent_Uninstall.exe from $uninstaller" $Script:LTServiceNetWebClient.DownloadFile($uninstaller, "${env:windir}\temp\Agent_Uninstall.exe") - # Uninstall EXE is smaller than MSI — use 80 KB threshold + # Uninstall EXE is smaller than MSI � use 80 KB threshold if (-not (Test-CWAADownloadIntegrity -FilePath "${env:windir}\temp\Agent_Uninstall.exe" -FileName 'Agent_Uninstall.exe' -MinimumSizeKB 80)) { return } diff --git a/source/Public/Service/Repair-CWAA.ps1 b/source/Public/Service/Repair-CWAA.ps1 index e762d88..1ac2158 100644 --- a/source/Public/Service/Repair-CWAA.ps1 +++ b/source/Public/Service/Repair-CWAA.ps1 @@ -1,4 +1,4 @@ -function Repair-CWAA { +function Repair-CWAA { <# .SYNOPSIS Performs escalating remediation of the ConnectWise Automate agent. @@ -6,14 +6,14 @@ function Repair-CWAA { Checks the health of the installed Automate agent and takes corrective action using an escalating strategy: - 1. If the agent is installed and healthy — no action taken. - 2. If the agent is installed but has not checked in within HoursRestart — restarts + 1. If the agent is installed and healthy � no action taken. + 2. If the agent is installed but has not checked in within HoursRestart � restarts services and waits up to 2 minutes for the agent to recover. - 3. If the agent is still not checking in after HoursReinstall — reinstalls the agent + 3. If the agent is still not checking in after HoursReinstall � reinstalls the agent using Redo-CWAA. - 4. If the agent configuration is unreadable — uninstalls and reinstalls. - 5. If the installed agent points to the wrong server — reinstalls with the correct server. - 6. If the agent is not installed — performs a fresh install from provided parameters + 4. If the agent configuration is unreadable � uninstalls and reinstalls. + 5. If the installed agent points to the wrong server � reinstalls with the correct server. + 6. If the agent is not installed � performs a fresh install from provided parameters or from backup settings. All remediation actions are logged to the Windows Event Log (Application log, @@ -106,7 +106,7 @@ function Repair-CWAA { $agentServiceExists = [bool](Get-Service 'LTService' -ErrorAction SilentlyContinue) if ($agentServiceExists) { - #region Agent is installed — check health and remediate + #region Agent is installed � check health and remediate # Verify we can read agent configuration $agentInfo = $Null @@ -114,7 +114,7 @@ function Repair-CWAA { $agentInfo = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False } Catch { - # Agent config is unreadable — uninstall so we can reinstall cleanly + # Agent config is unreadable � uninstall so we can reinstall cleanly Write-Warning "Unable to read agent configuration. Uninstalling for clean reinstall." Write-CWAAEventLog -EventId 4009 -EntryType Warning -Message "Agent configuration unreadable. Uninstalling for clean reinstall. Error: $($_.Exception.Message)" @@ -194,7 +194,7 @@ function Repair-CWAA { [datetime]$lastContact = $agentInfo.HeartbeatLastReceived } Catch { - # No valid contact timestamp — treat as very old + # No valid contact timestamp � treat as very old [datetime]$lastContact = (Get-Date).AddYears(-1) } } @@ -319,7 +319,7 @@ function Repair-CWAA { #endregion } else { - #region Agent is NOT installed — attempt install + #region Agent is NOT installed � attempt install Write-Verbose 'Agent service not found. Attempting installation.' Write-CWAAEventLog -EventId 4003 -EntryType Warning -Message 'Agent not installed. Attempting installation.' diff --git a/source/Public/Service/Test-CWAAHealth.ps1 b/source/Public/Service/Test-CWAAHealth.ps1 index a7d8cba..89bb89c 100644 --- a/source/Public/Service/Test-CWAAHealth.ps1 +++ b/source/Public/Service/Test-CWAAHealth.ps1 @@ -1,4 +1,4 @@ -function Test-CWAAHealth { +function Test-CWAAHealth { <# .SYNOPSIS Performs a read-only health assessment of the ConnectWise Automate agent. @@ -60,7 +60,7 @@ function Test-CWAAHealth { } Process { - # Defaults — populated progressively as checks succeed + # Defaults � populated progressively as checks succeed $agentInstalled = $False $servicesRunning = $False $lastContact = $Null