From 89d7df5ea9ffb4313814b25fd591892fe5e1b30e Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Sat, 31 Jan 2026 23:47:42 -0700 Subject: [PATCH 1/5] v1.0.0-alpha001: Major module overhaul for prerelease Health check system (Test-CWAAHealth, Repair-CWAA, scheduled task management), server connectivity testing, Windows Event Log integration, lazy networking initialization with graduated SSL trust, installer cleanup utility, credential redaction, and 3 private helpers eliminating ~300 lines of duplication. 403 tests across 4 suites, PSScriptAnalyzer clean, CI/CD pipeline for PSGallery publish, 6 example scripts, full documentation with architecture diagrams, input validation hardening, and WhatIf/Confirm on all destructive operations. Variable naming cleanup, error handling standardization, and PowerShell Core compatibility (5.1 + 7+). Co-Authored-By: Claude Opus 4.5 --- .PSScriptAnalyzerSettings.psd1 | 38 + .editorconfig | 29 + .github/workflows/ci-publish.yml | 122 + .gitignore | 22 +- Build/Build-Documentation.ps1 | 215 + Build/Publish-CWAAModule.ps1 | 161 + Build/SingleFileBuild.ps1 | 103 +- CHANGELOG.md | 78 + CLAUDE.md | 116 + CONTRIBUTING.md | 98 + ConnectWiseAutomateAgent.ps1 | Bin 286330 -> 236093 bytes .../ConnectWiseAutomateAgent.psd1 | Bin 11494 -> 4080 bytes .../ConnectWiseAutomateAgent.psm1 | 7 + .../Private/Clear-CWAAInstallerArtifacts.ps1 | 43 + .../Initialize/Get-CurrentLineNumber.ps1 | 5 - .../Private/Initialize/Initialize-CWAA.ps1 | 148 +- .../Initialize/Initialize-CWAAKeys.ps1 | 26 - .../Initialize/Initialize-CWAAModule.ps1 | 27 - .../Initialize/Initialize-CWAANetworking.ps1 | 140 + .../Private/Remove-CWAAFolderRecursive.ps1 | 68 + .../Private/Resolve-CWAAServer.ps1 | 85 + .../Private/Test-CWAADownloadIntegrity.ps1 | 58 + .../Private/Write-CWAAEventLog.ps1 | 58 + .../AddRemovePrograms/Hide-CWAAAddRemove.ps1 | 72 +- .../Rename-CWAAAddRemove.ps1 | 90 +- .../AddRemovePrograms/Show-CWAAAddRemove.ps1 | 74 +- .../Public/ConvertFrom-CWAASecurity.ps1 | 57 +- .../Public/ConvertTo-CWAASecurity.ps1 | 59 +- .../Public/InstallUninstall/Install-CWAA.ps1 | 446 +- .../Public/InstallUninstall/Redo-CWAA.ps1 | 137 +- .../InstallUninstall/Uninstall-CWAA.ps1 | 383 +- .../Public/InstallUninstall/Update-CWAA.ps1 | 272 +- .../Public/Invoke-CWAACommand.ps1 | 151 +- .../Public/Logging/Get-CWAAError.ps1 | 63 +- .../Public/Logging/Get-CWAALogLevel.ps1 | 59 +- .../Public/Logging/Get-CWAAProbeError.ps1 | 64 +- .../Public/Logging/Set-CWAALogLevel.ps1 | 62 +- .../Public/Proxy/Get-CWAAProxy.ps1 | 90 +- .../Public/Proxy/Set-CWAAProxy.ps1 | 175 +- .../Service/Register-CWAAHealthCheckTask.ps1 | 204 + .../Public/Service/Repair-CWAA.ps1 | 396 ++ .../Public/Service/Restart-CWAA.ps1 | 64 +- .../Public/Service/Start-CWAA.ps1 | 113 +- .../Public/Service/Stop-CWAA.ps1 | 89 +- .../Public/Service/Test-CWAAHealth.ps1 | 154 + .../Unregister-CWAAHealthCheckTask.ps1 | 79 + .../Public/Settings/Get-CWAAInfo.ps1 | 78 +- .../Public/Settings/Get-CWAAInfoBackup.ps1 | 46 +- .../Public/Settings/Get-CWAASettings.ps1 | 43 +- .../Public/Settings/New-CWAABackup.ps1 | 122 +- .../Public/Settings/Reset-CWAA.ps1 | 106 +- .../Public/Test-CWAAPort.ps1 | 129 +- .../Public/Test-CWAAServerConnectivity.ps1 | 143 + .../en-US/ConnectWiseAutomateAgent-help.xml | 4139 ++++++++++++----- .../about_ConnectWiseAutomateAgent.help.txt | 64 + ConnectWiseAutomateAgent_Functions.md | 91 - Docs/Architecture.md | 321 ++ Docs/CommonParameters.md | 175 + Docs/FAQ.md | 175 + Docs/Get-CWAAError.md | 67 - Docs/Get-CWAAInfo.md | 96 - Docs/Get-CWAAInfoBackup.md | 56 - Docs/Get-CWAALogLevel.md | 44 - Docs/Get-CWAAProbeError.md | 66 - Docs/Get-CWAAProxy.md | 60 - Docs/Get-CWAASettings.md | 44 - Docs/Help/ConnectWiseAutomateAgent.md | 83 + Docs/{ => Help}/ConvertFrom-CWAASecurity.md | 64 +- Docs/{ => Help}/ConvertTo-CWAASecurity.md | 51 +- Docs/Help/Get-CWAAError.md | 75 + Docs/Help/Get-CWAAInfo.md | 108 + Docs/Help/Get-CWAAInfoBackup.md | 75 + Docs/Help/Get-CWAALogLevel.md | 76 + Docs/Help/Get-CWAAProbeError.md | 75 + Docs/Help/Get-CWAAProxy.md | 76 + Docs/Help/Get-CWAASettings.md | 74 + Docs/{ => Help}/Hide-CWAAAddRemove.md | 67 +- Docs/Help/Install-CWAA.md | 308 ++ Docs/Help/Invoke-CWAACommand.md | 124 + Docs/Help/New-CWAABackup.md | 118 + .../Private/Clear-CWAAInstallerArtifacts.md | 64 + Docs/Help/Private/Initialize-CWAA.md | 64 + .../Help/Private/Initialize-CWAANetworking.md | 90 + .../Private/Remove-CWAAFolderRecursive.md | 82 + Docs/Help/Private/Resolve-CWAAServer.md | 87 + .../Private/Test-CWAADownloadIntegrity.md | 102 + Docs/Help/Private/Write-CWAAEventLog.md | 117 + Docs/{ => Help}/Redo-CWAA.md | 219 +- Docs/Help/Register-CWAAHealthCheckTask.md | 217 + Docs/{ => Help}/Rename-CWAAAddRemove.md | 74 +- Docs/Help/Repair-CWAA.md | 224 + Docs/{ => Help}/Reset-CWAA.md | 104 +- .../Restart-CWAA.md} | 74 +- Docs/Help/Set-CWAALogLevel.md | 132 + Docs/{ => Help}/Set-CWAAProxy.md | 183 +- Docs/{ => Help}/Show-CWAAAddRemove.md | 68 +- Docs/Help/Start-CWAA.md | 109 + .../Stop-CWAA.md} | 74 +- Docs/Help/Test-CWAAHealth.md | 130 + Docs/Help/Test-CWAAPort.md | 121 + Docs/Help/Test-CWAAServerConnectivity.md | 117 + Docs/Help/Uninstall-CWAA.md | 226 + .../Unregister-CWAAHealthCheckTask.md} | 71 +- Docs/Help/Update-CWAA.md | 152 + Docs/Install-CWAA.md | 310 -- Docs/Migration.md | 167 + Docs/New-CWAABackup.md | 44 - Docs/Private/Get-CurrentLineNumber.md | 45 - Docs/Private/Initialize-CWAA.md | 45 - Docs/Private/Initialize-CWAAKeys.md | 45 - Docs/Private/Initialize-CWAAModule.md | 45 - Docs/Restart-CWAA.md | 90 - Docs/Security.md | 144 + Docs/Set-CWAALogLevel.md | 67 - Docs/Start-CWAA.md | 99 - Docs/Stop-CWAA.md | 91 - Docs/Test-CWAAPort.md | 122 - Docs/Troubleshooting.md | 409 ++ Examples/AgentInstall.ps1 | 22 +- Examples/AgentInstallWithHealthCheck.ps1 | 110 + Examples/GPOScheduledTaskDeployment.ps1 | 222 + Examples/HealthCheck-Monitoring.ps1 | 245 + Examples/PipelineUsage.ps1 | 183 + Examples/ProxyConfiguration.ps1 | 191 + Examples/Troubleshooting-QuickDiagnostic.ps1 | 276 ++ LICENSE | 2 +- MAP.md | 170 + README.md | 164 +- TODO.md | 334 ++ ...ctWiseAutomateAgent.CrossVersion.Tests.ps1 | 219 + Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 | 1194 +++++ .../ConnectWiseAutomateAgent.Mocked.Tests.ps1 | 2642 +++++++++++ Tests/ConnectWiseAutomateAgent.Tests.ps1 | 688 +++ 133 files changed, 19290 insertions(+), 4435 deletions(-) create mode 100644 .PSScriptAnalyzerSettings.psd1 create mode 100644 .editorconfig create mode 100644 .github/workflows/ci-publish.yml create mode 100644 Build/Build-Documentation.ps1 create mode 100644 Build/Publish-CWAAModule.ps1 create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 delete mode 100644 ConnectWiseAutomateAgent/Private/Initialize/Get-CurrentLineNumber.ps1 delete mode 100644 ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAKeys.ps1 delete mode 100644 ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAModule.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 create mode 100644 ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 create mode 100644 ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 create mode 100644 ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 create mode 100644 ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 create mode 100644 ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 delete mode 100644 ConnectWiseAutomateAgent_Functions.md create mode 100644 Docs/Architecture.md create mode 100644 Docs/CommonParameters.md create mode 100644 Docs/FAQ.md delete mode 100644 Docs/Get-CWAAError.md delete mode 100644 Docs/Get-CWAAInfo.md delete mode 100644 Docs/Get-CWAAInfoBackup.md delete mode 100644 Docs/Get-CWAALogLevel.md delete mode 100644 Docs/Get-CWAAProbeError.md delete mode 100644 Docs/Get-CWAAProxy.md delete mode 100644 Docs/Get-CWAASettings.md create mode 100644 Docs/Help/ConnectWiseAutomateAgent.md rename Docs/{ => Help}/ConvertFrom-CWAASecurity.md (53%) rename Docs/{ => Help}/ConvertTo-CWAASecurity.md (53%) create mode 100644 Docs/Help/Get-CWAAError.md create mode 100644 Docs/Help/Get-CWAAInfo.md create mode 100644 Docs/Help/Get-CWAAInfoBackup.md create mode 100644 Docs/Help/Get-CWAALogLevel.md create mode 100644 Docs/Help/Get-CWAAProbeError.md create mode 100644 Docs/Help/Get-CWAAProxy.md create mode 100644 Docs/Help/Get-CWAASettings.md rename Docs/{ => Help}/Hide-CWAAAddRemove.md (50%) create mode 100644 Docs/Help/Install-CWAA.md create mode 100644 Docs/Help/Invoke-CWAACommand.md create mode 100644 Docs/Help/New-CWAABackup.md create mode 100644 Docs/Help/Private/Clear-CWAAInstallerArtifacts.md create mode 100644 Docs/Help/Private/Initialize-CWAA.md create mode 100644 Docs/Help/Private/Initialize-CWAANetworking.md create mode 100644 Docs/Help/Private/Remove-CWAAFolderRecursive.md create mode 100644 Docs/Help/Private/Resolve-CWAAServer.md create mode 100644 Docs/Help/Private/Test-CWAADownloadIntegrity.md create mode 100644 Docs/Help/Private/Write-CWAAEventLog.md rename Docs/{ => Help}/Redo-CWAA.md (50%) create mode 100644 Docs/Help/Register-CWAAHealthCheckTask.md rename Docs/{ => Help}/Rename-CWAAAddRemove.md (54%) create mode 100644 Docs/Help/Repair-CWAA.md rename Docs/{ => Help}/Reset-CWAA.md (56%) rename Docs/{Uninstall-CWAA.md => Help/Restart-CWAA.md} (57%) create mode 100644 Docs/Help/Set-CWAALogLevel.md rename Docs/{ => Help}/Set-CWAAProxy.md (51%) rename Docs/{ => Help}/Show-CWAAAddRemove.md (50%) create mode 100644 Docs/Help/Start-CWAA.md rename Docs/{Invoke-CWAACommand.md => Help/Stop-CWAA.md} (53%) create mode 100644 Docs/Help/Test-CWAAHealth.md create mode 100644 Docs/Help/Test-CWAAPort.md create mode 100644 Docs/Help/Test-CWAAServerConnectivity.md create mode 100644 Docs/Help/Uninstall-CWAA.md rename Docs/{Update-CWAA.md => Help/Unregister-CWAAHealthCheckTask.md} (53%) create mode 100644 Docs/Help/Update-CWAA.md delete mode 100644 Docs/Install-CWAA.md create mode 100644 Docs/Migration.md delete mode 100644 Docs/New-CWAABackup.md delete mode 100644 Docs/Private/Get-CurrentLineNumber.md delete mode 100644 Docs/Private/Initialize-CWAA.md delete mode 100644 Docs/Private/Initialize-CWAAKeys.md delete mode 100644 Docs/Private/Initialize-CWAAModule.md delete mode 100644 Docs/Restart-CWAA.md create mode 100644 Docs/Security.md delete mode 100644 Docs/Set-CWAALogLevel.md delete mode 100644 Docs/Start-CWAA.md delete mode 100644 Docs/Stop-CWAA.md delete mode 100644 Docs/Test-CWAAPort.md create mode 100644 Docs/Troubleshooting.md create mode 100644 Examples/AgentInstallWithHealthCheck.ps1 create mode 100644 Examples/GPOScheduledTaskDeployment.ps1 create mode 100644 Examples/HealthCheck-Monitoring.ps1 create mode 100644 Examples/PipelineUsage.ps1 create mode 100644 Examples/ProxyConfiguration.ps1 create mode 100644 Examples/Troubleshooting-QuickDiagnostic.ps1 create mode 100644 MAP.md create mode 100644 TODO.md create mode 100644 Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 diff --git a/.PSScriptAnalyzerSettings.psd1 b/.PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..ff8946c --- /dev/null +++ b/.PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,38 @@ +@{ + # PSScriptAnalyzer settings for ConnectWiseAutomateAgent + # https://github.com/PowerShell/PSScriptAnalyzer/blob/master/docs/Cmdlets/Invoke-ScriptAnalyzer.md + + Severity = @('Error', 'Warning') + + ExcludeRules = @( + # Set-CWAAProxy must convert a plain-text proxy password to SecureString + # for storage in the agent's registry configuration. The password originates + # from the user's -ProxyPassword parameter and there is no SecureString path + # through the LabTech agent registry format. + 'PSAvoidUsingConvertToSecureStringWithPlainText', + + # Repair-CWAA uses Get-CimInstance Win32_Process (not WMI) for process + # command-line matching. Suppress in case older functions trigger this rule. + 'PSAvoidUsingWMICmdlet', + + # ServerPassword parameters in Install-CWAA and Redo-CWAA accept a pre-encrypted + # LabTech agent password string, not a user credential. The LabTech agent MSI + # expects a plain string via SERVERPASS= argument. SecureString is not applicable. + 'PSAvoidUsingPlainTextForPassword', + + # Function names use domain-specific plural nouns that are correct: + # Clear-CWAAInstallerArtifacts (multiple files/processes), Get-CWAASettings (multiple values) + 'PSUseSingularNouns', + + # ConvertFrom-CWAASecurity uses [switch]$Force = $True so it automatically tries + # alternate decryption keys on failure. Many callers rely on this default. + 'PSAvoidDefaultValueSwitchParameter' + ) + + Rules = @{ + PSUseCompatibleSyntax = @{ + Enable = $True + TargetVersions = @('3.0', '5.1') + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc0ef4c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# EditorConfig — https://editorconfig.org + +root = true + +# Default: UTF-8, CRLF (Windows PowerShell project), trim trailing whitespace +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true + +# PowerShell: 4-space indentation (community standard) +[*.{ps1,psm1,psd1}] +indent_style = space +indent_size = 4 + +# YAML/JSON: 2-space indentation +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 + +# XML (MAML help): 2-space indentation +[*.xml] +indent_style = space +indent_size = 2 + +# Markdown: preserve trailing whitespace (line breaks) +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci-publish.yml b/.github/workflows/ci-publish.yml new file mode 100644 index 0000000..4cc73f0 --- /dev/null +++ b/.github/workflows/ci-publish.yml @@ -0,0 +1,122 @@ +# CI / Publish workflow for ConnectWiseAutomateAgent +# +# Philosophy: +# Full testing (Pester, PSScriptAnalyzer) is done locally before pushing. +# This workflow is intentionally lightweight — it smoke-tests the module, +# builds the single-file artifact, and publishes to PSGallery. +# +# Branch strategy: +# develop push → smoke test → build → publish prerelease to PSGallery +# main push → smoke test → build → publish stable to PSGallery +# pull requests → smoke test → build (no publish) +# +# Publish gating: +# - Prerelease publish requires a Prerelease tag in the manifest (e.g., 'alpha001') +# - Stable publish requires the Prerelease tag to be removed from the manifest +# - Both publish jobs use the PSGallery environment for optional protection rules +# +# Required secrets: +# PSGALLERY_API_KEY — NuGet API key for the PowerShell Gallery + +name: CI / Publish + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + smoke-test: + name: Smoke Test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate module manifest + shell: pwsh + run: | + $manifest = Test-ModuleManifest -Path ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -ErrorAction Stop + Write-Host "Module: $($manifest.Name) v$($manifest.Version)" + $prerelease = $manifest.PrivateData.PSData.Prerelease + if ($prerelease) { Write-Host "Prerelease: $prerelease" } + + - name: Verify module imports and exports + shell: pwsh + run: | + Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force -ErrorAction Stop + $functions = (Get-Module ConnectWiseAutomateAgent).ExportedFunctions.Keys + $aliases = (Get-Module ConnectWiseAutomateAgent).ExportedAliases.Keys + Write-Host "Exported $($functions.Count) functions, $($aliases.Count) aliases" + if ($functions.Count -lt 25) { throw "Expected at least 25 exported functions, got $($functions.Count)" } + if ($aliases.Count -lt 27) { throw "Expected at least 27 exported aliases, got $($aliases.Count)" } + + build: + name: Build + needs: smoke-test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Build single-file distribution + shell: pwsh + run: .\Build\SingleFileBuild.ps1 + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ConnectWiseAutomateAgent-SingleFile + path: ConnectWiseAutomateAgent.ps1 + + publish-prerelease: + name: Publish Prerelease + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + needs: build + runs-on: windows-latest + environment: PSGallery + steps: + - uses: actions/checkout@v4 + + - name: Verify prerelease tag is set + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 + $prerelease = $manifest.PrivateData.PSData.Prerelease + if (-not $prerelease) { + Write-Error "Prerelease tag is not set in the manifest. Skipping prerelease publish." + exit 1 + } + Write-Host "Prerelease tag: $prerelease" + Write-Host "Full version: $($manifest.ModuleVersion)-$prerelease" + + - name: Publish to PSGallery (prerelease) + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: .\Build\Publish-CWAAModule.ps1 -NuGetApiKey $env:NUGET_API_KEY + + publish-stable: + name: Publish Stable + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: build + runs-on: windows-latest + environment: PSGallery + steps: + - uses: actions/checkout@v4 + + - name: Verify no prerelease tag + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 + $prerelease = $manifest.PrivateData.PSData.Prerelease + if ($prerelease) { + Write-Error "Prerelease tag '$prerelease' is still set in the manifest. Remove it before publishing a stable release." + exit 1 + } + Write-Host "Version: $($manifest.ModuleVersion) (stable)" + + - name: Publish to PSGallery (stable) + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: .\Build\Publish-CWAAModule.ps1 -NuGetApiKey $env:NUGET_API_KEY diff --git a/.gitignore b/.gitignore index 84fe22a..b5f4f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,25 @@ +# IDE and editor +.vscode/ +.vs/ +*.suo +*.user +# Development scratch files Scratch\.ps1 +# Claude Code +.claude/ -\.vscode/ +# Build artifacts +*.log -\.vs/ +# OS-generated files +Thumbs.db +.DS_Store +Desktop.ini + +# Windows reserved name artifact +nul + +# Blog drafts (local only) +.blog/ diff --git a/Build/Build-Documentation.ps1 b/Build/Build-Documentation.ps1 new file mode 100644 index 0000000..424c62a --- /dev/null +++ b/Build/Build-Documentation.ps1 @@ -0,0 +1,215 @@ +<# +.SYNOPSIS + Generates markdown documentation and MAML help for ConnectWiseAutomateAgent using PlatyPS. + +.DESCRIPTION + This script generates markdown help files for all exported functions + in the ConnectWiseAutomateAgent module using PlatyPS, then compiles + them into MAML XML for Get-Help support. + +.PARAMETER OutputPath + The path where markdown documentation will be generated. Defaults to 'Docs\Help'. + +.PARAMETER UpdateExisting + If specified, updates existing markdown files rather than regenerating. + +.EXAMPLE + ./Build-Documentation.ps1 + Generates fresh documentation in Docs/Help/ + +.EXAMPLE + ./Build-Documentation.ps1 -UpdateExisting + Updates existing documentation files with any changes from code. +#> +[CmdletBinding()] +param( + [string]$OutputPath, + + [switch]$UpdateExisting +) + +$ErrorActionPreference = 'Stop' + +$ModuleName = 'ConnectWiseAutomateAgent' +$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } +$RepoRoot = Split-Path $ScriptRoot -Parent +if (-not $OutputPath) { $OutputPath = Join-Path (Join-Path $RepoRoot 'Docs') 'Help' } +$ModulePath = Join-Path (Join-Path $RepoRoot $ModuleName) "$ModuleName.psd1" +$EnUsPath = Join-Path (Join-Path $RepoRoot $ModuleName) 'en-US' + +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "PLATYPS DOCUMENTATION GENERATION" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +# Ensure platyPS is available +if (-not (Get-Module -ListAvailable -Name platyPS)) { + Write-Host "Installing platyPS module..." -ForegroundColor Yellow + Install-Module -Name platyPS -Force -Scope CurrentUser +} + +Import-Module platyPS -Force + +# Remove any existing module from session +Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue + +# Import the source module +# Note: Initialize-CWAA runs on import and may produce non-terminating errors +# on dev machines without the Automate agent installed (registry keys not found). +# This is expected and safe to suppress. +Write-Host "Importing module from: $ModulePath" -ForegroundColor Gray +Import-Module $ModulePath -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + +# Validate the module actually loaded +$module = Get-Module $ModuleName +if (-not $module) { + Write-Error "Failed to import module $ModuleName from $ModulePath" + return +} + +Write-Host "Module loaded: $($module.Name) v$($module.Version)" -ForegroundColor Green +Write-Host "Exported functions: $($module.ExportedFunctions.Count)" -ForegroundColor Gray + +# Create output directory if it doesn't exist +if (-not (Test-Path $OutputPath)) { + Write-Host "Creating output directory: $OutputPath" -ForegroundColor Gray + New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +} + +if ($UpdateExisting -and (Get-ChildItem $OutputPath -Filter '*.md' -ErrorAction SilentlyContinue)) { + Write-Host "`nUpdating existing documentation..." -ForegroundColor Yellow + Update-MarkdownHelpModule -Path $OutputPath -RefreshModulePage -AlphabeticParamsOrder +} +else { + Write-Host "`nGenerating new documentation..." -ForegroundColor Yellow + + # Generate markdown for each function + $params = @{ + Module = $ModuleName + OutputFolder = $OutputPath + AlphabeticParamsOrder = $true + WithModulePage = $true + ExcludeDontShow = $true + Encoding = [System.Text.Encoding]::UTF8 + } + + New-MarkdownHelp @params -Force +} + +# Count generated markdown files +$docFiles = Get-ChildItem $OutputPath -Filter '*.md' + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host "MARKDOWN DOCUMENTATION GENERATED" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "Output: $OutputPath" -ForegroundColor Gray +Write-Host "Files: $($docFiles.Count) markdown files" -ForegroundColor Gray + +# List generated files +Write-Host "`nGenerated files:" -ForegroundColor Cyan +$docFiles | ForEach-Object { + Write-Host " - $($_.Name)" -ForegroundColor Gray +} + +# --- Post-process: rewrite the module page with categorized layout --- +# PlatyPS generates a flat alphabetical list. This rewrites it as categorized +# tables with enhanced descriptions for better readability. +# When adding a new function, add it to the appropriate category below. + +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "MODULE PAGE ENHANCEMENT" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +$modulePagePath = Join-Path $OutputPath "$ModuleName.md" + +# Category ordering for the module page +$categories = [ordered]@{ + 'Install & Uninstall' = @('Install-CWAA', 'Uninstall-CWAA', 'Update-CWAA', 'Redo-CWAA') + 'Service Management' = @('Start-CWAA', 'Stop-CWAA', 'Restart-CWAA', 'Repair-CWAA') + 'Agent Settings & Backup' = @('Get-CWAAInfo', 'Get-CWAAInfoBackup', 'Get-CWAASettings', 'New-CWAABackup', 'Reset-CWAA') + 'Logging' = @('Get-CWAAError', 'Get-CWAAProbeError', 'Get-CWAALogLevel', 'Set-CWAALogLevel') + 'Proxy' = @('Get-CWAAProxy', 'Set-CWAAProxy') + 'Add/Remove Programs' = @('Hide-CWAAAddRemove', 'Show-CWAAAddRemove', 'Rename-CWAAAddRemove') + 'Health & Monitoring' = @('Test-CWAAHealth', 'Test-CWAAServerConnectivity', 'Register-CWAAHealthCheckTask', 'Unregister-CWAAHealthCheckTask') + 'Security & Utilities' = @('ConvertFrom-CWAASecurity', 'ConvertTo-CWAASecurity', 'Invoke-CWAACommand', 'Test-CWAAPort') +} + +# Read SYNOPSIS from each function's generated markdown. +# These come from the .SYNOPSIS in each function's comment-based help. +$synopses = @{} +foreach ($funcList in $categories.Values) { + foreach ($funcName in $funcList) { + $funcFile = Join-Path $OutputPath "$funcName.md" + if (Test-Path $funcFile) { + $funcContent = Get-Content $funcFile -Raw + if ($funcContent -match '## SYNOPSIS\r?\n(.+)') { + $synopses[$funcName] = $Matches[1].Trim() + } + } + } +} + +# Preserve YAML frontmatter from the PlatyPS-generated module page +$existingContent = Get-Content $modulePagePath -Raw +$frontmatter = '' +if ($existingContent -match '(?s)^(---.*?---)') { + $frontmatter = $Matches[1] +} + +# Build the enhanced module page +$sb = New-Object System.Text.StringBuilder +[void]$sb.AppendLine($frontmatter) +[void]$sb.AppendLine('') +[void]$sb.AppendLine("# $ModuleName Module") +[void]$sb.AppendLine('') +[void]$sb.AppendLine('PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Install, configure, troubleshoot, and manage the Automate agent on Windows systems.') +[void]$sb.AppendLine('') +[void]$sb.AppendLine('> Every function below has a legacy `LT` alias (e.g., `Install-CWAA` = `Install-LTService`). Run `Get-Alias -Definition *-CWAA*` to see them all.') +[void]$sb.AppendLine('') + +foreach ($categoryName in $categories.Keys) { + [void]$sb.AppendLine("## $categoryName") + [void]$sb.AppendLine('') + [void]$sb.AppendLine('| Function | Description |') + [void]$sb.AppendLine('| --- | --- |') + + foreach ($funcName in $categories[$categoryName]) { + $desc = if ($synopses.ContainsKey($funcName)) { $synopses[$funcName] } else { '' } + [void]$sb.AppendLine("| [$funcName]($funcName.md) | $desc |") + } + + [void]$sb.AppendLine('') +} + +Set-Content -Path $modulePagePath -Value $sb.ToString().TrimEnd() -Encoding UTF8 -NoNewline + +# Warn about any exported functions missing from the category map +$categorizedFunctions = $categories.Values | ForEach-Object { $_ } +$exportedFunctions = $module.ExportedFunctions.Keys +$uncategorized = $exportedFunctions | Where-Object { $_ -notin $categorizedFunctions } +if ($uncategorized) { + Write-Host "`nWARNING: The following exported functions are not in the module page category map:" -ForegroundColor Yellow + $uncategorized | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } + Write-Host "Add them to the `$categories hashtable in Build-Documentation.ps1" -ForegroundColor Yellow +} +else { + Write-Host "Module page rewritten with categorized layout ($($exportedFunctions.Count) functions)." -ForegroundColor Green +} + +# Generate MAML XML help from markdown +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "MAML HELP GENERATION" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +if (-not (Test-Path $EnUsPath)) { + Write-Host "Creating en-US directory: $EnUsPath" -ForegroundColor Gray + New-Item -ItemType Directory -Path $EnUsPath -Force | Out-Null +} + +Write-Host "Generating MAML XML from markdown..." -ForegroundColor Yellow +$mamlOutput = New-ExternalHelp -Path $OutputPath -OutputPath $EnUsPath -Force + +Write-Host "`n========================================" -ForegroundColor Green +Write-Host "DOCUMENTATION BUILD COMPLETE" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host "Markdown: $OutputPath ($($docFiles.Count) files)" -ForegroundColor Gray +Write-Host "MAML: $($mamlOutput.FullName)" -ForegroundColor Gray diff --git a/Build/Publish-CWAAModule.ps1 b/Build/Publish-CWAAModule.ps1 new file mode 100644 index 0000000..974d3b9 --- /dev/null +++ b/Build/Publish-CWAAModule.ps1 @@ -0,0 +1,161 @@ +<# +.SYNOPSIS + Publishes the ConnectWiseAutomateAgent module to the PowerShell Gallery. + +.DESCRIPTION + Validates the module manifest, displays version and prerelease information, + and publishes the module to the PowerShell Gallery using Publish-Module. + + Supports a dry-run mode via -WhatIf that validates the manifest and shows + what would be published without actually calling Publish-Module. + +.PARAMETER NuGetApiKey + The API key for authenticating with the PowerShell Gallery. + +.PARAMETER Force + Bypasses NuGet dependency validation during publish. Use for CI/CD pipelines + or when you've already validated dependencies separately. + +.EXAMPLE + ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' + Publishes the module to the PowerShell Gallery. + +.EXAMPLE + ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' -WhatIf + Validates the manifest and shows what would be published without publishing. + +.EXAMPLE + ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' -Force + Publishes with NuGet dependency validation bypassed (CI/CD use). + +.LINK + https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent + +.NOTES + Requires PowerShell 5.0+ and the PowerShellGet module. + The NuGetApiKey can be generated at https://www.powershellgallery.com/account/apikeys +#> +#Requires -Version 5.0 +#Requires -Module PowerShellGet + +[CmdletBinding(SupportsShouldProcess = $True)] +param( + [Parameter(Mandatory = $True)] + [string]$NuGetApiKey, + + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +$ModuleName = 'ConnectWiseAutomateAgent' +$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } +$RepoRoot = Split-Path $ScriptRoot -Parent +$ModulePath = Join-Path $RepoRoot $ModuleName +$ManifestPath = Join-Path $ModulePath "$ModuleName.psd1" + +# ─── Validate manifest exists ─────────────────────────────────────────────── +if (-not (Test-Path $ManifestPath)) { + throw "Module manifest not found: $ManifestPath" +} + +# ─── Read version info from manifest ──────────────────────────────────────── +$manifestData = Import-PowerShellDataFile $ManifestPath +$moduleVersion = $manifestData.ModuleVersion +$prereleaseTag = $manifestData.PrivateData.PSData.Prerelease +$isPrerelease = [bool]$prereleaseTag +$fullVersion = if ($isPrerelease) { "$moduleVersion-$prereleaseTag" } else { $moduleVersion } + +# ─── Validate manifest with Test-ModuleManifest ───────────────────────────── +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "MODULE MANIFEST VALIDATION" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +try { + Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop | Out-Null + Write-Host "Manifest validation passed." -ForegroundColor Green +} +catch { + throw "Manifest validation failed. Error: $($_.Exception.Message)" +} + +# ─── Verify module imports cleanly ──────────────────────────────────────── +Write-Host "`nImport test..." -ForegroundColor Cyan +Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue +try { + Import-Module $ModulePath -Force -ErrorAction Stop -WarningAction SilentlyContinue + $loadedModule = Get-Module $ModuleName + if (-not $loadedModule) { throw 'Module did not load.' } + Write-Host "Import test passed ($($loadedModule.ExportedFunctions.Count) functions exported)." -ForegroundColor Green + Remove-Module $ModuleName -Force -ErrorAction SilentlyContinue -WhatIf:$False +} +catch { + throw "Module failed to import cleanly. Fix errors before publishing. Error: $($_.Exception.Message)" +} + +# ─── Check for existing version on gallery ──────────────────────────────── +try { + $galleryModule = Find-Module -Name $ModuleName -RequiredVersion $moduleVersion -AllowPrerelease -ErrorAction SilentlyContinue + if ($galleryModule) { + Write-Warning "Version $fullVersion already exists on the PowerShell Gallery. Publish will likely fail." + } +} +catch { + # Gallery lookup failed — not fatal, continue with publish attempt + Write-Host "Could not check gallery for existing version (this is non-fatal)." -ForegroundColor Gray +} + +# ─── Display publish summary ──────────────────────────────────────────────── +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "PUBLISH SUMMARY" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +Write-Host "Module: $ModuleName" -ForegroundColor Gray +Write-Host "Version: $fullVersion" -ForegroundColor Gray +Write-Host "Prerelease: $isPrerelease" -ForegroundColor $(if ($isPrerelease) { 'Yellow' } else { 'Gray' }) +Write-Host "Author: $($manifestData.Author)" -ForegroundColor Gray +Write-Host "Description: $($manifestData.Description)" -ForegroundColor Gray +Write-Host "Source: $ModulePath" -ForegroundColor Gray + +if ($isPrerelease) { + Write-Host "`n--- Prerelease Install Instructions ---" -ForegroundColor Yellow + Write-Host "Install-Module -Name $ModuleName -AllowPrerelease" -ForegroundColor White + Write-Host "Install-Module -Name $ModuleName -RequiredVersion $fullVersion -AllowPrerelease" -ForegroundColor White +} +else { + Write-Host "`n--- Install Instructions ---" -ForegroundColor Green + Write-Host "Install-Module -Name $ModuleName" -ForegroundColor White + Write-Host "Install-Module -Name $ModuleName -RequiredVersion $moduleVersion" -ForegroundColor White +} + +# ─── Publish or dry-run ───────────────────────────────────────────────────── +if ($PSCmdlet.ShouldProcess("$ModuleName $fullVersion", 'Publish to PowerShell Gallery')) { + Write-Host "`nPublishing $ModuleName $fullVersion to PowerShell Gallery..." -ForegroundColor Yellow + + $publishParams = @{ + Path = $ModulePath + NuGetApiKey = $NuGetApiKey + ErrorAction = 'Stop' + } + if ($Force) { $publishParams['Force'] = $True } + + try { + Publish-Module @publishParams + Write-Host "`n========================================" -ForegroundColor Green + Write-Host "PUBLISH SUCCESSFUL" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + Write-Host "Published: $ModuleName $fullVersion" -ForegroundColor Gray + Write-Host "Gallery: https://www.powershellgallery.com/packages/$ModuleName" -ForegroundColor Gray + } + catch { + Write-Error "Publish failed for $ModuleName $fullVersion. Error: $($_.Exception.Message)" + } +} +else { + Write-Host "`n========================================" -ForegroundColor Yellow + Write-Host "DRY RUN - NO CHANGES MADE" -ForegroundColor Yellow + Write-Host "========================================" -ForegroundColor Yellow + Write-Host "Manifest validation: Passed" -ForegroundColor Green + Write-Host "Would publish: $ModuleName $fullVersion" -ForegroundColor Gray + Write-Host "To: PowerShell Gallery" -ForegroundColor Gray +} diff --git a/Build/SingleFileBuild.ps1 b/Build/SingleFileBuild.ps1 index bbcf50c..1017dd4 100644 --- a/Build/SingleFileBuild.ps1 +++ b/Build/SingleFileBuild.ps1 @@ -1,14 +1,101 @@ -$ModuleName = 'ConnectWiseAutomateAgent' -$PathRoot = "." +param( + [switch]$BuildDocs +) -# Function that needs to run for module setup +$ModuleName = 'ConnectWiseAutomateAgent' +$PathRoot = '.' $Initialize = 'Initialize-CWAA' - $FileName = "$($ModuleName).ps1" $FullPath = Join-Path $PathRoot $FileName +$ModulePath = Join-Path $PathRoot $ModuleName +$ManifestPath = Join-Path $ModulePath "$ModuleName.psd1" + +Try { + # Read version and prerelease tag from manifest + $version = $null + $prerelease = $null + if (Test-Path $ManifestPath) { + $manifest = Import-PowerShellDataFile $ManifestPath -ErrorAction SilentlyContinue + if ($manifest) { + $version = $manifest.ModuleVersion + $prerelease = $manifest.PrivateData.PSData.Prerelease + } + } + $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } + + # Build header + $header = @" +# $ModuleName $fullVersion +# Single-file distribution - built $(Get-Date -Format 'yyyy-MM-dd') +# https://github.com/christaylorcodes/ConnectWiseAutomateAgent + +"@ + + # Concatenate all .ps1 files from the module directory + $sourceFiles = Get-ChildItem (Join-Path $PathRoot $ModuleName) -Filter '*.ps1' -Recurse + if (-not $sourceFiles) { + Write-Error "No .ps1 files found in $ModulePath" + return + } + + $content = $sourceFiles | ForEach-Object { + (Get-Content $_.FullName | Where-Object { $_ }) + } + + # Write header, content, and initialization call + $header | Out-File $FullPath -Force -Encoding UTF8 + $content | Out-File $FullPath -Append -Encoding UTF8 + $Initialize | Out-File $FullPath -Append -Encoding UTF8 + + # Validate output + if (-not (Test-Path $FullPath)) { + Write-Error "Build failed: output file was not created." + return + } + + $outputSize = (Get-Item $FullPath).Length + if ($outputSize -eq 0) { + Write-Error "Build failed: output file is empty." + return + } + + $lineCount = (Get-Content $FullPath | Measure-Object).Count + Write-Output "Build successful: $FullPath ($lineCount lines, $([math]::Round($outputSize / 1KB, 1)) KB)" -Get-ChildItem $(Join-Path $PathRoot $ModuleName) -Filter '*.ps1' -Recurse | ForEach-Object { - (Get-Content $_.FullName | Where-Object { $_ }) -} | Out-File $FullPath -Force + # Version consistency check: verify manifest version matches CHANGELOG latest entry + $changelogPath = Join-Path $PathRoot 'CHANGELOG.md' + if (Test-Path $changelogPath) { + $changelogContent = Get-Content $changelogPath -Raw + # Match the first version heading: ## [1.0.0] or ## [1.0.0-alpha001] + if ($changelogContent -match '## \[([^\]]+)\]') { + $changelogVersion = $Matches[1] + if ($changelogVersion -ne $fullVersion) { + Write-Warning "Version mismatch: manifest says '$fullVersion' but CHANGELOG.md latest entry is '$changelogVersion'. Update CHANGELOG.md before release." + } + else { + Write-Output "Version consistency check passed: $fullVersion" + } + } + else { + Write-Warning 'CHANGELOG.md found but no version heading detected.' + } + } + else { + Write-Warning 'CHANGELOG.md not found. Consider creating one for release tracking.' + } -$Initialize | Out-File $FullPath -Append \ No newline at end of file + # Optionally build documentation + if ($BuildDocs) { + Write-Output 'Building documentation...' + $docBuildScript = Join-Path $PSScriptRoot 'Build-Documentation.ps1' + if (Test-Path $docBuildScript) { + & $docBuildScript -UpdateExisting + } + else { + Write-Warning "Documentation build script not found: $docBuildScript" + } + } +} +Catch { + Write-Error "Build failed. $_" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a138a8f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0-alpha001] - 2026-01-31 + +### Added + +- **Health check and auto-remediation system** + - `Test-CWAAHealth` — read-only health assessment with configurable checks + - `Repair-CWAA` — escalating remediation (restart, reinstall, fresh install) + - `Register-CWAAHealthCheckTask` / `Unregister-CWAAHealthCheckTask` — scheduled task management +- **Server connectivity testing** — `Test-CWAAServerConnectivity` with auto-discovery and `-Quiet` flag +- **Windows Event Log integration** — `Write-CWAAEventLog` with categorized event IDs (1000-4039) +- **Lazy networking initialization** — `Initialize-CWAANetworking` with graduated SSL trust (IP bypass, hostname mismatch tolerance, chain rejection unless `-SkipCertificateCheck`) +- **Installer cleanup utility** — `Clear-CWAAInstallerArtifacts` for pre-install hygiene +- **Credential redaction** — `Get-CWAARedactedValue` using SHA256 hash prefix for safe logging +- **Private helper functions** — `Resolve-CWAAServer`, `Test-CWAADownloadIntegrity`, `Remove-CWAAFolderRecursive` to eliminate duplicate code across Install/Uninstall/Update +- **Comprehensive test suite** — 392+ tests across 4 files (structure, mocked, cross-version, live) +- **PSScriptAnalyzer configuration** — `.PSScriptAnalyzerSettings.psd1` with 6 documented suppressions +- **Input validation hardening** — `ValidateScript` on mandatory Server params, `ValidateRange` on LocationID, `ValidatePattern` on InstallerToken and TaskName +- **GitHub Actions CI/CD** — smoke test, single-file build artifact, prerelease/stable PSGallery publish +- **Build scripts** — `SingleFileBuild.ps1`, `Build-Documentation.ps1`, `Publish-CWAAModule.ps1` +- **6 example scripts** — installation, health check monitoring, proxy configuration, troubleshooting diagnostic, GPO deployment, install with health check +- **WhatIf/Confirm support** on all destructive operations via `SupportsShouldProcess` +- **PowerShell Core compatibility** — validated on PowerShell 5.1 and 7+ +- **CONTRIBUTING.md** — development setup, coding conventions, PR workflow, versioning guide +- **EditorConfig** — consistent formatting across editors (4-space PS, 2-space YAML/JSON) +- **Architecture documentation** — Mermaid diagrams for module init, install workflow, health check, and system interaction map + +### Changed + +- **Module prefix** renamed from `LT` to `CWAA` (all 32 `LT` aliases preserved for backward compatibility) +- **Variable naming cleanup** — all cryptic names replaced with descriptive names (`$Svr` → `$automateServerUrl`, `$tmpLTSI` → `$restartThreshold`, etc.) +- **Error handling standardization** — consistent Try-Catch-Finally with context-aware error messages throughout +- **Debug/logging overhaul** — `Write-Debug` in Begin/Process/End blocks for all functions +- **Server loop refactored** — duplicated ~300-line server validation loop in Install/Uninstall/Update extracted to `Resolve-CWAAServer` +- **Download integrity checks** — centralized in `Test-CWAADownloadIntegrity` (was inline in 3 files) +- **Folder cleanup** — centralized in `Remove-CWAAFolderRecursive` (was inline in 2 files) + +### Fixed + +- **LocationID type** — changed from `[string]` to `[int]` on `Redo-CWAA` to match validation +- **Empty catch blocks** — all empty catch blocks now log via `Write-Debug` + +### Security + +- **Graduated SSL certificate validation** — replaces blanket bypass with IP auto-bypass, hostname mismatch tolerance, and chain rejection +- **Vulnerability check** — `Install-CWAA` warns when server is below v200.197 (June 2020 CVE) +- **Password redaction** — installer command-line arguments sanitized in debug output + +## [0.1.4.0] - 2024-01-15 + +### Added + +- Initial public release of ConnectWiseAutomateAgent module +- 25 public functions for agent lifecycle management +- 32 legacy `LT` aliases for backward compatibility +- Single-file distribution build (`ConnectWiseAutomateAgent.ps1`) +- Basic Pester test suite +- Comment-based help on all public functions + +### Functions + +- `Install-CWAA` / `Uninstall-CWAA` / `Update-CWAA` / `Redo-CWAA` — agent lifecycle +- `Start-CWAA` / `Stop-CWAA` / `Restart-CWAA` — service management +- `Get-CWAAInfo` / `Get-CWAASettings` / `Get-CWAAInfoBackup` — agent information +- `New-CWAABackup` / `Reset-CWAA` — backup and reset +- `Get-CWAAError` / `Get-CWAAProbeError` — log retrieval +- `Get-CWAALogLevel` / `Set-CWAALogLevel` — log configuration +- `Get-CWAAProxy` / `Set-CWAAProxy` — proxy management +- `Hide-CWAAAddRemove` / `Show-CWAAAddRemove` / `Rename-CWAAAddRemove` — Add/Remove Programs control +- `ConvertTo-CWAASecurity` / `ConvertFrom-CWAASecurity` — TripleDES encryption interop +- `Invoke-CWAACommand` — send commands to agent service +- `Test-CWAAPort` — TrayPort availability check diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccb99a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Used by MSPs to install, configure, troubleshoot, and manage the Automate agent on Windows systems. Version 1.0.0-alpha001, MIT licensed, Windows-only, requires PowerShell 3.0+ (2.0 with limitations). + +## Commands + +```powershell +# Run all local tests (primary CI — run before every push) +Invoke-Pester Tests\ -ExcludeTag 'Live' + +# Run PSScriptAnalyzer (static analysis) +Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning + +# Build single-file distribution +powershell -File Build\SingleFileBuild.ps1 + +# Import module for local testing +Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force +``` + +## Local Testing (Primary CI) + +Local tests are the real CI gate. GitHub Actions is intentionally lightweight (smoke test + build + publish). All substantive testing happens locally before pushing. + +**Test suites (all run via `Invoke-Pester Tests\ -ExcludeTag 'Live'`):** + +- `ConnectWiseAutomateAgent.Tests.ps1` — module structure, exports, basic unit tests +- `ConnectWiseAutomateAgent.Mocked.Tests.ps1` — unit tests with Pester mocks (no system deps) +- `ConnectWiseAutomateAgent.CrossVersion.Tests.ps1` — PowerShell 5.1 + 7 compatibility +- `ConnectWiseAutomateAgent.Live.Tests.ps1` — full integration (excluded by tag, requires admin + test server) + +## AI Testing Requirements + +**These rules are mandatory when Claude or any AI assistant modifies code in this repo.** + +- **After modifying any function:** run `Invoke-Pester Tests\ -ExcludeTag 'Live'` and fix all failures before considering the task done. +- **After adding a new function:** add corresponding tests to `ConnectWiseAutomateAgent.Mocked.Tests.ps1` and run them. +- **After changing behavior:** update existing tests to match the new behavior, run the full suite, confirm all pass. +- **After any code change:** run `Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning` and fix any issues. +- **Never claim "done"** without a passing test run. Tests are the proof. + +## Architecture + +**Module loading flow (two-phase):** + +*Phase 1 (module import — fast, no side effects):* `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively, emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. + +*Phase 2 (on-demand — first networking call):* `Initialize-CWAANetworking` (private) is called in the `Begin` block of networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`). On first call it performs SSL certificate validation bypass, TLS protocol enablement, creates `$Script:LTWebProxy` and `$Script:LTServiceNetWebClient`, and runs `Get-CWAAProxy` to discover proxy settings from the installed agent. The `$Script:CWAANetworkInitialized` flag ensures this runs only once per session. + +**Public functions** (25 exported) are organized one-per-file in subdirectories by category: `AddRemovePrograms/`, `InstallUninstall/`, `Service/`, `Logging/`, `Proxy/`, `Settings/`, plus standalone files for security, commands, and port testing. + +**Private functions** (2) live in `Private/Initialize/` and handle module bootstrapping (`Initialize-CWAA`) and lazy networking setup (`Initialize-CWAANetworking`). + +**Single-file build:** `Build/SingleFileBuild.ps1` concatenates all `.ps1` files from the module directory into `ConnectWiseAutomateAgent.ps1` at the repo root, appending `Initialize-CWAA` at the end. This flat file is the distribution artifact for direct-invoke scenarios. + +**Dual naming system:** Every function uses the `CWAA` prefix (e.g., `Install-CWAA`) but also declares an `LT` alias (e.g., `Install-LTService`) for backward compatibility with the legacy LabTech naming. Aliases are declared both in function `[Alias()]` attributes and in the manifest's `AliasesToExport`. + +## Key Conventions + +**Readability-first philosophy.** This project optimizes for human understanding over brevity. Use verbose, descriptive variable names (`$automateServerUrl` not `$srvUrl`). Comment the "why" (business logic, API quirks, security trade-offs), not the "what". + +**Required code patterns:** +- `[CmdletBinding(SupportsShouldProcess=$True)]` on destructive operations +- Debug output: `Write-Debug "Starting $($MyInvocation.InvocationName)"` in Process block, `Write-Debug "Exiting $($MyInvocation.InvocationName)"` in End block +- Error handling with context: `Write-Error "Failed to at ''. . Error: $($_.Exception.Message)"` +- Use `$_` (current exception) in Catch blocks, not `$Error[0]` +- Use `$Script:CWAA*` constants for paths/registry keys instead of hardcoded strings +- Functions return `[PSCustomObject]` for pipeline compatibility, not formatted strings +- Comment-based help (`.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK`) on all public functions + +**When adding a new function:** +1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory +2. Add `[Alias('Verb-LT')]` in the function declaration +3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1` +4. Rebuild documentation: `Build\Build-Documentation.ps1` (generates `Docs/Help/` markdown and MAML help) +5. Rebuild single-file: `Build\SingleFileBuild.ps1` + +**When modifying existing functions:** +- Maintain the `LT` alias +- Rebuild documentation: `Build\Build-Documentation.ps1 -UpdateExisting` +- Rebuild single-file after changes +- Consider 32-bit/64-bit WOW64 behavior for registry/file operations +- Verify compatibility with PowerShell 2.0 and 5.1+ + +### Security Considerations + +**Graduated SSL certificate validation.** `Initialize-CWAANetworking` registers a `ServerCertificateValidationCallback` with graduated trust rather than blanket bypass. IP address targets auto-bypass (IPs cannot have properly signed certificates). Hostname name mismatches are tolerated (trusted cert but CN/SAN differs). Chain/trust errors on hostnames are rejected unless `-SkipCertificateCheck` is passed, which sets a `SkipAll` flag for full bypass. This graduated approach is necessary because many MSP Automate servers use self-signed or internal CA certificates. The callback is registered once per session and survives module re-import (compiled .NET types cannot be unloaded). + +**TripleDES encryption for agent keys.** `ConvertFrom-CWAASecurity` and `ConvertTo-CWAASecurity` use TripleDES with an MD5-derived key and a fixed 8-byte initialization vector. This is the encryption format the LabTech/Automate agent uses in its registry values (`ServerPasswordString`, `PasswordString`, proxy credentials). We did not choose this scheme -- the agent requires it for interoperability. The default key is `'Thank you for using LabTech.'`. Crypto objects are disposed in `Finally` blocks with a `Dispose()`/`Clear()` fallback for older .NET runtimes. + +**Credential redaction in logs.** `Get-CWAARedactedValue` (private) returns `[SHA256:a1b2c3d4]` (first 8 hex chars of the SHA256 hash) for non-empty strings and `[EMPTY]` for null/empty strings. This logs that a credential value is present and whether it changed, without exposing the actual content. Used in debug/verbose output when comparing proxy passwords, server passwords, and usernames in `Set-CWAAProxy` and related functions. + +**ConvertTo-SecureString -AsPlainText usage.** `Set-CWAAProxy` converts proxy passwords from plain text to `SecureString` via `ConvertTo-SecureString $proxyPass -AsPlainText -Force`. This is necessary because the password originates as plain text (either from the user parameter or decrypted from the agent's TripleDES-encrypted registry value) and must be wrapped in a `SecureString` for the `PSCredential` object used by `System.Net.WebProxy.Credentials`. + +**InstallerToken vs ServerPassword.** `InstallerToken` is the modern, preferred method for agent deployment authentication. `ServerPassword` is the legacy method. Both are supported by `Install-CWAA`, but `InstallerToken` should be recommended in all documentation and examples. `InstallerToken` uses a URL-based download path (`Deployment.aspx?InstallerToken=...`), while `ServerPassword` is passed as an MSI property during installation. + +## Key System Locations + +These are centralized as `$Script:` constants in `Initialize-CWAA` and referenced by many functions: + +- **Registry:** `$Script:CWAARegistryRoot` (`HKLM:\SOFTWARE\LabTech\Service`), `$Script:CWAARegistrySettings` (`HKLM:\SOFTWARE\LabTech\Service\Settings`) +- **Files:** `$Script:CWAAInstallPath` (`C:\Windows\LTSVC`), `$Script:CWAAInstallerTempPath` (`C:\Windows\Temp\LabTech`) +- **Services:** `$Script:CWAAServiceNames` (`LTService`, `LTSvcMon`), plus `LabVNC` (remote control) +- **Ports:** TCP 70, 80, 443, 8002 (server), 42000-42009 (local TrayPort) + +## Terminology + +- **CWAA** = ConnectWise Automate Agent (current prefix) +- **LT/LabTech** = legacy name (alias prefix) +- **InstallerToken** = modern auth method for deployment (preferred over ServerPassword) +- **TrayPort** = local agent communication port (42000-42009) +- **MSP** = Managed Service Provider (target user base) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0732d09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,98 @@ +# Contributing to ConnectWiseAutomateAgent + +Thank you for your interest in contributing! This project benefits from all kinds of contributions, whether you code or not. + +## How to Contribute + +### Reporting Bugs + +1. Check [existing issues](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/issues) to see if it has already been reported. +2. Open a [new issue](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/issues/new) with: + - PowerShell version (`$PSVersionTable`) + - Windows version + - Steps to reproduce + - Expected vs actual behavior + - Error messages (use `-Debug` and `-Verbose` flags for detail) + +### Suggesting Enhancements + +Open an issue describing what you would like to see and why. Include use cases so maintainers can understand the context. + +### Submitting Pull Requests + +1. **Fork** the repository and create a branch from `main`. +2. **Make your changes** following the coding conventions below. +3. **Test** your changes: + ```powershell + Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force + Invoke-Pester Tests\ConnectWiseAutomateAgent.Tests.ps1 -Output Detailed + ``` +4. **Rebuild** the single-file distribution: + ```powershell + powershell -File Build\SingleFileBuild.ps1 + ``` +5. **Submit** a pull request with a clear description of what you changed and why. + +## Development Setup + +```powershell +# Clone the repository +git clone https://github.com/christaylorcodes/ConnectWiseAutomateAgent.git +cd ConnectWiseAutomateAgent + +# Import the module locally +Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force + +# Run the test suite +Invoke-Pester Tests\ConnectWiseAutomateAgent.Tests.ps1 -Output Detailed +``` + +## Coding Conventions + +- **Naming:** Use the `CWAA` prefix for function names (`Verb-CWAA`). Add an `[Alias('Verb-LT')]` for backward compatibility. +- **Parameters:** Use `[CmdletBinding()]`. Add `SupportsShouldProcess=$True` on destructive operations. +- **Debug output:** Include `Write-Debug "Starting $($MyInvocation.InvocationName)"` in the Process block and `Write-Debug "Exiting $($MyInvocation.InvocationName)"` in the End block. +- **Error handling:** Use `Write-Error "Failed to at ''. . Error: $($_.Exception.Message)"`. Use `$_` in Catch blocks, not `$Error[0]`. +- **Constants:** Use `$Script:CWAA*` constants for paths and registry keys instead of hardcoded strings. +- **Help:** Include comment-based help (`.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK`) on all public functions. +- **Variable names:** Prefer verbose, descriptive names (`$automateServerUrl` not `$srvUrl`). +- **Returns:** Functions return `[PSCustomObject]` for pipeline compatibility. + +### Adding a New Function + +1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory. +2. Add `[Alias('Verb-LT')]` in the function declaration. +3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1`. +4. Rebuild documentation: `Build\Build-Documentation.ps1` (outputs to `Docs/Help/`) +5. Rebuild single-file: `Build\SingleFileBuild.ps1` + +## Versioning + +This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (`MAJOR.MINOR.PATCH`). + +### When to Bump Each Component + +| Component | When to bump | Examples | +| --------- | ------------ | -------- | +| **MAJOR** | Breaking changes to public function signatures, removed functions, renamed parameters | Removing `Install-CWAA`, changing mandatory parameter names | +| **MINOR** | New functions, new optional parameters, new features (backward-compatible) | Adding `Test-CWAAHealth`, adding `-Quiet` switch to existing function | +| **PATCH** | Bug fixes, documentation improvements, internal refactoring (no behavior change) | Fixing a regex bug, updating help text, extracting a private helper | + +### Prerelease Tags + +Prerelease versions use the format `MAJOR.MINOR.PATCH-TAG` where TAG follows this progression: + +1. `alpha001`, `alpha002`, ... — early development, API may change +2. `beta001`, `beta002`, ... — feature-complete, testing in progress +3. `rc001`, `rc002`, ... — release candidate, final validation +4. *(no tag)* — stable release + +The prerelease tag is set in `ConnectWiseAutomateAgent.psd1` under `PrivateData.PSData.Prerelease`. Remove the tag entirely for a stable release. + +### Changelog + +All notable changes are documented in [CHANGELOG.md](CHANGELOG.md) using [Keep a Changelog](https://keepachangelog.com/) format. Update the changelog as part of any PR that changes behavior. + +## Code of Conduct + +Be respectful and constructive in all interactions. We're all here to make this module better. diff --git a/ConnectWiseAutomateAgent.ps1 b/ConnectWiseAutomateAgent.ps1 index d612e6e417b3133a9e71e66fbc8b1a284af713d9..332b3b6596b867daa161663ae08f182b768aa860 100644 GIT binary patch literal 236093 zcmeFa+jd*Ykv4emr`V=A4bVkEJX2y@E`uQH;$M`+Ay}pMh_Vb4nim`(+xFVp`fna3t#1FOm)5Sky)@}`$CFX_ zaysb_`bjOhoOXMYWMge(r?$3U+vL-?lgV(r_xSNmcXB(utX~c89$(##@LK!6HyB+F zI_dav+1$VR&Goc@C1W>xX?s*_zG^g%`*^q4OGk~-q4X)DhI2|YLekYlvcf-M`J-Sc0;Bj5& zI5=!I&yG*ek6*rU)10THyKWzNo%GWWc8Jwvlp07@Il*+;0rbgWn2uz0#RTztst8N^?K^`sp^agC zatl<9QVi7YB-f+CT{5{%lPkb7YWKQ-2e@~G&J;TWzyJh5bbu|lu14MAWG~urGtmn- zPvKLRE>1bWhqBS@dEgbU%XB=crPtSk(Ioi*`b^T%XfPTla!?(VBmcaqi)(#z>hvedGqvRqw$et+D5Ke$q2 z@V|0*R+eO_pLB3T9(qbS_pIr6^SMNfqRKk{VITE%m zZC~9c)n#*PIXku}DWr^$epZ4da-=_{lbR*unv4<+Svw?YG?W>&aMb#f-aYsh&doGU zeslnFDE_o2gP|Yq2%IiWW{%-TP^58;N6DG+208fP5u+j=p=`;$?GeH=P4>uRP>PZ0 ztYlH5v)7KnnUb0evm6atGs`+luco8%!`7#(JOn56WD!x?;m5ACod`t*yJ2X zgI*^cDSSMea|Fd{Is*Rg#yHKx$t@(@XgmS)0)K9UcU974#RCpfx05Dv z9>&v418NoQh~h;SqbHoSwc%cNkAm+Bzo2X(?^Y}%)lQlY!6>sMY`PK1)JFp_Ix{7( zlJ>40oFaqT;HU$h)Vub_?j3kPq>*8i6i$f>q5%%CE`}0KaIKu0Y#?B{fY6FpbMDje zD#qwy0`N!3#XIODo#{wWDr(FW!k7%F_c$xaGBU#Lw-t2I96;QmtPnAg+(JF*af0g1 zgT3NK&^e`_=q{CUviu5UaD06_g5(&beeegW8_VD+*s8nWW2|u4z9EBet76vW821jp zAeh1Q<~BJUd`L$v&{z+nLw?y|-?vBI_9dk3U3vwWyW_jrvPTp?H~P;dd0LRMAh&Vr zb};RAEa!ceET502X(W~$D6d6KLEjlwp95Fz$pBhe{02$;HC597&6{PiEP44C6KvgU z5CcVi^_;4O@=W>fY!b1H)13Dtd_s z{*2Pebkuh!^umRpE}ynk9jHgUfX{)s)uf`FG&z!kSXr3~r94YW^y$4s28n?=$9{Qs zuK-faos!wD?|lY4vtv<;H*dSWPAIf=1DupvKo}PYbk6YV7ALj#^73yml#(Bk<+t_W zxI2dOI%#+NVC+AYyoVVN!Ta2sZaXa4_TNJA-`0<&y`HEfGpVR#ayxbgcp=k;S6~NG ziZCj{B8aRRezv!K)b3G7P_AeGEJTE-Q9G-lkPSy6pDMHLfQ5Me)ybphaaz?CBj;(KZmKb@{2smu}pRb9;Z-<_R6jkCt`SXEi5KGfU@tYa4z9w_UgO5da(X=>vyx)pR` z7%UlI-KKZxs&V9up{1KUQ^BHgz@m z&Z@8GZ|aKQiAP30p!`pJP^{^Hicr;=4&m~_r=7%L&ES`->kS6OdeRh?bgT-kyxkoo zzz38KMGBs~>0Y^owXd;%z~oiheJ{EP_5V~z&`z?Nz?y+d%T0iM1Qw@iI(NF)&`Y78 zVuS)$(pmcN(=Ntx_WQ|s3$)k0N?)XtSLtQ5*M+Uc)n3xfhlyw$N!9n=Hc)0i5TxLh z4};M=favX>%k&y91JT0q`9Fdp{3UeV*Kgh|6Hn<#Ht84BcOOCjG>2}FCdUw>$$5Nb zm)bz~?O;6V(`Wbi_>@Mb@EwvKC{CP`P@-r;lUf1~pgYeUUD_DBzR{$hx+*b$H-ZsP zZ;vcKtDuUG>mQ`b1AYPaUlFhL*KkYMzpCJ^WXZaz{kThs=iub&{u_4?`~zih<<>9{ zM1?Q(Ug^@lR}Uf%?O6(LmmP)xK1vSz<0>u1eQTs*D;#k}Q4g6V0z)P-ECeqh*VOJqnjJC|ol3cda}TEkiU zg999DhmvvzjAD>M3?Jb!f}LVe>;3P;?l7%&L|RZPfIt*6D(bu-3ZZ($lLy+|17H7` zaD)W<0>>vahkn$9^FhScUHfl?Q5}K^1ZStNruCb8Qu$GSHa6hgUfZq|FF#%*t^2}7 zmngpkxs~={p4AlT;>$zk2N(L5lI{j>h9uZ(s zUCGi=Oa~<}skb9}4Zp^8j3B1s{mmcq>b?a@=O@eIk0UpHP`D;Oq^|&*q9D3g%m;<9 z^srdcDBrx&jf>%m|4M1V=TVsf z&g^Kr(*Z$2kyDU}VTdDot{{rQNyvJx1|w*Z2&B}(t=f!b?m6wj_&U>i2eIgGpMm3; z(|OqZZ`4OD4wRfpN8{-g1G?8ZI9}mbs7+W4ZyCb?Zc>B8YarJ|Exn;5wx3yH?Qa99 z9u@e>Ot_0XWhii&r&LHmf%{p^K1w)IbgiH{1~uFuTxY#(r4ezXO@MhJa%rR#5G7&T zyEKKJ1P0!{o8E!l|DDRcXytdG5as}fym|+-=Qf2^HHxP*4tabm6N^U^5cPd%HHD3x z8bftFOs~4vU4R+E#&F5_79Praa(pc+jtmA`yG$XJB3sBkyNbvS$#H^RCn1U&IvP!f zuxm$9C~aVU4*-8Krw)&&cU{u0JL2T!YD0r;@cl@_btd3SYB+~wgvfAvimV7XIZfyF zjm@p(+kHesREBUA>?ZhfcYF(gU^6jfPa6%##ZCCUb^Q47-wpwxgfa}~B+j2EVwJvi zhGISam@;y~J*IS~{%26-#sefz)(A%j@(A*P@vJe7=pLR%_!wssu#g1)yBJDumX8p- z75BD7LJ(Fkwivh0MG^P{ov*9&UO-~No8{+vZd$+@(E3a^T3-C(HW`7WLZFUZ3uRUf zkoAi1_*v3|C%^_JEE=(4iy39t#I8`+OHGpzQ(>dG*{`-6`#KwaJGK6VtuNQll5=+-S~cm;rO+iNfPgD$zq*^ z_RAFp>bQ~GgD_vKuW|Hm-|X$3QOs0>*;bQvj1J=YeP&L4wqjzgBI>8)h*pe}brB;O zEQZe31u>J@us(DIMk0$`_476-60RN>Or25rBw_(xFy`1ShK)Z!(TWcj(cx7B3lcm{ zN-jU6;ML_Z!QhZw`ebl3&)+IuZ}&6YGbT#H*xgywZU~dKDgu&Hx{XH#)THH#i8AK`{tbW;k(4 z+LO_smsDF!BwLjHQYdd&X_|VN%_bI93;S_J~x3At!hpS9J{CL00 zvvUGGfOs06j&i+lxGfI1CF}VnZTBX(kDm|VEFOq+A43k?-4WqXx_kUx-)?#`X^-FO zNc9*0K)HI3R08c~p`-{CtSZ!IW)5I&hE_VMR*w5PK#*Wy12lk)+~coen^uYxsY7zU z^ZQ}3CN*xFuh?9hU(NuSWuZUuGP89y9-vk+ke2M?A|`Jsrlj1hxGs%hv=uNPexC{7C1U~=WP z&bilKzudUm>}juLUFMto5@6Pq{S zW1+ZRlJWG^Est;8ATjt0tng?try8ETdUJF?oD6P8?cwcxU8w>>q$!Rold-={%G|$) z4_F-7GE}{ZyCKs_`VGFS+Ck6LkCXZ#yjx6jz$o9HA3dq#aLBtEzy&%P`J413##L`; zHJIy18;y;kBIg5x&C16OgjRo2gqa0GCL-80P7UA&BTJUrAvm}cOjvJCFO|frYpcnV zm6bP3;epNs`pr>(l>BMh9(DGDOjzDtN_tQg;fOwM85jMzgg;31xRIkCVQ#18NBW^>9)f8`!|)FaU15H}rja?V?k!IO0?s1hha}<)$FI%f{e)5`>8ezH(k0d%vKSnYZ zB`Pp@p^1-fQpp2?U>LxQLbji^ni?fDtg6uBpH)_NfS;kny1OW@9Hk#>~aKfAh6WE}#Lyl?h%fYx# zlWLjmjdap=lP>-Uc9*>!r7s=x9@?b#Ty9LQsDX(W?$*UEO)szWRKq*W8&{fwV|U0P z!`QXRW9{Ph&bu^;#?>e>5dgq(HNhCB%H4%B)BgLt)3cY&!&d9%+1tih^PA)ILr}uz zMp6@Rc2e2dsw6eqwyzP8IGv2B^fHW4>!&!%K?Estp7Im}@3jQkI_8v-gd73YlLm}x z@NZhW6ly|<{C*#FXJ5q^NT6r@2fK0ylIx6v)3-c?Ob4Me-{1!>?bY#%gX6QGEC`gT^_!852kYA|E8ne`=N-W-v<6slLW5wMBIcz=n?5$CraLTMd%lk@+isjIjF}0KQoUXuv&9YLl9wdsqZgK` z+~MKZ7SVN?6-;doCOC;|Kiqh4#?nt=nJ-TPoKW!Ni7GAp>G4>uz8Xo!C(O}e9-RYC znDJeB1@iz(-2BBoZY|I-Z$A{b@yQxaTCa(PDD7o}VE&Hj8 z=sCloi7AQ@L$pQ?*ArD-pj_cJ4f;K5eoX9uE!t+127WVg(7YHhXb9#mG(P4Z1U#A( z!SUiYMmEA~r#f4$2R0!#Di|4W!2iGh=YRh1|JQ&1$Nv#sLWhdben04T5C!+ajrP@O z0G+{VKu+m9N6HtBU*-_srtKkRDoki*U5nb-NB)W2!bF-gF4`jxTBM`gS7g8wx*RXO zbI~g2LRXo3)JFGbyiDnSo+XuUzCC%qchP!zbpEPwc6ed(&@YTvU738*K;Z7&i?NP1 zcOVR?&*w)pet)^2;UL3NFlrN(r0(da3nW5*-&AENgvY#{j?SU4xiNBY^IN;P{2*qv z6oY0#1ms!r$7;ppU(y4OKi*%#y>BuMQLsXigC!RoE)L#UplpQU0mJqZd-skhFdF@M zgc1Kzb=koyU;DKd{q~>#@jqdqa5%7UzJ(lEvG`2U3zLW|l*ys1)z|AAZ@#uUha1R0 zT=`-1r_6_VkOLo|zTZl08dViVIQV}kva#{>X+8L#9!ZY=V-bkOpK<(`XAZ8{?b_d& z6KVc^TYK}nDpDi!pRW9FMS-(9-AeTi2e_D}`C7`S3Dt5j1Vx_`DrS&FJyheLmpYK7 z109$gg_`PPl+rbA0q57b3&v=XjCGe=b(}YnwxL5IQ zdXEFagWzOQmSZ$~47HCh+^ZMn-SI_p>tJnX>u7U#edAzje`jm$$C9TjTbPjFP?YFwg=Y}tD9dmr!d9)6XTM(aN}S6u>GWgDC7D;ZGUHXyS8<(vs-Hb z%-Y&fi6)&G)ned-!FkG6K!4z`-LgN^mw+SbngR_)2!{!VRw`|x0U zZU11md9?kd*0|VQLZbP7x1vwOx|TVVN-TXW7Jzh_K+H?>nyph2oJL!2po*M0Idm)w zR8~?b51<>n5!BV7!!?XesvyfU7hYa-vhRxdPBvW$a%imdFuF|`jiD(4zoV+GOUZCB zoNDQ#1u&#v5qjcoHv`1IhIWD-;g;RAnquYH3g-O{DPS*eLT zuqP-^-9b7L>&9wiU4R7N z$ygk6H@(3nR9EXP4NVvLA12)%BEI$#rTX&dqJ;6*M_#rqEoC=}XUHJJFSP?~1_%qE zZNh`YyQlO`D*u&^uPsPV6hcPbA9O`v*~V1H8-HswpzFhi!~#QojF(bqBGRh)#Uu7ML+dqa0M3| zG(b4lXzqGGjmxh>->Zqc2CMg)@zzHqafOGjsA?Hl#t`BHVT@YMsfnaj-UP&_jN>EW z>|J+=v0&Y1z*jn?GfHz`{GAHMe7wsxyGBv#w#LcCBP}*FG~>=x^6=q+ z98Y{6S`9Xkvw$@GGe$~2(iLl$_e>r|J{oS6nve#fHz{$5tHs#yarcG^hylLG%GY_2 zYsoj}PO22G!;J#Kg-rZC96#i~3oKQ>N}3m-s4^e|-hA=6)p+6a59O_J+~zHO8js}_ zTTKqcWvwMZKT26W1jAKvzPMcc+HAm2fRafy1v(tB*20ePpctZQ@;=2LtONS=6$g~L zz-PErGqu}io-)j1?$l`gAWDzmC^$!l%kh(w79{%V-)_v{_PMx`q+Y&H4Fh%T0xwu4WS~Z`V?Ue*?k!u%>0Z8q{Ptp)g7e!%I3KdK7s<*{;FZyQNU>?Tc_j!v^V^K@m97 zN-g^nzpUqhNg)e#WNAw|$aPU=jJgpV8r9+Qtk7~ls1_)tI+kzSPn7^-=R*d=SUpe zt=}aZ>28S6-sDk0(Lp8)Lz|?!Hx*UY1L5ZVcNS}vl{%jfQO4Q$vpgR6>|@R@`{UAY zw2q?2^ZqsZs}AKf|4H4ZT4lbnAIBM3|F*sMG=O;gufH*o(kpt{jH@|#Ait#FXx?EQ zV3o)!`5TYJGidGzwrS`zeon#^R#!GzR5~6}!c8bR5Lh8a&P=G1XU~#8lnSqV_+dHp zrw;qovxo#a6iN@IEN56Xp;sv3t4Rj0p)mA1FyD@Td;<5h$d$Z$G~imecApio1i*@yUV@lyHC@hMLCh>W;9ES`i}#_(NF8DUnUl>tH>!O(Jab_& z4+MuYgzG1dIt7}k$4hb20iP%A`MkbEl{`|=0Zye+%5pThBg6L#`Q6eZBcyEZmkBHf zqVKQ$rR1xkx4%l3{@TyJ4|0$Ywilf~wTV#hC%-pNO2jEEd61)(#pAqQfJ-6iBE$R? zB4B5(*rgxh9H-WS;HS?fy>rl5n=G~5y#j|~484vNIEe0yAy56SN1>$<&VZUKffWbI zjbuXW2y&_sT}M{oXg-2(l~Tr(P@;gQY9T9vs|a~kyZ}bAedR4iUQ540TC8YX@InZK z6TPBN#TAyM?-v&0PeK;uLRyhumaWWf156jDA16MbqyvM{nU9K!FS%h=88_kno_ z9n>ae2tLGoa~i%Rd0KOe2re!5)vHo)~|DQpWJBnGh9g} zP9jOWpy!TuDAZRFR&2`1#_K-%^83)5nAPfCa2+i_!^0HfJ32A`>5Rh;HSFnL3furz zyLBF?v;%htBq$C2;3AN+VF6KBuM;A&r%BIlN*db1_}(E~&7wx#6(*+yH^7RWaC<1M zB{`Vd>@LzaF(*bAa^uHS_$sWR0M-IMOK9!*u%c<-y4z z?lK-CpD1}h?V*UQ)+X$x;}v-gW(}MT7!yQibEHufqc^4ZHDOg*S7iyb*X}TthISNE zuRoxb2ZH?EYq_Wb9ne}5HT5t`D++Nrn)H^~93dgR#_l@}8R(Lysgi>gpZCV=8=!#1 zJe0GDM*o!=!00<0l&npgxYp}J*mosM@X1R$$3mhb4;98oSd9H zz>5+(Nxt?0IhEwV{#|3%;3u~Y$n=#?~^7)oHl1x&@w!_DlPC_DVg zaxJ2YGiPqXxyuX2{9>9M)6}oqh|9yVgtv`{LhnhC0BA4hRPd{4g*0DL3j!i!@!;#p~a1eJInRvMW+r@ zX@G>d$UZzvNPJ{XFS2k=e?s{l%Cd8G!s(^BLkQFPir2xMh$#$3iLPSLBwxXZ^eakm zr~gI-Y`&>f)TYlV-ilxw2uc#B}QLmo*F70|DdU5I}5n3WDu%4yx0(CJXxQn(na zjNyI=;D{n0I)4x!2yX1TVa|b-;!Oc}g@&(qHA$SoSl0&{Y%W9)UaPk z1*zW%j12DZ#{%Hs(yTn~4@jmo1pJ-l-I^M`ZX0pH5K~Om(CePE2lbQV7vH+wu$5^) zseu}$-OSEUxCIMvs?29`9L$4b>o{nCCjB0Yq>0U9MF?@5S9TZuA74C30c{9#h``2 zGzza3E9DYIP}463nk*uO&Bn}>Ylf~5b%bTRd@UbnnK~W^q`#aa9Q7`_A56u@Q88q) z&Vvm6<+m|^|6bTT-hX|G+sHscbz=+HcQ;p)t?gChEw3i{Yj+0~u6FU+_EYbZTYfRX z%uK-nrv$V|QPnR1Ct}fMCW$WK$kBXV@U7}JAThcB=lyDtz;Chk3?IDAo>AS9_Hz>}stRzy z0r?XTN-#KN!5a8*W~Jfa*_rt`e=@HS@{+;z`2~<=ALPG+igM(Ik9tIgpGJEh+#uDp z3B9vqhQl}tl8C0mIqer*P{n)o!r@~pCH#iJFLzl_z~0rj_wo(%g}F?&sal8bUC$R>s5uEvL_Ep#dA`F#|nd&iXr{=xTVWuCRy!^u@xtR^plk zbBYyMy7D|^K3eL>-}B5Ba;@@}ipo7WN5QNy&dwc2$RQj!tV{ zR+b3L)@unN0|p{71|anEX!rKnA*~vam+;!~15@yDWkzHs!)L!@(rpB8F|v!#S=O@6 z;vzbS_V8)x7ogc4HQ{=tXV5z}vcnexCAflz#EXLLdt_fyP4au&Hch)hX3&pB1>99m z6I{qSY$R*E*ewb-xVB+2Mi)XNBbS&M>}qsAG)<(@4DFaOv) z0Q2~cKZ*a!2#I-eMIJw*0XSRgcqO9jIpQ=TX&mQJ_ocVMGRh<}T6jOz3kBS#6@h?T z*8*2jmCSJexB^pPMD7!d;t(x+7Q_M6Ha&aM!^u};+*`1UKP@TpZD0B#xGUwg|0S@L z$J-2?MO+RBgYg?6u~=La@Me6xMM@5R6s25G2+T#Nf{2LSlo%ram;R>zjVq8BosmLs}Xn$JMW0Ps79D! z0_TGVIq=kx0dG^8#|{oXSj(a&J9o?pf}wQgFVcdkfHdZ;~9|U(vZ00rTZtTZ=o8a$cHZ50ZR( zSW=kIf7wLW9j28J`cYXsC9Pk@LH0|mkDdx4B(+7pxNoP}I)s+^aG#dK57h~$(lo_1`6#)xGud!y_Hx#NebBUiLJ6ogGJU%WdKI(=Z!_LMfl&M(F6aG@=K?s1+!$WQ*K(|9a3-x?EVWPdU>I+ZFm$w79UfQ9jw3Q{cmR_lr7kh^5${Pyo`R4x6me4V)E>rcN4c zWTBHLw@>zvc=z1-Aa>4WP5m$6lktRDbh;wq^rUZSvs4%NQGT8uKSi-SM0ikUY2ZTk zXlK4Db|7RQDsvymTse{tX3Kq7JYW8?q8a_lt#uKdP2n{9Ol&SiX#aE@gB7jbX`5bC zYHD7BKnvwi#JbRHE|O-(8@-a@BjxmM69<2*E6ABBv?T19;FnU1pQ&rXnC{-V>rTPc zP!{i3ZFPLb3IeJilbRUh)vr1 zTqVZ`X#c+Pq_)1b?Ky(fC&sqQRX~{fCBcQ_&YmR8mGFPiRm@W^{VABsU?N6%7RfWboiD$kIeiuIs3F;;1+I_Q1p zip1;umj~^8Uc*9(0*r*6u9Vnr1&0q!#Fg~3`{Cdq64YYLK4M9um?C6=T!w;<0r*3V z%dVkzQy#ty6nC(k8RxMx11_m8W>23vrOf_hgHA@?4JzVmor`2A7+@SP2+znbV!^cx zFF{Qtq}5w3A5vYij^7AsZ0 z%~Iyz2Nhc6>zD}?eya?j@GHk03cn2*MBxJ@yTUJ&aTI=KnMmQ6O4h|+2~#Ql$}pJh zmlg-PeP`YeV~9bWgnTtpnTjoO4xv?>)$YKBs^;>4ztM` zkvbx?QNk^pz98MQDbSxKOr|s;Un!iYyMdsFBycuF>LxAIg@~-(rB|w&WDlmdHL1{< zi?BLa)_zssU50DX?5vQ<>^Nz`$^P`xifK%lX>}1Jm^03B)#}_K^y;3dnP#fGQmT}r zu3$UaQ#E(%Y5^#bibx=lU(FG|2$%=~n1qk!os`71#E?az{Tz*)z!Mo!a*HNwDNaJr zoFHVAVDRjKe2YIhWko*@|BL9QAyJtfid$e&gb(IKX85K4o*V1w%(0AqlplwmWXIZE zn>m)_pOvEFa)YhU94utFx$$yiZOj}?6@nt5iWX@v5@erOf?G86QaaAkS zm#a%ofk&zLCDCoHyfrLb;e&x}VZ2Tc8j~7m0f0um;3x$iqu~sH{?Q8ruqkJex4kA@@KrSf@U^6i`E=Vpe zYC}qfk|^`t&SrF`aAN+Rr?Bt|1?}Z@f;2{q@~P$}j}fF86j4~^ zHnvcfI;j%xA{Fh?U}Xjdib2YM8h;Z1RR(-!TRm(;lb{@L8l1M(<+t@i^g!1uKc<6R z&~2V*@N?^L`rkw7%3dkgnN4Y=PxoJ*r>5cjDcpSXT$g;9Jy*2$)q8fO2VI;uPf^N} zPC^!tKxG}NV#x?Ntc!>HGI9BrJ>lu1L8(3tgFHwSG(0({WRjVrY@utTB96`?$ejMn zK^$xz-0CiJ*Pq~jTU)q4YDxgP6b^E-6dgo|y(!wJ5j#)T);8Rj*~jnD0z^8}>U-Xq zddzDOR9?R>2 zaTR&DCirYRKLNeQ9Ks;pL}j zamc?Ii($%KJT@AoH`d$MDt7ZZB_sgSslx$RpwuB71jhbexJ1&E22ffB z5>px4;bF|&=;m=VL0xgaZLz~G6W+tCT!hvcugtA4zp|89N^!?7 zI!2mAgP|d^(B=s{f{l_p@VH9}zHCO%-j9k;ubBNHvWmT5RIp|KE|ebTHe9*xXy_?! z-yqoqZeQ-8zeE_4b#C+GK&YFfqG3&L2(Qk&!cWL2velv% zpCRv3FPct>g!plsnvnZIApcs{+D>jOz!=IKaE9oR#C#7svPv}7RLmAO$P}anN)%_C zIeG|5p^2o*mg=KRF9y_){X`HB4w2Fc$Vzw#=73NNbkmGilMs5PkAE#su7+Bg092aK z72!sc(S8~u#hH6$-o(+ClJ%R=E*C`>E$f;D&$amz;(y?6j{ za}E&iI@cBfVau-bW~i?~?2jH_h*2|J7dLiHIPA4tL76pVanb~*4j0+;ZBF?We>`OR zoBr4MAp_<>@-ue@l;Bsif|C8vGE{sFaxzZ}H^k+hU>we==!2T9HF7|X<_t;x5(*(p zwMlL&)6e^0AYpq%jv&zjxl$JDV0!H(shN>ole$;X!;i1spV({Ei@tMzM;+~c1T4&8 zv_05rhaZR3&*8eH>d7bsuGU0^nnz7`!7~>v0R`%0zJ>+oVl0G;C2N+V_NK*#{Yv%C z;NC0e;7>7XjCv}-=MeM>YtE7lXAbb78!x0#tvX=~+CcAsbwfDe#|R+rY;SMMm8CM= z7E#z|jZK&zC9brH9bZkB0tGp%R7ZevFt;LbJh`srjD=#n*S-W{Ipuj)1|KNpYn<3 z7M!GNJ(4m{%y^6C!EzrG1j!4AYnJ=a%yZId1QZC`1eZdJgE{Uk33VpyjLHHY$x>4P z9a~~u)Zcuy2f<=NTnRpwmRVon!LO_O>k4jQ<=8`vti^bL>^KTQFFNp93@o!|>I^#Y z0X_5P3F{&7!gVxK5fcmc7Uc!AJ-edZPG| zJ)2P~*wkSK^}PkxnD9;`*)ac#2o%Z__7GCqzVV}&$?PDJPXON5sgk zjOgM+{P8$6pdV{!)Gee4d-|##MBxvo#>ZB}+ZHnXs@%enra^_3MCK)_O`&IA3w4U` zAQv;XbJ~9+)n&<2HELVH6mUdNoAxrL$dM{BsIa1riETyU|=X06^gY%#xlMNgBy zY3l-$?}TI5M(MC8U5CYM^ff$v%A1DOe~sCVypr#TiboKSs0#y1RQ zzG(8AfEwYNug>v}IXW;hgooL7QbklsKoT2@V1)^4nd>nNWv&XX@R4jl10;+VCt zj6X*g6}jo{lKc^~pOmQ*{9gCP*!yrYBVm;ZOz_&KtUjJh%udmhIRx+5=8%=}U{y5M zBRUP=v58lc!7#VP7`XVa5&@tPMk!Y?=uj+Fihr}HNpM^c2{xHc?inAS zj*QnJ?50DZ;H>ak`35(3KMp}CAQ@QmV{lnFDkK-4ORCIMM(w~}_pZyw)Stff^zu=EXx05<5B>j**y0ccy2MLn_#2!9?W`gJ0xHpnz@^`&t_rmp>^RVMH)HDCJ%6|Kl5G>A0M<+Xn#vvw&9A5J7$$g&La?*$px6WmG z{Y(V>IJKTO#4s;@4F0e4)kPh?N3Sa_n{f_5PDZ%J6}(r)bMo-Y#rSuo>p5lLG8~PL zg;_chFjVideiv_WI`z&FR@UrNEe*Ud8?u1IN6Q!2(C)|h>9btH@(r!S?-wSZDj@^N zh-0Y>=<^~O4)2_YKc61iD8UK`EyawBYlNCh5X~@oK2CFq@xq}$C6tj{GzWU{!o7OV z*XI!jpHVazg9{O@vC1xOz#^rY;a0xmt#$Ju~4l4wp zBdLrFhEvaUKKPY9)HCx4tudhylX2~ZFfrlkmbRo^YQ6JnIbktI+jk>&3=1td<9CU+ zhj7jC zCy*lkrkg&LeGJLtY7Xo9Tk_=k*c3{B>?L@7NS51HBhmB=%DjB<=@QLE! zT`W76sQ4DS55r}}P!kQyPE~nk?<+&am^@FRgCUlJCU8kOKvFx$&1`6As8*7!vsC6h z=aw8|H;D;973wl&v>hWi=dHJ!Sv*mp_ER>KAjG`Ejd;!r=;`=%CKhLB%u^Y^$!v=` zZn1MPN_$MIc1lYoD+O{YKUs-uP!^FPDU*Leqm}LDjOD*3lEhES$W{rmBTHmF)I%Yxfebw{3PIoA&zq|#OUyU>2Lbq zLwMu7rJ`H?DgI(EL|9BAu2qYSO(zGg1t(}P^9?ju@o+=4Vb&cQdCl=N^9_1u181Ke zAa_RqLp?TQK}SaO>btQd!W&mpk#`vd4s=XS5M|g1Em6@59SsJNLXSupgfX0Rh(fK? zYvcTkIyI8%wQ~^rW{ka`^TTZyB|vT+#2G+eb^D#chcP=5=K!uc(CKbFLJ4kY^XQz$ zjDGK)ZS>GEQ8dFbs>HzKdY|;t>xncu0-`!;AH#H}R}+cz$BRL*1|`{(L9YWRE?}h0 zMCS{LJ6lL5>y#tvj~LZ{sR z4Z!#y{VHKD-L+vKSvN|X4a7_k;Lc3^@Pv8+?d05%s{r(ic+(zIHzW&X>|xWO^_MQa zfI#yZO5Kd9Fa#}2k4;alk)cEsN7I`KWF*U{(@Rh^B8w$&BLhd@k6**nv`%n6>S!>< z`FIv2QzCoUeu+NqAPE1pG9UnwX_}GOE4`U(0F%rn3uKYhB2N^bD?CfL%<2|w6g_D< zC|xzjxS9!gq)&`{Dbc#@*u=~Mj~z1C43}9GhP-0-RI}#de6c1`$*@cH z>4?^qU!ySEf&?`VYk%_lWxomkJwrRKyY!(bMv}QO%K#aU>4c-(-<789U)g`l^t0@9 ziZBuS$r#Od9cB-=grVuqSy|3!@DVVsqVhOmH0u>3XPt8&0>3QC557Vf5Kx;kPJXcl*Y%n^a~)mMe1 z@o^cktf+z9JyF4-Wep~Xz#9$pd+q)Q6_nCluc>gQtTHR#RV&CsM-rPf9IMPKb5H=b z5tTCl=SzRARx%ngQbqF_a;1y66B7x%B@@HXJ`|u*tjvOiB!HTje5!B}1X zdL=Rb4C+lZJ>{u5ThgAbNRtb*v_T){JFCb^ZxR2ooV6k{QIas=XVnjQFm6S)DmP2= zz8@WtS23jU(8=HiK0g+@M5guT8tT&2243cB<5XOuD9Um51oJlOKbXAV|yG(1);09lMY0DyQ~ zJaYVRMf%npAU8rFRReP%23?AQ92-d!=~`sXsuN$O?cC}X3RbzwXF{6d>KIueAR6Cz zft*T3iT?LXxqhOqB1XIBiEzg7Jl~DJGs8~+W+9%P0*8oqd=!@cRSuy;2VyX((tV=+ zxe;i(Pn^cFl2M~IcQx-XP}ku((8ppl(vOq@vmgs> znOovrwPkN*O9`N+MQ`wdvCG2NB+-ToNI$!??T^*a-4;2_Ua!4D%4pz>i@#levyyxj zegAVg>xk=OPn|F$SHaeZs5o}y=EAMji$pWaJF>Q||>FGYn`;acvE3|7(lj1ltoMvE%mpw#Np;SBS zeMFOmcjMCW7-$b;@7*zOT}xI$XUYZuOjK6KRnmb*j^gqjG?A%`d$r{8P7)jCezI3W7FRc#%kxVRDsh^V3=3?WR?N`0$Ho;0b8|@b1bi zl(Dn&XLkyQPQRbasmIXXKQr)9Ed()sxl>c73Xoe+h4^O%9*RTI-ejI=d6MHlGw{43 zK+*|+X6RM+G1YRcBRHGJpX4V0}oD?e`etQ7t6q#W0m;)2umNcjrZ;Ojy98kH?|ot zMaED(Z7haum@)4;{W95ib1;%WIdd>F+plJ@5hUNunZFD*9!W)x{W&l$7>nbc8Lxhc zAM`=qeFbVU&;IBiFZ0gWbK<_4=e04kZoIAkGXWPmBISr^M0v*&|%X&-g= zBncMHw$Qn11i`^-SEudq7=7GPBjp@(xuzv>s-uyhzkF7Iad@5_;d*GwrtisSeH&%P zQ>ijKM$ta4#4}z^p0|z@+|!ix>Rs_`#3-@oNqRG4y*ngjc9CarRbP#mpZB`xC67CugWgnMB_@XhRP8e8G!$Uf{XR&CYSzKRP5*sGn`Izy{preYxCE^fm~u4Wxr7%AA}eUT!eZb1<(isvMej>yCY zMh0U8$>NtO2p$^~bOG_e46d%GBYQnJyH-o~(TryFp*a#D0c7vXX~d0T1n#iZ;FRfY zS@q|dK4+%BjbHS4W5fVK2DoW_)4sX~c@2Am`#bENxjtOa9#YVtJEpmPI=1&iF&X}!yXNL}g9dLiH(zf%Cvsal!#Uh5tu&|s3karn zVwhEW7M(jUOnD^@?@iiLyc`kK?#hF78U(;ghHY2I0>I%v5 z<=l!i8`P~w|J2tNEg(nuzKgYmq%q2i6!@AoZ4iXMw#t70FW2u*&_eQPhNIS zV9xX$EDp`&CU3p#4i5$s6#R1-;74QSld(vnRN1Jn@ib&bTRp*9RxTSeSi_Pn+w0^2 z5Pt9GM(mZD0pn!czD^+zrGy=K`>HpU1OT*i)tWqp^Ba*wLMC`JIe4HHOEOiy$xrf( z?#19$yPI9Kob^y{-lpvylBop@Wq2R(mL~Wsb%Zii7e_NA;?QhCi#6P7XS5VTNU4Ui zS;UNx{?8(|6SNi*Yy@YT8`8viN7Z7S*|f>Behy>%_hiVxSgUpN`23`mT=^#hbwb#z z;o#sdRNjCyGZ!J|{(;8Pw2j&iz~IfS_ubK;FZk0Fi?elIGt42F$AX;Av^NY{)k|Ai z&Q?d`VTHbs2Hh`{nt3A~sZLh;li__G`5VoS0!Fsw>qMH@!h@sDTu|LPGh~MK0|^G- zpAUS@_>&6mQ;qJ2sM*XNieX=O`&SOSm5OH9tla%#;ARk05qT1Wxdp2#b5IK4wYn3J z2Du(l93Bg529~-F^bfyWVCKzelD~erj8)t0R5}$>WrfR0(pgY>&Fd33XlEN)=9Xh0 zkYTEhJc7#vi%r0@Jm;mZ)su9`gPRP&i*h6N2s-L1%8#=cdRgzxSvMnIV-(smsa_nY z#y)b%g$erSR9%t9m9MMS*XtW^zLqvdKdi5A{B%)Y`C;>?%!fa|ZrA>frbuthzi(@A zepkJy7ks+%yA{(sYQoak-qk>SX($BzeFWzK^4&#?ZoA6=Z{x~^tmtn_wu92a!e&?Y z59azQM77p&FDxDGcDm|*n@LoeJzrq!GB;RCuA(%0-S)UzIW-!;B~h79!MAmf?IVS^ zV2Tzog9a_ln7X>vO7-jU-d{)w*z7l7Ew2>9VGfj$9EEUvF}z4KPA`dmfyu1eT{u-p zRgIZ!55`^%mZ^6?(R|1(wHxG|EpxXdEe%v zg}f5wOTv4qi7VR)#O!rsSQE3mb~~|cnj0}SyWn)-jPE~?*URx*&Ko%3aI^1 zyQpZcCSy^VLk$o5=y-ZLX8mngv$O{ST}uj9qO3$|C2rK=EztS)mWw`hqJr#HI)W_A z&SX;v_%##h$==w@v4R#_`YXE=Nk6UUgFfW{LsRI5jW3>YOKkEda-AATVn2@M5uG~Q z*oBGx^IN`fh8Yp=XmY}itcty)g>i?_VDOt_=F7q31&;sCZCLd+TUWe+%rjkuQ3@1^ z&XfB(x+C|my2Ey_ZnDgdQO9Y5Z{FuZ*ETn5+gn?>3zj97a}k7<7ikKwER;9+1V+%Lk=5MjpmY!{F!&<|Bk#vASd-AI zT9(=2m-#Uqrl!RBZMWB9EvcmP&9^7d_bys5kIr8;&JHi0cdteR-Xp$<-K&s=)8WO7 zgHsUe88HRZ#kQ!+T@}%by~ttElX?d4Ks$Io*W9m;OCW?uR5;h#uln_GTPqjCA5MO% zXoJ6^o~-T$pqWj9a_5sI>9<^vfExz2UiTdiBmIuQE54Sev}!9*6u6qrIZ+1J$EKmC zx8EC1%b(?!EeqGP#1h(}H}ODG?uoj2GJ>6h5dPQi6`5~>5t+a2KCkT5H%j~d6<-qK z*!&S*QgRIk^f)>CxJhnxDE9|y=1jaC(w1%aOliYnEb93B-({mL7z!*NCaHnrvk6em zj4KpjfyiMd9A8#p=BT&O!Ult2AOrSkDy@4;F%*szKss~Hvw=-&Pzz|uox!YcXL^W- z)A4tImb2gR?`UaeIPwX23)yGc!fRKJ(Elf_dQpnUq%6A2&M{!1;Njz>l>EcRlrORP zo8nKK!b@Y5CY&XbF>-7f7cz%6(H7cS$<{Raf2HI_{KrB*5~LRYd3jl~ulc3rKRN7a z4@h^wXB_$IfLUwCovinIed2jwHAVUC624O)I%J!{N)f$DR~RjivA>iwc4H%ka>b@Ir4A zS=`uv%NH_3b+#|(*;^bf_)JE}Y34CIOC?#`V8#v=FFF5d{*#|WYt`P+Q6bexUv+Bj z (HvL;@)+koog>Z+XILJQPVjy$2Jfoq;!{rYa@FRwRhTW`>7Y3AY1)18P_h<<1Q zHbFs7vi7>+$1q^rF?o>4jnQEjFz0mSA!O7H+R+X26C_;t$-vS=jrPlelm|5b$qcab zAb%cG3*n3*@_+f?topJ~6Ro89^LctVWMO27hUMPiM$%~DaK~jH2J(;9!|jcrWmt)L z3}Y^Zb6nVW>-f_1Ng+_W0DJF>mA6kQ8DJhtfH>mjK5jwU>RI@7&m#os& zIW~JZ3%1-Uh3y-m^}yLZVG-N@Fa1sb3kOT4+`2zV%ibTamZ&4JT=;>dKPy%qXc|qP zsKW8USk^r+@vsH6TL(S%28>=Tv?|JIw(n1w6@wfebbF{#y$n7?=HJft_U3l(9Q$W5 z^VY`N+FH@faebDZJnN85x)-}>~`)`^uIRna! zDp7l7YOXDuVRG(OImu&TeENF!sQZRbWar_N(qA$ip)r&jjT>*CG5)8Lm&~{B#dt@e zX>tG=_Q_04AXlWtTk{#RvAQ17gyxY+19i=ZRlk6@NfB!UsZ2Tr#(SDovF8BOF_;YE zk^uQ6>A=w+!{t27@7v(V$Q@E7xf%H^lg(^KTxRTA@_u7$t-iUr{?%SK)xk%fH9@bP z{BOsnQf3m;g@Jk?;j8DZ^Q78OKNv9Ly1I`NjK98)@prc{expC=-`_DYG)iPO7ytj& z$d!cYV47Oi9^Isp5};MaJ4yBAe0@CuZ-y6oeY0RP5PN<7=`JQa>VBj}mR!LR&ZN?$ zaeAD*pZ1sxcd6Z>tIh8ZSCjvNeoM*5+6LIHrX(^1miodXx(NxgA%mnn4t*ue1pe&< zmv<;lyz6GyX&z!Ij0(pRj*lc2VL<@ugVrzh@gbe0hE|Gc%^++qImx9pa`)xN9KuI6 zhbLkaemTWew862cT#S=tlmbGw?5T|!kdhdVp6kMv!NAw%M3`vmGpjS1zrCi!!TCct ztxwsU&NrE(Odgnhq+)2dRN+^>)LfZ^3>Ct2z$Hcussl0)6(O^r7asV(cZczT=Nq1z zVR-Fa7OrE5+b&95=>S&{Ay5WM^ZIz7>zEBzHb8`{sYyY`iTR|Lt?g-A7s1>G2i9$CRlE<(t+z8AyL(hMP9`pZ zMJ9p4jK{@Z59ZF9WtK=H1A`_?AJ%@vDEVZ(XYpUMgUS<%?IsQ&59RY}V7!+0XW(ng ze;N!rN|oMx%9>exFb0XASiQo#br!*1*?C$R5KgUx2-Q*g*}G{&I|C}aX&-V8e49>y zibQKRVwojz8zK|+I$O;3$|ni7vw>FmKNTfhoe zh>Hj|Pr@boB!`G4(ULNG#kZn-gd{xAJa{~On2lQZuRaQ0};VF~JC62BrS9U=-M{_5Qdnl0CxxPC=f0Sz|5_Y=9k0=*XC0q1adI9Sv& zHkCbNYrCrdV2&ba4YRw_|KnBycz#^%C7RI3Ov2PsM#K*qrE;%yI!X0#@lf2e-k z_$7O#dvA}aYZoo#s=+gmUYO?Q7i7cCwS3syuU@{|+1lKA0ezDrP138|%t%?fAX4~~ zA#q^6;2y7yk=11nxI6xyE^!rOUI)hgAlp7OzReRd`57z+gg8R#X)jHO=yAUALE+>j zG(W3N(W9Byqv4l1Z@s@_CP;Wmu`Pb9UUJ?%#ocK*ZL6L1x;=$GBOchij`~P0xHSI<3nehfIm`uR{_GCBea}x zJW#+&F2P5b2`ZrC*5ydr-=a@2K10uXT#`#iluGaI^)($=|0^`&eBscSqKJ}xa+xPL ze9HcwqZUXugiN}S9Bas(=^}*^-gWu)tFOvV2B}Dm0diM6blKB{$-UwAO!ej4m!XD* z5ZAPe^{gQ|Z1hq|s{%g9A0hpC669IRj$oR2C}2Z2r$DXK!HVRk{icMW+OPRC-!?}ncDQ2o+peK4fhtNEN6hNC?CJdGk7)h6Lb;YkgO7^EnaSp01=xv^8 zR&Ve%MmJNv1o{l;&fiV5+qv|(oBVYF_4Thyc`Pio4$rDkobT= zYo@XT%`6>mN6jdPq$%p9z74Qt8_>p$a0d!E*PuSY-`E*5Y07xxP0!m3{DM-dG{&-> z(EhtTLE6FqN!rg6W~VTTp#`&-5oH+|a}=V%y+20A(+7zc!UNptwr~2lpWlTa)O`UD zuy8+&U(IW2b|C&6^YjV}0EnIgePV1d0=|vz1p~Hgv1KFb^$ar&hve@wx6D2qYQWnw zm&`YoanXz+TC-72+NeNbHMq1z@`sz+-RH77F3)klWr`@|%gevPO1TdZD>$A5c7@Ij`58FUX#=Lm$ZkQL{eX*Mmi$VLT zAUr-*GZeF5duxOTlwlh&@HizbT%)WLj*j*ftcX~scWowv|JZO*h@_fn$crRwFPWBJ z|7GdT?dRYlt|`~W!O}GXH3nGYy=%jLSLCv@6a+Ci z9rX+MFuyDLPq-l0O!4F#RQU0}t`|#CUXGZ6euO5HC8sq$CKY*|kzju_8PZhl`6Z3v zuqVd2K7_lGlxcEpQ*sHSb(aV-L4S!7WW8v15i$)KFLJlePM`r|y z&?if`oWf^3HJ;h){4Yim1lKdce;DPcoK!^(D`7Puy(!x<*;byJnlc5r9uL}p&#yI=%sE8i z3Su7NczM1Ue!GCviH|P`l-{QdOASdl0ai?EtF<8Kt~;2;^f}#Zm@|y|3o4*s+~QeR zHeQR_Y!G3WFHjc%OxAH>q-yhTl<|$qV>XeDIc8a)@5Te38n`8}a*P z8DEMmV+bH$6t#ufPY|xAl$B!=(>W4BG8@OYu`+ble495Vbv%;cA*u~ePH_9ECchnb z5NukTS#DevZ)1Bz2FHVnixYVTc1Vm5k^1>8q1oZlCQ@blkmLqLxmV?@S#w_g{5m-z zi_9tr4ju3e(1!(rJ~2P#k5za7432l)JQz;)GU)?0Ge>S((FX?!F#q-#cZmTry*cKu z`sro4=*rsLGNX`NLVx%bq#P)644XiXqyA~H+`TP4zXk6`LwOfZ_3Ph1L*9U~mZ=Bq zz5dI^=%WATtFM#l*Lx$Wv+yHA*f3vL_7eLg(?<4^-&_CBABz1yghMDcSO=MaXg`^S zxQ@iZvn7c}8IHkO_!FO$;!g?$kY#crk3L`ic^$-drAk<}mU3 zoj#1z{kf2^`mEeb;;?HDI11} zKbaGJsye5D0VwdWV~wFa0H^}xj`YBvJpTFVXPw)Bq8u1}Fv@3;J$FP|&_ZX|Hb}Z{ zBimbqsd(^nk@e3ysWIKi!PLqH#<^kt0K+gTA!1NV;)yA7K!q1oxKs}}y){wLNIXk^ zc>kLv$_I3_Qmh$y0uPTSM8bWAsDeqVZeQI(=YSVtic%ogFyI2qEpR(}Fmz1gygWq7 zeKL~e>hk<*u|NIhr{B0*0bWBX?|qgkuYW7ob^N2Jqq zgazNhcy&Q-Fm}TpZU2rL^C72tYO{5T1g6CSdp?@w2IIHHtGIkLH`O7ct%)iUNg5$3 zq>HXQ3F>K#=p7o{FjG}GZPi`+_H}>L$yI$t6X`jJE94OgG;FM-+P5XebUKtqA8vFc zuu`(&`V^CvN2om--0Q||Zr^Yy(+NacnG8|A9GNzjcuYHpAnh$E$$Jh69a^ksz+dEF zuY+^2N0mi@V9cXI)?j=y_q+tggxTrP5z7V|WY{fHdU5E`k=T&RJt5vN_wHsrp?eO& zBA1Ti*5vk?CxvM1dH>6ni+Pfgu3@;{8kH^Rex=#mawVOcggbq7oj}<)8?3~i?GNN& zdkH@SR(r=*PK6X{&NM=W0kR*|?CAJfv>R;Zdw7hpt*3DHlQn7qx}V9@T)bOXVF1-F z!EKgXv(R8a?GhDHmniAU1(IUoCyUIWhsJxf^U$R2uwjQVGV4QkGo5=Vho+>uY@|{E z$T{dx=JqZf=`})Rn+|P*vfPA5^)S!62^+q)-9I}Y9azrUbLAUn?Yne8_I>|k<5O~FZ4e_Q|O=hNf59547tmF+&`)Q|vUX1hbAX_p(ztn-X7q7W;e=1u9! z{n&fwM$*o1)l=9$W;XO9b`YLL|H3YA6B0$j<2@&q_v@v|Noq>Y+1rl%6g6*#ZXi() z`sB8378vhrBD(}tdEOqqczJ&4^*`9Cw}TNbvfYlro6p<#y}`&WS2}0xL1!=6SA25(;#)VE-G&=Tr0Jzx`oH3&6Pfc0*Tlx*g?|7hjg=bozQ`bt z{viLn*2}N^tc%;fsoLI{QPMM<%9@$4Ks=!2A@eQ`y2aQ|9dg~)UmSQ?n;9lb zttPqV9T`Z;sY}|J=;elAvD3=J(EqdD3W}5Pc33s+X;R*9#kMa|F5pd%*Y*oavfyKP z?K>L3@o7C%ytoNwuoHXfpdsLWqy+SX6r9Lfl~6h5^+M({;^-4c^6n7x#jzbp|x``G~!2Z1-=i{iQwlKfqToHZ((jxi)jeS|A6i0Aql z9x;&wA{bHzS8Am|*?<1W|4eW>VGJt4)vf!-3dDFI289}&5aigHRAR_@bJ&K7MCAxA z&3J=(q{3A9vt7|m%&`*}nK99$l|qlOku@z~7s?~AjB#4pVPJig`14U$RiUX=xZ zbak4~py`nLV(c=9(B|wURK>i#T&5!{mJeWvGk=^0v2EAK80Zx~ier8r^TbYxA58#y zSooTwDSYZcsvUm3N~Hyat331)sa~RiHQVUbgG^ud$eX3Pi#bU9xKx`h6U;;nN45lk zKk*XzNy#$#)n~vg9cqrzq2}iU-Y>=f7EmNJmMsNlo&3=;{Z7iKYx+>eX%rt*xSJ3V z=i!1@mKZ(GP7!$ch`KyG;-?NE8wrPM!kDeq37OdwZJA#gUpE}b?2B<67I2wMG0jZC z(*h_=CfI*JG@zmzQ?3lK0F?57{T|y_R>0J}mc^}Xft@t_r9pytO~JwKW98vj1!q9Kk>-+ zfcmg-7TEbH^zTxsoQuZbapATLA=1B;g->?Te+>}kAk;tmWe^lx0LA3RrEWv2MoBXB zr>tCeqo9t7p2O^NWjhwiu{7tnX?)HkUS3+X`%|1*8T&a(w#E~X32fk^50~!oDNw4D zBD;X-l_hkb(wtlU*GC}z9Px^s4vKY1$tfu*XY4@J)jL;SZrM5$k($Ha_5gOtN?+?C ztk~R}N)Zq)N~1i$j+a}U{UV&meuh#p=6YA*6p_-f$FravR({NLFLLZ&2dRfJc$z&Hx`I!kF58>rahP( zN#{aLhDZZS(eISnuVm@GtvgMsZmJxvcr=icg<8%MJl-?%F#Y^PBNsoQ^I94OuOvA$ zP78@NF^{7ol*M9=llcCloV|zXmoa&`Tf&*>%Mf=7Mjlije?hbgl#Fy!Y2-N!4YOA~HH~O26TGx}V4E5HHG=Ue9b9 zF;l#vkQc;k%nBsOsml0)u1QyvVcj^S&Vj70Hf@g*F>gvl385`f`sXjgJYhNba^~+} zY!RNuepyb_Rd)L;@fkBaSpVW&M$cd=*IhWH(DA-pVx zclt8P5u+4A(=qEOj#$wUW1zz?HH&$Zwq2Jw<}CV2d7jT%@ZBXs;CG2{)7?Kn^(+jJ zTLo=0_PSs;w`BAZMyzjwl2~vf2}}}jfQxC`?zvU4df<2!-^)dz`{Z4Ek3Z=h z8mpE|%%LfO%^8B*>WVr0>c~S_k4x-RVd{Bo)3ZhM(z}!(fAe3nbuo1<-5jrh4Xo2- z9OXUO;;N`Oc72UNO*kV|G+9YfmF;}%D25fxWjWc;QBNhv3E^T>A2mUigze=CD!M|P zm9Dl2#6YVMKQ<(UZF6_S{52dTQ&4}vsPMCyTk1wBb(lw!0-ikB8aQh#6^F}S_^o~U z{fnj_3F#H*l9Hl8_N6&AYj&97%x0wiUd1+(Ky=H|OhC5q!=Us1>5F;?z>o|FeH*eN z8ScQx#(vY>YZgLvGzPR6A>6~Z8@$<;B;4>_*0h;U*yJ!j4EM)T=@UBnSSJ+dl2J^j zR7a%l$)~}SSc^Wbq~VUZVYeC?buEb85a=@DXR~AAUIH?ULG{r|NveQp#PT$q?)5cP zOc+G&0df*r5o5oOb)@e=-S1v^jdRvvLz2yWV!Hzd{tFyAAzu32jK6p<*}q37l2ip` zL^0$Nl0tw44W_FxYt3f+gmGVN<-kpWYz|&5VjBvmS?Kr#dh{&Jb-jRHG(roY(ew%h z380JP8mGMkfoX_`kvduc3`ovEsw^OvlHw+RNZt;YV@sp@b-AO@{q-E@ShIE9$s>=O zm7yb)Qt^3JZX2UyooIYvj7-!9$Ha>B`8b+XBSZ=);ePV3FPwQ4pibQo4s|{?!zR0D zax|3qBWnM=+>BWLl)n(%wgjfm?%B#GNLCJu>Z~iD~7e3&3&vqP7$Xm4di($9k_u$vBA=dqm!`-ix`T} zrV6+zj7eX*nV0J2HQK{sd${176eb|VB^$?gzD^QQyMND!zE|EH#BAC_aU!S~rdMQN zsvjs-&pwx*2pi1WJ2WC9NDj1B2e~3C`qFJC*d$%PP?k_LwF^}+*uSxs_PqubaR^9G zq?3-56iL1yWEPp4S_*M|#jzaEo?WWlyj@=S#6Vw|m-R7kE_E(AG_<-{zo-P%NC7dSkSd`afFUNx_=FNKOglX&R;&H0XxRB_1r4IFd_D*}un8fG$0`7xyll<=NS6 zl7KQJ&wX9mg3xcl=X6js*~blvIA~06pU57ouJgr{3{KjAzlUcQTptyS|E`h-9J19q zd3=7-T1{M&2en;5;~$~7hiSH6^gc?^ep-}dSQ3cJ(hLEM7_+O^YBI*kU^m^}rKs^Y zK~gQA!6k`HQ@&V&?SWxq@1vX2B;CKK!r#$%;)53CQfE#USL)Wue(FvMomUd}s=h`) z`Q97cq;E;{a5Sq!=4E$skHQ<>{#AF_?$wPhd`#U9-=GR1(jrW(qRNZGE$Ihm>+m5; z&2s$UCL=0k)Png3evAEYf+giDg$Vq|3A^!@oYdX_;v`vm^4yeh z(AFLLW%~e7m$*N0W=bjn1kry69Zy>aBl@Af`Sv^5vBt0S&9{ete)}B?B(^d!j9h^N zRU>T4l3dxH@X%p}$jpiR@-9OmW<`KMR@u)%XJ=OeOwU5UCvTrOn%^A1IL!4Zc-}g# zcu7OI!<_eD9-Y5xoE>I+9DLp&>Rfr)7`EBMtHFn`UBY?LF5zJV+LbkXxSf%XZKa2F zbO8?4fdM|gI6&!-&Bl}M?cJUI-JOH2?Z$fJXzgiZ?Wp-=_o&hQFEZit?$u~81_CdP z6uU5j3tke+<6!B}+kF^Aor~ZjhmGde!P?H&(dO>@#=+M9&eqzKo&D|ogTuA$gZ<|2 z_R*(d;{m|I88Si#<@SZj&-EvZ@048+7DHVP=E$c(8&cm~$elWS@x%6$#^&1E`ax}f zXLq}{b+EHrYk-<+Ye$WzM{7@-N4w3&PoD@<&_CoMAZ>1K>^$9A@6?{QI{;~8bGP<% zeQUdRy}sQ^)7=#CZhaz1bHVr%)uQBdfU5xa7pQ4@cXKzn_+e}9^7_fulZ{&1S?koc zu6K57m%E#3ZTm@kv$Na2+}VDzF%P2V2@0tnw%(q-e0gp+9Y-3iEYm)8w=3-Y#SdR% z>5ENtw6(K#u+^*`Y^?9rws!WnYERbocWV3FhX>nh`v<$tqwP;z(U&@wCF^j*e66;p;Rbeb?QpC1 zba$g!J37FSJBQ89wS(PH8Fy>@DF!$^s5Kf-o3*Xx(R%G^V`Hnfy91H4wte&zaDU3U zyZeWmPr#gOYt8iyFqPb8JRI&msXaNse>R^yJ=}V-wfp2~K65OhhyAtv zC(VO{o!S#zaR)tY;J1y#r?sbB+uQ3~YwH_ZjrC8#!~XjI+FE04v$osV-l}au9jNVZ zZiBaPZEkKoSzkYDH0E;7LU{L^+Z*f6C$){;r`Wg6qr=+M=F|0Bv)R}^e9~y{H=9pC z1>WX1w&nn!0vn)T>|U*LcyLfV+Bw=e+&DN~KRSHaz8z~IKU9@IQ#pvxQ84`Ha-r|e z&JJIki*A0=vYKC}Z`U00+nhPZLMm-Mgw<%hv@BToL-7iGWbfj4Uhn_B!JWbHqibSx zE}mkg5yjduYDOg$rx3AQ7O#~>AHdPflGn^v{>J;~4|=fh>Da&Sj*#z;*e1O8 zmp;}(4-ts%p$`QmO}`Ei4;1% zU1|_{r$52>O(4;Gz-;6D#{>u-O8Yk%#3U7ZXBvrjYQ}@{_6P>F9@?_5;C^<`u{aQM z`H2}Eq~1^Tu167Z${8nvt3mI3C0x8yS+B48)cAM;r9lpCBHrYt7zK`Id}TVQ&zi1r ziDrwLzAqwD+;}>LDfF*h*pWtxc8OWeK*)4+TtOy(9m&NL3plC9mkkwr3N6?jJ!sK- zV$=)hC4SZ=k0bVuvPbL)&cNc!3=yT-#~C&(T|+QK1f*=5BO!C*7H!j=uY}4?&Wg-J z5fvXgLgAR0@2hB6R@}e)GiFDo3~(!9xc|SscioQTxY7mx`zdmPXro}UiWkWqH%!T< zNbm?_QeqR3WDh~B3qX-X3#h`WDv&~3I_s?S4D)yX%GtsKXRg;01<*Ugn`6mBzIcoy4b2{+uRe5WDlttXN z{`^w>HF+s=*yO`SJhuL5E0;|kyjeb*^4RNVtjRL2nYBVOQP86lKbq#pM;O5h%ryb_ zHYB2~6BSG8HaIC@Sj!I?r@R$Y$t$uAM!X^*7)j|6QQ;n@ti}v?h9v&}ZuRHS@$nQ@ zz$Pb0jk{rGuDl%3T@08SU)4vm14PW6?!tu+0DphC;rr%F`Lh&D&Kb1a#ZAq$tNT*Y zfOJ__?wNH-f$uA6b?6U8H6j~sIcyhkx73P=U==nX^&Lz=Fl)P^%L{@rTzG;$lSK8l zN=o@Qw4}$>BsKM=!ywOIhluGt3X@XcktpJ1zJ&A%P;a)0xRZ1Aw6%JH)6wAw0&l(B zO9U+b)}Hbbq{!7^i_AeM(f<|_WZ99}#D=~Ff=GQzI)C_PyhgqsRo z++Z*2;AxK$lZ6a58Au}LEq=1o5c5+xr=9`#`g*#%U{E8=EBvn1BlcGH=VOL}v$qo__MwpLgHze(GDcYMLZIlQFvXr z8ECWmjWl;h{urWy7?0hFmA0^#Cn)tn(X`3f36k)x1gtjD`E2dvdRl@g3RVoNo|Iq* z%0nJF-D?~?1lns0HRd1nQnVy(AYUTDVDtk5p0?3p{%q6>_}RR@0iq4vBLB9(zkyF` z38Dnpe;ADfaY$@d&1SyI>Z?9Xf%73$LKJryNZ`m%urMJYXmRfapDa_RQXDx1$Tr5< z3XW_{Jjgyq`tSO8v~iKPeSp3ND0PuTgH(OU$dLRK|5swRI;invc<^qlU5Xb`VrqOX zL7OL`Isj$RghE?2U8k)5nBqdgEGEEIkDoqmSHL)RwqZ&PBoc{ly| zzMRH8=P_s}nY6kyTPyS(_oXR=`!%F6_U%L_I@rm6=mwfj0AuSp9xJU2b5!>~1MQa) z#kuT~u{Ajl7DAEOMlLL!ZO}GYbBg9Coja3sIIW^c8w}HFQ)>#4U%TnLeXlrOWVTn7 zee(2e-zNT>bGeP!J2nHeaEbXK3+ZVQYC7mhQ@H~pXmpz`QCBiuqt|Ru5yHk?s@H6q zVUBVJV4?^hEw>eGk`eLNBD>`%4Kkw}DVZM|Tvy9kibBg)xrPb~BByM^*!-lE_?OhR zz^Ihpcn;VlPQj8Qdm;JVkxP#Qv+s~QFnV`9IiEogAN??A-C;<_yHEaNvN!^QJ(=w5 z@r#EqBrEJa!j+}g8S|rBFP-#0P!`D6#rt8o!^?_WUYsrLzFc^AKd?K zlYeF+RjwiD^#o=Azy_eHQPAm}l7k8w5O}w@{g<7+M_)bOmYRm0m~&Y8uP!>M-wK99 zp@txEK$Os*L+eMGvGr`K*HD{h>Qk&X*1y|mxlGato7c$l-!3EC=?YqfI!;ywq4{a^ zliGQa=6*ac^nI|{@hS8s{@rnG;Eq5E#kYNTYilV5Tr6+UR0wG%r{~Dj!$`P|j`;W4 z1bI93dt*$d3JQ71_ef-u+}s91IQnePsDMK}SoMnKV62Hr!TJgoy9)rzis7 zD=WJCM&9`{psiE4?gT-R^U+7G4>f7YA^BD+V)YUPh;i7^-9MREpOo!Kw@_#O}Lt$@6Ni|4+tR%Un?bhdcO-PcjER+&-fnIV{>~87WfKm+8Zhh)v5qvXp zgmlz)&ft5~eMX7&{Ba{95D?&Q*q_`Dy-1{#P3V(i72ESL@yq)eM8h z#HtytR<@AqQyA1Hx1e-koa{{P#i$@g>v0L8Ic30JoRM6P9y!@fk7ai^=jSZF8&q~Q zq(HO8Yz{7Nhw*L+pK3)kUOE-V~TJ%r#RWB%ah`d-7ZGlBrbylrlgfQT?CXmpj zQ8F-;@v(qDtI|^{bm&2m2qwp28lIs`5)>>}eb9n#0;;-k8IWF_npO~0Q$#^S#4w$Y zrRhbu8De2BXuwiKO&`w-c*GfGUX8pX8Ym6457LA;i^%Ge>ER1nZI)p!)MB!V>L_{`p(p@YY$+BNzXw+Sd)W#LrRjbEAysNaS z@f#F1rb)=)4I{*|ib9;63laU%?|@>7+v>Sca<1)m7zZU15rxRg$#||gR8p1gn_61M zUIb2Gbd=)Y8QSKPh|N$4?%w^ogZrOEa{#Dqa(ay3E!Jt$hiRiFRJOwXi25aq#xxEc>5(WGPZto|I=Y|zMeDS=%4Mk@56M6(7%S$n zi=g#D)0t~EseXBdrgV!!<&jZDW>@uOr0wy$ zx2Wi2rz_Bt8$eGGsK7tYNuUoVfcN(bID#7Q}@DS zv;!3jWwqo9l0u@~7k|61^t{3v!Enpjbs;hzzc4d!*&IHLW7XH|cq+#0>Z66!JvlIX zwfn^qGElOY?At)O5p%L@F%-294_!mxckI1gkN`xqI5+);X-`QQ5Icg}bR z|9*Uy8&)GCIJ)aNev%E=SGHvV^f#lBdFYR9+`IeR0ZO`*q4qb!?$`$zdI*84GWb@* zu)mfXph2O1XJT;A7i876l>}hciyK)`yPaX&4DJ?3?E3t|E=|^zy5>o%bmD+ylGv(j z$JIa;*%6=QPa39*mDQtnlea%q0I!gF624kLQwn!uuhXE^njyi=jL=Oq5M%VE0$L*Q9obL{+_uvl9>Uo( z8ur)dLM#}BPXf9E4B>(Y<;GkG{$9pfCW5w-EaLVRSzClgf7AcwDUs{0r>MdCfiZd)!>`_VDT?R6ZxUbRUK){qTGolf#n;<$$U*5+L~R5q@^ z$!?Xg5V%0t@$t)`OX5Jt+&+*H;)zU0)d3oGQIum0i#JCXkpzJ$*D8v%Zsp<#zcrOw zl#pslCSY~p)FqQda;~u$L@@xzu%e${?2O-8_sl{3H7SMh)31K|mB*1)?nT#&NO|Fd zTeA0q-M@bM)$`q*-9QN%p2UqT2CC^=MiRGvf=>3wV?nody*#QW2mglnQ}2yd3YU96 zL?srDo_V?_Ak7BzALb$-)neE@Oe->j?YH8!ADhWzsuHF7!$og zBSP>O6RctY(;iSsOo@fWd!ACGqc$SmT9j&{jn21;Etvj5dk{qIcM!J;-fa-~$;nI_ zSO%zodG3?-ISt4a;^EkJ0Af z;nC3-6XfLe`u9KCj2uLTdW3}`+$zD2Im8Gt+P2KA`=3>gCR@ZuuxscQebB!Zcx_wQE0yVg;kv2S(9Za!+;YY+pDkpVytLPG zOlf+TnHaR-(CzzXlTk&@ks@y0B5Mw~WTb`zPNCG}mL%@I22G~tMVJPT@kR<(JwD?vth++XZ#XZs2kycuGg0om5pR+Y!6Bl^@b@`P0~ zB~QmG$>8e)y@Gz3r-3>o3&y;r5-`Or(K@#ip5 zoPH(u(yKp}H32@F-yeiZ2fzC&!wVnvpU4^I6TH$ByjL%o9X-bdgrG9H?|L>EA&te? z#IscW)b1tmOUotx z`AOA-MfZGE-9xkzi!;n2`3$GA3Tc#mRH|}olfQPCwZ8c#gd3$BbP0VkM&8@;p--8^ zo53NKQRmV1t z4yTtel8ZF$WYIJHVd;jG%~dA>{1TVwvsZm3S{p9_X8{pKEeXk@hVT zyhyn0wE7xtbGOjB7#W93HOTBx3$9WVBuxBr{Q5#E4pnL2IF`zh+5>~8S?qW3m z15V2byalc+J;C8L5h(sFwLpD$(fSH@$eJ+t{TSUmoKf11qp*q?C|kTiI&i+2SPKYj z#A*C()f+zBJ5^1?Y`?s_9cTDpBofEs+ntG5Jb)c{B+LUy5fYhI1n?``HzIWZKQMCC zar2+n)Dd?DyzyH)RYP@)E805}6kXEZk-@gW=#k83#QK3u9}!5+9Q^sN_XLvcinkm7 zA4U)u!YKp7YP?u^;ti4-1fT?GMvw&AH*N{3)Am0sA^)(1z@Do zb#C7UX3+;+Zb{<7_T{_w@aXWM(*FDlVU!eo2ct)tNltvHYJ^$%FvjouepU*H--7~6*F zOx%OlLurplH`|77%|=vkZ3}0l$$&uBithcvbxRhxXnqZ))j+P>dKxF9#oDrM?m;C_ z_8G0)v*-mRnF^*?oK2S&@R!<|bmPRkszx}+=!#P~srNOwJj}KGcJh8!y=MUpr2zaP zjai`ooDNZ|m0@-u2&rh|#ByqruWl0}zMt+r{m?cc=L1+Rn$AFg5tk9JkG<*nsOC}2 zK+L4csu$4qQV1F5eLUTB8is?y$u_AluB)~| zaGNC+%U)PTJ0OcJdlsE%B5V4qj9h@Qx#9Mi+>jz>A;l$aKaS0k35YfGq=X)J31;k1 zt9xPNIAJu3@UNCQx#k$)A|1mBcx~aLVK8E6Km5f7vs^py;>X&oJvi`2H)>PY5|eRF zn`;e+BG7g zj^kEiH9=F7FNfBAeD~v}Lu+u=8un(uXTV@$8;d}-Tfyjhu3>38D*Q?!?{Nhz8S1w(Sb_|1_B~J<~yfq zvB!NX0QP5x0P!g45Uj}1a3(GOrK>|Vh8TQ=dPUPQ_=S4h&W5uY96E5sLWVjT9ZoOK z*knS}l7komxjHh>))%bixhBtxvs)jNzZpw_&fPDbZl(>>%=`QP z?Fbk^KW@gNfz-sIqg|R}_Qvb(eWJ9vhug*AX-z%)=OmlR9zybWc-Dl57Z8Q<1}E$K8*UnRQ*x z&{G@F;d48lyK?Bbx11-C?kyA`gC|yf{L&FYY2Xki5sc(`c!CTX2}=Z*hs#_u*`}wX zsT8dff6pa1xu{}*@>!)%T!Ng_HJ z9S#9mm7OqxOLR=eCl|@^I7hQ{fS@OtwTW%L%>BbF$72pTo&!X_t*r@W^U;~sDJ6|N zzqwFS=H-72>qJ8cQ`sdFO?d%_oQ`CB;ON4xiO7zdbog!p6>^9#kDv>q5Ngy08JZG@ z&IXM~`J?oyDeY66)9QYEVxbQNv9!j{bzgxpqJHGsdD`Lur!L`uGKES%3 z)Un_`B7nJde`<)0A}zsg(`D+-j%9QQBdn$tUKfXszZE7}iL`1{l;7bJiY_=7(T)<;Y(#Ruh{*7Za7;nJN` zl!h(Wq|3+902d7NY&b`IoQ<@1Z-dFo((OMP50NpY#u^K3!^EtTgvu2GUQs`<2xx94 zNdbz-Mxb$_l+mg*Xf?Lb->tt_!w>y@Hv)6y9}WrsE%1``>lEuKE-3O^0n6yWKEFsL zwsZ*oUSG2b@Wj;<$XQoFR*jeW#O1}xDGF>j-!|U+vT;t~Gra>YeQdtQ0Mr%JQ_~!? z!o7-X?Pi2TdD6RXh zUYgc@fXmW)c>gA8Jxn6^xC@RSfY!srtJ1o^_+e<>XXCwlDOwK$tU&8I_2+-JoLXfn z!7zc^DJn8VI~{!MGdE61T^p&dB9+CrVcVGl3{?=~w*3ikciw@0F z2PZII!m>syY_y$F2sSA45+gd1{`b19$K*6>+pFK?BIRU>9$?aI)o+2%^7W;HZ54M{oQ!sK12MvH7pQse7<#e zWK7>N6595dCPjL=f4J}szLS3F4;#_eusp=*_Vqk#Ectv*Mt&A zdWw8G5sBa0X@RAcTZ+-ihDnFDH>ySG4uBHv=}H{Goy+xy!YIC62f39Xere0~jcY9T zF_P^L=R%NSmF5#}h%K z@@P0_Z<>rJOz=9+fVaEweEN8{U%*FMTOJ#A;zk;dFFeapR*4j3fT_nR0J~s)k0g3b zL%MEyO>Hq;vUfCQEw_GgHR-nVJ~!k zJoj23W>+hG_4x%Rl7JfV;DiU;y3NeQA^2)N-{UBLT?RBUV_gomdr6$y%M3Q>lRq11 z_fybz+4Xy*h8shkUr8Slz!W6MR(hBI97s!H%F&cU_H$zkZs{S~=O}OXV@ciarG3Mz zw(#u?-0Se|tM!Db|6ChxfXby=T7g^yr7z@-j33FueQEImNmk}xURRf?<+6Tmp)TRS zQ-`!bf)L2FyZiLc-qYRN)wiRAM`&y~wl08YqC_DwKvZdYJjy(`s;>}1P)YB&$@maO zmmr~paG=@u#|WT5MpsY7QqM1eaBu77kksY||TwEz*p0k+r6~1%$g>xsDeCT607s z*ThWP`!TdZ1ea*{2dPQ0bzy;APTNRRW0vUCLWue<`QDcJF2J zJwL-MzUJ&|nb#mh35a->?U%GDS+y&6rGYg!hb5B{R->u(dWl5!5WRX4vLSZNiN(>FQ{*}bOeaN8zr_^hh`}~^?}0`a zDugTPzWBoyuk4%Q|3-An2BZ-=?NKK6m*myJF}o-U8;?r%c;ZjJb#Vflxab&cE%sww zI#IK8inx61!m;_|`}gnRc#vl3?~i9AIw96J2fM8A*W36J*!;12GMR1<58pbqqEI7h zf=-VSz};554sTc$KthK=_~{AmCmeBwzHNhu(s%*Yapdv$U2ppcBFpQ%*1OV2cAOr+?2v+Bicz80$d11S~ zSqPeQgf2rvTvZleq=7ujCFG)x$vQ%k$hgiZLLaA_ z8rr`te^dVSYK%bXq!WiCCCR>vCb8hzu;&{Q+vTPNjwJqDM|4h~IJILK@87~uKjsOo zP_gg5os!T-Nll;9*b(KT$JIvfkH?$6KR)P|Qc*UVx2Kh|-y#{2rgt8H8 z{?tKDPDT4o&KKK}(Gm;9m;9$=)`e&B>U^X<(zv{+@~8DR2x)!*ahmtv4U54I6HbZ8 zljfIFf*_@J2&lLjO>9yG;$BDiMZ=nsn=PG=_;1_?mjS?Hx)lvK?;zo{TA#Bf!XFyy zf=>~L7x+?9x06)s@5$TTJKUQpyZCyGQZKkx>X~u~LY^DEl(LTB03CuJTPqghSBvJl~51bLJsGkz)M(EgFOa6?e!7 zpl}(+FYXYB;7@s{y1&tN7m>LL^0ws{2di281>V|!7Gk0IXzBd8fUrwM?8;?#Lx>gv zixjy4D>V%By+~kTlm&wj%SF`s0D-{xLFc+7`;=tGO1usG-`VQ6KQx4#_7_^oZ;doR?M{A#bqLmL*AfR zbgO#E(qX|lDbckOsT=}=Ya#0Sjoh^2$EC_h$Twy2%e#^9sowW>uy~vP_!F^VG&)EShK? z+N7o%PeFMF^`k9^({a&pG*&(ye4%_q%AX1uhB6rqj_B;Z!0T7L-CTOQ^wIJGe(s}H zgX7wdmXE*s(egKc5l1TkbCvYb%O5SD{^vSc&BGS_+qGNrY7$K^2ymN!4AM>6P}k(e z;chNc3U>+LBkx1Uf`#r~6{#T1hdmX`^W4QJS17uMRyZN;0 zAq2!e5??ZzmHn*@1QK)JfPvm+R6m`cLwlzH`a;JAzrv%>M#CAx!QBYjjfV%E9^xYCHNA=8%v)W zMfJaBX`7!AN8A~3%R1iThz7Z$vFeDn7<0a9vTU1AIzJDDBez9we9e~840AE{KjiEc zVy0uVCkNsW_oU02;Kv713sS|Ekp?p3sKd1&xD_`w)$K(TF5PEg;-0C(is+4+k39Wi zGPf-bEKp8G;@roSEx#Hwy($r%L|YV|wG`2JU7_Pc>(U~T^ETDxBoBj)9dt>dcgo+>SRGHh~OB$A?) zOy~uU(vpPmFWRU6}LYPWH{=ovC?oWru7zu7=<)GXBWe zhEnkVOjyt5_!?2Vjh=P$rzo@Bm)~}ds*ms9U!p#<9HM9~Q~(fQYSLCI(#5{oyln+C zO3&o%p(gIc-H5+E(z_rdS|!xh)pb^V)^#1G=`YrP{t9KD_oOl>OMarh!$?}JCv_)u zN}*2_uh6jWGtC6in<~Gfal3`pDjxK|*HxY)tG}Vgdo&de`^9K@GJh+@Zuf?>@6Aru z+H@6WajCioQ=pOumS4>fox+;VN7bA|;BICP(J=~M9+tbeX;%DOnOfUidLneGGKbGO ztiF{_;IsOKbGAi*s5k@XIvXDA!q`-O81jbMUL4La1mz%9CZO7zSyX5iixi{ZX^(SZ zaiF3RxeGP*=D@96H;CO6GZh+?T@7!9(7>LC)6b z2c%z00>SWbI)Ov&6t$X<&#>@QM1&$+9FuSbZ%MP&y*?YdCoi!BGCD}a73^qbXxUS* zTOy@yNb=ZgEYP}rkEVN*??+=9`A|~KkEF|qB0ws{9tz>a+Pa~p!Wed-Io}>s5CzQd z$7M}X(U-)ARw_8UN62+!nUIsDDDA`QV0ifbxm2ztG+yjQ_bfl}0K!0U=~32Yju1JZ z0lDvvhZ2ZEZu7a!U>V^tO+R0Fc$1s%rAJVRM3BPc0HX2g8#pBj-_hvoWO5;%r)nD& zXOCDp!pgOT6BS0*T{x8O<%s9N6-s|61mA#hR|LW^!dG5sMMX0yRZVqnOgZ}9F=p32 z3v=u4ox$xVS@*yob|slGj0~U+A1sbDs(P}yI{oeH=>)Pp_HO5~l3$KGZ@e2(8=nso z1kh0hRC(#n;{iai3=P!i9(#c%K2RiNZw~S0s&z6+q9J*Z?8#(&q*}(@6#i zo3vgxg9!1C@s%%dprT7${sOVh-;a1S5@sj7Dw*MT4M?BR`5nNNzx+HfLC8QBngHuU zUFX+q2vj?y;fT$!W;X4KB^(-* zokC|GUYy`CfUV5#K%5$*w9B_heQ;&{A@M7N-1MLWEn zF8*9f;Rl#s%HMFCPxAs5G=Ze~{+7HjLJJp01@{mYJlp!)%e(!59`^tJ)ko`_K0Y;y zP~Raxc1hBCW4OP)#5fQ8{|5%#AME${xAyN~)Z(DVl;aX0NW5B)$Dn(f{r4DsP_+#n zh!4g2>P)jR@C$Y_>)pFu-TU3|Q*;SPIca}d{Vo~6v+g68)Se!n!^kpDJiWPcOHGT^ z4b5_Kn!SqOkm75(+v_ewG^}*%rY_O=kw+lkQl3X9O({j#W6r9>cC3qh1t^Kiznq?M zF#4Io1I-+imp`1IyehnhYn(I9syqMk_4bRuR(JmL>}hpp?@N@hwQI1t|5-kGLj%jr z1AGq+a{ToT>DDb};8@NK2G5Yi1Xc)>N1WqUl?s?O z!BRyhNUGxv?|26BgvAKbd+>aGf`LtD3^)Rwdkut$xmc2A*M)5A_Sn)(11@#&P=u}- zRx>&rAWap9mN7~NnqB!NB*@VZueP?fXNSYHQExpzwnh7CZh>6e%_NTu!#SC#?m!xn z8$F+?O?)m?&3}pTCSm@XMeyoRW#5NyG>OoE4@f4f@*lk~kHqemlfFN}f4`=o6<1C3 zfcR69HvAJUDVa^0Ka`%j%<>!`lbvnJc};N76{fgH!>qL#pCLInvT0 zbD1##flW7ZF}cPGHgYRzq)!6d>ZiMwHCzRQ-6gNOUq!?Vfk4^e^PKCq7vq{k7=A`m_0m~tVYJ!>A8Eu?uFa|{iH z{FFv2VgVQMxc%`|GZbhh_%NFX32&2G*e_q{;QX)FrHoj^|(Yjgmsk z-rcI;8-)EQn6rAj{d@i2_V4|Ev(2)Vp$zYj&S2GSG$4 z7FF{iCD3FTU71J>8nk{+ttwN?yd6N@{;v`_+R#LK>@JOk^*$PuK1Z z?yj+psI>n1bnWZCC;fZBTl?c@zk0AovPz!2zgFSR@$6Igz0nGDI-Ctok7>)Byq=3n zgxtd;G=jZz|L)yS?%ch9=k$1fhXAap^7BAd7_~RE{i@2k5# zGdl%&cCdFs@KReRmNs~BCmkGd9=M+>ffiLmVIP^i-z8D)y#8wZxhQ(aquFP#*}DSBYJVb5z6&qF z&g=g+0d*IKX!$n2{>-es!tGJVYxaC{a(sC4+0L6WoaK-X9^7erBo49z<+-^+!Q^A} zV|?~}@7~>KSd;%9-j>I{9Ujm3)RfF8zj$yb{Um>uYx-);IpOGVT;(6;FPL{u-|-jn z55o%&?vxkvfP%c($qD9DG!mK{poK4FvSi2QWm5Xj~gO=!T#5 zzns8y@EWRc)z=|+=NI_ni(?r1$5r1YpS7AOwD#am93(*n2~#J-1FDYV4!Ofi=8`(do?mT+2{jezuQR2u3t?s<|+C63sF^O}CI`H%%Txx&W*?aWW<86t0 zVcDn*8+?>M^S4uE>#qekRI!9?0YMK226`>l)6oW%!VV$I-_Z|pWO3+RlIJIhkw`5L zW*TZh2Dz90RLwd9Jjtr;X)FaTr*ow%5%0|ZdW2$BKY2Z?uE))A#m5_PsM=|}l>^oC zEJ;a%3$ieSPnHTZ$R~ilsIuEuhe%j6V&p3*g$r;SbXB4TS8GvjRz%c&$O&dw3krxc zwc?~RIN`o+;HOj&HTtX69g&%zM@O$uk*emE4#Xq8Pn(Qc(fEiO5iymBz50!!u z(i-vb9-=K;1mm1cs{8QhjnCPD((Plq3=Ldhx9$k!$4ww%T!4u!0LD!PNE-fSfEhLn zH2X_8OQs5#1el6)B#?#L;f}l zY2N{>y7n`uEj4z@4}9Uqw{J8mnv*n$#KnGnVUjR?~Z532gj)D#v;$y6RMMAhKE2FNh7V`uuUS4 zppm0-j)H}z6AVzoA0CM%+@Fyx!Od9^2kFQ`pNT+Pkg8c<<0MRMRGkDr?B-D>d)WD8 zagl!*A+UxBPmFoUo!DsGQA&ZMTtk$d!NA%O;K@TiVw;s6+xSqc6Qg;X`WUAX&Cg*# zf*U_hATzQv_|jk#a1WvH<>s`fEO;W!`!K``;BYJ-CluCC6du589*LVPc3*v5a?0ew zY8F_JkUr&mH~_*E+2xx=h>DP`IEt!M_yVocX$TyKamS-K zxSwz#AQ+h)w0on$o5Ag>4_*lYV+Nl2mH`Fe7L&~+{H1?iH#i}xoCSsnmH_-Uyrq%T zTn+Af2%1&M`J2Lir}}MmG`wgo0*}}-nz0@6vTt|^T7qff#T;3lk|eK2Si<4jfbsn` zYLZD~vfd-UfvbsOHSIwr2_SlIR|=46`ZzS&ChU&@LDaxQYWH`Ok6R=RS&dv6q#`u_ zs@)ioV;TaaIa}!p33Y#Y0w^B&DOhPAJZ{J+PpQ8DN!8Cu5ybq--LPI*93cpaD-{dC zk$5{fIht*SQ=|9!Cq_Dx*RM|yg?T$6PkrFtT_GtWpUVW=$i9Z;ErDwyVDs-E|5W?%M@HQ65&QSM{;S{g_6LPeH-EE1U zvfDl78AuTT{Z0StOO98}x@wAhFq4dNb~nD9j7QOPygoZW6dciT1)oZoqGlG&8XPk& z0mlJEi~Ir&BUB;F<#gN=#x0TpPV4dv`8-S*+l**nE%KKKlgY^|Idk@vany`-zB7aWVu106YL`JiVn$*G5-TgG5X2GV%d2P_AZ*~qdE;o(90(a!7eC$!(T zq{K);grJRtQMOC4rAwm-&}O8m5bh9A;it+^*rhv!uMQFHjW!>Po0uf?;`hD}5i zx;2PgnkuJ^9p%@WSw7CvW(vRi&!DgIe0s5WaRx4Gu=L#lvX(B~)8^vhmjI{|>Rs|c z6W(>T|3EMh)d8fE?m7eTpj1gYj*PhYYMFxg4To^Pupw*uE}(M32?M*$Iq78>Ct>Kk zBZI2I+c1D;iksw(M6Bb|X5X;pRmoZWCyXH>h@QpeSs1+X>k0!TcIh1&TUr1-HDs_|l9h^eYILCZt*DhuLj@!bZ zC_d`U${vN*A6>YO7n+D)7}6|p@p?cK>mC!0A+T7Up5B|1V+HrM2+|bLVMB@ctL$M0 zkgxMgye@YJEQX@F+PZ*Z`z=Ow#)=f+ zNf-`BE)Dm*vraBmq7|_iKeOui7_&?M2A}n?xpYVwSWX;+Z5CcsR8bu0VtAD zJFsX2ZwEC1i`Fx3+e-94Vsmo2^c^A7hsd*f4IL&u7%%cjL>KCf79y$$9p4Sw8?b*75>|}L*rJ2JCwSoddh820%DtyMCsV8kS+BS z+5@R*1*swju66m%v+YoH30VHjWf8aVmiGzxZ@v=jPZG=%90KjMe>r*10Z2v39FSI& z^3?^~m#INDad635${SbybV-?sfUhVw@s$;1CqBNC{KOZ^YbwZ5&4=?>Z&5Kbk0(M# z!J?`;!ge9do}N`bXr<_@Je%8ccBQP{ZDcgUG)g6r_RlZC?B)l^U*Ll;Mu#JKJCAai zXPq8Z*Z^dBhzeQyCLt^{zn4soBQ&q-1Be{QBR*(Amyuw;*0$4Io}F+l13_{985A5T zhlHQBLBsEERbNij!9h*4wkr(tm_t-Saf2E2CD4BQq-+aq1z#*(%>fQ4eK>jg&_g# zGRVp&QBG$zv1*NAg?Iwob;)VSFTB!wZymSxVt_1vHMNFP=P_Z&9cPq(KuIhWfa*Gk zp%HB_$tDpBzY4k(1#rp?SfYQlnu~bsG)v zyOycj3}EOqDd;LsluTx~U&9CwmOVX&1Iah66n}-CXPvk1)N#(mpPUl&ziL_ca)M|} zH7`6uT`o{nNOWeX@C^KmF8pj4c^~uZJ$LXoj*0m5dh4lMFJ~_p!t|772eHTVVT2@; z16B1;<{};C;IU4BjSCeYEQ$5CQ~6LMIly}nm7wPck}Z@ni=pJ{BS6@+m(auC!qNNi zU{*uLro=z=W}5{KZGoJ0uUk7)@N7p1a(nw7lCvKBu9d$Gs<1I6z(s4B6;={_6bVNH zxrymPyigun6&vd&(S;esk+fy6Qyx>=Ek0plFIKQ*ZsT+>uIk%`GNuHpyYT%f%v4Eo>Z zsqt^Sq&a^^NuVHn!AuReIR*4g#YNl?lAhmG+-n53PtMv!uvYArA#Q%!`~UzfAo4k~|Qbj}~B+K8(BY1ERJpf8_d0 zzEiF`<&!p+&zE#-TW_ySTDpu;u(UnGuJ4mwn@mcHK+{)hqI3{)TLfvYfJ#tvBUOy8 zi^y$Tne#Y{8AuXpOxyuUXZ#W=of?4aqJLS=bIE&C>!TtzQ^R_fGTJZLpezxvE&3tH zZEXqdBnEm+B}-dIi}bYI)k@Dx+;+V`6rR^o<_kBg93sDAFWT=^?)?q93S=!i=8c)| zNzGL)5TxR-H9L!LgEqP%jQ(@&{s#n-qr__QRQsIG)92st-xl*h`Urnd0Oy2Pa*K2W z)a0l}b7h|DfaYutyj25aKtuLAT_;iooykX%_2hg;Q<++16(4_GQNBXPHa- zvdEjo;K9X`e*@LVD+8)8RR!G<;QuPZSJJ}x>VkdxfF6?OPIjC%7BEt#C#By#C#qtu zsd7jwS{USl>H?`~uyN1%3sYSp`s3wNWDHFr>5^Fz(rESkHz|TVvMkuJOgJO4vxUey zVb(DFPhlx^QD(atQs? zFm7|_?N%@qTL@7~jb>tw4KAMV+O@C%O&_O*o`xi5lau-b{L4;URy0LOX^}@vK-tMe zm^LBhR`rA#js{i?5R1`a8B&Q@{wyuXiR><(6 z>vOH+e*9d+!{I(`7skr`AdHgCmHOSlp01v5;FIv*9Nbm`p^0w3yhx%=F_x~v@KX+- z=fM2QA!|ou88l&Rj>I;m$!X-luxDLQ^68vkrGy+4W&AEuI3ojnqz- zJ+hP|Tmjer-cac`G2&>R+=AL~W@^&R9t-1O_nTHnw5)efD)k2I!trZVB!+Lss;#s* zv#VagwZ3Wf`bRTF97>z8aX>BWOrab&)4%DNLY^pVA%k%vcYl|@WBnLXxz+1J^Es)~ zGgH$O3`~NN*6KMCUt!KmBBogpCXVfSykU;rx2UvTy(hZfPtMUF;VlXtAfubA>V($5 z!#NuBp`@b0Pr1Nje`ry=LOBMyUJOAXAtpo@mCeSnrc6c!~wsV$`)UaSxz z0==rEEk+~LMFdgJ?|wcVy&fU|i&YGeg)h1LB`qRly%O~#)>WFqp0M0}O$f~SWQ!n> z4M}KFIdMz+YOFAJEp8`HyqvII4zr<-0T`Nozp~aFm_#^aWY-ZC{?&H%W=Pm;Hea2A z0ZBcQ>_)$T_kQN3tp$Mv4~o#f`A|f!PY)7N*R1~Q#e(cvbx8T$SMcQ7&@*nle~FdUDTvqeYcf}7Te)-3pXQ0g{InP z)pMf%y%Eu5H{R9@1QU7k7bcb1KgW|kz6@TKN~j2r=1k9J zqW4p)m_&^>49aQ$IV2b<5CbrG)M_yS33#5k#D|W`LM4b9B%t!U?89k=+MZ$1*&z$n z4m$kt(gX`);W$Qu^qc(vpQ~+Naf@dNEOAT@IUNGLb!ps7Mn}8v4j(~IWWcRfd{cf8GmY@DSY;{>Pm8+=VreC( ze~7&#w3=KMWzD8MMU3`()D>K+MKPWfxw65HdTuOqh66-i$hr+16mri-N7!6E`fN0u zoli%7?X<|^KIqzYIQPH%C~81HE?chA&&3uN0&KND#;t&vKpVfV&^rWuJ>Ij=?R0;3 zF+*`)G%&Fz$o<#gSFZXKXe($mhJqSl?pDaPRe%5F_D>s|FZ=gi1-8PM_|u#`84AX{ z$aa2Y^Ypm2INT^Hitu%?83}Pu3XJX_p&hT#!!B^^o3F%$VmN4>+JJ+^7$<96W<-p^ zg}QshPJpuQMJ_dye%GAC>^$D`*EtxZ2Na5aqq35a#z3k?%4f=yYAP*<_8+BNQTu#+ zDGeB*^M7NO^%Fb+^M{!TZfblNIO(@_q+1N)@WWQws}=Fg1w=o2bQRbGc(e&8g<8^$f?C= z=f8Eq_M?wl*Qmf+<`R3Jp}&ggnNZV>Is_)gS8Xu_BT13?vuVuO>M{Io~kddv(dNfeN9P5}2ug0#R}b1P6_S(koK%$|r4|wMppq z__QMPV{^V;h_0wNWNmQDOk8mFo)y~g;>*X!Lo}mAg3I9yBJ<=-zCg~n96=15sBtL? zDP6dPu#qM;h7APomh}tFx<9M)* zYTfMD!Tk?gDTM-xR02vUl=9=?$(Y>1<#x;uuk4T}8iwG+!giI8ENWUq8bMib=|)52 z)Cxo|%W2tuC9Z}ISU_#J#S#_%qa(Cgg72H?~wQ;o>r>}4n;It=`7%i zohQ@#tbxKN=IN3$Zc^hGRCU_8MG>ib%CP-lx3k$fkjtcrpl*j&T~Z7H-)5%B0+|s1 z3vi!hM7lN*N~F~-@RrpERw5we0GOC7ds6_WjJZ{4E4#ho~}7>O)j1T0#Q`4cE^C^e^E_GX`!V$*S=#x@P_F4Y@=-tYM)?WCFE}$or+n zX;|)scoWF<3G8Y0W;#3^iGPa@1oZh@=s3V@fp4VOubvq=2QW75U)<5T>Y{k#qB@#L z5{JwT69vTycEUMD$zYIO7_mlrrn*8SKv)X(C-Fmn^C-HbH8cgnoG(M`;WV^aju0oqenWVn%;)=s`=*2N6b!_-^mtc=&@ufd#Al59Lgv>1igYV$?>c4AZK#5 zySV8<;FwpnXp~3g$oz4{w3+{-VFCR8=ddea+M72kL`g@nqW>+q9+FD2;(7@755Sq| z|8(J`{jz-6=0rhvT3!u~8q+0JU=^|gnYOFHle41s+p78QiWgo>=#;hIb(Hnz^=t~9 zn>sp(JhjH@fh_n*g1?Mb4>@+1@t0+1nOnA+;#*o8O&|ruH*TSrEec9+ZI29JkbyvG zQdp-;-xd9;2+m?1%gG{!8XlpZ4iyN_jkMC({K$y}33+rfhhkj!p-+z@X&@2Um zy$Lb}tgg}~{!%M-4{q$47F#M<1DofC0SfuOObKJBY(P;Lo~fiD(@E)M(g_dl5;nyuqCcG0_moK z7z6lr$Ue4qjJRMwz>PpaS?l8Eu4alz4{`U@`D*jON``>A=q_VGqKMR_p`ej9tpRc9 zHe=Yml`I0hq@~c%YT>jI2l|`-_eRVEq0%NQ6{D7f9i<1iRNLD9>z7|W-`$A;W6xv5 za@Jz&Ptd2!%21%bH998F7&nL#wF?Xhrr4J9JTwMU-e=K6##?Be3m?_;i$Kw}jrF{S z`S5#Q7HJ!BFUH9R1FT7kmfXlXoS#p@nPD@~;Frn4zag?kTf3u81~v{~9?)e#tB9oe zZ$xKGmgXd;g4^zmu>o(_dQ;P4Fw>MYO5`Z$h}$6)HjSR=y|?hGzdj!eoCr4Iljw@< z*)Il4m<>b1{KYyKXpDLR-#KbNfXxkGkLVAk)EJmPB5xQ^N=MxF0!4OBm zxlx^+PtVXHF6qr~^pR}x;q(}misRu{7`|^6!!1kITM zk>u~l#pTy&`kp3PvcQYPX@VADkwKjo5FZG#*DMi;>>KPhht?_%8vJ-Bx&Ibs}xQGB^o^y0VF>kq-iu;L&z8mXoAB?*gLNSZX__*Fem+w z_>Ca}0bz%jhp9^=tRL;=fveg?+Ic&kM!+}cv9T!`Kr%T!MscH~+u1C{L+KrYMB= zYUtV=*g0lIkBW@$j8is+4N&}Dr17%SUzq4~F2Js)XwoH&8K4}Y@GW@_Dw@7IJ~X7R z9=NHTbSe1^`;yjmv+Ehk+0{s+kPW8a6@LnOG*q#o%>VD&EJxgF! z1eUYsZ|m2}&s)BX9IWwx9g^mvik%Q_p(a!WDq260T}B!d#z}2CNmw-WwvHUiFqCMV zVYs@d*gFe?C!U;O3$dI=QJMtsg9)T--<4A1 z`o=YOtFYf_E9R=)&G?wTpQU_=V`9nD);S15$Z|!}N>L|d-H7cU=5VCK=%#Y7-Im$9 z_@MBY(KK>^OZ0j1wunDAg9UF@&(Yn;T5{kL%$q4SplSCtwAMn8)U!lA^Bj#_f7@>I zyP65Lsf17lAHIWLDH0r{C;7lyM|u0Knv;eDH^Ip81isnX(1}AVjVTBUHW(OMpc#rd zx$@N5izbjO;HojaqUM@#1!aIKf1=Qp+R);BAgjUz!6|u=W2&M+~ei?FPtAL zfOV#=t&;k9W94lqiYv(^oBEJfToq@L{W_$W9cQ8uImKfalmh8YC@Em4aqIJ;^ryX3 zEuU(hoARg4=WbPBnLPqB;~e6xGw)Q9DU)nj7;am+(Zg(S!uKGW{OJ;0zX1rBDf^-L zw9(sO;L`?2Dnb$<2^v(&f0QWiLBq_!XY;R)Zb3~nbLI~FdkbJSxtve=Pjia=ll(92 zG7M4C47wFfAqYQGDPN6>G=~k%3PxqNN3aP-MlIai6c_+qgD-nLwR{ohcszl&@o2bv;-lfC>|rG_ z7k#u!lduB&sPM2d2?K&KkEZfb;n9?;>x7;?4&Y?#I4XazJloCinC^rA_eR_XUyoHF z9ckxe1xai%d&AlHi@XRgMyD|CFN{~yRjGv+aeZ$`N9Re<#tsC{7UWnPeybPU$DBe+j24_YXAO@%4Cf4lR)q9%3dGOv3r&4%; z##ap?L0Vl1PBe(4-Ia)?HSM`0})yA`n6#NDx!|L!H4%5^A7+6B_Ds@nay+5Ua)lFgvM%*M7 zNLQfvAQrOS^D{c+XQh4y`=8|oES_}{N%B0kw(N@?k4Aznx4Z}OF9G-s2%2-CXLmRb8hC4VILL2Qq=pKkAM z*Qo8z6J$vKx|wEHpg4q;wWg-PT{(GQt%b=HH#k-}G3?%jTBz$O{i1Eu`TXpB4m$F8 z^n+%Wc*H9g$e;AgHt3rVWDbu6<3vP8QFKwv z3E%51`rjK7U+EVhnMq}x1$i-Wf=bWlSyx_6W+hUKEiW>++M1|T5?3Mi)9;HY7tzCl z`&`TC!smVt;ZjV%95A@%^3jNE}66@ojJ;7KWKsDDrYVos1oPYfz`2XO@2YXZXi+*x(J|XUNg7t_o$G?-x zN{VdKSRP0-Gl8qhC9Z?f;Sj!YnEwzNgF#1AI$aqyg96c5@+<jw?n<;eT!(zdqmymJ86-aG;PA6)ABHxL!Dvkp;(uBHGTufwGoU{PT+1NC;kYw z7*U`FiV1|+9kn>}I{(&^Q_n`H2VhR^=^0{6>0@&d|AgKrdaWV)rh!787}V?!QD8}m z;Fyh)RM!C4K^7ad(B;M#e}4LGYk&8zyL;Qu_8*;3c_?s#=hF!?5$5~0v$OrcYmg%l zN!@|*f6JTW-}2uSY;pnaR<+*r@~7|ccYhYgO@r<(0eLMFK{maJqx$6K3UWIXF$9Z0 zt}=?(9>SZvx~q-9z5LtStCxSPUVZdq?~e~Y{oC3mKlljc<5n5A=8F@fEiTjkljA8o zAos2`m>g##ULUX%w#~+Aav$O2V+6e-g7-oRo|S`IM~Ymjz9DUDPx_p4QnObeRRrba zDgHs&q5K$xITcl1Yz%vP+lsK6Wf7`qp{lh{R|d5FDWpMELr`w8GlrIH8z;=dzLCO6 zU!s;+pmwch6^?vgcO)F91`3;VbNoRV0F_~OrGVjJJ4(AK3!}vmlh3|!0l0ibQ5Q&= zT=|HE`GoOBw~HeV`9{d{IYB;rwXu&MUa|YlAd5CO>ywi?7)~1*Pe^0Rcxx_{cXC*L zlAMy*wVbP2AoX@ToM5RWl!f%wjK3MP}<|-rOb&G;X63N zr2?0UC=ry3a)F|8mg9{2oBsF475UG#m)+9p`~jXH9raO&8aFgUzDtcsK9pkrba=2g zI(%zuND&-1GxaNB7s~p)sJvVmmW8OQ z4j8(Wv5;kiE|S7@it2G7*LX8DRU46fmee_QjU@#HN^e@8dXd(7ouZISXKg~zl|{yE zfK;Aok@X_BDV<#BQk4ru39YPGTQuSgN_^KL?X4>5eJCj}-@TG_7oR9eb=iU6TOiRH z2MAXegveQ*9<7tAZGNyo>M_d#546Bz!Ni_{BV(r4pk2_$qyIG36LRrn~gYsxWH`z z{|PFig+b-x=WwGSq48)FInk3hZz!dmAg>vTw3KSq)et-*aKCUF+M-k9tLAVUA4Ii= zf{w~s5b%IEk?v%k1%j62H~_a$xYKC@lEdbnEt2h|sxzfdpL3J-KN5sBVE8vgJBjS- z@-{XH(*W^;Uhr>tsIEt1bC?8%pIu&aX#OioYrgiCm+{cbgux*Lvw6`@FS=-8Tr1*BMopEna zi7y^_i`QEV%6jvQGn7bR>()nG`xfL(pVBvvqEYxh+Q|i~GKC2uNklx|>=w0A*r2o2 z3wKmw~=h|P?}!{-~Y{s|(ZWY1Y%LkpKMcExChlG6TeO;F-C4XXhj z7xD^2{TBQ$EsmnJ1bPSqBeM?m7bc_<0V%h&VcLokOBnEXD4}vHp}F$3RbDBKR|Hen zD}yzv2jn~z&4RgYrVs>5wGP6lflIQP zq}_uLNo6GK_}-_v>2MI4j70Wz1A8zG9qfs<~tBxjaL&X0+ zIUZa8ng9je{(L05zt;au27_po;<-(pdog+wFdXLrf7L(8f+7NRb>$e0z=1^mh%*GF zB_S<61ur`A{?_~A<=^)AU;Sp2-m3L`TZsQy1+`)FvRQ9&oXb)hQCdo4E93+5!a|f_ zImFR)rKL2e#FEyyON;46sRqhdp=$DaOXzq)UqHSxXy=LH25ko9kSk~iMx+~Lyj+}( zs^F=wp%s!6YgQ34=tDAs0|3F<&2~r~cuuDxMiwBNrh}FvDV#j;{^V3L&cx4K7s5Gq zJf!hALf?w&aAb*T2$81R>kWr%`#{fegJPQ63a3XZ3CEDEWblr@m>H1X4SyLs4I=bR zdfqcW)#D-)F(vG6;5i(VKesT(Y;=M|Fp(DD(KrMV_IxTrG=*PSHW2sJQ%qU8E(E&C zwJ2*81{Vc19am#**yWs=x=QMp#A6tX zqH4S+6nBX7l9;&mo|$-E`(>)^3&DU;ui@VpdX=qJcP`4^xn%8Wwn%qZE`>j&5ZT0x z^=5CIU{h?(L;*ZfrO4 zPK4bN@xFjn0AoRp} z^T)53Gf$JlP;=`zw7TEd$e_cV${VhMO)H3+x>jxATB>Km7%hfGGZkl!l7c!5R=Gri z*+cOP*-SP;g^-x!0yY+=8+s$IvKMgtsMBMylT0)1PbM%Ic{IE9El0~wHyH37!`KD0 z7DC=^--OCLtcjYwh0OX1O39|tLK`#4L7dD--;%r&utV|n++k=szGu|^&SNcY>7{N1 z*TpfjZZ-^OVdV#Dv=eFughHJ0;(RBWDNm$i)8C(a~edkZ;%R3aiy-MVN6kkHA5jm18ooi9N{t}%aP|W(>zQe?3F2!FQzrXDQUO-fqnjdF zPk_v0=QBbOCe>@zR@gpq)o&6-h<&S3hRnFch_WGQaGWF4R>mWtyoE0La@`AqnYHxctAPo@&Zzt2O>e1UNNPlm5aWaXPaVeO(r93K{Lgn2L;;3DvEQJz8 zu-9e=&=}zU(krlnLIMuLhfXPluSiYJ7i7B%pC|iw?G-I}Yf$)9OXu_5&nM^OBc!}p zjKaXoS~I9!y^Wp6Z2UoWWU0p;0V}-()HGGjb+9w0Tca{=KpP=n_bJNXl9kn8i_)l9 zT4GQo9=m3PbAVGsd%LP-Dl0gZepF8>^CTF(el#bdVl}sodyTXz8DB`JZbK!8a?w)9 z6K;ZXJIaP7QyT14hDb$9z8-lu_dW6(4NL*4&ODev!w?os?L z+xE4uo<4@iCDm4zp*kEkBRM}_E4&%h}5&4s^B) zHG}Q)w(5$cZT}Tui8R)<0}A3uJrEZnYnH#Tu_g9IKy$f^E9Z}mReFhEX9T`65T%Wh z3a+ROiF}qns@3J8KAI~>c*QA}QV;B3Vq`0%0N1FeF5bE7x&RMZ(>?Qe{;JJkEsfS zDy6D;*ya(-3j;j1<}qS#8&3y#iO55W&4C&v8?E_dp{u6|*&Va6A2maGY?>>x4!@#Xg@OfjmWcEZp2qx;j# zmeD3~#;YG(J8Zv>ga7K4yVOW2_hIrj6o)I0C`Vt0QYEATUfkt@bxN%ICoo_dtz^%a z*svb_ww{~Ws&7tRAg&{W;a|#70#P`RkPAvp_EghDcx8<$J|Nf`n_v5K``iDdLMM^V zp9P$JX`8yTT4{JPn`u`@=AB&%JJD!)6`4HxbRwzgYOM?c%25jELE8Sai=FX1vuh9H cuN189_W9{oKmBS4?beQmC&&LjQgQD81APIZMgRZ+ literal 286330 zcmeFaX?GPzmiK#JKWp9ZpaOLXx=Jz_tv@kjWM*WZJVIcW7UVg3a){XT6g&RE|M%;~cZ(;BdyAvR!^Knkw6^%m;=X;` zTwJkV4(#u~ojqE7WWPVL&o}JsA@Cka zM#YGJZJySi8&7=LJnPG5r2n#cFw@U%DX5_iq{uz{n;0=P+M0EJ7C! zVaL!{cJ-mnMi73}K23-4r5*@Bwz2(WPlq$wkX|!N@_9p(j=!8bog$CyDd1)8n1*v` zuiNwCiuVmdHy0lp?d}*=+_FzM7hf0+-?TG-HO|?y^Pex?Uc6-xV%D3+WpL3wqY0qF zFT7>b&OR|ZTU*?2`0dD^dt`9DYj1epp8IeCk8mB_zqWXHGKDjaO@j{@>Y{0Ib!dN* zMf>(W<_4%68O7+svp%+Q!YA8yEmuEoWP^C?$nIG)n15-IN76BWzyHJIvE*;{JDwR7 zkUc)4HT(3`o(vR$*I_63tlQfU8_sh)|JmMmX0dLvQkeU^k&9RD|C@&8J9hO?_U}o9 zpNEFq`*!XJ8yoL|^AGKbk^te!ckIfG4MpB5{=Q|;`O*HJNq9J?r{9@;+q0_<4RV`? zduRj*#h=p|^~pFrJ?6x=th=@NZ^PIP!`Gg{;4`CC=z(v4wNE$gyTfM3XqhWMwrkhy zc+393Vc+)bxNY}s+qsYI{}1i|ok0rt(l8jXnNm<$!tNUmkcG(NN5^~_>pRU>#}rO$ zi($;)YB29K3xC`(o{jMy#x)n-!&t8wJa5|U;#rFC82P%v`M`MQevy9koNc>*->7BJ zX7bFgg>sOvU)h}xOoFU0t{F`|GWzo0ZrHcS&F9Y>ng$Qtqw~-@nBwY<#oz3BV0?hi z13oUXuG@FnAiV4A24=3;T|WGmT^VEQwv7pH+_n4Qr31t0Ipd6Pv*&NwT)6Jc;*a+J zTlOoK5BAKFQ5e<`_x{IFwK0>6Ho0AJs#k2)#h<% zZ*yBBoWEvg%a}T6Ph2z7@^d zwQzvK6bjLNu#(Q&zZ*t(KiKgnJAP+JtZOKeqid`#*%)LH_6)r6*zR-f6=YZm0;%OF zc*ufWx94-w?P@*k_l=f~b?+G)9g+o^;QRqqAzkn$pfUaaNi!Sd2v$6@Z+JxY z^W3qgBL8aU%Q$nNN3!u`VX$eETkybsIoAu7{&9neb%XqY>4d`~AC@Tux$l}I^)!WZ z9Zw4Pc%@ewx|bbTPX+6yL4Gud9u=g(dSl1jGA+8Z_&-Lgd-j%(O=s;CVHN18Pls2!trBOqc!kt?>NNcp2|Lm8Z~i z@Nw{4t{X+|H*iMwVEMZoMs8tux<3iU@kD%?r}mjS;J5j?*n7y|7_7)!kzeB82o^5y z!6w6k=!ywwPVlil19@q)m=98zoi%!XV9&;KVt#n5AK4LE?DkYD5yn}Rxuy4=jYx2h zwRCC;bzQY(+5($z+||USundTo!QVes-=E*NWqf~N_hW%PHK?Jjo*B-F3H+bMWsC2; zXIHpwgcQKe#KHs*>6o9{Xs{#Q61``iuwsb<@y>%|3#|_7U%vu}bI)hReD9kb%=4k=>-JW7<3TfP z$JFw($9DU(J=bG7WyzuO^^aax2`eWx{y*6GW1iJGx{gR2Y@-Ja7W;j|{2Av)y-9bc z7WdP}{{G{nC-rj%CV_+O?3Sd0WY@8u zZu7h9=s3R%nXg##ntgJ=`A?(p_-iKH@^_^AM!%+K{afw@-hM7Opn&Pp(EYSNS$5WN zh8%L=e$6brkL=w<8T=EGyo=fQH?!}=x}Bnk(Rob&(BK=a34SLU!0-1P%K@7kt^1`( zd}t6o;A4onf7fh%G99;%^ zf1~_m*U&+d&+}}e$sVCkM;~kTd_*M}xBB2CIR9MdWWJfAr9 z6k_W{G22=EE9MOn2S;<0BO?#R{|`(<i-soSw!&svk?-aO8JE5F;$Gko6ITu1iYuM1fSVmLe(@Aq-x_p3JQQU)W| znX)rn)MKXF_mz<~Uv;;@Y2oPCQcJI)&fJ+F>!AX-U1O)`Ax zsJ85>C3p(1>o%uQ>GA$Cod6rz6QbZ?3-D|Ho?DI8>GbO~#tfVSlxMhR_YutwwII1? z4nCnpyxJoh8J4V`spzP%-#?!7Ht&ARTND*b1?AskN;+8z%i-!Z3N=(EjLu74+l^0; zk3XKaG@>yymd`(>0c6+e=_(%sv;<{3CwQF!(q?|n8K&A)qY1(~)<*Jm{g^rAP-$ak zBg+TtEjpIRUOrnplQA<;^rudK4b5iEo2SDrg--enUl+}n>U_V({~v3u#5Pa<+_Oz= z1j&qdOEeT-tmT2VM9;<=Y0NX=e|)EQt~k_21ieiSVXWQbw<`Xf+6J-qiSHaY(@l$q zhS~}FubvYIZf>(t$3ga;%tNTmNcDL?-C&<#xl%ns9Y~^@1SmEP_ zdtUDO}fEF$5(>F^bt|nj<4x>W^|=z z^ zyDZc3v4Rv6x9x595w(03P1oB!+K}c<+G}i(zlzV5DEYQUG;{^=KCpJ~IBV|tf3hOj zB2KHy;E}lsHlE@qpBOx`9^vjMcGO+iGeEiSt`F^fj>TY!2HV-k5kHY^KUE(AyIe!Y z9RviovG`tWGD?`Dz7HT>C_A`&V`~Qsf(xyQ!Zh920ehSJI4xSMv}@|E#&6 z+%Rj+7{P6O>Q9X{aqc>2S*5ouau4-iYW{!As+suqrd2d?^xxjGS~J>EAg`I;=^mgpa3bCAWVc<7SPH8YPf zU5hD=Qpdj03M%jj_FOGa^z42Nca45{=6b%4{qGU%$Btf4*KrO?)3fqppb){_dA%eH zOO%*DPv6TFs$cVCY*F)O&lJ{W$+nHUfpoe?F~)INE4pE{ROX8FCaJwr*jMWb8u=Z< zslG0%3v zV+^)6Uh2U4bDdR=-likQ>x?Uc`s#IoA=WT?bSn98nHLahdsF|_H350eeY^UgXtiq8 zT}RDkt69}1n4jy2N{^YzbsPWX5u-nEmISzk#>tl6w{@Lh^Bbe)+l{rsO1Le93F~n~ z#@H#~Q@i4ttux*__Uy1}T{gwI7iE`igDx29e1H|}Pi1Y_TRGFIP49BbbNWFCGDDXF zfkyDerp_;xC^7%YK1=C(&)z-;C-D=(3bWZVyus~H4AZVHA2%zAh-5ypIubkzD#y7# zmk+@LEd9D=ab03X4-87*7)1D+hJhF=xy?sK^qQ>_WCZbb@m$Bfcbrxkj^v1X^W(Vp zu&T>(;%iINSVjKquJ5uX&OML65DkdqIBD((3gHY{xu;ExK$SeI>QNKlh3fB|CgRh*2{MvDF(#)sKbeXxrN#kbA9m~(R4mx#L)#k_r zt$;Mi) z+R1ft?5pL~DCB*?MSxpcr#EG$!baMYCsa??V17LIL<>5m8e;XDMDOkA1YM=$xU0wV ztlpwy3X>N}gRkjIIH^63qm;U<>#iD0b^T8*|C*jnzu|+Rt;<%Bq|LByNoByWgjptr zKx8xI;k;W>tmnM*i1HiM<|-}_;%YH}5=}@mZaLkgtGEI*N}QI$S5K`LVo7;{Y|;y876XA~YwFK|{KNuhr)e`!%Ve zd0l!~T12=|6sQ_5AAt{z>2VqXf0E_u^5B_XP$Nl5jDS0R57@WGtY{6&tN2L46IG>JT^oe|AxJ>Jn{%p3xo?S&Z(aVWR}JIdJM(as=cP17_17Guyk*e+$?7o4 z{{dfW+u^M_p1s}h$2{ZQG46E?&GS5<`LMw_RV4E~^OnJl`UL7m=YXHD%e>#rd!FYp zSM>x~W{legIU-v>9rwKVRVCg<;oBwV9xMeY5xa;r>uxy#Tkk1+wShAI+NW~q-n?{#ZT{>3+Rq=c*VW&gFsUKZf zdIvb8I_aV5TjWfbA6)prFo13-*T|Xg@+)~%NnEW^PWufCrd`DXM zo!Tm?gwCxAX(TMIe5aK39ptxvwl!u*D7@%R`wXtAD+gB8Ptx<{rsc=!F7lVkRY~bv zu#`*lqgoQqTeElhUM_g{&<>WL)6J9Tza856oMQPDL-wBC6>O_B)|t+`v#Q5k3cD0K z>lxJP6B1-AnQ~t_ zEaLV;-@VAgp9Y?<_rxnfsQd6y4~$ovsD-wPBxp{g_nas)M z03E&F&H7F0*h0KYU^m* zZGGRtFJ9S>J=+y6B<9t&%kj&w`?2BO+Q_Z>)Upe4aM@D87Te10tK0&@=fDRWYjbIa zvIv81Ua}p>K3#T6kWTTQS#|5k1Tpze{eH?6xDm6b?`GMb>aQ2m4D+K~&s{!bes$OP zV5$Fk@%x5;)*Jd^S48cbM`us%tcSPxxY<99GxyBjW3>yB7S<22g89g<_T9MJG)j)V zi@I39{GSG))PL?8ch~ut4n8*TirrTDKX8WPMZsq&V|0Fu)k%bWX4BWQbJwwUlv#XI$!}UVYwwBp*}P_i?bQySo3X{$B$Y`u$K-%R0X^5EUGF%hL>`F z!d*GqDZP%TQI2n)#7uMOhcP2Jj+%ZQ%#Wz9uyz_p8%LJz8jt;N!wCJfj*_@n%40Qg zhf`Zp7$58XUAn&@_Xe#;~+okP&9WG)Zw>aXpaGHzRqUVYr57u$U4o;gfB0KDOt;6|n?$S*+@oobK4Ud)M$G@8x5| zP^e=Mwrrc`v<4BIw~ZyQ)r?niaGiT-?PqcIpL$_6ef&flN^l9|k8PRQP8{F$j@6o; ztquJQ0++HQIkj;mXes0L%&vIaWT^4l@yy*$2dh{k*qeDC3F!4vPUwWw zEva~n7yx@_eqyqE>lmJW(9_rg6uo0b7ziF2G(16 zyvWb1;+}e=@;-wxO|k+c^k4D}4f9eQz@so4QDM4((Z#^dx| zhZMisw5zT(m5t~d;Y1jw+KRdvbWnc;EmTf00@rw|Z&BY@Fv*j1noUQYb|B!|WoSB% z+vZPZHyPW)bu_BS z0M9N>e7z*I6ODMkW~UPF^!gEMUrQ8{-^Ho|uA@eIh-NgSF|-puKZZY_Q)s*>Y^Tyi zb1a`NQ${QSn3qcl-@mFXM;dW>=ZAOihDUY-~nzmHK zRx4k-`jHC*XH6@3zagw1qJH^^v^QsaC(f;2`$cbP&(Y;z=)m-2Ii7){VvAE!(ue_=8a*F<$gMK0H{|8rws)w9B5}U9 zE$1{q*Rp=doIUQ-#ZItDWh}thGC>RDm(n*Fi--5Z$7#3V5>=mTx~0Xr=rF(jF#(;> z=PR{BeO_a;d+9T_YzE5nA-P=I@jpD;*3Vdv!8YAAzXMoc=~JajHg3(d&c9nd?*)6t zdxr5Xiw6C{JQTMwb8O>_maf+Q-0M&L!0;EJRVO9&MjN7&)URU~LFU28u{z?6;)Yor z-C46T{Lr+-Cs->SdkT&tUqS3N)z5#|8eNlCat2QW`>p+sddsEE9p2Rs4H4e7X|zsX zP9TI;1Gm0qp3tui@7^|;T^xw=_0O|B5;(v7OWnSY&@CMh)i+OeBN;Y+>y}4QkLcaK zzT6gN1hS_;H9S0A{L`qW?0x0Z6xg<9NsZRepCrlZ6&hN24RgCp?eFyX#a%e+( zx&LiDrh^U}n*ZAH?jBey^#boY&v@3oD~2SB)e-wmt5U>uLo&U8yfIX2cwv4CQSzPo zr9pW{<+H>ifLgpGfyY!8*+^pv!@5E`A~Qgc*`{yU-$oPX^?saa8P|?G$1}~X0ry?lX&PC?+BWsu?YHMOZpG=TvPtth zo)aj|YqO(@)Sr5;*YlDg<8o*` z*&prJxOCJQf*s(TRnAA3uz@e@mj9aVtaG6%-n5#xzxzVp{+9W9msUFab_G=oduD&Z zBu1aa65RVGm##`Z|C;CO&Y0?h1xl17&Fg;D*yL!IQtrzwR^s>2dstNz46 zG5_T;I!XL?oicv1*DQc1?-|ptO;TuRCgrp8NPq*>6!^{gu*=_x=aOdRp$asl{Cba+ zo_s#u=i9D4{i}_Ps;mHMuW7t#=cq9I-i|p{^w);hcTiIN6sxOLQJq(nhq01hYLBzO zx;(YrhpU0Raf-C{)o?EktDA|Je2Ef_=JbpRd@Jm+cNbu|6%mno(c1QDS*qGOBsk#&Ow3!zlTm zv0t|H7wz9QyXHMRUa(JB?8%KVO92j!tXemKU;cXkCDi7I~2)Kkfgzj?>{CwM1~fZaPL-!9wy zwhbb{<)T4h)8O%TgQd*|$9&$jD;zeP4NSHTI>6{%gUL1f{fhlg)?hiDzWOsfY202i z?7U^zywFhh6~p6IqbYV31-DTCTlNWS9Grvs%f_qha>kjf4Q8*|x6_N;IcM0#+vvat z1{Yr+Np0|AvW)J z1k&|Mrs2XheZH3OJpol<`+JPfExd6NYRyYW&F^;>Ia+p(spg(%UgI&QndunYRB1gK zd1!jWh25CF5>}OUzNa*FRD(VD^1w!Im7GSHAM3a)zpLvzob|+JN!2Hrr&ZGL4SNSW znETu4%@*5oI$yGCx<;R$Po-7<>(KY+{KEd_SX>@a zjww=ciZI6cqdZ+OnufD!ca4n}b&Wa3>r|lnLk*jKIB@Um4_W9i9O^c@A&_|KS5MINP0{Qz@3EF+J=3Emmgm*9tVU7PPFm0Ja?EP4lyKiUvjesJEAgqHo?9@)VARd1%NNqj zhmK{4r~cVgsO#O-B(tU^?D&qSKz@Lz40hm={SMXCR3uEnzeb{?=z+ZS5Ejlf*)D1; z1I&r~6Afm~__7$#m~`yZ@uIjqik{QwooLK-vhUBN=*_i9GEa0)nQFDC5XB=6R1Zu! zvuD(TAG>Be@VU{+K@%q!(yi`+c5CL;W=b{p9CX#Mp@!f)?e1}R)m`0xRr%NQ*?Qk5 zru(3~R_C)2?WdY&jK{L7%ybI7^c%X67}?K7%_m8=Zd~FkHg;|8BOWeY0q-}*Cl)n_ zlO(mq)DVW}*JMRS;o&ZqOkMh%F_kOXOZTN(iF>uTcj@Y2HH??hF)ij9**rFb)+fVF z_Ru3~#A}E!Jht85Z z2XX4Wmxo=QcI$^-|F1DbvEG7w>d%IkpBlsYszD&ET0iYMa`-Gn?X=3Uoqx`y)r-oL zr{kTHD=%3E<^E*Xjkb~0tQ5mufzHWm#M1U|QHImS(qoh5?BUq=g{t(f&Q4xWK(`9w zqI45^WHx&D)gHZbP08xRw*Rad#PCY9NyBvpvc;7bsn6h_bXM_o#`xrN;;*YSURP(l zuFg1_`jeBEqjT0Sly#?U++!|q*kqG zQ-}H*)n$*rZY-yhs?Esb=suJB>WOl67QZz32jgPz<2}{jY0neczS9wNW>+MlrJ^#$ zz`a+|Pz>B7*11<83LZMf{!$vj3o-Z*$0%KD<6 zQ|2IvARGrdFg62_N(jLyKh|BPN_0{BvRqq}@qW2ESed52x~`X>pQp}p3TG_K-E}ng zTjlh!94McC74ix6+a)^my+wUIN4%Q#Ze<8cj-t)mwx48wP1lw>n@&<6ZE|2UXLUN& ziy`-_cu%OiZI8<%{JL^XC9dADc`vI!+G9Iei!YxKSe&D#fi*7q3Jz(B_Ah}>ycXgl z>_E+Y9(%Aor@Glr(H|Q5vU;RFW@sghk$QGnj_KF@Sa)^* zRsFx#>mZFSrr6*co`&4^N$Da%q72#eZ2Ik0$bX9i124df%6yOa`eS-Z^V96Dm#!V~ z{4^QYA581|`aG)bsVVS%uyfB|dbl=sJxBgk^bu!T$4J^3BCE+=YYzwIuD>>jYE1^2 zF^5`MS?CbfN=5F*`T^!%&!Gx=(MIT)pf_MAg;i3uy|Ys85nS`5tfA$tz^*6PXoTgs zKyQw5pL4^W!4Rmo^%D3Z!#KCXY0D7L5w_{w@+HojZM9gK}~;C?}2EIkU#U^}Kj@?Qy&7fNTTVuIasb{c-#L z)E<~KGd?DInoF>H|59GTYSyyp>B~l7>#`{!&a&zpyr@R=d)>9bBmA+4{JLv_e8!r* zC4TYit_7^2pts?w`cM*&d19Wx<56pXR+Fof3>iA68fNHf{XXrk*nb@pi_=?mj6Jcu zey6tA$?L8KE)&$lq*e!R8~pvM1-t@K5Vnpr{?ul2Ehpx@*D9*tNjd%f1|43oeYztcYv6 zj?30w^mi?A9s9a#K^OG%^ZiX*5(Lfp zx@&>&Y*?-eVb2n}Bh$B;Ecxrc1?R1w8+**9H8kbE1Rp-9*I%~4hXM_=*OP($4m-`TeyXKh3Jny0& zD`1;h)02T($0tpvv2@=3?+n$o*X%iTb@{{~$SOf<>F5LYpX0eo%eew1?mzWavbE(Sp`m}Qn=%RUPbgNx!=qgdlK4t(m>&y?P15xWPg9Ylb!EB z#~#(|CNtV{BS1fgN-BkT$KG>390QfpNj>1;sNukmY*zOgi26qDz~X3rt=k#MO;&FinfWjX^{f7S4N)MVt2s^1kN z=3UUrM|Z>CLX{>Xj`J;JE8nzN=z+!U;T+@?_N?3Zn#+#yBHHL<(;uFd(LO1h&1KlO zJ>@}jJ#as4dIkXtD$1YQFVE}^R5XV(bYbL;oVjP$9~@uF_qOyZpH&?~`PY0Tf3cCU zS4^r8+0We1x2c#tmVGa}(r>hqtREV$_+PBTDfx4i^`gE)}YQ`OI zV4wSs?EEwHIIz!l?K^9aL)NB0E7P|Ed~I>oYC?V2SZS)Y7Cle_O`e`St9J#DDNnrG zZTH`@@iWVO{rySq>!nf3>!<#Ly_uO68LOJLjJj8~DWV~_m3>c!wgnCT;@#A*+dHu^ z9gAH%6!!OD9-qTmRqsc^RmYeLb_}-D^YbZmp+RWKS4TW*K7DC?%o^ETQtUSs_x+<7#@RAe&9$z;R3iys$K5HQK;P@OCmi)WZ3e|GFqI_0d8Sk=q z+cX*S{E=yN-+4LiEWOo8f7dtYQFf%nH-nDu+5hONM|LjVH*B}@va#m;p1TIm_m6j; zxzgw^sFUA)|7-eGL&Hc{_Jhm!%|?d**u2Zv`q1eU$$1T39D(w|jIel5_ug-)ZL8Uz1(x>U6_!!?W?` zy_&-Py>IOtI6Z3cbjKif)hNehIhKag)L{d?SVuz((i*#VcX=PCc-N)2wZ-#YQzF?fPBx73dlbj_!53~SIcoM!kF&yxI$!2`n{{6dsuF>z3&9O{Q*NSb} z)B0$`-RQHO)9(vF7L>}k9M`fbVMdD7?gUje6!|0Dj z)$AY%eFJs*Y&lJQY+NgNQad(Zoy1q_BbI6Y|R(1@6(|y`tJ(Mj4;w;4Wu=9q! zSdZ*@RE3Pk6dn}!bj3r~%+|m&LI%bi@951$9|`0h(viBqk4zh29o?`!l&_n1ec!G? zQ{_B{gp8k+uf1`vwQKkI96S16M-_Ef?HC5IvIU*C6*0}~xH!q`&?x4z8@y)+n2oJS zyq(yNT-HViwi4rZZ9x}#BoSHb-`hXx<$kueVm-d!#Nl0+9+;nT$1o6VOuSt6=X`AT z+kLZy?%Vxqi#HlO=s{ySu&X#{+WLvJseR%11^X$TJ=dpaWPiMGPshG?TWrn7^`C}S ze84)|620n}%BPgya_=LSKID?fW4h%*K1#aebh2sD1H3sbJZKC{fyh&^h^c~#tx?a0FKY|M`evSfBxqfa z541hvwc{uOJIr{Ti`W+uDfi;tmQrUPu8{0-O+7{XPLmk9c3K{Dw}6;W;JZh#o%9KBEf^y6GS z6lc%^{^yLW>rLZx@8Sp@^`!PV(@Yt8jK}7SQmICs>@CX(ZkZ>soUO77X&WK}9+RP( zXwAk6{5_MCS|C_w*ziPn=ugMhp0`V7^Qt2W{j?=~>A0-ETLt9bG>TwtmPZ81!@3_# zo*~4@P7)tj*|qfvf@~cg>aLoKrBz_(Me@ZMJoH1h{A>Aa9xwZ_@l3Ewh@14sO!ka+ zsTI3p)(rj$->DiX+qCN@Kd8)89iG3ztChCxJU+oumg6bsEZ2{7-~PNXQY8JZJ>}4z z;}*GQLuT1m3#VhCU#6cak2y$wa?sSW@PA&5h+a=M!5Q;jJvWn9!{>MY*+$j(WX~(G z+JY|i_bu+ZVbCQf$I;(X#;Zmi>sjyvY&M%2P#ww0855twT2aM%-4}Ps6s~isD8uWu*62W6+9==^NE!|E57U#At| z&X4$SL~p3IjcGgPC&%d*2CuPoT-T22Q1Rk;-tH|rSB2lTYCM;Nn+;_N;#Y0NKw0$m zg^dh3MU11Sz6^Z;jRzgQXOsa9+9PymyZj{T_tWv;b<69pgY{qS@3H+I+8^{2BsR|q zRkeI`$py6?*d6VzF{)eKHNHxJ9g9=#o&6$kn$9_{&R*^O%aTnLU;Z^cn|`b7M<~e^ zYqE8d1ML+qP=GS+?LA1)bE>d&2?9m4ib6e%Le)j?)#U5-hGkgS2z?DDxw#U>rSK7C z%Mc(d60d=Ae)svOu*$ClPS{&(R@cdA#hHC)==hoZ3eT@u9n)p|yJY`rBzWmbKJ&NC zGrrLLje$UFCAr0yN@?G}$5%*rG#%L$+g6S^iKIaE1Twa@?QFeuQWg&x+|yW&BQ?Nr`^?DMsr~Qm72ma{km9aw*KHNKYqn#xOyJfWXv(ebE_Iuxzzu51-22f8)zkgxR zzH613=bC4xzunF5)wSpC`twE;!fzjR1rHr_uj9H=)~@+a8&<_6zvhd~GTv%5+QSYx zpeN_Yxo9_F(7in2tecL!vTRW~$cxKuPZx#6}8JyNDlPTze$Fw=6Pji@Z z?w)BARwEpltZT2$_nhYE4GdVr_0T@yt#38;@juvS>`B(h+_JM;-4$ePj>~ifp)fym zTm~OEZFHg{k;p(#tFb(VSerd2AtY>UvK?xoE%tjYhJ!1tEOa2d&A-B!) zMoPYIQt`Zflf-kmiL69!mdPe6nL85219 zYx|w94Dy{MsmAWil8;_d86?Wo3cRxYk-sVbF1*0cFWRoo$CiIzH`x?N06lu8SZ0t& z=gpo=qn2+pGCuEY5_HH1nFi;{dxmN6;z1;DxAo@m`!uPd*$D&rS_r3JQ6uXU#3gW! z+xSEGY?$@+mdTYZv%D^u-8IDm(m3;D%xPmas7Eg7PU7p3+t8(o5P!$zm#;-31I54P z;H`P&IIXkn{?%yGvVWr|4;`m*>a@FdOnxK%L}gGB)a|k^k6AA-f0yA=jm+0U=RAyd zCo+S)4z{;!?P)YI4@`%+a2n@cG=6o?edl;Z=CYMkXPf#pEF~KUiM=4K|qRtxJKWnlWU?AAp|F@lcV)!6Nd7)X^LEMmk zcx-M9^E;A*wFTgA&162SkFdUl=WVlCckLIw0}LY_$o3(nJS#}o8GKm2g)z83%q@PG zKkzQn2TA#@-Rtsk&E5T2x4BdmZNBnim;QHR;oms3skM}HB zQyTc@uO{{P3~OIBzWP56j`F>*;`8}=WP<()#And|;STSbaK$jL8XMLTrBT}4f*A4w z&X^azjEvGeg=2a^mFK&4yo#SW#U3UdAHr%;dgQpB04x7x@rsSbf7>`*rpA&~8vA?u?NP(i&Vg7Kzq3F5jX5CNJ;Fbjo+PHCC@0WyGTVeGZNH03HWHRXvo0MiK{-1?R=V38yRv{VE`jV(dMC{ zX|}#7Z@IhEwOMmOSu)1^p9I@xeveKN&@smAj^RkFSjmW>E1#IA#ZNkwI6J>r^Ex+^^_CZMO`~cj)N+=RVBT*j@gP z<*04i-P7(89TE|3#}9o!53epxD*}ktrJfNq?ENTmkMx>diAM40)$%cln1aVfuJW-l z2RzquG%48EXT|lWmdv9!r#*iecg>_H z75#hZXh=+OUfqt331^|fg6EB2;~IV(mz}ZE#S(vt#1Pf%7`iu8fAGxSu(kMqOxJ%h zOGMzc_7t2so^2wT@IRJH$wv0h*HS88 z=Q%9Fao8Sr_40AImxUUpMG}_5UmYguuA0ib^JC{$!;_vDBvQ#LCAfyHNM7|P|5b6! zuNyDa^ONv)?3uc^iWP`gi|s*XnmU5}jW?a=FUa_9HBy1+!Q;wU)M2_G?)|@x#YT*U zvGKGN-Y(-xUJ@QeyR#Bn6bO^feMSu1 zyOOJGeoj3@V=dz-J)3^pvC%+t{rg_ZRbr(4w)txQ#ec5Cq(2cNOah_4!Yx@j=#non3mz{+rXG((lFsQ=pXpZ{YRQIvw; z`z1t5GlGGRV|iax(H)QN?#qabJ2Zdmo{bpl2r*E;5z%8US65u3`?<&h?_KbP@vQgQ zS9Q?XH$mG}GGSq{&YVu<+6Ul{@%U52z)@pgab?~ufh*t(?UONXmSf-jxn)m3x77r1 zS#0>t#yZ@O#spOGaQnwp&o!c$n0w~2mz{%vnX-r}H^nwCGxVA9KNRIqo6th!^1ZUE<~jY+p#N9# zew{ub2h02=sp=^$z>Is-HLO&Bgf66is)8M3@Q6&;^YZg`&vgv?`qPItF2;7@c$FyM zwceJhH(xvY&{m!PVT7g<^$GG?-RkcYW#yw^CQDR$lc48Z^hqPBk>t!Uf45`23)-8D z|81TyE6$D#3iT8rIONA}%mU{+EauPDcR`~29n9bJb6N@g-tnCJp#Yue4*Ey+bDhSM zAE(_lR^R7u)%Rf@71iVvQX{4G79GdUweIS=s{$2taT`=1Yqbw5@NSTZsZ^_df>-I~ zEB*UMhK5L2{MTB^f_<#-+rCqdT_~SCM1CG#_i9@-v-SgY?Tu}V=9d1>@73;7skfM4 z#VY+VwACJ?GIL$6OI;%N6c&qHzK)B&8B^clD>{H1I4|$)EjrTijMYA(9skGhl}Dj+@Oo7JSh& zb{4rre7oEk;i$RlOr@R^Ec;ikrBXM>Si62O@8|{xx$Tis#3wcrrv;}?u-W(I^AqQl zul-?1=X5?@R}dG`%Bv0QO-esfcFqp-`N(i{#pbbRv4W2cThMjsY2&ex%iDCsrC(j& z1xqJ~`L2z0$8wHP(_S;u{3*4gM5g@HD6o!Oxtdi<{Sqkeaj0HZ_40=K4s`}*)jr(- zfaX8UvUcsbVc+|!vw;**$#U#ZvQ|g&6xw-Q)#e$~4b*S>z8Uw--}X<^^U?umU$5pQ z)=lIAeTMCkYOkp^ow=siwx{!a;y&n2_r3b`m0+PGm7TTl(>*#0o5nnU;;?-xwm05k2;}46C$mLN(e{LmsPLdrFnCD@Ged@y58I-6u zw4IJOt$5ooIl%Ryrn;Z@1&^-ddh;Y~8sbT!jvMx!o|JnwLw>tul!M*L=zzn$W(>ZY zDKl9HQ|mT!R+B-Mw+#+($pd>Aohg{5uTMDK;xFm?7sH)L!@44`E`_h#b!5eTx68bL z%Ghp=P^{)a@vb!T{<#pG}hPgS*t118o%z0ReOiB@;qI4QmgpD zporE&R&+9`Gi}c-@9Rs}`^E5ya}m>Y z!BqK>QueFH$t4Yu@=tCFqbabvFEy0uS=C(LiT}syhE>Xw*t*>@deu6^@jDT-(Pcz)^WQT`{T(dtyRYvbyo?49^1%Qlev`p(PV}1&8^-m z`;DFu)p+D|+8T)%%el7LFnI*ldB6HXu(Ai)N0bsP2|pq24h7z}%`Ty~>L8An8j8E8|I_cvfgRMJ#Y7-0>qF4@f^d` zrh4I;Z7xsdl)5$hD7ogL(!;ZmZs}4ZgstJ(e|FUlec-Pf%ub8-FB-|As0`5@R$1Fm z07rd3Fx>)J4M0={sJ=N$Mu{?XWNxR&1CFK6UrmO%_2m1JJ51;x^TcLO+>Ka;cTR9w z;&q;3mAaxOE=7VBG*(s!RyxkT5qi)}OQaj(&;j!+jsk|BUVA~t?^=<)!{XU`AZO~mp>olBqkke!^$!W1t z!~G=p2dRa@%B!`2>fj;G;JXVm580HS=?(Q_9)Tau@K zA#sUN^B(-{sb1ppzQ;w|n`Vrrf3e<1OvTf^3o?Wr-<5VCdMmsjxoT-t8g3bXV%(g@ zj!Yx$@)yQoUr&SX(57zh>>8)LhoB5$+YZ7C!n;E*Xf+C-Q(2VmF2?WidYtyJAr4dC zxh&Xk`MqPakQ)}UyxPP$!uZGKis$tPa`-D2JvPoZ{wq^ih<5j z{05Jyvm&`$G!T{$wr8l(7$to7ts&YXu1!__r zW#8-o(E%EU^@({mfi;t!T(4d>#MZs)I*gG0Y3^3%R%>$}{m_*?wd$2e?Y<~zh(m*x z=ZQj(g~KZQgxQO};@u_ZseJbAaKKE;W4Da{ss2ZTkQvE&4n5{8n6!e|ufSGr>#Z93 zFxPBD&Z4WH__WRZznaROc-LBNa0ZkY7bmX!4bMvQsRq7zcA`?W761EbL+Z^_dva5 zBVs)xN|v=8#>_arsNzh$QOEu_cmKL+yyE*qfE&*rXumIX;r%S9EPwxfs|%!l`@sG^ zF?sq2+arwnFZNmFF6z`+%lwPg!vSf}ww||NsVGeEWDjxj7X0=fra5-ZCfqiDr^e&z z;y))wO+_bNb9XFzR)qSyA*{Gvt+{|HdhS0ksCn&Y!1WlpG^DkKUmg4P%($z{{zhyK zDWc^WqtoSW)JSyE0M!DiZ9ir8X7|&^@0MNme4sJBXs>#lDurUpEM1+&ZjsbP?Abez z#HAi3*zCb#@Te2qoJZ5u8zyKwyo0L^7rV`sLVVn=-Cgi0tp!1D%<;z3v&q57zG>YG zjd6UatMQERY(!_J=-YJN6jMd8xu)3bISr*06XI{)=Qj5-*}0Ue!`w;6Qzd~$`64AaD{iSqm1HK%_`UH3F;v-f*m(SV8KiT+?2ctGw4q!%R#V)gBL0nE z96IUhWY!Y8J?44DpY0j(ecf?b>heO4KE9{R+bhp1mn_J~ICJCvdApOH*8zqq=bE*f zF^vZKJ4MHkEoN_OpyyVB*DXMGAho!41u#CW_;9NO8p&qOEK(PG=4nMS5=+b@sp8uaLq^~T`MSYz0S=bGGk z*w^dOzJFs+EA4ON5vo8l=Pjla+r+o+-OLNzv7(WY@s{8zhA}Fh6Gq?8T*lYr&naD< zkB2yvcm61M?YP~<_R`)U=PWO(_{}xDoBY=&HVdjOgtHF~_nrgtN+ot>6ozvcVdV@z zF~2LHiL``682jl5d!zT@C*PIgAzTgQl@GaXSNV?H@R)Pj9~1nA`vT;k7_L88fEAG+ z*6&J=5x?9p%4+W=i*)Q7W9ePP5PQ)$2vn|U=MRRJ1Dk!{T!ENJ&{>-uaFgRDf95Li zlyVd}M!Nd$@O^V|+#t1)WcM4o@ID22o$x&oUiK{fyG3y?m|S|#uH3ZUafkq~8zuBT zJ*HTovYW^cMoGTcM>z&6^HNkdH6sD1@h7t_MVGo}Nv0gVM2wDm@tyFqsBNviNBQHf z>r1@3s=1>Ho*c`PuJPn^RzG5@fV`8yQT5zIUNF{SX=TZWHYV@cmFrZ=$}5I#Y?K3| zaA*u^s}*Iwi#T1jB^$Qw_XkZTBc5$Id&}gq?-+-qkd*$&o(W%|OI+s48u67uaaB)j z`{k84%3qYTpX}lgW`lGfdX&$JH_GmEJ1mBtpwewkA@zWgX7D2HDUI^1&E+pP8n+*@ zJ5!nHeZu(NXYJNN8nN;<$G!sfD|;3k=(eiYm>=4=cwJrzT6+eM;XXIsrzw6`o-uUm z4~Yqe;+Qm_)9Ri!ZA_<}(wqcid0U~MsryoH`R3GIAGy|ZeT`=d@Yg61~(Kv^W%SKQFq+?31^?&OruWKGL9(IhQRg*+!=tqMzR&ns!@|~qV zZaj>51u)fC-gzDkvtM}1-3fJ6F{MwV2xy1wf>XqqV~){$(+mpIo>fSF5~2&9y1zS4Kvwfr#b_P=`>!hhT0UD}-5AqG8Y>#c77@d|``Cf; zlKX=98^4^KC>aUPQyY}#L35osF3uICq~Zr?JZYo+*~3zC8CcL2D%i>%+glbEYfaq( z)-rs91uJVc_K(_jbboH4&O(#y7;CAeBMX%TeNwYb!x}+K%jIj!XMOCs=i!zn6@aOK zls>~H5t`0-7D}U#8k;Ck5q0kf2Ic9kHF;1yr48$0n$<|sxhe*i^T2p(lK#eFHfeRp zbew9&%fYR0?)aqa1hi>&N1)gak^GkG_Z*cUM$wt0WCD?C`rnISfm(P}ATZ+b<9Br1~J|B&ipFW4| znHQGATRAr7@K}y+OOQCNl+_1?4eNqf9(S4MQ|YG|)0)L-iQ}ig#!@SN+K%xgRXzQ( zK8;QeOK8y$k@TQQTKwIXO>Kk@f3HxPufRgzWjV*Zy}9UooQ$W4Il6(3qGwX3pJj>_Q1wh-bHtDblcx83D1`k zL-$_Df}tYN;(u@$q+!~z{EvoFcx4!dRDQ6=jOeky**oBON$`|soWIJH62mZ!=|Yi2 zT;ljT5d!?I-2}2hDlHF<@+j*(CPoIH0?zwK)`g=T!o-ZKBC zepHEXB{5>Ggwaf?jj5}_E3b4!My2q~S3ANzR7WEf*`X$fKX=n9LR=B%66!30W;kDl zcnMZz7+-5f?2>vo3lcPhVr9F=fklk_25VLpb1)iKg;;dQ9&$W^HARY4FW&OS2%a|*ljX5EQTvHM&k5d_PeqhH77Fy_EV`i%P~t71F0zYd3a zkF$1O4t$ic^s3tg4-XTczF#iM3Xm(;<6%n(Kf2 zZRj*E^P(z&OJh9k6})8Rd`0@*qR0LsYjNkPW*nYjUT+u2uxe}c%6?axM_RVdF5Ztz z8O6hiUQCo_$xasoW{&qZhM`@1ZrbPNMOAGA|6u8){8+M^ zM3H#Anu}vmoj&7V##0Kk`qU^Kj0L&jZx~mDsgcfCa~WHQY1~@*a3A+jMICm~uKn|P z4iW2d6_#b%n(8;kvkJDeLw|J@i^F^wQ^FL8Le+CtD3+mKJ{xE+ww0jb)DjoYbku!F zt9F#9dre-~&Vgv^jbu#-Yw^>nGU@G?E9044bgGFs_q<1=GOlnMG1OR|@Hvf2rjn1} zm$6g2W$Ji|*q&)D7yD#%Y&Cf9{m^(qHn4OlwAOxyXExUx9%2_YRvGzvQTlkfqVui2+|54|C89b>I4uQS~D zZyHc;*Ui?xV!!#?;5!DB zr$*05h7mG+_Zw`)otm!O6`vT_mpwB{8ef+i!+*>OKDIpCUbD`a%r^88kAN}a8BiTY zrtQU!#mDxUVd=_>N0Q@(T?&6#ylu8Cb}Xyi_Y3%XvCv|zm)AJpvF805!Q!39e_-r% zfnXKX9pmSVmg~A;^V(j#Yxeo�q5+I%nD`HS}NnqBjr9WU6YD|Y2Oc7&Qw zmLh%&b7W5|_PC>B)9LU|=HuNlxW}5`^&dN)(?Ot&`Rhg-RP@kkZr`pcU*(osOdnT_ z2Cf>`q5W${CGQwDylH3NG8*CB1)~M%<+A;|Y7*pveY$F&uh^BB?T(YBkJZ3VJ84`s z_)(1kp9jzOqCsF4oRwq9aib0etk*d_Vd!>A!R4WjAB9yC{HQx-Utc!JZX3-ZpDr3z zZ8kIqU2Qg0`L@y6rd{DOYSXSlCcS6>LzP$T_>TR*ZC1_6QsL*;2mAAeJ5p`2DmYy- zXuN6fd(ZyAXx!~9^!Jb9^k&1goV#e(0lTm5)8$5k-8Tr`KZfvICl06O;qjM7PWsB+ zPfYqfYGmh6#*eaM|E^)&A$q)rRM<*|+7n ze%zc+pYB|C{4d%z>>fYA5PQAaa5k3dRg?DU2;@B#M;D9^;ODpOH?#;8gY`oHl5M;0 zZMz;T_T7exuGsfE^hE85k-t~%?~=X!S~E`9VMNP`kTZH8@q0FYMtsqZ*xOj^jF$T^ ztdz!67+-4M4J@xT5JlVW7+*S9@)@pt&%VEDS5Tb^E*R(A_S+SkDg6A-362wwWASD+V0ylo?9?9hvQK-jR?>=%dmD&3-o~ge*n5eXKo!s_T*g?i)6v_P?3`QmT!k(3 zuHAXjer0xZ*rT=b*y-`I!Rn&H8N4Ieple2a$wstepP(0{0{R6Sc*o9yiz^K^^V`Y6 z_=>^K`3K9#Eg~qy<*{=ivI5BmjL|auLuTWr17)avl_O3wV_gxb7ad{frjfewzj!N& zmAXW4Z1z@w{IMIWVhsl*5Q5ibnE$*_sz!rcl+1Q7QD!@ zmog-24Vdy1%7jkqV>_H_4PDOY>i^7gGS4h3K<5qOq4YE>t;px# zp`9`G>ZE5zk$JAD%{ONZ8k-h>(XLE%sRcJ=AE>xteNf-pqoJ%Idr#Kn?#g-1b;B8% zH@b>=mh=rfL#+|Br~0J?f%7)=F)$z&Mbw>|ETnTD>D)3ZBX-YQ*A{Oq{%KdFl}PF6 zJ4SfnhlsazH~apDoxN+e z(7EQBb=RJ^>(3jegx@~s3LZMvt>)9!`BAuvp<3E=pbgiQ9>e{vqjY^YZH}3vMyHmGuE0sb(IUi+)d`>oDaUd44Lnk4zGIB^H`O z)izjVN0vzh22?(f_rZ$TG7p}5lqYuXsm)>Ep2XS@pLLLcIWEigxyIOaTm~OEZFE#` zqr+mzw;?wJ;qQH3|&iuv9k?K^I|Ma`}e{cFIx@Ezoqs^NH|S8O|N zIdsQpn67ulwB=7_MTGO_nZ{Pu>K|3~gDE(i-A3Gp^QsqL1r*a5>-DLkVckk9y}CLq zc8xMWzszoBdREzD(63#`^@hipzxP)F0{s#iiRcnqvd}B}B*FE&yj|cM{FUIFOw%c; zm&|B+pW_d0mUqo{iN~52WFBMsr%8Xm!&kCgv1ecpGft?n{f22$PE_v{p37zTxqfWc z;Ov;35LND*WC+|bNB3u8KIV+ES}=^38gaCpM;oXa3;k=tdKvHh%StfzjbV>Z>CgB{ z&`;W}+}GHEkAS6Rd8>p`(TuNudTN#haRcb5q(hx{!pNsk?tI)C#{M^h6O|mSw^IeR z&mrtOHD#9?BAnf&8S*Iha#QkwoF@;yUmFryN;#z!?{{DDNMCFHNeLAANIR~ zeU^78?HWOTzBHonu5FVn#GZ~!ek>;|O7Dqh&I&#x^-&XrgcJ7-F35`~&5<687`wj5G&ojY~YYJtQc6P2_{l2;>sjQ~qyOoxpXRkJt~Ttm8>@k5kr*&P(Rdsr^2OEztF@X}&?%)8?`rx}Lw@_!~z?RptI3vd%r) zIftgyNalgla!;9d_q4mtTD%JRJwu0@?@+Of%zBkE(ls|lLY(Sy?jIk4Ez|Tgk3w?n zbrkkRM`6oY744klVV~t4rO^N@OY5moY0Xx)VO760Irdh+X-E~}5 z9Jq`>#e%Vii0tLn*l8_t9fh8;$lo$lDoBW0yQ95xj~Z`I6XUkua~Psf)3) zHhmTG)sv5_io0IkxGLJdE1tRvI(gY5rgP|ONdz6I>F(w3p7u`3u)4KLUIRC0mCt&y zE3<6#=`q=9(AcqP3mJMrrCYL>bkV9-#dw94d9Lc zZM0IyM-6- z*=H;`&$Bo@b0{k_&<)#Cq%&k3&lwis1%KD0CU4;D2|Ul~6|msjtIPJ6&QZ>x8^&RN z+|~6vw}@l%tOM3RdF6!`-=I; zHGA!T;=?%CjC5RYWGLJYE>%^`*r|RGRdlSe0|NV&e@7*T|o9OP~XQtz_y?X3plOO+R-T+n7x!^~`ihPr6_Fgb}!_GXmtH#CIgoiG8 zjfcE25c5@AZi08};ym9T)=s9~9jLA7qdu3CIkp(H&kKzUH6!U#y7skz)Km7&eADh? z^#nbx;Q;DZz%_LlKiWDydY%6B# zNj+Z%55>^(^?Y=&2i9~KO(}_W=UuaBwAlQ37hg75f_Hr7pmqQVYX*nb98Q^2iH^ov z3|&-~!^!E0Rgvj0fv2Y7MRU;ep-ks*GO@`2^nF0xVd>O1y-Udyh}Y>1%}Gbm-c(5n zZ+&I@1AjJ_smn=FxU>|3t%dKnRjlJ67i8|ZJz7+&)s`@Ktg4N5KA#yq-7qNbHyS~C zIBXX9U%NFR4c2Tm_eb`(+ewm)f0`rSAzuvreQ zTJOz>>fSJIA=|qa%2d1`+Ee+PGUM1T{Jp)syXN}Pd^VM{@igP} zS+nQ==ds=zN8OSOsXe-8t81ySWAx}Dtg>Kd2VN)A2Q#!YOXA+pG(vjiM z_j5#Rh;zA)T`LZnUEjg!Rl5o~7I&aqZ=OJH#h!8ab<^Bas4IraEz|nG+M5v&EprRr z_olo&>iKmtP;@pn3aI;ZjOjDykuwI%ZR6-0Ci8As=2D(pfM2|~SX!GhpF~2gKgbalHF6Px8}`VmUUH-_oq)^g|U@a`nraua%p-LB z&z{(|uC?4w&>oM9(#;vwl%7_!EFGUmR^j0tvvug9HeKWBO+0g2eCZ2&yL`_+ocNl| z=_3`V#xyrpYlQKYDKc1#KC2ok(zx>DGLaf`1d49rN$eYaVyzPo@t6<#z{j|J>_sD= zD+UvfeoEX$8Y#P z`^74Sz@)#8-zu;qScLuMH`QxRIj zsmS|w#toP#?m28HeqnE;SJpF|wRiY)*|*#iR;fe!<8+cCu`Zb|qgSa-Vp=<$x+EUq zYBWg=l}jFW9c5FLGH^u7plWy({eo

(golw{(`xIo9NxUCV75w;>(SD{+n)sRON2 z&r;XRws!8~W^%mkiU~Aw;Fp?tq`lC2YTb;2#3c_BYEEkNlDCc}Ik88o1<7ym8Tn1m zQs&|3koi`8|>WM}aNBF7GSWT3Q&)#GV`i&&>R6wzqx;n|Z;Gibwlv!FIoT>RL1Nvwg^CdL z;2-R7%3SMYjNYi@4^6J!{nB%WYi0BVnUpR<1$!7T8r(Zu($jLx$j6PCS{QW}o5nAF z@|9TWr#5?4N2}-Zf#F28b8-K&e%S_0*bB*PM1djJ4G|3AD`Cqd6}UdM_aQA;0k=Bn z*5IoTZl!n;+@|_Emv8j?pf_3iU_^+k?8GstVVjvyTr zMG8?NA9-wvhHFl5EXeIbqIe#e!qbG3S;K(!0A>1jU(q;@pJW^K$6oyTzFSxDCzr_| z$8?C#FlJvOAmS9~xGd3Uh-!qf*YJmAsg8or^sK|p23K8~2mjTzbU?v> zUX#&5mV~+jvRU*AP&9;`BUvGN(ys4IyAtUMRbXcWdA`YxBVLcp|8|rIvD;YLtXwO3tKFV| zSf@!v8&bzt=8k<%h#-_QXTwj1N@GieHFWGb{*h60X}|HjhBK|4nE!pB8{d6y`|rKg ztg*OZP$CEnzB-i4)MrXNwPZeka$Em$?n5R&O!X|^#WfvXIPPjuae(Q{6i9lJxEy*Oyq_- z1hgGEp#RWQyDIFgsu&}Y2Cv%~>YyMV&~Y5+%vt}QuZ>FOU%9hISI?z3M1gv3Mcz1A z)In@2q`c}kSV`=Fdbcu$)IYNFujR8lMtjg*unXc`Dr{^P=@vz}I#vgV=)2aqW?2!}aODr@v(z%S-6&%`aGs0i}o=msRSB7;AgEaEt@!8Nb zz-i;LMcZ}_>fhT~J#MPFW9jS-o2_?CKx!a6fR8ZMwzAW;ojzj!pL{R4R-6-CFVqsn z9X?X)@wVmZ9~(xHW=GaFfVV3Xe9o*3Dw3$9BHMgu-@mcXbb8n~37D?(Cvy>7w|5hJ z^0sBCm>Uw)BfjzIO0}}*jk99g9G{gvJRjTr@p;LI+cr*J;qv{!u5{a%6m}6N!kOLy%M{wMz6@5VUH27>LV9MMMON#G}8Q`!P_h2y7EZflou>Dutj9? zhmVAySTf2s&zh2<%^NLDg zXstCy-YB`^awf)^W=ZE`Xeu5jbQB_NtPb05IG?BGvq)<_+w+({%kpUBI>H0v0o7hH z=6u(S9it&EC%E}ZBaN`F)Kw((cqw0($}Za(mO3I7^T z3eEnrrLmKNIIw6|oRe|M6ml{toPm@wW{w5AG%#Mz($@7IbBGg33!TZ{H~M|!SYG=W zJWuoM;!pN`M(=Mb&)H`p8;K55;STuCpAb{b5Y9y}nwdYdB&}Ju+Vgh(d4s#>eCjx=4=z(ED!>)V73iy*y88G_qtkJ@ zER@FPIxj>QU5=;%R`D2f&2Y^4HIHr!swS3Am4NhpSO*4g zc^;FIp)n6lLI2uBDF>OvMbc1ZZXXAQkBRaA;X4{&+(O56OJ3JN}^)6`n zEvo=!htSXu8QW6UQjjkB*vtA_aPK-UgO8g=3+$P9(?%Xcz765&aP?URm?DXFuC420 zNTC_r%7TvBZIX^D=S+V6huIdlt%@A^^|r~Y^Y$&3Xh<<6TIoDGvaSN%vm!w+0i{sE zj&{RN$3M(#hS9;n%9EF_DsHU=oWg%W3&@iKPToo0Y0IHIMzhq=mOnM_2TtB2cqJZH z08?-{GX`h!**uyT(^%YT(mgFnud(sGeDrzU{1hmcErt9|`S*YuhgO(aD zJ8x$Ny<10(fk^V)eS3&rtE-3mi1TT3jei;GN^HsNspVP3BMyFnV_+FfYqUBp7je~7 zx{lJW#A2y|ig%ev?cvv*8D4j0=+C)N(_dYbsq?Qcg$}QHdEKc&R51M*BzNA=gkSpB zGby%!zH{lfV;1Yn)wv;u+q^uQt|*!3*~>j;+TGLcnnyFss*1DhrHE9}#(P$|!d^b? zsO7zNyn+}{IAoiMV)a$RtXaNkJ{yZH;t%FwgnmK&@7}cRie}-va(M=dY7BZWEVJSa zTik1uH*F1)?>)7QMK+B(#Cf^jAl!O9JdfskHg?M*8&r)Wq5f$z@NAUVU0pE72G8>3cQi`N=#x*_E4wdR%T(n?kO7 zT9!JeTG<##K{U}a^tPNPow_n7pOLJ+r}7J=TdpcUfaHk$ah{RYscz0){deT z(>OC00u^WOANkrhEEOUV&;hms7A-Y=bIJT1Zedhu3}soqaVnV}Q}LR2tzvXd1uWWK zWtY)fIW+WrPR*!i>bRVyocAfxlXbl;yoPfXUZ7^RUR*II3)(BQ=D_znILPbwWYdRu z&K&w@ZrkFdU@V-*lG#15xT3E{%I4O#OG>2YJ^RDcIy5=y(IC9#V8z50eyg&g=b@#! zE#MOL=h2HHQci~-Rv&u}6H%)J`$sKPjxpJw0W-Nrvu5!;U-ccVu%P`yXCdAI&L1}Q z+vE4Y6I{BEtC0N5QOLBrr`?rC{g;unG1tF%y34(pWv!_>sAL?5#$CNU9#0qky5+_+ zzjPV=)nTIUs;N?ce(cEm_Ds{4Cc*a`X^w8qHK)8CU&Dx$#%J+WNN52x09pa--+dcm z?f5{fIii;X(f9jJ_qebc)K@XGBQX(Me;R%dx`c1&kgevqk1OL}{W$3Vbu4CLjEs%g zq_B6*SYC@;?!d!XgAZ9N+u{+o?Xq-C-x&O%1AJL<3VmQ{;qPJttl6*WDDeMSKl!cU zjcki7fL!nQ^KThP>Dlxf*(>O;yh3-~ej~0FpjFBV5S_|%KwjS(<~X#TZX;WHGQ93R z_+rXz0z>&+{o@{vK*rdPEx7Uh9^FnOrtKYZg;|cQtb;}#+5aK`_Je(cdXN)d?GBCh zM+m%hrE{PDqoGRV|N< z$Zr1LAt|>Ub;K1LWq-LQmKpQhJa}~88}6u;V{Nt28s?>2&sS6{COmvkj`1#;F3z4G z+Nln#;}^{oF7M}z)5!sKl~3e!pPE(=9mYzMx-5a~eUliY%jvAW=c|cAB%$4-812QZ zg0&(XVcmq>)L~=Gx+N3F#hPfk0`^-p)8tYN39o-*mre2mXQJ*z(!`;c2D7CL8Wm%j z3tCvc&U6RI*oyZ)X4Jl?JkV#Gl5IW`_$X13v;j z)VPT&JZ`*I_d@6^);$+xndM){)W@AAnJSp($2uO$@6z{AY!-(G|4kch+QE{sm8o#d z==7f7HxjGsiFI_{hE~|KddisS6^SOxXFldxWlg79=f_k7veuQ}J9wpdmC%!<`-btO za;U^q+_w_74O7;X{j$AY%lDbQWtzgP_waYn1;llrZTz{*RTNqWo3Zg0!8C?lE?dqR zWVbEvexup7;4e+QX&KrJwnH5G3Lb3@#q>j?|5xdY@fz?`qo};LMSP@wh(YI-By#+O zgA>zp@mnP=smZC=v7(dHso7Hf3S4VwNOOv5e^uJ6gn<&}OxIUyiy^I08Uyhox7h;y z20cIztTB+BOCpy>`dS9p%it3HUX;`=Q&37NeKZzxXRO7?M_lW?*L|}b)Wa`$d*4Np zKi^wN927}ENa&%r>C2LSRw75#seJk zXlQ>&|GblZ-j`qRp@(RAhsQJSCcH;Qvik9-D^y;U-uCNOyG*|7e%3)9+7O&1m3EKg zW-cwgCYsNwCo*T}cb*<-IDhikEM{=-UcpICG1=np z%@d0|#pCzynRoe_tq;PkOSw4x=4;(l^_O!&K1y)5XYWMv##Nc|XGWEe*GCj{!j?UO zNTBzG33=JDhHcmd+wFiBvBphQ73$Ms?Z-;Jgh&8gnVaZ*0>yMofZez zid_&bU=>{8A79-JHFy+z91i;IuF`w+bDN8Q7<_;it9$(4rcnuXDd+>vus0kL@P41n zqvmOE_R!JG-w^AsG5i0nH)CPdfZ(@ooWe7G1zkMeGmHOes1x}BR+$MgYp(R#1CG9$ zseeXo9`e`}qo6A+IgT#A)4lm|-Jt!I!QAUEoEC1_H`bMZocywB{OYU6wM!1WZrw_* z>cjrfn~{*)77N+8H($3AJZ@@Oe06>NU8C^2RAO^3d04H11?MZ-!x)t(dDy@_&3d>- zAa>{dm1DfR#eEuQv}cs|*kYl~c_3YVRX;l?+VvJTT36zp3e`MkuY`o6ooLH~9`+4@@8Ceo^plsD4T%&^Z4l^#IVlVNVRvtJ@CV zc|P8icFn3L@0*69&LGv*DFxNp!a+ipXkJuEraow>ykD_GsakdBhaZ}FywEf zGuCKNk4#cb?JLvnSQC!Tl2k%km9R`*w8tHS%nWh3KAerS-cv}5%Pu`9Kh8JPRMN~b*C}%dv^&+G{nqg6yVB521C3C^t#~C++EB$+8NcaS|K|U< z_tXqttII)`yVSnNvtE_XD!uJ>WRCiBs@lA_&g;mWI?A70(D$JoO;OJL)rroP`Qqh@ z)2%K>mq)y&Mc;;CT(V9|Z_dvxAAhT}YOgg`z^fRYOCtg&5mC$E`0_^5PHHAEM+EJp zps|X`S+^CP&Z;D*WRa(qVE@_E<9Cv|xZn6gzQXEtEX*UXady-_k<*>w_n1mpN3+^k znBGRdHTAKEVqql?x_ulodiXr<@6SJHQLEJd_PkT>spio)@g7+&`SPyW^5?~)>5I~( zyzSP^Q0}_%XICFZ5I%QpG(?|#hH;MSuhxt0Bg=HX zZ@Z0>MZ0F0_p0a}JL47PWXM^4!4=39_Js#Z{ymNO(RY~@{GM0L<9VKGj3bE`dov&@ zegrKYhoIJ`r`pKH%K$fzD8-a6xaimqJt)+x&4P3(J+1td)1&Fy5!N4!Mv?Z4AuI1Q zEy79;FT`=Sjq-Ui^$Dys>RyMQ!Y%zaXLL1>KBaPd7^6Ni|Gzf2RTKXCmYoe53qHs7 zl6f^2d zFq`}h``cRl!OoANECMjL8Sgi%{s^(qyJBBROg%O#xNJ(K@vX#7|@A@ z%yder)Z(#A656s2Z7Oh(My_F`F@I^UoFBIRm92tJek^q!4|B?QKXL929dB!RYU~@s z+e;8ddQhPpQ|#%0XsY+}S?AxCN3ZqJ^81&e@hLQXlGGJy$X^V7ZJ7KS^X74w5Bk2{ z^FPpjoAacb1NY@pToPvWn&nfo2CxjCm@FsuA&a1YM9|%MxtG5y)hP8gNX|h+k}u$* zJ%S*G+@txvfyR+Vlh6i_P0MLT40HCK_pqY9N*X)WI~w4P1j~Ldxz-+Uu%@UE0fKJ1 zQ`M)pwyBY^hv=7VeRg6>?ZJ2!zG@y0LW56}8mp0QZK%ZBxjp-U|9J1I{3&DSI8Iia zPA!qcKz7bFY%j})ucdX_gA4SV^Xn?5b^flCm)7kOoNj5|9`9>u{ZmWFNXEG3Un{M1 z*!~u!bq)hTa@Eqh{a*NRHPX6YtM>moR>#KHNg9_@=F+1CSWeha76-hj!0x0dJQrJBBmg`MQ7AK?SjXYn*Obj)K0V#G{YQ7rkY1#HUT(V*fZI7pfYN zI>BeoYb^544;mr9bQoQIUQY14-Eh5khG5P}f^$vmlNde`P{xiw=;I494>E0`Vn80{ zCq~QEF_44t_0H+M>hAi`-srOmH4!2D-+m&!0NK+mWPrJbbscw$5t7?>b?|8E8XjOM zZxTp&H2RMn&p=+T9&%$LJ`e0z{WBhRxySWegxs4wkb|~7%5vXiK^Fz)7Rjp%#d;b; zQ;}odHwa30lFj(-P+T{x`lzZ4Zm((Vwn=Za6gDrFk61X$qP6vC(3k05+osLYk+pY| zokmZQS48i`_XQpFTjcl@uA#!F9v3$agNF?k>+cEg3DEDmJI7ymXShGMGv}>;^2`aA ziE2IJ{n$5SwK^Gd>Sy+o3aC&S72ogUSH&T{UARy8KC?G@)>xL1s`_Ht%ag;Ek}Q5! z9;J`PcLI%LZG3)Ri-kv*XZg!<4{Qdk@wCQI(n-hDTF2u4mPV=LJ)>AvTKqg?7qOTR z^)4ORV|%^X+u0>X;9e-w>$_ubjI*HZwBV$qwSf$s>f(s>j_DSUfBSmDo2Jb_wtsYy8LppnySWtM<$hxCWG3(rsZj?u zX{3*=2(ig~b_MGMu$aAKC5)@QCMEBaDqX00s~GF!ZIr-6W8_oHeSs#1B}2G&K1w?Y zPIHPX(OQm=b*tGvFn@}!UQ|)BZwQ@6wIiOo2b~;s(nU7zx^oiLDX7F*$dj8f`heXg z9~vg$v=T>Bjep&)UAvLp#F`yoL=?&a=T)__kC^@ z@m1ta`;*K4iEkO#lJjHT5p`%ziTM+j#j&vI>#+ST{toCDE5X;a1^oM%OW2z-wzA9; zRrde7VbweP&0=Ft3=6pL1H}T0F)%C0kBCPG|Y< zy(u3^%X;rY4`u@`BT#GU)XPV7yoW=Z3a+yb23E(=hD1JYd++E5#}gM!}Kb*%FNy6)~eKSYB;M#fUWvj+86#Mbc|?C>NWVB6+&b9yKBz<7D?A#`T4&5QGRXhcS#8%Fs1eGeRYQM zI-EAH+;$34zUAA|);_ky-Tje(_v8jpxVRHSTKh`irp$^mZi8bg6>5yA+w4A~ zT6;U#irD{H+@%@Coss-qaX$>ta7>j7<9JNcET;BYuPPEcol;$|&PDSvFP#SZ9vcr7 zymcJnu(H+&wdYgx#xx3vGYnJSy4;NC(33BRLD5p()QJfVaq>w z6(YvNHAMB2RC)qidY%jtkUlz&Nz{)+d_cc8A`Kn~qaQ_F$0siw56MR?J45)zI|vni z(>$dTf8^0vxA%$bw)bd^qqyBUkkZV^!L1wg+=n=Cmdq5%TpEAi>8{A{G7@>{jp6NW zJjrtk*Es&h%zT{ui}gmH)1@OM^c!*TKdN#Z=Scc>oU-{@EbM>cc(;89_KX|oWrM!CtxPKXGdq`d{CrSFHnq+lJs_D0QRl{;)kvyoU-+T;h zsirHI!F(9kGqX3nqpc#jX&<*e(*~Q3Wl&mGj8+IdppilU+xFB?Eb`?My*=Z)AC2qy z#p6$OCHRwlKWl$f`4N5h_4VW$h?Wyozi-wq_6T*dX>VoCp!Rs$gm`3}39$Bcxb8c_CpB~fMSYLgdBrd^KzUIpm(JIgS z^z@G+s)S{X_eZVJzU6X5KUVFX5G*(9y0wGFgXUT?S=t@NZ31;3+Op?)9(?Ed8$w4Z zBu6-3UeQvrbyA3?-_UH>GiB>de%89>*oL*PRyqTnx;63ARduaw@#}|8MVHd+RuoGpwJ&wTV!QBgJv{OPnBCc_qNYT2df!69kIv=t9fEMr$IZ zd`ay5_S7?VUUqd)PoJLQkUEqE!y2C3Om}r%-nw-4_8N~^Rl|IDd(J6$2?Z1*+eQPV zGS-c#o*?alpL4fF-TJz^HPq<}oTWg0q0Us`Nhy=N+thXrBR?DI!c#Bq|U)i%#4)YN$vrgr+c*b3eqj;CW z`4&`^6y>lfd2B}=71JJNM*jZw=ugp9Xg=4Tm+Q8CrLVA#}_*Fugn6g8gUKHU9Qkly)C}ET>Y)5&eP-bzMXW}Hs7eNk=H*JE?N095_XX` zqI}l-Vk6q~zTAx45*tyDe^+coIj*a3BgQj|DC#meWnCLl&gX-Tm}hs!d(PW_)wF*r zA17LxZQgRS5_Jzek5NqR#vI2k>Tyj`ro66$Cu=k4MU)TB&*s$K$L7&+T?0Lh$1_(p zE(b5Q5l{Qmxs~esmL>neJh(>|ktd?hz7=&m5xqQX;yZZaxvTl-5m~sO-E)2}h?zXV z8yg3AP@K@m!}-bdjdg_NcfD`lA!kXp;mCfG$8s4Ts!pS$!7kBe0b1wIaNzO+FyU)R*@sg2>e^Pw7pF(=>^|KS9OaAsgK>54c#qIGN zBKMIyWKX9j4PA{+ z2d(Q>HLCSVl%*C2Z`V6dA$4>(IW~#Z2_?Bkpr^78DbF(4DslKy_e!hPGI@K_vg^~?!F$b|Ir5G8$aSTa^%VpN`mw=|>FLfh=b3tT)cJGsd&of2?_>SBXBv-cQMbEZ5h>kA7a>U<^JA`i z*ZyN8dER9x)v% zwX7gE`^YTZT#M&eMf04R`^}K?jNgg#FD<_7CVJ6wV%+CuQBux#4j;6A9&t(Ew`7mz zqMs>jIYvQ_oh}Lo)n{3MH+qAtS;C`UO*LizrseJJpR(sO^?%2wKu^}_>)l(|_1NT9 zwX~W>zMq{g!>o@?gM%UQ2$?^6R5Vl+u^-LHHBS=1v?F<{VZ=95T#h{%#7*!E-A_nQ zN|l-|YKTLbHSr&IDz?6#^NN10Yt(eDyXgDVG}6gG*y3-^X7zP$ci;DTBTM`$=fyhh z`mnC6 zzbYlhsEwumjNM)rpO-As+g_KPg7`f>HR94B=#TZZd$Jy?LC<^LKQtYBYCO%qSiZ6T zd1TxT?>mjX2OV+r7lX}VPak-Sv#@_QzXz`%pTBrp*Qj&npv(GQqejksyj{nf2}$i9 z-i%|bVWIa2Yl>?>HoKnoiGDj+!DG|8h_gI z;Q4iu)bh7;GjZ5H=#%Cd>o96Gkw%Q3K%$cnIhrZm6Dl8E`>k~({&e_nTFa5)i@K6% z?u#K)yIJTZJe$u}oR zz3=`E6!5M-yHMkRZFWBdEasZ%Vrv|bxx7-L9yJnI>6-i!v?-AZ`kH#>hU>wJF*Uia}`WooqZ zVjF4}O>;l2jpd_66O#j@3yIVz34SO0T3;>Rb)?@rTH-yRpxA3JMfw=a_*wlLUNosA zqfa(fN&A~wdwh(u?ihi4J6PA>TD|?RHt!p@J~!>$fv1^m)-%d$ZnMiz^}0SX&yv^B z9Gr^gg%8J7N}j3y#=euJk-yWvNdGLa=J?v==B4o=rx>tqk_X3ywMr;q#6Q~l{O535 zSzotpA*oA{(hU-eWt$Z4$@a*qA^+_jYM z+AIAUnXb9+S~5r7DX!GN8vmoUrP;tPx2@jgfz9-R<(~Uuv4>|;N7rpC1<6dHpZpv? z&-4DNSUE+S@Ss>xdUIRZq_IICk(HP3>`qDvEFjhl=p7QvGsvJt{ zpLaDM3{+iG;XsXEy~e@0bzId4hu%D^+}ImVf+}MRu1`~i#i3zTUU?;B#FVMRp|;|s z&XvFA-d3!5U`*|J3aG*D6{B~AwnV$m@ zywYXkOn1ijFJp_J7|LIA-)JvVWERi-tHEk|1a4fF0j43#;?<&PRjhwn^-`MlhDbAA zpWKUfn#>t-TVH5B+uy2xT7S16u|^t3zxyMj(7f~9r;#YA3^We~6DJ673XA4-e16tw zl1|S47X8yW`)V)nNn14+ISunyyOss*;X3Ws@p|%nSA_B@b$u_rKBa@Bw(IlxIcwS@ zXLy+CNbn{IJReu|@poa*Q=5ju$gcHM93ydeP0ulV-|_X?jC_E!1Ks~Q-}A^M$+O<* zeBsQ)&1a9zV!8i!b2y8g$QK$8+RyVCpa8vhc*+ygkaU@SWvlo7Aj8z0A(PCQs4l*z zpPcEZ*7~YzSIv);nLf>~yhgqB<>r~t?}7d8b58NN1M{5eqnm2`@N3aFIfigQkVtD9 zUGKgYjqi@#c`CQ1?@V3i&1vq)^wcyr5DxpU=-7q@@6jE)i>_@sfH$VF;|E6baHUFS@)5OeOLS;2wmgtC5D z^eC^n#VtS8YxxKL6qqZgkqUVnwN_u0eB_uezkp*s0b#v`fX2G9Dn z{U_VwBlL#lJ&Tpd?U-s+TXd}Vy&-MlVt<)ZFS5z`ba z#S`G1Q${kbAle(MWUwQSt@g~yC`;sAkfR#mT`X{~KJTON+>E)TsGrB{l+wH-#nzcw zd*0%wUe1`R(r$5heFXJ)BOPbKDJ0INRAQ9ZzfI~ z$BkAJ?#0)t=RT+THrX>h!DZ;f4jGSKl%2QRJq=Wk*U~r1Hb&kdQvYFx%bDh+68(EKQL{oJ`(AD>JZ49o!Dpiu4`%cmIjF0 zIJW@4r$H5S(#ReoRUU90-)Q@5snVh~CLzTszut3C_x-{wIXvPfpxCyjQ}o< z{3X;;dRFVif=Zj}XZR-6fzL!{gQ$-p8z-CSCHcm~0i(Pgd@ipD^DbLnO_T4SdS>1i zmRWgX>ZY;Vr$Y@m`=IbEIb%7-d~ew^==wy}8i?qHy&{TtVi?3fgLmci5mCj?;@zch z_SP!(*$2sEK1Z+jXxI3rIucVh3B z)hb6Fk*G>EWsggb&hG&7i~d;Yi?^#hT5{i}e~unn_mM7-GMi=vp1HW(J=Sl$lf2B` z9~;M}ys{j|I8xp|hIELa)a6qih0qfGVX zfLtQh$pPhjIW@*LV*l$@pXWY2+L~R&KbUnI+f&D0biyQaMV&QOIn0Iq_kES+ZThr7 zja0*`B#r4+L{nZ?{jQNuu2%eC_I`53${h!z#2`WF>=|F@+vl-{=hz$`BAKxQ&n4)?i&y-*F9#hg8DPK^_aF9#>dB|~m zF;Mczb{&9kzZ>48BJ_n(`HkVwqv0LNIp`KCUn@4o`PONqigGQ>Z0y@u&xEl+CBB0a*0D-3KYam-61~sV|bB9}KT(=MQ&= zp1!vTTffa>J+gbN=5%U}C9C;b>s_rTd+;8cl)Sd5y3M4f@;jS5J0@P*rx9znqqaUO zyvZZ8(vmkxxBAHOt=Mxpp1yS>z*f4TfJ) zmoZT>MUbO(>C^I>OQvkgbeGcDnRJOvxh7S0^prnJr7dg3>SH6zy{~_1pA+xNwO^?Z zd*+{bF2&kav72af)8xP4cc13`H(QNE!;kk(YyN)F9{8r{3&%CYYuH!Gnw{Era``$LbnRjBZmD14 z+j)IM$~n#B{PNd&y+5L|TP>}xWCI;{-BzW1-?dZPcgDJ#$8{mE*lLjL!8Nwc9_R zA+e}6dUTjLSJ%3A)u&U}`O&zkJ55OtJFNb9&>Lk3kzda=1QF9d&$xX0yjjotcJ!o| ztjOh3WZv9v8=`t?GF#fMd#2yL+h8tt(JIE@q+NsTSU~p2nlSF!Eo~WGA0=)~vmVO# z&0TZ2HKFUDn3hf*ZOC9yk&HG0S-;-cKHv8d^=18gWABdKJO4-g(84>V?x@jDm3fW^ zYUkGfyGa@U$*1G59-0a(B)^Td@?A}74hbWbGn0jk+~WGOon7%f$TS)f%5bFRd<-t3 z)8%qx#3?4+5%1T1e|%Qv^apzfA9k*$Pqp+@BTd$Y92h&Ayz>pd%`f)9?2G4hrgtBM zNjTF0Bt#`Au^!e6s&G#tPQDlI|Lm?FFLz4yt_OF^F{z(bH5E%NwX|NpEv=KU1#>U8 zPPzJV|9_9Pv^7xs)A=@dy+}iSH|w794O;K{;ETOBdcjN2%wouo)oxbapYm88D~}`4 z?6hvtv`(%3-TW0An8-*T*%j9%O{N~Zt*1xY-m%%SOX8K?n{4AxHvhEy?(*w~H6vZ2 zevH1jWvR_Br*( z{n!^y#uZ6+w>USrjJK3 zh4JV5d$~T8#?y6I*EZtyJvItZ`M^+A>;cxszp0{oj7it2xTt$pxui*hznlJ6AMRckX>aGfw~e>!9I(C> zJFksGbFUQ>%;%iHTGQIBWnbF9?V7elPa0Q*Mt^#3yOOFHYxS0lMcx*9hB%V=zUNd< zhFnB^|IvWpsrNb^KI8SP@Rz>pl6c#m;GOA~?_*iBGBcvYJaSB>gm zL`h;6-UU_qRB;({!Nhh-YUdHe@xGz+tj6@&Q@;!ux~bZ*dAZVh{y|74ul9;O-BWWF zx!`=hLO%{+m8g+C8X|tj#%ttfh>7*stp0eIXXh0mukldMT{kifP^Y>wtVHzS#Z)(i zMQ%sO>PTtd&>VVWJieMf;$-#1Y@D}-NWv?lUX3zweRD}*?9&yWEvs0chxoCMtW$2< zE7iGwp6&hGGQnRC^;N`#p4v$`H_r0E`&JwExxG@|z%JK2k101N>P&?gzT6z&@)SQyl{c!!^u46$0FMps@|rqop-SDmIgPa!K7Z7`YwI%M zC&lS`UQIth^HZw;@lyoIfWwc}Cwu)~n>X6slf3@I_usY82AIb)Q}d8Uyo$V&XM~=c zRLc&vIH*R6wKysFM*1>|eV#26eQZ3J&ebTTp~bC_2L5EX2iXt!(<_Lm0;*ZGd>)D{ ztnXKD?^fUId202ArafN|=3%~3w=i?*7NRqbyAv*JcUG@crf&@+PRPB1?R4-%7L%u_7igJd&{1#^Aq?>rvbU|Ab$?Y!k6%|O55=6Uk^2O zJ*O+2hi>eqj8#giXMb()qq#X3SUltNqQB><(U}TAk1(^N_t{YGS;v2gu0G3rpMY%f zk==y~^pX}e`K#YizeIi+Ys7X>HWX$hRF6tGC3-9$8LBui)JK;2uI)Pb#^m9l;nLf7 z{l@BjZ`>^9&sMqmh?|M@SDx1xo z0DRJUE3MJd>3FVg@Lj~)bi>PyZcsf%r25{OXS8j`X?N5*nuF@pTyu;yMURfyA8K9w z%(u82X}llX9I1ezegv$hJ(9kc@_3LaYJFWwks~Vm@DYgAcu(m)`&@N2HMy-@hj+tn z`nrs@+;MKSlbig`eU&<=XG=Mh-Qi%a4-!O8UR!>@iXS zrp8}al(eiFY-fxg54EMzfhT5vr*=3yu1K10TZL&HnecM?TaybtMLtOVYew`{;UpQ@3FrgL`bNHCR3F~i6_ z*6ky&=SAv8T;{n(C~$mc-#9xg-#z+p@Ws)9vVnL8e;!s9kMZTOpD4e}Q;VMDwMg4z zMN8L_b>>uVb$MyWvAt(xO}<1eUE6D}Z_QnM{qF6oiTG_!Z~UvZF7;KbzZ=&e*D4=b zT;waW>eSL2uJN4OJBicXgesm#1mA zL}yYb6+7=DZ%b%dmnqFr*Yi=VPRG97P~vFmIPe}EQ0gLNOP{&+09m$iq~<4TrdUOb zFZ8Q?Z2eEu?;}U#*5SbXKcW<5V^UW(C{Xt;b;t6W?m_%q`Fq!Xu>(`=J0dzOCzay$ z=h0q0^sMtqIj2PRk^?S&15s|B*$$$Gdyjhn~*9$T3|!MYJy#D6Q~rpHo!& zG4eQ+J-KJp{?=&k+?gu+^UB)Gsxj}e=TVC(}t=BF*P^trAX3eZ^QAN1*4u_msuIh-W36mft;T5a{G5)!_4_)5$$@%MKJUBC$I< zzu~2w4sqi??44Ez4Ng4urd8c=>M8d{$9-@Aqi5*(g~q|_?p<@;J=(>`Lmdh+$-Qfz zRkL*ZRe6p4LZ0aA%V=(;%K7SbU6T9Hf>KoLs%Az+2VEmQa?kA}0?E-E1KZCXuLck{fKQ(g`V*mgE diff --git a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psd1 b/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psd1 index 123f0d06d959ebd53b2100b8d2d1e74e7d7033c3..1603e1aecdcd1857ee438e036eb6132ed9fd18ca 100644 GIT binary patch literal 4080 zcmcInQBUJI5Ps)ZSmL1|ZChaBcJHK>=oJ?b0`6KCq&dJ=$XCW!zV?9B>WcWnBCfJ3a!-u2e4S%vU3HZNUl=BO zt4M7p;48)f&-Zi9WINftx357Y`?*$q-^+6=Q-APQ2#L9uB0ore4=k|@r(P)$;bI8R8dY|^zIdH6lZhMf5ZMSv8u`sgCIy6xeaj>}Htiyd#Bv@Vt z0S}<<+P#!m<2BOWFcqsQY#?j1&7_FnATlT+k3_!h>X1GNA)AIdiWQZPWFw$sRTQ0P zobaWnL--?_^*?F+x-QzR$V_7$tGewO+=U%RA>H!$b$={`aRzqNvK$fo|+q~Pqihl%}>9me8hk`PBkk>h^ zn9|@3E^3@Y{2EgAS`S}v`H`o6QO^B$Wt=A&=%a6B=~lgu=F4?^R-yc$lF=xGqd1l0 z9;pE|${1D0VM@0>h^4ca04osF@FRKXuv&5Guz6{ihURKn{7!XHA37{uA~6SVX${<~ zOS{=gOsmbjYtztbMdM9!>nIhd=Bs+qIKy6H8c{|qrr}3XO%ZYao!ZjMIIsfKVM3K@ zwK&%0ZM5l471b>|l}nfMR1RJ8Q#sTVTGt@s@R0(w+e`ha(7Qxv8X5^|T$mLX7kEU0 zreUZ4R2$ciRf&)fJSIVheoXsTa!7tv_y%}?Kdng6FW3L!?pC%yU8Q?Jxehi7Pv$YMVA8OTs&xWbmP zrA752Q|Ggg%#0JM)`j|GI9%ZjvW^C^$cM4%tF>*bV?%IQYYja!+ZYa`Ohm(+GCrJ6 z#`n*`ec37ct=_RSo@5J=Vt9cS#X^d|aGctp35&7ImQa{B9ETcg zhu`=0{v;FhG;HvxB~8-pz$FgHB#AEC3}XK-hc$#j_dO$)3_~|2>u52o2!2`CTfJ5flC>T#Bgl0ubar^_xVj_69`$V`PiO_18?ur!4 zl6_ZLBD!S`{BDh6+PhBxx-em0ZYUx#1f70!FJ&x(%wl7)3I z#V@+ZB{nS_>+V<_O~g}m#EI^i7LUaFRI~fKlBB!Qzbi@Bi@zpaRo@$tFXw4bG%hr{ zw2|Mn<5!}$xHUE;<5UvfpV_6ViSK2diAIdH{#YxFW!H1rhpRnZJE=&KG*wEFb6%zC za+n17!iKon2oLqW*TU7lxVY2@Ti{*z%nMoVvChz~aXT$^h+Xm3bIrpRBT0dkn&$Na zynG$j!Uv81u@km+|5|#nwefWR6EQ#QI22|4VkCad>xm?;V0X88(p9eYr{ORdeb-C< zXqx&sd?qbD(Dy_h_DNXR+2>mEVLTg1i(LC$a^4SL=xnHCU*nH8{-J)4^!-#cEFyCr z(3M5-%c$dSOXoAoS#{32;-1YC-;j^h$8IwB+tK!+zW3djU@|41tmk@)_l*0|6r78Q>3 zw~2|iwR5eQ#a={PA*y^D-!f{4d5*c2Sw2;VBt9_Z|e?ihA@ zBrWQLpwhvg84-LSa@~umI>_$iQDWe=Rs&7&Y>#2klo3r$wT|Em`lZSMT`gy=38Qwy zUd#eXbDYhK|9`ITO`=6~{6c!6%EsDwDzz!Kc|ZCVK14>k))jQPCc9$!VfaY@epH3@ zX*}DKP6s-FxP&&V{!`~@tCgQ_EJ{@(F@T>?S&-AHKRKfsCq}4j^NN@ZVwI_T*Qq?} zB36E6MY0Fk7~PGdKUt+h9Fo1!qRr1~d$xpTcO|=``FmO=&q?_{0C|wTz0Tf>_8f{g zYW0z5b?X4>j$}jg{=1Z;nX_(b%3Y=J5|L<42*f_@e)PO#V|+M8JvIgv+?svKN__)n zu13w=g+=Swn9DI0eqRWE^Z1)*)dL~!Wt6K~2XAEWvzaE?SDAm8w!MkCk2aR~P3L17 z8J@)Wu=Do>l6z0W_A+O>2S!YEgd1uJs)BL`XbFZ`rRCUS zyH>hetYMuFqd?vu)7YL4>QsT&?eK1pRdJSs!7sQ&Dr%RsH*D5Wi8oJbbFks#LZgnl ztKpeE0(sU|75$(+8J~eZWFfrhwT`QjXf8hZc4G5K?npu`X2_hzr&ZB@ z^*Vh9e7Jq)p)@`aRjgEEt5mQjZ3cwm*@er3Hy z=)IAr7*|y)wqClA%}gKk_^LARM;*7_q1vy`sA>UA5ogUh^1I(i=Y8!U=r=ZxG&!iq zsnn;lQQ%1KFLgR5Uuij8!MYCkD)O=W056g}tk@SvRB1$E(=4K7-V9`Xt#PkJFO6k& zb|8ipM*Bb`utgfhehcnam8ZebDvmx9g~1G0n&bTcjD-EU+9^@6)Mgz4Q&;svS)|Dx z-^-r(3q4=Zep#F3cCOQ`b7p&+Hm$EMmzsWPmis)`DiuCcX*dO^0F^Y9m?JkYvLb0*^X`{T!Qc1v1k9=Iy2tskWRH@ zNCrxcTju`?${?P zEFH4XdKt4aqxaNHdfteWIggyL=)0xwq2}!Bm~@UVumu=L{lb6m_O#g~N6hLVP$_CEG@{*}1MFAt+$Alxq@;nkF};Yix-fZ}*1RW{f(X zGi=6vBtEbyJ?x0mV?BG}J)Qb4r7Bss4#5TUvm5bsEbjeTVO^V-lJ6q?G~>V9@2nQ` zvHuKxE~_hc@N{3XC1+%CbYanLPZ90Shh;o(XGYg`WcIWDUe)vH5Z=h=at|~_CEFXG zTgUNk{nEXLt}tlG-jfK({TrksF4uLhe20+^IQqJsJ*W@K#kPOri6gx`5IJ?XyK9Z6 zh-jy3ns4Xr*IHkoeq}}6J&Sx_Dq5}jHWb@gfbS{=P>}ig=ilNdi(?;tye{P5muD?H zdtF$J*IX)Ax}LLaYt%{f&ogN@`=KI4(4wCFTRmCy9Nmtw#?{0|D- BFOL8K diff --git a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 b/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 index 0d8ca5b..bf638f9 100644 --- a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 +++ b/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 @@ -6,6 +6,13 @@ Foreach ($import in @($Public + $Private)) { Catch { Write-Error -Message "Failed to import function $($import.fullname): $_" } } +# Module mode 32-bit warning: WOW64 relaunch in Initialize-CWAA works correctly for +# single-file mode (ConnectWiseAutomateAgent.ps1) but cannot relaunch Import-Module. +# Warn users so they know to use the native 64-bit PowerShell host. +if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { + Write-Warning 'ConnectWiseAutomateAgent: Module imported from 32-bit PowerShell on a 64-bit OS. Registry and file operations may target incorrect locations. Please use 64-bit PowerShell for reliable operation.' +} + Export-ModuleMember -Function $Public.Basename -Alias * Initialize-CWAA \ No newline at end of file diff --git a/ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 b/ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 new file mode 100644 index 0000000..b7e65b2 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 @@ -0,0 +1,43 @@ +function Clear-CWAAInstallerArtifacts { + <# + .SYNOPSIS + Cleans up stale ConnectWise Automate installer processes and temporary files. + .DESCRIPTION + Terminates any running installer-related processes and removes temporary installer + files left behind by incomplete or failed installations. This prevents conflicts + when starting a new install, reinstall, or update operation. + + Process names and file paths are read from the centralized module constants + $Script:CWAAInstallerProcessNames and $Script:CWAAInstallerArtifactPaths. + + All operations are best-effort with errors suppressed. This function is intended + as a defensive cleanup step, not a validated operation. + .NOTES + Version: 0.1.5.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param() + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + # Kill stale installer processes that may block new installations + foreach ($processName in $Script:CWAAInstallerProcessNames) { + Get-Process -Name $processName -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + } + + # Remove leftover temporary installer files + foreach ($artifactPath in $Script:CWAAInstallerArtifactPaths) { + Remove-Item -Path $artifactPath -Force -Recurse -ErrorAction SilentlyContinue + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Get-CurrentLineNumber.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Get-CurrentLineNumber.ps1 deleted file mode 100644 index 6d6fcc6..0000000 --- a/ConnectWiseAutomateAgent/Private/Initialize/Get-CurrentLineNumber.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -function Get-CurrentLineNumber { - [Alias('LINENUM')] - param() - $MyInvocation.ScriptLineNumber -} \ No newline at end of file diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 index a6d1f6c..b454c55 100644 --- a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 @@ -1,14 +1,55 @@ +function Get-CWAARedactedValue { + <# + .SYNOPSIS + Returns a SHA256-hashed redacted representation of a sensitive string. + .DESCRIPTION + Private helper that returns '[SHA256:a1b2c3d4]' for non-empty strings + and '[EMPTY]' for null/empty strings. Used to log that a credential value + is present without exposing the actual content. + .NOTES + Version: 0.1.5.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param( + [AllowNull()] + [AllowEmptyString()] + [string]$InputString + ) + if ([string]::IsNullOrEmpty($InputString)) { + return '[EMPTY]' + } + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString)) + $hashHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) + $sha256.Dispose() + return "[SHA256:$($hashHex.Substring(0, 8))]" +} + function Initialize-CWAA { + # Guard: PowerShell 1.0 lacks $PSVersionTable entirely if (-not ($PSVersionTable)) { Write-Warning 'PS1 Detected. PowerShell Version 2.0 or higher is required.' return } - if (-not ($PSVersionTable) -or $PSVersionTable.PSVersion.Major -lt 3 ) { Write-Verbose 'PS2 Detected. PowerShell Version 3.0 or higher may be required for full functionality.' } + if ($PSVersionTable.PSVersion.Major -lt 3) { + Write-Verbose 'PS2 Detected. PowerShell Version 3.0 or higher may be required for full functionality.' + } + # WOW64 relaunch: When running as 32-bit PowerShell on a 64-bit OS, many registry + # and file system operations target the wrong hive/path. Re-launch under native + # 64-bit PowerShell to ensure consistent behavior with the Automate agent services. + # Note: This relaunch works correctly in single-file mode (ConnectWiseAutomateAgent.ps1). + # In module mode (Import-Module), the .psm1 emits a warning instead since relaunch + # cannot re-invoke Import-Module from within a function. if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { Write-Warning '32-bit PowerShell session detected on 64-bit OS. Attempting to launch 64-Bit session to process commands.' $pshell = "${env:WINDIR}\sysnative\windowspowershell\v1.0\powershell.exe" if (!(Test-Path -Path $pshell)) { + # sysnative virtual folder is unavailable (e.g. older OS or non-interactive context). + # Fall back to the real System32 path after disabling WOW64 file system redirection + # so the 64-bit powershell.exe is accessible instead of the 32-bit redirected copy. Write-Warning 'SYSNATIVE PATH REDIRECTION IS NOT AVAILABLE. Attempting to access 64-bit PowerShell directly.' $pshell = "${env:WINDIR}\System32\WindowsPowershell\v1.0\powershell.exe" $FSRedirection = $True @@ -20,60 +61,91 @@ function Initialize-CWAA { public static extern bool Wow64RevertWow64FsRedirection(ref IntPtr ptr); '@ [ref]$ptr = New-Object System.IntPtr - $Result = [Kernel32.Wow64]::Wow64DisableWow64FsRedirection($ptr) # Now you can call 64-bit Powershell from system32 + $Null = [Kernel32.Wow64]::Wow64DisableWow64FsRedirection($ptr) } + + # Re-invoke the original command/script under the 64-bit host if ($myInvocation.Line) { &"$pshell" -NonInteractive -NoProfile $myInvocation.Line } - Elseif ($myInvocation.InvocationName) { + elseif ($myInvocation.InvocationName) { &"$pshell" -NonInteractive -NoProfile -File "$($myInvocation.InvocationName)" $args } else { &"$pshell" -NonInteractive -NoProfile $myInvocation.MyCommand } $ExitResult = $LASTEXITCODE + + # Restore file system redirection if it was disabled if ($FSRedirection -eq $True) { [ref]$defaultptr = New-Object System.IntPtr - $Result = [Kernel32.Wow64]::Wow64RevertWow64FsRedirection($defaultptr) + $Null = [Kernel32.Wow64]::Wow64RevertWow64FsRedirection($defaultptr) } Write-Warning 'Exiting 64-bit session. Module will only remain loaded in native 64-bit PowerShell environment.' Exit $ExitResult } - #Ignore SSL errors - Add-Type -Debug:$False @' - using System; - using System.Net; - using System.Net.Security; - using System.Security.Cryptography.X509Certificates; - public class ServerCertificateValidationCallback - { - public static void Ignore() - { - if(ServicePointManager.ServerCertificateValidationCallback ==null) - { - ServicePointManager.ServerCertificateValidationCallback += - delegate - ( - Object obj, - X509Certificate certificate, - X509Chain chain, - SslPolicyErrors errors - ) - { - return true; - }; - } - } + # Module-level constants — centralized to avoid duplication across functions. + # These are cheap to create with no side effects, so they run at module load. + $Script:CWAARegistryRoot = 'HKLM:\SOFTWARE\LabTech\Service' + $Script:CWAARegistrySettings = 'HKLM:\SOFTWARE\LabTech\Service\Settings' + $Script:CWAAInstallPath = "${env:windir}\LTSVC" + $Script:CWAAInstallerTempPath = "${env:windir}\Temp\LabTech" + $Script:CWAAServiceNames = @('LTService', 'LTSvcMon') + # Server URL validation regex breakdown: + # ^(https?://)? — optional http:// or https:// scheme + # (([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2} — IPv4 address (0.0.0.0 - 299.299.299.299) + # | — OR + # [a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*) — hostname with optional subdomains + # $ — end of string, no trailing path/query + $Script:CWAAServerValidationRegex = '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$' + + # Registry paths for Add/Remove Programs operations (shared by Hide, Show, Rename functions) + $Script:CWAAInstallerProductKeys = @( + 'HKLM:\SOFTWARE\Classes\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', + 'HKLM:\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC' + ) + $Script:CWAAUninstallKeys = @( + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}' + ) + $Script:CWAARegistryBackup = 'HKLM:\SOFTWARE\LabTechBackup\Service' + + # Installer artifact paths for cleanup (used by Clear-CWAAInstallerArtifacts) + $Script:CWAAInstallerArtifactPaths = @( + "${env:windir}\Temp\_LTUpdate", + "${env:windir}\Temp\Agent_Uninstall.exe", + "${env:windir}\Temp\RemoteAgent.msi", + "${env:windir}\Temp\Uninstall.exe", + "${env:windir}\Temp\Uninstall.exe.config" + ) + + # Installer process names for cleanup (used by Clear-CWAAInstallerArtifacts) + $Script:CWAAInstallerProcessNames = @('Agent_Uninstall', 'Uninstall', 'LTUpdate') + + # Windows Event Log settings (used by Write-CWAAEventLog) + $Script:CWAAEventLogSource = 'ConnectWiseAutomateAgent' + $Script:CWAAEventLogName = 'Application' + + # Service credential storage â€" populated on-demand by Get-CWAAProxy + $Script:LTServiceKeys = [PSCustomObject]@{ + ServerPasswordString = '' + PasswordString = '' } -'@ - [ServerCertificateValidationCallback]::Ignore() - #Enable TLS, TLS1.1, TLS1.2, TLS1.3 in this session if they are available - IF ([Net.SecurityProtocolType]::Tls) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls } - IF ([Net.SecurityProtocolType]::Tls11) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls11 } - IF ([Net.SecurityProtocolType]::Tls12) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } - IF ([Net.SecurityProtocolType]::Tls13) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls13 } + # Proxy configuration — populated on-demand by Initialize-CWAANetworking + $Script:LTProxy = [PSCustomObject]@{ + ProxyServerURL = '' + ProxyUsername = '' + ProxyPassword = '' + Enabled = $False + } - $Null = Initialize-CWAAModule -} \ No newline at end of file + # Networking subsystem deferred flags. Initialize-CWAANetworking sets these to $True + # after registration/initialization. This keeps module import fast and avoids + # irreversible global session side effects until networking is actually needed. + $Script:CWAANetworkInitialized = $False + $Script:CWAACertCallbackRegistered = $False +} diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAKeys.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAKeys.ps1 deleted file mode 100644 index ab3b684..0000000 --- a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAKeys.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -function Initialize-CWAAKeys { - [CmdletBinding()] - Param() - - Process { - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if (($LTSI) -and ($LTSI | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { - Write-Debug "Line $(LINENUM): Decoding Server Password." - $Script:LTServiceKeys.ServerPasswordString = $(ConvertFrom-CWAASecurity -InputString "$($LTSI.ServerPassword)") - if ($Null -ne $LTSI -and ($LTSI | Get-Member | Where-Object { $_.Name -eq 'Password' })) { - Write-Debug "Line $(LINENUM): Decoding Agent Password." - $Script:LTServiceKeys.PasswordString = $(ConvertFrom-CWAASecurity -InputString "$($LTSI.Password)" -Key "$($Script:LTServiceKeys.ServerPasswordString)") - } - else { - $Script:LTServiceKeys.PasswordString = '' - } - } - else { - $Script:LTServiceKeys.ServerPasswordString = '' - $Script:LTServiceKeys.PasswordString = '' - } - } - - End { - } -} diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAModule.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAModule.ps1 deleted file mode 100644 index c77dba8..0000000 --- a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAAModule.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -function Initialize-CWAAModule { - #Populate $Script:LTServiceKeys Object - $Script:LTServiceKeys = New-Object -TypeName PSObject - Add-Member -InputObject $Script:LTServiceKeys -MemberType NoteProperty -Name ServerPasswordString -Value '' - Add-Member -InputObject $Script:LTServiceKeys -MemberType NoteProperty -Name PasswordString -Value '' - - #Populate $Script:LTProxy Object - Try { - $Script:LTProxy = New-Object -TypeName PSObject - Add-Member -InputObject $Script:LTProxy -MemberType NoteProperty -Name ProxyServerURL -Value '' - Add-Member -InputObject $Script:LTProxy -MemberType NoteProperty -Name ProxyUsername -Value '' - Add-Member -InputObject $Script:LTProxy -MemberType NoteProperty -Name ProxyPassword -Value '' - Add-Member -InputObject $Script:LTProxy -MemberType NoteProperty -Name Enabled -Value '' - - #Populate $Script:LTWebProxy Object - $Script:LTWebProxy = New-Object System.Net.WebProxy - - #Initialize $Script:LTServiceNetWebClient Object - $Script:LTServiceNetWebClient = New-Object System.Net.WebClient - $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy - } - Catch { - Write-Error "ERROR: Line $(LINENUM): Failed Initializing internal Proxy Objects/Variables." - } - - $Null = Get-CWAAProxy -ErrorAction Continue -} \ No newline at end of file diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 new file mode 100644 index 0000000..f112fac --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 @@ -0,0 +1,140 @@ +function Initialize-CWAANetworking { + <# + .SYNOPSIS + Lazily initializes networking objects on first use rather than at module load. + .DESCRIPTION + Performs deferred initialization of SSL certificate validation, TLS protocol enablement, + WebProxy, WebClient, and proxy configuration. This function is idempotent -- + subsequent calls skip core initialization after the first successful run. + + SSL certificate handling uses a smart callback with graduated trust: + - IP address targets: auto-bypass (IPs cannot have properly signed certificates) + - Hostname name mismatch: tolerated (cert is trusted but CN/SAN does not match) + - Chain/trust errors on hostnames: rejected (untrusted CA, self-signed) + - -SkipCertificateCheck: full bypass for all certificate errors + + Called automatically by networking functions (Install-CWAA, Uninstall-CWAA, + Update-CWAA, Set-CWAAProxy) in their Begin blocks. Non-networking functions + never trigger these side effects, keeping module import fast and clean. + .PARAMETER SkipCertificateCheck + Disables all SSL certificate validation for the current PowerShell session. + Use this when connecting to servers with self-signed certificates on hostname URLs. + Note: This affects ALL HTTPS connections in the session, not just Automate operations. + .NOTES + Version: 0.1.5.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param( + [switch]$SkipCertificateCheck + ) + + Write-Debug "Starting $($MyInvocation.InvocationName)" + + # Smart SSL certificate callback: Registered once per session. Uses graduated trust + # rather than blanket bypass. The callback handles three scenarios: + # 1. IP address targets: auto-bypass (IPs cannot have properly signed certs) + # 2. Name mismatch only: tolerate (cert is trusted but hostname differs from CN/SAN) + # 3. Chain/trust errors: reject unless SkipAll is set via -SkipCertificateCheck + # On .NET 6+ (PS 7+), ServicePointManager triggers SYSLIB0014 obsolescence warning. + # Conditionally wrap with pragma directives based on the runtime. + if (-not $Script:CWAACertCallbackRegistered) { + Try { + # Check if the type already exists in the AppDomain (survives module re-import + # because .NET types cannot be unloaded). Only call Add-Type if it's truly new. + if (-not ([System.Management.Automation.PSTypeName]'ServerCertificateValidationCallback').Type) { + $sslCallbackSource = @" +using System; +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +public class ServerCertificateValidationCallback +{ + public static bool SkipAll = false; + public static void Register() + { + if (ServicePointManager.ServerCertificateValidationCallback == null) + { + ServicePointManager.ServerCertificateValidationCallback += + delegate(Object obj, X509Certificate certificate, + X509Chain chain, SslPolicyErrors errors) + { + if (errors == SslPolicyErrors.None) return true; + if (SkipAll) return true; + var request = obj as HttpWebRequest; + if (request != null) + { + IPAddress ip; + if (IPAddress.TryParse(request.RequestUri.Host, out ip)) + return true; + } + if (errors == SslPolicyErrors.RemoteCertificateNameMismatch) + return true; + return false; + }; + } + } +} +"@ + if ($PSVersionTable.PSEdition -eq 'Core') { + $sslCallbackSource = "#pragma warning disable SYSLIB0014`n" + $sslCallbackSource + "`n#pragma warning restore SYSLIB0014" + } + Add-Type -Debug:$False $sslCallbackSource + } + [ServerCertificateValidationCallback]::Register() + $Script:CWAACertCallbackRegistered = $True + } + Catch { + Write-Debug "SSL certificate validation callback could not be registered: $_" + } + } + + # Full bypass mode: sets the SkipAll flag on the C# class so the callback + # accepts all certificates regardless of error type. Useful for servers with + # self-signed certificates on hostname URLs. + if ($SkipCertificateCheck -and $Script:CWAACertCallbackRegistered) { + if (-not [ServerCertificateValidationCallback]::SkipAll) { + Write-Warning 'SSL certificate validation is disabled for this session. This affects all HTTPS connections in this PowerShell session.' + [ServerCertificateValidationCallback]::SkipAll = $True + } + } + + # Idempotency guard: TLS, WebClient, and proxy only need to run once per session + if ($Script:CWAANetworkInitialized -eq $True) { + Write-Debug "Initialize-CWAANetworking: Core networking already initialized, skipping." + return + } + + Write-Verbose 'Initializing networking subsystem (TLS, WebClient, Proxy).' + + # TLS protocol enablement: Enable TLS 1.2 and 1.3 for secure communication. + # TLS 1.0 and 1.1 are deprecated (POODLE, BEAST vulnerabilities) and intentionally + # excluded. Each version is added via bitwise OR to preserve already-enabled protocols. + Try { + if ([Net.SecurityProtocolType]::Tls12) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } + if ([Net.SecurityProtocolType]::Tls13) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls13 } + } + Catch { + Write-Debug "TLS protocol configuration skipped (may not apply to this .NET runtime): $_" + } + + # WebClient and WebProxy are deprecated in .NET 6+ (SYSLIB0014) but still functional. + # They remain the only option compatible with PowerShell 3.0-5.1 (.NET Framework). + Try { + $Script:LTWebProxy = New-Object System.Net.WebProxy + + $Script:LTServiceNetWebClient = New-Object System.Net.WebClient + $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy + } + Catch { + Write-Warning "Failed to initialize network objects (WebClient/WebProxy may be unavailable in this .NET runtime). $_" + } + + # Discover proxy settings from the installed agent (if present). + # Errors are non-fatal: the module works without proxy on systems with no agent. + $Null = Get-CWAAProxy -ErrorAction Continue + + $Script:CWAANetworkInitialized = $True + Write-Debug "Exiting $($MyInvocation.InvocationName)" +} diff --git a/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 b/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 new file mode 100644 index 0000000..eb48873 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 @@ -0,0 +1,68 @@ +function Remove-CWAAFolderRecursive { + <# + .SYNOPSIS + Performs depth-first removal of a folder and all its contents. + .DESCRIPTION + Private helper that removes a folder using a three-pass depth-first strategy: + 1. Remove files inside each subfolder (leaves first) + 2. Remove subfolders sorted by path depth (deepest first) + 3. Remove the root folder itself + + This approach maximizes cleanup even when some files or folders are locked + by running processes, which is common during agent uninstall/update operations. + + All removal operations use best-effort error handling (-ErrorAction SilentlyContinue). + The caller's $WhatIfPreference and $ConfirmPreference propagate automatically + through PowerShell's preference variable mechanism. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding(SupportsShouldProcess = $True)] + Param( + [Parameter(Mandatory = $True)] + [string]$Path + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { + Write-Debug "Path '$Path' does not exist. Nothing to remove." + return + } + + if ($PSCmdlet.ShouldProcess($Path, 'Remove Folder')) { + Write-Debug "Removing Folder: $Path" + Try { + # Pass 1: Remove files inside each subfolder (leaves first) + Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.psiscontainer } | + ForEach-Object { + Get-ChildItem -Path $_.FullName -ErrorAction SilentlyContinue | + Where-Object { -not $_.psiscontainer } | + Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False + } + + # Pass 2: Remove subfolders sorted by path depth (deepest first) + Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.psiscontainer } | + Sort-Object { $_.FullName.Length } -Descending | + Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False + + # Pass 3: Remove the root folder itself + Remove-Item -Recurse -Force -Path $Path -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False + } + Catch { + Write-Debug "Error removing folder '$Path': $($_.Exception.Message)" + } + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 b/ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 new file mode 100644 index 0000000..7bf2a14 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 @@ -0,0 +1,85 @@ +function Resolve-CWAAServer { + <# + .SYNOPSIS + Finds the first reachable ConnectWise Automate server from a list of candidates. + .DESCRIPTION + Private helper that iterates through server URLs, validates each against the + server format regex, normalizes the URL scheme, and tests reachability by + downloading the version string from /LabTech/Agent.aspx. Returns the first + server that responds with a parseable version. + + Used by Install-CWAA, Uninstall-CWAA, and Update-CWAA to eliminate the + duplicated server validation loop. Callers handle their own download logic + after receiving the resolved server, since URL construction differs per operation. + + Requires $Script:LTServiceNetWebClient to be initialized (via Initialize-CWAANetworking) + before calling. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $True)] + [string[]]$Server + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + # Normalize: prepend https:// to bare hostnames/IPs so the loop has consistent URLs + $normalizedServers = ForEach ($serverUrl in $Server) { + if ($serverUrl -notmatch 'https?://.+') { "https://$serverUrl" } + $serverUrl + } + + ForEach ($serverUrl in $normalizedServers) { + if ($serverUrl -match $Script:CWAAServerValidationRegex) { + # Ensure a scheme is present for the actual request + if ($serverUrl -notmatch 'https?://.+') { $serverUrl = "http://$serverUrl" } + Try { + $versionCheckUrl = "$serverUrl/LabTech/Agent.aspx" + Write-Debug "Testing Server Response and Version: $versionCheckUrl" + $serverVersionResponse = $Script:LTServiceNetWebClient.DownloadString($versionCheckUrl) + Write-Debug "Raw Response: $serverVersionResponse" + + # Extract version from the pipe-delimited response string. + # Format: six pipe characters followed by major.minor version (e.g. '||||||220.105') + $serverVersion = $serverVersionResponse | + Select-String -Pattern '(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}' | + ForEach-Object { $_.Matches } | + Select-Object -Expand Value -ErrorAction SilentlyContinue + + if ($null -eq $serverVersion) { + Write-Verbose "Unable to test version response from $serverUrl." + Continue + } + + Write-Verbose "Server $serverUrl responded with version $serverVersion." + return [PSCustomObject]@{ + ServerUrl = $serverUrl + ServerVersion = $serverVersion + } + } + Catch { + Write-Warning "Error encountered testing server $serverUrl." + Continue + } + } + else { + Write-Warning "Server address $serverUrl is not formatted correctly. Example: https://automate.domain.com" + } + } + + # No server responded successfully + Write-Debug "No reachable server found from candidates: $($Server -join ', ')" + return $null + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 b/ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 new file mode 100644 index 0000000..18ec5d0 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 @@ -0,0 +1,58 @@ +function Test-CWAADownloadIntegrity { + <# + .SYNOPSIS + Validates a downloaded file meets minimum size requirements. + .DESCRIPTION + Private helper that checks whether a downloaded installer file exists and + exceeds the specified minimum size threshold. If the file is below the + threshold, it is treated as corrupt or incomplete: a warning is emitted + and the file is removed. + + The default threshold of 1234 KB matches the established convention for + MSI/EXE installer files. The Agent_Uninstall.exe uses a lower threshold + of 80 KB due to its smaller expected size. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $True)] + [string]$FilePath, + + [Parameter()] + [string]$FileName, + + [Parameter()] + [int]$MinimumSizeKB = 1234 + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + if (-not $FileName) { + $FileName = Split-Path $FilePath -Leaf + } + } + + Process { + if (-not (Test-Path $FilePath)) { + Write-Debug "$FileName not found at '$FilePath'." + return $false + } + + $fileSizeKB = (Get-Item $FilePath -ErrorAction SilentlyContinue).Length / 1KB + if (-not ($fileSizeKB -gt $MinimumSizeKB)) { + Write-Warning "$FileName size is below normal ($([math]::Round($fileSizeKB, 1)) KB < $MinimumSizeKB KB). Removing suspected corrupt file." + Remove-Item $FilePath -ErrorAction SilentlyContinue -Force -Confirm:$False + return $false + } + + Write-Debug "$FileName integrity check passed ($([math]::Round($fileSizeKB, 1)) KB >= $MinimumSizeKB KB)." + return $true + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 b/ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 new file mode 100644 index 0000000..b6b6827 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 @@ -0,0 +1,58 @@ +function Write-CWAAEventLog { + <# + .SYNOPSIS + Writes an entry to the Windows Event Log for ConnectWise Automate Agent operations. + .DESCRIPTION + Centralized event log writer for the ConnectWiseAutomateAgent module. Writes to the + Application event log under the source defined by $Script:CWAAEventLogSource. + + On first call, registers the event source if it does not already exist (requires + administrator privileges for registration). If the source cannot be registered or + the write fails for any reason, the error is written to Write-Debug and the function + returns silently. This ensures event logging never disrupts the calling function. + + Event ID ranges by category: + 1000-1039 Installation (Install, Uninstall, Redo, Update) + 2000-2029 Service Control (Start, Stop, Restart) + 3000-3069 Configuration (Reset, Backup, Proxy, LogLevel, AddRemove) + 4000-4039 Health/Monitoring (Repair, Register/Unregister task) + .NOTES + Version: 0.1.5.0 + Author: Chris Taylor + Private function - not exported. + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $True)] + [string]$Message, + + [Parameter(Mandatory = $True)] + [ValidateSet('Information', 'Warning', 'Error')] + [string]$EntryType, + + [Parameter(Mandatory = $True)] + [int]$EventId + ) + + Try { + # Register the event source if it does not exist yet. + # This requires administrator privileges the first time. + if (-not [System.Diagnostics.EventLog]::SourceExists($Script:CWAAEventLogSource)) { + New-EventLog -LogName $Script:CWAAEventLogName -Source $Script:CWAAEventLogSource -ErrorAction Stop + Write-Debug "Write-CWAAEventLog: Registered event source '$($Script:CWAAEventLogSource)' in '$($Script:CWAAEventLogName)' log." + } + + Write-EventLog -LogName $Script:CWAAEventLogName ` + -Source $Script:CWAAEventLogSource ` + -EventId $EventId ` + -EntryType $EntryType ` + -Message $Message ` + -ErrorAction Stop + + Write-Debug "Write-CWAAEventLog: Wrote EventId $EventId ($EntryType) to '$($Script:CWAAEventLogName)' log." + } + Catch { + # Best-effort: never disrupt the calling function if event logging fails. + Write-Debug "Write-CWAAEventLog: Failed to write event log entry. Error: $($_.Exception.Message)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 index 41cd5d5..c6639ea 100644 --- a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 +++ b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 @@ -1,47 +1,63 @@ function Hide-CWAAAddRemove { + <# + .SYNOPSIS + Hides the Automate agent from the Add/Remove Programs list. + .DESCRIPTION + Sets the SystemComponent registry value to 1 on Automate agent uninstall keys, + which hides the agent from the Windows Add/Remove Programs (Programs and Features) list. + Also cleans up any leftover HiddenProductName registry values from older hiding methods. + .EXAMPLE + Hide-CWAAAddRemove + Hides the Automate agent entry from Add/Remove Programs. + .EXAMPLE + Hide-CWAAAddRemove -WhatIf + Shows what registry changes would be made without applying them. + .NOTES + Author: Chris Taylor + Alias: Hide-LTAddRemove + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Hide-LTAddRemove')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - $RegRoots = ('HKLM:\SOFTWARE\Classes\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'HKLM:\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC') - $PublisherRegRoots = ('HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}') + Write-Debug "Starting $($MyInvocation.InvocationName)" + $RegRoots = $Script:CWAAInstallerProductKeys + $PublisherRegRoots = $Script:CWAAUninstallKeys $RegEntriesFound = 0 $RegEntriesChanged = 0 } Process { - Try { - Foreach ($RegRoot in $RegRoots) { + foreach ($RegRoot in $RegRoots) { if (Test-Path $RegRoot) { if (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { if (!(Get-ItemProperty $RegRoot -Name ProductName -ErrorAction SilentlyContinue)) { - Write-Verbose 'LabTech found with HiddenProductName value.' + Write-Verbose 'Automate agent found with HiddenProductName value.' Try { Rename-ItemProperty $RegRoot -Name HiddenProductName -NewName ProductName } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error renaming the registry value. $($Error[0])" -ErrorAction Stop + Write-Error "There was an error renaming the registry value. $($_)" -ErrorAction Stop } } else { - Write-Verbose 'LabTech found with unused HiddenProductName value.' + Write-Verbose 'Automate agent found with unused HiddenProductName value.' Try { Remove-ItemProperty $RegRoot -Name HiddenProductName -EA 0 -Confirm:$False -WhatIf:$False -Force } - Catch {} + Catch { + Write-Debug "Failed to remove unused HiddenProductName from '$RegRoot': $($_)" + } } } } } - Foreach ($RegRoot in $PublisherRegRoots) { + foreach ($RegRoot in $PublisherRegRoots) { if (Test-Path $RegRoot) { $RegKey = Get-Item $RegRoot -ErrorAction SilentlyContinue if ($RegKey) { @@ -58,26 +74,24 @@ function Hide-CWAAAddRemove { } } } - } + # Output success/warning at end of try block (replaces if($?) pattern in End block) + if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { + Write-Output 'Automate agent is hidden from Add/Remove Programs.' + Write-CWAAEventLog -EventId 3040 -EntryType Information -Message 'Agent hidden from Add/Remove Programs.' + } + elseif ($WhatIfPreference -ne $True) { + Write-Warning "Automate agent may not be hidden from Add/Remove Programs." + Write-CWAAEventLog -EventId 3041 -EntryType Warning -Message 'Agent may not be hidden from Add/Remove Programs.' + } + } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error setting the registry values. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3042 -EntryType Error -Message "Failed to hide agent from Add/Remove Programs. Error: $($_.Exception.Message)" + Write-Error "There was an error setting the registry values. $($_)" -ErrorAction Stop } - } End { - if ($WhatIfPreference -ne $True) { - if ($?) { - if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { - Write-Output 'LabTech is hidden from Add/Remove Programs.' - } - else { - Write-Warning "WARNING: Line $(LINENUM): LabTech may not be hidden from Add/Remove Programs." - } - } - else { $Error[0] } - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 index 8946c25..3f6ded3 100644 --- a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 +++ b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 @@ -1,4 +1,27 @@ function Rename-CWAAAddRemove { + <# + .SYNOPSIS + Renames the Automate agent entry in the Add/Remove Programs list. + .DESCRIPTION + Changes the DisplayName (and optionally Publisher) registry values for the Automate agent + uninstall keys, which controls how the agent appears in the Windows Add/Remove Programs + (Programs and Features) list. + .PARAMETER Name + The display name for the Automate agent as shown in the list of installed software. + .PARAMETER PublisherName + The publisher name for the Automate agent as shown in the list of installed software. + .EXAMPLE + Rename-CWAAAddRemove -Name 'My Remote Agent' + Renames the Automate agent display name to 'My Remote Agent'. + .EXAMPLE + Rename-CWAAAddRemove -Name 'My Remote Agent' -PublisherName 'My Company' + Renames both the display name and publisher name in Add/Remove Programs. + .NOTES + Author: Chris Taylor + Alias: Rename-LTAddRemove + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Rename-LTAddRemove')] Param( @@ -11,16 +34,11 @@ function Rename-CWAAAddRemove { ) Begin { - $RegRoots = ('HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Classes\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'HKLM:\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC') - $PublisherRegRoots = ('HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}') - $RegNameFound = 0; - $RegPublisherFound = 0; + Write-Debug "Starting $($MyInvocation.InvocationName)" + $RegRoots = @($Script:CWAAUninstallKeys[0], $Script:CWAAUninstallKeys[1]) + $Script:CWAAInstallerProductKeys + $PublisherRegRoots = $Script:CWAAUninstallKeys + $RegNameFound = 0 + $RegPublisherFound = 0 } Process { @@ -33,23 +51,23 @@ function Rename-CWAAAddRemove { $RegNameFound++ } } - Elseif (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { - if ($PSCmdlet.ShouldProcess("$($RegRoot)\ HiddenProductName=$($Name)", 'Set Registry Value')) { - Write-Verbose "Setting $($RegRoot)\ HiddenProductName=$($Name)" + elseif (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { + if ($PSCmdlet.ShouldProcess("$($RegRoot)\HiddenProductName=$($Name)", 'Set Registry Value')) { + Write-Verbose "Setting $($RegRoot)\HiddenProductName=$($Name)" Set-ItemProperty $RegRoot -Name HiddenProductName -Value $Name -Confirm:$False $RegNameFound++ } } } } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error setting the registry key value. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3062 -EntryType Error -Message "Failed to rename agent in Add/Remove Programs. Error: $($_.Exception.Message)" + Write-Error "There was an error setting the DisplayName registry value. $($_)" -ErrorAction Stop } if (($PublisherName)) { Try { - Foreach ($RegRoot in $PublisherRegRoots) { + foreach ($RegRoot in $PublisherRegRoots) { if (Get-ItemProperty $RegRoot -Name Publisher -ErrorAction SilentlyContinue) { if ($PSCmdlet.ShouldProcess("$($RegRoot)\Publisher=$($PublisherName)", 'Set Registry Value')) { Write-Verbose "Setting $($RegRoot)\Publisher=$($PublisherName)" @@ -59,32 +77,38 @@ function Rename-CWAAAddRemove { } } } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error setting the registry key value. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3062 -EntryType Error -Message "Failed to set agent publisher name. Error: $($_.Exception.Message)" + Write-Error "There was an error setting the Publisher registry value. $($_)" -ErrorAction Stop } } - } - End { + # Output success/warning (replaces if($?) pattern formerly in End block). + # Guarded by $WhatIfPreference because SupportsShouldProcess is enabled and + # these messages would be misleading during a -WhatIf dry run. if ($WhatIfPreference -ne $True) { - if ($?) { - if ($RegNameFound -gt 0) { - Write-Output "LabTech is now listed as $($Name) in Add/Remove Programs." + if ($RegNameFound -gt 0) { + Write-Output "Automate agent is now listed as $($Name) in Add/Remove Programs." + Write-CWAAEventLog -EventId 3060 -EntryType Information -Message "Agent display name changed to '$Name' in Add/Remove Programs." + } + else { + Write-Warning "Automate agent was not found in installed software and the Name was not changed." + Write-CWAAEventLog -EventId 3061 -EntryType Warning -Message "Agent not found in installed software. Display name not changed." + } + if (($PublisherName)) { + if ($RegPublisherFound -gt 0) { + Write-Output "The Publisher is now listed as $($PublisherName)." + Write-CWAAEventLog -EventId 3060 -EntryType Information -Message "Agent publisher changed to '$PublisherName' in Add/Remove Programs." } else { - Write-Warning "WARNING: Line $(LINENUM): LabTech was not found in installed software and the Name was not changed." - } - if (($PublisherName)) { - if ($RegPublisherFound -gt 0) { - Write-Output "The Publisher is now listed as $($PublisherName)." - } - else { - Write-Warning "WARNING: Line $(LINENUM): LabTech was not found in installed software and the Publisher was not changed." - } + Write-Warning "Automate agent was not found in installed software and the Publisher was not changed." + Write-CWAAEventLog -EventId 3061 -EntryType Warning -Message "Agent not found in installed software. Publisher name not changed." } } - else { $Error[0] } } } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } } diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 index 10f3ac7..a1eac5f 100644 --- a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 +++ b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 @@ -1,47 +1,63 @@ function Show-CWAAAddRemove { + <# + .SYNOPSIS + Shows the Automate agent in the Add/Remove Programs list. + .DESCRIPTION + Sets the SystemComponent registry value to 0 on Automate agent uninstall keys, + which makes the agent visible in the Windows Add/Remove Programs (Programs and Features) list. + Also cleans up any leftover HiddenProductName registry values from older hiding methods. + .EXAMPLE + Show-CWAAAddRemove + Makes the Automate agent entry visible in Add/Remove Programs. + .EXAMPLE + Show-CWAAAddRemove -WhatIf + Shows what registry changes would be made without applying them. + .NOTES + Author: Chris Taylor + Alias: Show-LTAddRemove + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Show-LTAddRemove')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - $RegRoots = ('HKLM:\SOFTWARE\Classes\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'HKLM:\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC') - $PublisherRegRoots = ('HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}') + Write-Debug "Starting $($MyInvocation.InvocationName)" + $RegRoots = $Script:CWAAInstallerProductKeys + $PublisherRegRoots = $Script:CWAAUninstallKeys $RegEntriesFound = 0 $RegEntriesChanged = 0 } Process { - Try { - Foreach ($RegRoot in $RegRoots) { + foreach ($RegRoot in $RegRoots) { if (Test-Path $RegRoot) { if (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { if (!(Get-ItemProperty $RegRoot -Name ProductName -ErrorAction SilentlyContinue)) { - Write-Verbose 'LabTech found with HiddenProductName value.' + Write-Verbose 'Automate agent found with HiddenProductName value.' Try { Rename-ItemProperty $RegRoot -Name HiddenProductName -NewName ProductName } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error renaming the registry value. $($Error[0])" -ErrorAction Stop + Write-Error "There was an error renaming the registry value. $($_)" -ErrorAction Stop } } else { - Write-Verbose 'LabTech found with unused HiddenProductName value.' + Write-Verbose 'Automate agent found with unused HiddenProductName value.' Try { Remove-ItemProperty $RegRoot -Name HiddenProductName -EA 0 -Confirm:$False -WhatIf:$False -Force } - Catch {} + Catch { + Write-Debug "Failed to remove unused HiddenProductName from '$RegRoot': $($_)" + } } } } } - Foreach ($RegRoot in $PublisherRegRoots) { + foreach ($RegRoot in $PublisherRegRoots) { if (Test-Path $RegRoot) { $RegKey = Get-Item $RegRoot -ErrorAction SilentlyContinue if ($RegKey) { @@ -58,26 +74,24 @@ function Show-CWAAAddRemove { } } } - } + # Output success/warning at end of try block (replaces if($?) pattern in End block) + if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { + Write-Output 'Automate agent is visible in Add/Remove Programs.' + Write-CWAAEventLog -EventId 3050 -EntryType Information -Message 'Agent shown in Add/Remove Programs.' + } + elseif ($WhatIfPreference -ne $True) { + Write-Warning "Automate agent may not be visible in Add/Remove Programs." + Write-CWAAEventLog -EventId 3051 -EntryType Warning -Message 'Agent may not be visible in Add/Remove Programs.' + } + } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error setting the registry values. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3052 -EntryType Error -Message "Failed to show agent in Add/Remove Programs. Error: $($_.Exception.Message)" + Write-Error "There was an error setting the registry values. $($_)" -ErrorAction Stop } - } End { - if ($WhatIfPreference -ne $True) { - if ($?) { - if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { - Write-Output 'LabTech is visible from Add/Remove Programs.' - } - else { - Write-Warning "WARNING: Line $(LINENUM): LabTech may not be visible from Add/Remove Programs." - } - } - else { $Error[0] } - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 b/ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 index 7bd5cfd..e68ee94 100644 --- a/ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 +++ b/ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 @@ -1,4 +1,31 @@ function ConvertFrom-CWAASecurity { + <# + .SYNOPSIS + Decodes a Base64-encoded string using TripleDES decryption. + .DESCRIPTION + This function decodes the provided string using the specified or default key. + It uses TripleDES with an MD5-derived key and a fixed initialization vector. + If decoding fails with the provided key and Force is enabled, alternate key + values are attempted automatically. + .PARAMETER InputString + The Base64-encoded string to be decoded. + .PARAMETER Key + The key used for decoding. If not provided, default values will be tried. + .PARAMETER Force + Forces the function to try alternate key values if decoding fails using + the provided key. Enabled by default. + .EXAMPLE + ConvertFrom-CWAASecurity -InputString 'EncodedValue' + Decodes the string using the default key. + .EXAMPLE + ConvertFrom-CWAASecurity -InputString 'EncodedValue' -Key 'MyCustomKey' + Decodes the string using a custom key. + .NOTES + Author: Chris Taylor + Alias: ConvertFrom-LTSecurity + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('ConvertFrom-LTSecurity')] Param( @@ -20,6 +47,7 @@ function ConvertFrom-CWAASecurity { } Process { + Write-Debug "Starting $($MyInvocation.InvocationName)" if ($Null -eq $Key) { $NoKeyPassed = $True $Key = $DefaultKey @@ -32,30 +60,28 @@ function ConvertFrom-CWAASecurity { $NoKeyPassed = $True $testKey = $DefaultKey } - Write-Debug "Line $(LINENUM): Attempting Decode for '$($testInput)' with Key '$($testKey)'" + Write-Debug "Attempting Decode for '$($testInput)' with Key '$($testKey)'" Try { - $numarray = [System.Convert]::FromBase64String($testInput) - $ddd = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider - $ddd.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($testKey)) - $ddd.IV = $_initializationVector - $dd = $ddd.CreateDecryptor() - $DecodeString = [System.Text.Encoding]::UTF8.GetString($dd.TransformFinalBlock($numarray, 0, ($numarray.Length))) + $inputBytes = [System.Convert]::FromBase64String($testInput) + $tripleDesProvider = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider + $tripleDesProvider.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($testKey)) + $tripleDesProvider.IV = $_initializationVector + $cryptoTransform = $tripleDesProvider.CreateDecryptor() + $DecodeString = [System.Text.Encoding]::UTF8.GetString($cryptoTransform.TransformFinalBlock($inputBytes, 0, ($inputBytes.Length))) $DecodedString += @($DecodeString) } Catch { + Write-Debug "Decode failed for '$($testInput)' with Key '$($testKey)': $_" } - Finally { - if ((Get-Variable -Name dd -Scope 0 -EA 0)) { try { $dd.Dispose() } catch { $dd.Clear() } } - if ((Get-Variable -Name ddd -Scope 0 -EA 0)) { try { $ddd.Dispose() } catch { $ddd.Clear() } } + if ((Get-Variable -Name cryptoTransform -Scope 0 -EA 0)) { try { $cryptoTransform.Dispose() } catch { $cryptoTransform.Clear() } } + if ((Get-Variable -Name tripleDesProvider -Scope 0 -EA 0)) { try { $tripleDesProvider.Dispose() } catch { $tripleDesProvider.Clear() } } } } - else { - } } if ($Null -eq $DecodeString) { if ($Force) { - if (($NoKeyPassed)) { + if ($NoKeyPassed) { $DecodeString = ConvertFrom-CWAASecurity -InputString "$($testInput)" -Key '' -Force:$False if (-not ($Null -eq $DecodeString)) { $DecodedString += @($DecodeString) @@ -69,6 +95,7 @@ function ConvertFrom-CWAASecurity { } } else { + Write-Debug "All decode attempts exhausted for '$($testInput)' with Force disabled." } } } @@ -76,12 +103,12 @@ function ConvertFrom-CWAASecurity { End { if ($Null -eq $DecodedString) { - Write-Debug "Line $(LINENUM): Failed to Decode string: '$($InputString)'" + Write-Debug "Failed to Decode string: '$($InputString)'" return $Null } else { return $DecodedString } + Write-Debug "Exiting $($MyInvocation.InvocationName)" } - } diff --git a/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 b/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 index 8ca6d70..a63a26e 100644 --- a/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 +++ b/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 @@ -1,4 +1,27 @@ function ConvertTo-CWAASecurity { + <# + .SYNOPSIS + Encodes a string using TripleDES encryption compatible with Automate operations. + .DESCRIPTION + This function encodes the provided string using the specified or default key. + It uses TripleDES with an MD5-derived key and a fixed initialization vector, + returning a Base64-encoded result. + .PARAMETER InputString + The string to be encoded. + .PARAMETER Key + The key used for encoding. If not provided, a default value will be used. + .EXAMPLE + ConvertTo-CWAASecurity -InputString 'PlainTextValue' + Encodes the string using the default key. + .EXAMPLE + ConvertTo-CWAASecurity -InputString 'PlainTextValue' -Key 'MyCustomKey' + Encodes the string using a custom key. + .NOTES + Author: Chris Taylor + Alias: ConvertTo-LTSecurity + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('ConvertTo-LTSecurity')] Param( @@ -15,7 +38,8 @@ function ConvertTo-CWAASecurity { $Key ) - Begin { + Process { + Write-Debug "Starting $($MyInvocation.InvocationName)" $_initializationVector = [byte[]](240, 3, 45, 29, 0, 76, 173, 59) $DefaultKey = 'Thank you for using LabTech.' @@ -24,27 +48,34 @@ function ConvertTo-CWAASecurity { } try { - $numarray = [System.Text.Encoding]::UTF8.GetBytes($InputString) + $inputBytes = [System.Text.Encoding]::UTF8.GetBytes($InputString) } catch { - try { $numarray = [System.Text.Encoding]::ASCII.GetBytes($InputString) } catch {} + try { $inputBytes = [System.Text.Encoding]::ASCII.GetBytes($InputString) } catch { + Write-Debug "Failed to convert InputString to byte array: $_" + } } - Write-Debug "Line $(LINENUM): Attempting Encode for '$($testInput)' with Key '$($testKey)'" + + Write-Debug "Attempting Encode for '$($InputString)' with Key '$($Key)'" + $encodedString = '' try { - $ddd = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider - $ddd.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($Key)) - $ddd.IV = $_initializationVector - $dd = $ddd.CreateEncryptor() - $str = [System.Convert]::ToBase64String($dd.TransformFinalBlock($numarray, 0, ($numarray.Length))) + $tripleDesProvider = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider + $tripleDesProvider.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($Key)) + $tripleDesProvider.IV = $_initializationVector + $cryptoTransform = $tripleDesProvider.CreateEncryptor() + $encodedString = [System.Convert]::ToBase64String($cryptoTransform.TransformFinalBlock($inputBytes, 0, ($inputBytes.Length))) } catch { - Write-Debug "Line $(LINENUM): Failed to Encode string: '$($InputString)'" - $str = '' + Write-Debug "Failed to Encode string: '$($InputString)'. $_" } Finally { - if ($dd) { try { $dd.Dispose() } catch { $dd.Clear() } } - if ($ddd) { try { $ddd.Dispose() } catch { $ddd.Clear() } } + if ($cryptoTransform) { try { $cryptoTransform.Dispose() } catch { $cryptoTransform.Clear() } } + if ($tripleDesProvider) { try { $tripleDesProvider.Dispose() } catch { $tripleDesProvider.Clear() } } } - return $str + return $encodedString + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 index c4d0b7d..303342a 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 @@ -1,10 +1,71 @@ function Install-CWAA { + <# + .SYNOPSIS + Installs the ConnectWise Automate Agent on the local computer. + .DESCRIPTION + Downloads and installs the ConnectWise Automate agent from the specified server URL. + Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles + .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy + configuration, TrayPort conflict resolution, and post-install agent registration verification. + + If a previous installation is detected, the function will automatically call Uninstall-LTService + before proceeding. The -Force parameter allows installation even when services are already present + or when only .NET 4.0+ is available without 3.5. + .PARAMETER Server + One or more ConnectWise Automate server URLs to download the installer from. + Example: https://automate.domain.com + The function tries each server in order until a successful download occurs. + .PARAMETER ServerPassword + The server password that agents use to authenticate with the Automate server. + Used for legacy deployment method. InstallerToken is preferred. + .PARAMETER InstallerToken + An installer token for authenticated agent deployment. This is the preferred + authentication method over ServerPassword. + See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + .PARAMETER LocationID + The LocationID of the location the agent will be assigned to. + .PARAMETER TrayPort + The local port LTSvc.exe listens on for communication with LTTray processes. + Defaults to 42000. If the port is in use, the function auto-selects the next available port. + .PARAMETER Rename + Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. + .PARAMETER Hide + Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. + .PARAMETER SkipDotNet + Skips .NET Framework 3.5 and 2.0 prerequisite checks. Use when .NET 4.0+ is already installed. + .PARAMETER Force + Disables safety checks including existing service detection and .NET version requirements. + .PARAMETER NoWait + Skips the post-install health check that waits for agent registration. + The function exits immediately after the installer completes. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. + .EXAMPLE + Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 + Installs the agent using an InstallerToken for authentication. + .EXAMPLE + Install-CWAA -Server https://automate.domain.com -ServerPassword 'encryptedpass' -LocationID 1 + Installs the agent using a legacy server password. + .EXAMPLE + Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait + Installs the agent without waiting for registration to complete. + .NOTES + Author: Chris Taylor + Alias: Install-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True, DefaultParameterSetName = 'deployment')] [Alias('Install-LTService')] Param( [Parameter(ParameterSetName = 'deployment')] [Parameter(ParameterSetName = 'installertoken')] [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $True)] + [ValidateScript({ + if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } + else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } + })] [string[]]$Server, [Parameter(ParameterSetName = 'deployment')] [Parameter(ValueFromPipelineByPropertyName = $True)] @@ -26,20 +87,24 @@ function Install-CWAA { [switch]$Hide, [switch]$SkipDotNet, [switch]$Force, - [switch]$NoWait + [switch]$NoWait, + [switch]$SkipCertificateCheck ) Begin { - Clear-Variable DotNET, OSVersion, PasswordArg, Result, logpath, logfile, curlog, installer, installerTest, installerResult, GoodServer, GoodTrayPort, TestTrayPort, Svr, SVer, SvrVer, SvrVerCheck, iarg, timeout, sw, tmpLTSI -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($myInvocation.InvocationName)" + + # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. + # Only runs once per session, skips immediately on subsequent calls. + $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck - if (!($Force)) { + if (-not $Force) { if (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue) { if ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): Services are already installed." -ErrorAction Stop + Write-Error "Services are already installed." -ErrorAction Stop } else { - Write-Error "ERROR: Line $(LINENUM): What if: Stopping: Services are already installed." -ErrorAction Stop + Write-Error "What if: Stopping: Services are already installed." -ErrorAction Stop } } } @@ -48,18 +113,17 @@ function Install-CWAA { Throw 'Needs to be ran as Administrator' } - if (!$SkipDotNet) { + if (-not $SkipDotNet) { $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 if (-not ($DotNet -like '3.5.*')) { Write-Output '.NET Framework 3.5 installation needed.' - #Install-WindowsFeature Net-Framework-Core $OSVersion = [System.Environment]::OSVersion.Version if ([version]$OSVersion -gt [version]'6.2') { Try { - if ( $PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature') ) { + if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' - if (!($Install.State -eq 'EnablePending')) { + if ($Install.State -ne 'EnablePending') { $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart } if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { @@ -68,25 +132,25 @@ function Install-CWAA { } } Catch { - Write-Error "ERROR: Line $(LINENUM): .NET 3.5 install failed." -ErrorAction Continue - if (!($Force)) { Write-Error ("Line $(LINENUM):", $Install) -ErrorAction Stop } + Write-Error ".NET 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Install -ErrorAction Stop } } } Elseif ([version]$OSVersion -gt [version]'6.1') { - if ( $PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature') ) { + if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } if ($Result -contains 'State : Enabled') { - Write-Warning "WARNING: Line $(LINENUM): .Net Framework 3.5 has been installed and enabled." + Write-Warning ".Net Framework 3.5 has been installed and enabled." } Elseif ($Result -contains 'State : Enable Pending') { - Write-Warning "WARNING: Line $(LINENUM): .Net Framework 3.5 installed but a reboot is needed." + Write-Warning ".Net Framework 3.5 installed but a reboot is needed." } else { - Write-Error "ERROR: Line $(LINENUM): .NET Framework 3.5 install failed." -ErrorAction Continue - if (!($Force)) { Write-Error ("ERROR: Line $(LINENUM):", $Result) -ErrorAction Stop } + Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Result -ErrorAction Stop } } } } @@ -95,36 +159,37 @@ function Install-CWAA { } if (-not ($DotNet -like '3.5.*')) { - if (($Force)) { + if ($Force) { if ($DotNet -match '(?m)^[2-4].\d') { - Write-Error "ERROR: Line $(LINENUM): .NET 3.5 is not detected and could not be installed." -ErrorAction Continue + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue } else { - Write-Error "ERROR: Line $(LINENUM): .NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop + Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop } } else { - Write-Error "ERROR: Line $(LINENUM): .NET 3.5 is not detected and could not be installed." -ErrorAction Stop + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop } } } - $InstallBase = "${env:windir}\Temp\LabTech" + $InstallBase = $Script:CWAAInstallerTempPath $logfile = 'LTAgentInstall' - $curlog = "$($InstallBase)\$($logfile).log" - if ($ServerPassword -match '"') {$ServerPassword=$ServerPassword.Replace('"','""')} - if (-not (Test-Path -PathType Container -Path "$InstallBase\Installer" )) { + $curlog = "$InstallBase\$logfile.log" + if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } + if (-not (Test-Path -PathType Container -Path "$InstallBase\Installer")) { New-Item "$InstallBase\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null } - if ((Test-Path -PathType Leaf -Path $($curlog))) { - if ($PSCmdlet.ShouldProcess("$($curlog)", 'Rotate existing log file')) { + if (Test-Path -PathType Leaf -Path $curlog) { + if ($PSCmdlet.ShouldProcess($curlog, 'Rotate existing log file')) { Get-Item -LiteralPath $curlog -EA 0 | Where-Object { $_ } | ForEach-Object { - Rename-Item -Path $($_ | Select-Object -Expand FullName -EA 0) -NewName "$($logfile)-$(Get-Date $($_|Select-Object -Expand LastWriteTime -EA 0) -Format 'yyyyMMddHHmmss').log" -Force -Confirm:$False -WhatIf:$False - Remove-Item -Path $($_ | Select-Object -Expand FullName -EA 0) -Force -EA 0 -Confirm:$False -WhatIf:$False + Rename-Item -Path ($_ | Select-Object -Expand FullName -EA 0) -NewName "$logfile-$(Get-Date ($_ | Select-Object -Expand LastWriteTime -EA 0) -Format 'yyyyMMddHHmmss').log" -Force -Confirm:$False -WhatIf:$False + Remove-Item -Path ($_ | Select-Object -Expand FullName -EA 0) -Force -EA 0 -Confirm:$False -WhatIf:$False } } } } + Process { if (-not ($LocationID -or $PSCmdlet.ParameterSetName -eq 'installertoken')) { $LocationID = '1' @@ -132,126 +197,112 @@ function Install-CWAA { if (-not ($TrayPort) -or -not ($TrayPort -ge 1 -and $TrayPort -le 65535)) { $TrayPort = '42000' } - $Server = ForEach ($Svr in $Server) { if ($Svr -notmatch 'https?://.+') { "https://$($Svr)" }; $Svr } - ForEach ($Svr in $Server) { - if (-not ($GoodServer)) { - if ($Svr -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { - $InstallMSI='Agent_Install.msi' - if ($Svr -notmatch 'https?://.+') { $Svr = "http://$($Svr)" } - Try { - $SvrVerCheck = "$($Svr)/LabTech/Agent.aspx" - Write-Debug "Line $(LINENUM): Testing Server Response and Version: $SvrVerCheck" - $SvrVer = $Script:LTServiceNetWebClient.DownloadString($SvrVerCheck) - Write-Debug "Line $(LINENUM): Raw Response: $SvrVer" - $SVer = $SvrVer|select-string -pattern '(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}'|ForEach-Object {$_.matches}|Select-Object -Expand value -EA 0 - if ($Null -eq $SVer) { - Write-Verbose "Unable to test version response from $($Svr)." - Continue - } - - if (($PSCmdlet.ParameterSetName -eq 'installertoken')) { - $installer = "$($Svr)/LabTech/Deployment.aspx?InstallerToken=$InstallerToken" - if ([System.Version]$SVer -ge [System.Version]'240.331') { - Write-Debug "Line $(LINENUM): New MSI Installer Format Needed" - $InstallMSI='Agent_Install.zip' - } - } - Elseif ($ServerPassword) { - $installer = "$($Svr)/LabTech/Service/LabTechRemoteAgent.msi" - } - Elseif ([System.Version]$SVer -ge [System.Version]'110.374') { - #New Style Download Link starting with LT11 Patch 13 - Direct Location Targeting is no longer available - $installer = "$($Svr)/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=1" - } - else { - #Original URL - Write-Warning 'Update your damn server!' - $installer = "$($Svr)/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=$LocationID" - } + # Resolve the first reachable server and its advertised version + $serverResult = Resolve-CWAAServer -Server $Server + if ($serverResult) { + $serverUrl = $serverResult.ServerUrl + $serverVersion = $serverResult.ServerVersion + } - # Vuln test June 10, 2020: ConnectWise Automate API Vulnerability - Only test if version is below known minimum. - if ([System.Version]$SVer -lt [System.Version]'200.197') { - Try{ - $HTTP_Request = [System.Net.WebRequest]::Create("$($Svr)/LabTech/Deployment.aspx") - if ($HTTP_Request.GetResponse().StatusCode -eq 'OK') { - $Message = @('Your server is vulnerable!!') - $Message += 'https://docs.connectwise.com/ConnectWise_Automate/ConnectWise_Automate_Supportability_Statements/Supportability_Statement%3A_ConnectWise_Automate_Mitigation_Steps' - Write-Warning $($Message | Out-String) - } - } - Catch { - if (!$ServerPassword) { - Write-Error 'Anonymous downloads are not allowed. ServerPassword or InstallerToken may be needed.' - Continue - } - } - } + if ($serverResult) { + $InstallMSI = 'Agent_Install.msi' - if ( $PSCmdlet.ShouldProcess($installer, 'DownloadFile') ) { - Write-Debug "Line $(LINENUM): Downloading $InstallMSI from $installer" - $Script:LTServiceNetWebClient.DownloadFile($installer, "$InstallBase\Installer\$InstallMSI") - if ((Test-Path "$InstallBase\Installer\$InstallMSI") -and !((Get-Item "$InstallBase\Installer\$InstallMSI" -EA 0).length / 1KB -gt 1234)) { - Write-Warning "WARNING: Line $(LINENUM): $InstallMSI size is below normal. Removing suspected corrupt file." - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - Continue - } - } + # Server version detection and installer URL selection: + # The download URL and installer format vary by server version and auth method. + # - v240.331+: InstallerToken deployments use a ZIP containing MSI+MST (new format) + # - v110.374+: Anonymous MSI download changed; direct location targeting removed (LT11 Patch 13) + # - v200.197+: Fixed a critical API vulnerability (CVE, June 2020) that allowed + # unauthenticated access to Deployment.aspx. Servers below this version get a warning. + # - Pre-110.374: Legacy deployment URL with per-location MSI targeting + if ($PSCmdlet.ParameterSetName -eq 'installertoken') { + $installer = "$serverUrl/LabTech/Deployment.aspx?InstallerToken=$InstallerToken" + if ([System.Version]$serverVersion -ge [System.Version]'240.331') { + Write-Debug "New MSI Installer Format Needed" + $InstallMSI = 'Agent_Install.zip' + } + } + Elseif ($ServerPassword) { + $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" + } + Elseif ([System.Version]$serverVersion -ge [System.Version]'110.374') { + $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=1" + } + else { + Write-Warning 'The server version is not supported. Please update your Automate server.' + $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=$LocationID" + } - if ($WhatIfPreference -eq $True) { - $GoodServer = $Svr - } - Elseif (Test-Path "$InstallBase\Installer\$InstallMSI") { - $GoodServer = $Svr - Write-Verbose "$InstallMSI downloaded successfully from server $($Svr)." - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$SVer -ge [System.Version]'240.331') { - Expand-Archive "$InstallBase\Installer\$InstallMSI" -DestinationPath "$InstallBase\Installer" -Force - #Cleanup .ZIP - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - #Reset InstallMSI Value - $InstallMSI='Agent_Install.msi' - } - } - else { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr). No installation file was received." - Continue - } + # Vulnerability test June 10, 2020: ConnectWise Automate API Vulnerability + # Servers below v200.197 may allow unauthenticated access to Deployment.aspx + if ([System.Version]$serverVersion -lt [System.Version]'200.197') { + Try { + $HTTP_Request = [System.Net.WebRequest]::Create("$serverUrl/LabTech/Deployment.aspx") + if ($HTTP_Request.GetResponse().StatusCode -eq 'OK') { + $Message = @('Your server is vulnerable!!') + $Message += 'https://docs.connectwise.com/ConnectWise_Automate/ConnectWise_Automate_Supportability_Statements/Supportability_Statement%3A_ConnectWise_Automate_Mitigation_Steps' + Write-Warning ($Message | Out-String) } - Catch { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr)." - Continue + } + Catch { + if (-not $ServerPassword) { + Write-Error 'Anonymous downloads are not allowed. ServerPassword or InstallerToken may be needed.' } } - else { - Write-Warning "WARNING: Line $(LINENUM): Server address $($Svr) is not formatted correctly. Example: https://lt.domain.com" + } + + if ($PSCmdlet.ShouldProcess($installer, 'DownloadFile')) { + Write-Debug "Downloading $InstallMSI from $installer" + $Script:LTServiceNetWebClient.DownloadFile($installer, "$InstallBase\Installer\$InstallMSI") + if (-not (Test-CWAADownloadIntegrity -FilePath "$InstallBase\Installer\$InstallMSI" -FileName $InstallMSI)) { + $serverResult = $null } } - else { - Write-Debug "Line $(LINENUM): Server $($GoodServer) has been selected." - Write-Verbose "Server has already been selected - Skipping $($Svr)." + + if ($serverResult) { + if ($WhatIfPreference -eq $True) { + $GoodServer = $serverUrl + } + Elseif (Test-Path "$InstallBase\Installer\$InstallMSI") { + $GoodServer = $serverUrl + Write-Verbose "$InstallMSI downloaded successfully from server $serverUrl." + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { + Expand-Archive "$InstallBase\Installer\$InstallMSI" -DestinationPath "$InstallBase\Installer" -Force + Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False + $InstallMSI = 'Agent_Install.msi' + } + } + else { + Write-Warning "Error encountered downloading from $serverUrl. No installation file was received." + } } } } + End { if ($GoodServer) { - if ( $WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -eq 'Redo-LTService' ) { - Write-Debug "Line $(LINENUM): Skipping Preinstall Check: Called by Redo-LTService and ""-WhatIf=`$True""" + if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { + Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" } else { - if ((Test-Path "${env:windir}\ltsvc" -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { - Write-Warning "WARNING: Line $(LINENUM): Previous installation detected. Calling Uninstall-LTService" - Uninstall-LTService -Server $GoodServer -Force + if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { + Write-Warning "Previous installation detected. Calling Uninstall-CWAA" + Uninstall-CWAA -Server $GoodServer -Force Start-Sleep 10 } } if ($WhatIfPreference -ne $True) { - $GoodTrayPort = $Null; - $TestTrayPort = $TrayPort; + # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) + # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. + # If the requested port is occupied by another process, we scan sequentially through + # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. + $GoodTrayPort = $Null + $TestTrayPort = $TrayPort For ($i = 0; $i -le 10; $i++) { - if (-not ($GoodTrayPort)) { - if (-not (Test-LTPorts -TrayPort $TestTrayPort -Quiet)) { - $TestTrayPort++; + if (-not $GoodTrayPort) { + if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { + $TestTrayPort++ if ($TestTrayPort -gt 42009) { $TestTrayPort = 42000 } } else { @@ -260,145 +311,150 @@ function Install-CWAA { } } if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { - Write-Verbose "TrayPort $($TrayPort) is in use. Changing TrayPort to $($GoodTrayPort)" + Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" $TrayPort = $GoodTrayPort } Write-Output 'Starting Install.' } - #Build parameter string - $iarg =($( + # Build parameter string + $installerArguments = ($( "/i `"$InstallBase\Installer\$InstallMSI`"" "SERVERADDRESS=$GoodServer" - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$SVer -ge [System.Version]'240.331') {"TRANSFORMS=`"Agent_Install.mst`""} - if ($ServerPassword -and $ServerPassword -match '.') {"SERVERPASS=`"$($ServerPassword)`""} - if ($LocationID -and $LocationID -match '^\d+$') {"LOCATION=$LocationID"} - if ($TrayPort -and $TrayPort -ne 42000) {"SERVICEPORT=$TrayPort"} + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { "TRANSFORMS=`"Agent_Install.mst`"" } + if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } + if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } + if ($TrayPort -and $TrayPort -ne 42000) { "SERVICEPORT=$TrayPort" } "/qn" "/l `"$InstallBase\$logfile.log`"" - ) | Where-Object {$_}) -join ' ' + ) | Where-Object { $_ }) -join ' ' Try { - if ( $PSCmdlet.ShouldProcess("msiexec.exe $($iarg)", 'Execute Install') ) { + if ($PSCmdlet.ShouldProcess("msiexec.exe $installerArguments", 'Execute Install')) { $InstallAttempt = 0 Do { - if ($InstallAttempt -gt 0 ) { - Write-Warning "WARNING: Line $(LINENUM): Service Failed to Install. Retrying in 30 seconds." -WarningAction 'Continue' + if ($InstallAttempt -gt 0) { + Write-Warning "Service Failed to Install. Retrying in 30 seconds." -WarningAction 'Continue' $timeout = New-TimeSpan -Seconds 30 - $sw = [diagnostics.stopwatch]::StartNew() + $stopwatch = [diagnostics.stopwatch]::StartNew() + Write-Verbose 'Waiting for service to become available...' Do { Start-Sleep 5 - $svcRun = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($sw.elapsed -gt $timeout -or $svcRun -eq 1) - $sw.Stop() + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) + $stopwatch.Stop() + Write-Verbose 'Service wait completed.' } $InstallAttempt++ - $svcRun = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - if ($svcRun -eq 0) { - Write-Verbose "Launching Installation Process: msiexec.exe $(($iarg -join ''))" - Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $iarg -WorkingDirectory $env:TEMP + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + if ($runningServiceCount -eq 0) { + $redactedArguments = ($installerArguments -join '') -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' + Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" + Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $installerArguments -WorkingDirectory $env:TEMP Start-Sleep 5 } - $svcRun = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($InstallAttempt -ge 3 -or $svcRun -eq 1) - if ($svcRun -eq 0) { - Write-Error "ERROR: Line $(LINENUM): LTService was not installed. Installation failed." + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + } Until ($InstallAttempt -ge 3 -or $runningServiceCount -eq 1) + if ($runningServiceCount -eq 0) { + Write-Error "LTService was not installed. Installation failed." Return } } if (($Script:LTProxy.Enabled) -eq $True) { Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' - if ( $PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy') ) { - $svcRun = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - if ($svcRun -ne 0) { + if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count + if ($runningServiceCount -ne 0) { $timeout = New-TimeSpan -Minutes 2 - $sw = [diagnostics.stopwatch]::StartNew() - Write-Host -NoNewline 'Waiting for Service to Start.' + $stopwatch = [diagnostics.stopwatch]::StartNew() + Write-Verbose 'Waiting for service to start...' Do { - Write-Host -NoNewline '.' Start-Sleep 2 - $svcRun = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($sw.elapsed -gt $timeout -or $svcRun -eq 1) - Write-Host '' - $sw.Stop() - if ($svcRun -eq 1) { - Write-Debug "Line $(LINENUM): LTService Initial Startup Successful." + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count + } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) + $stopwatch.Stop() + if ($runningServiceCount -eq 1) { + Write-Debug "LTService Initial Startup Successful." } else { - Write-Debug "Line $(LINENUM): LTService Initial Startup failed to complete within expected period." + Write-Debug "LTService Initial Startup failed to complete within expected period." } + Write-Verbose 'Service wait completed.' } - Set-LTProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False + Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False } } else { Write-Verbose 'No Proxy Configuration has been specified - Continuing.' } - if (!($NoWait) -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration') ) { + if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { $timeout = New-TimeSpan -Minutes 15 - $sw = [diagnostics.stopwatch]::StartNew() - Write-Host -NoNewline 'Waiting for agent to register.' + $stopwatch = [diagnostics.stopwatch]::StartNew() + Write-Verbose 'Waiting for agent to register...' Do { - Write-Host -NoNewline '.' Start-Sleep 5 - $tmpLTSI = (Get-LTServiceInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0) - } Until ($sw.elapsed -gt $timeout -or $tmpLTSI -ge 1) - Write-Host '' - $sw.Stop() - Write-Verbose "Completed wait for LabTech Installation after $(([int32]$sw.Elapsed.TotalSeconds).ToString()) seconds." - $Null = Get-LTProxy -ErrorAction Continue + $tempServiceInfo = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0) + } Until ($stopwatch.elapsed -gt $timeout -or $tempServiceInfo -ge 1) + $stopwatch.Stop() + Write-Verbose "Agent registration wait completed after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." + $Null = Get-CWAAProxy -ErrorAction Continue } - if ($Hide) { Hide-LTAddRemove } + if ($Hide) { Hide-CWAAAddRemove } } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the install process. $($Error[0])" + Write-Error "There was an error during the install process. $_" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" Return } if ($WhatIfPreference -ne $True) { - #Cleanup Install files + # Cleanup install files Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False - @($curlog, "${env:windir}\LTSvc\Install.log") | ForEach-Object { - if ((Test-Path -PathType Leaf -LiteralPath $($_))) { + @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { + if (Test-Path -PathType Leaf -LiteralPath $_) { $logcontents = Get-Content -Path $_ $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } } } - $tmpLTSI = Get-LTServiceInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if (($tmpLTSI)) { - if (($tmpLTSI | Select-Object -Expand 'ID' -EA 0) -ge 1) { - Write-Output "LabTech has been installed successfully. Agent ID: $($tmpLTSI|Select-Object -Expand 'ID' -EA 0) LocationID: $($tmpLTSI|Select-Object -Expand 'LocationID' -EA 0)" + $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($tempServiceInfo) { + if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { + Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" + Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" } - Elseif (!($NoWait)) { - Write-Error "ERROR: Line $(LINENUM): LabTech installation completed but Agent failed to register within expected period." -ErrorAction Continue + Elseif (-not $NoWait) { + Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue + Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." } else { - Write-Warning "WARNING: Line $(LINENUM): LabTech installation completed but Agent did not yet register." -WarningAction Continue + Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue } } else { - if (($Error)) { - Write-Error "ERROR: Line $(LINENUM): There was an error installing LabTech. Check the log, $InstallBase\$logfile.log $($Error[0])" + if ($Error) { + Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" Return } - Elseif (!($NoWait)) { - Write-Error "ERROR: Line $(LINENUM): There was an error installing LabTech. Check the log, $InstallBase\$logfile.log" + Elseif (-not $NoWait) { + Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" Return } else { - Write-Warning "WARNING: Line $(LINENUM): LabTech installation may not have succeeded." -WarningAction Continue + Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue } } } - if (($Rename) -and $Rename -notmatch 'False') { Rename-LTAddRemove -Name $Rename } + if ($Rename -and $Rename -notmatch 'False') { Rename-CWAAAddRemove -Name $Rename } } - Elseif ( $WhatIfPreference -ne $True ) { - Write-Error "ERROR: Line $(LINENUM): No valid server was reached to use for the install." + Elseif ($WhatIfPreference -ne $True) { + Write-Error "No valid server was reached to use for the install." } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($myInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 index 810bbd9..9c4a239 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 @@ -1,4 +1,57 @@ function Redo-CWAA { + <# + .SYNOPSIS + Reinstalls the ConnectWise Automate Agent on the local computer. + .DESCRIPTION + Performs a complete reinstall of the ConnectWise Automate Agent by uninstalling and then + reinstalling the agent. The function attempts to retrieve current settings (server, location, + etc.) from the existing installation or from a backup. If settings cannot be determined + automatically, the function will prompt for the required parameters. + + The reinstall process: + 1. Reads current agent settings from registry or backup + 2. Uninstalls the existing agent via Uninstall-CWAA + 3. Waits 20 seconds for the uninstall to settle + 4. Installs a fresh agent via Install-CWAA with the gathered settings + .PARAMETER Server + One or more ConnectWise Automate server URLs. + Example: https://automate.domain.com + If not provided, the function reads the server URL from the current agent configuration + or backup settings. If neither is available, prompts interactively. + .PARAMETER ServerPassword + The server password for agent authentication. InstallerToken is preferred. + .PARAMETER InstallerToken + An installer token for authenticated agent deployment. This is the preferred + authentication method over ServerPassword. + See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + .PARAMETER LocationID + The LocationID of the location the agent will be assigned to. + If not provided, reads from the current agent configuration or prompts interactively. + .PARAMETER Backup + Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. + .PARAMETER Hide + Hides the agent entry from Add/Remove Programs after reinstallation. + .PARAMETER Rename + Renames the agent entry in Add/Remove Programs after reinstallation. + .PARAMETER SkipDotNet + Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. + .PARAMETER Force + Forces reinstallation even when a probe agent is detected. + .EXAMPLE + Redo-CWAA + Reinstalls the agent using settings from the current installation registry. + .EXAMPLE + Redo-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 + Reinstalls the agent with explicitly provided settings. + .EXAMPLE + Redo-CWAA -Backup -Force + Backs up settings, then forces reinstallation even if a probe agent is detected. + .NOTES + Author: Chris Taylor + Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Reinstall-CWAA', 'Redo-LTService', 'Reinstall-LTService')] Param( @@ -8,13 +61,13 @@ [Parameter(ParameterSetName = 'deployment')] [Parameter(ValueFromPipelineByPropertyName = $True, ValueFromPipeline = $True)] [Alias('Password')] - [SecureString]$ServerPassword, + [string]$ServerPassword, [Parameter(ParameterSetName = 'installertoken')] [ValidatePattern('(?s:^[0-9a-z]+$)')] [string]$InstallerToken, [Parameter(ValueFromPipelineByPropertyName = $True)] [AllowNull()] - [string]$LocationID, + [int]$LocationID, [switch]$Backup, [switch]$Hide, [Parameter()] @@ -25,66 +78,68 @@ ) Begin { - Clear-Variable PasswordArg, RenameArg, Svr, ServerList, Settings -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($myInvocation.InvocationName)" - # Gather install stats from registry or backed up settings + # Gather install settings from registry or backed up settings + $Settings = $Null Try { $Settings = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False - if ($Null -ne $Settings) { - if (($Settings | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { - Write-Output 'Probe Agent Detected. Re-Install Forced.' - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"ERROR: Line $(LINENUM): Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - else { - Write-Error -Exception [System.OperationCanceledException]"What If: Line $(LINENUM): Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - } - } - } } Catch { - Write-Debug "Line $(LINENUM): Failed to retrieve current Agent Settings." + Write-Debug "Failed to retrieve current Agent Settings: $_" + } + + # Probe protection — outside Try/Catch so the terminating error propagates to caller. + # Matches the pattern in Reset-CWAA and Uninstall-CWAA. + if ($Null -ne $Settings -and ($Settings | Select-Object -Expand Probe -EA 0) -eq '1') { + if ($Force -eq $True) { + Write-Output 'Probe Agent Detected. Re-Install Forced.' + } + else { + if ($WhatIfPreference -ne $True) { + Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Re-Install Denied." -ErrorAction Stop + } + else { + Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Re-Install Denied." -ErrorAction Stop + } + } } if ($Null -eq $Settings) { - Write-Debug "Line $(LINENUM): Unable to retrieve current Agent Settings. Testing for Backup Settings" + Write-Debug "Unable to retrieve current Agent Settings. Testing for Backup Settings." Try { $Settings = Get-CWAAInfoBackup -EA 0 } - Catch {} + Catch { Write-Debug "Failed to retrieve backup Agent Settings: $_" } } $ServerList = @() } Process { - if (-not ($Server)) { + if (-not $Server) { if ($Settings) { $Server = $Settings | Select-Object -Expand 'Server' -EA 0 } - if (-not ($Server)) { - $Server = Read-Host -Prompt 'Provide the URL to your LabTech server (https://automate.domain.com):' + if (-not $Server) { + $Server = Read-Host -Prompt 'Provide the URL to your Automate server (https://automate.domain.com):' } } - if (-not ($LocationID)) { + if (-not $LocationID) { if ($Settings) { $LocationID = $Settings | Select-Object -Expand LocationID -EA 0 } - if (-not ($LocationID)) { + if (-not $LocationID) { $LocationID = Read-Host -Prompt 'Provide the LocationID' } } - if (-not ($LocationID)) { + if (-not $LocationID) { $LocationID = '1' } $ServerList += $Server } + End { if ($Backup) { - if ( $PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings') ) { + if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { New-CWAABackup } } @@ -97,20 +152,19 @@ if ($PSCmdlet.ParameterSetName -eq 'installertoken') { $PasswordPresent = "-InstallerToken 'REDACTED'" } - Elseif (($ServerPassword)) { + Elseif ($ServerPassword) { $PasswordPresent = "-Password 'REDACTED'" } - Write-Output "Reinstalling LabTech with the following information, -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID $RenameArg" + Write-Output "Reinstalling Automate agent with the following information, -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID $RenameArg" Write-Verbose "Starting: UnInstall-CWAA -Server $($ServerList -join ',')" Try { Uninstall-CWAA -Server $ServerList -ErrorAction Stop -Force } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the reinstall process while uninstalling. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 1022 -EntryType Error -Message "Agent reinstall failed during uninstall phase. Error: $($_.Exception.Message)" + Write-Error "There was an error during the reinstall process while uninstalling. $_" -ErrorAction Stop } - Finally { if ($WhatIfPreference -ne $True) { Write-Verbose 'Waiting 20 seconds for prior uninstall to settle before starting Install.' @@ -118,7 +172,7 @@ } } - Write-Verbose "Starting: Install-CWAA -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID -Hide:`$$($Hide) $RenameArg" + Write-Verbose "Starting: Install-CWAA -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID -Hide:`$$Hide $RenameArg" Try { if ($PSCmdlet.ParameterSetName -ne 'installertoken') { Install-CWAA -Server $ServerList -ServerPassword $ServerPassword -LocationID $LocationID -Hide:$Hide -Rename $Rename -SkipDotNet:$SkipDotNet -Force @@ -128,12 +182,11 @@ } } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the reinstall process while installing. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 1022 -EntryType Error -Message "Agent reinstall failed during install phase. Error: $($_.Exception.Message)" + Write-Error "There was an error during the reinstall process while installing. $_" -ErrorAction Stop } - if (!($?)) { - $($Error[0]) - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-CWAAEventLog -EventId 1020 -EntryType Information -Message "Agent reinstalled successfully. Server: $($ServerList -join ','), LocationID: $LocationID" + Write-Debug "Exiting $($myInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 index 2c0ef12..1681648 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 @@ -1,4 +1,62 @@ -function Uninstall-CWAA { +function Uninstall-CWAA { + <# + .SYNOPSIS + Completely uninstalls the ConnectWise Automate Agent from the local computer. + .DESCRIPTION + Performs a comprehensive removal of the ConnectWise Automate Agent from a Windows computer. + This function is more thorough than a standard MSI uninstall, as it also removes residual + files, registry keys, and services that may not be cleaned up by the normal uninstall process. + + The uninstall process performs the following operations: + 1. Downloads official uninstaller files (Agent_Uninstall.msi and Agent_Uninstall.exe) from the server + 2. Optionally creates a backup of the current agent installation (if -Backup is specified) + 3. Stops all running agent services (LTService, LTSvcMon, LabVNC) + 4. Terminates any running agent processes + 5. Unregisters the wodVPN.dll component + 6. Runs the MSI uninstaller (Agent_Uninstall.msi) + 7. Runs the agent uninstaller executable (Agent_Uninstall.exe) + 8. Removes agent Windows services + 9. Removes all agent files from the installation directory + 10. Removes all agent-related registry keys (over 30 different registry locations) + 11. Verifies the uninstall was successful + + Probe Agent Protection: By default, this function will refuse to uninstall probe agents to + prevent accidental removal of critical infrastructure. Use -Force to override this protection. + .PARAMETER Server + One or more ConnectWise Automate server URLs to download uninstaller files from. + If not specified, reads the server URL from the agent's current registry configuration. + If that fails, prompts interactively for a server URL. + Example: https://automate.domain.com + .PARAMETER Backup + Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. + .PARAMETER Force + Forces uninstallation even when a probe agent is detected. Use with extreme caution, + as probe agents are typically critical infrastructure components. + .EXAMPLE + Uninstall-CWAA + Uninstalls the agent using the server URL from the agent's registry settings. + .EXAMPLE + Uninstall-CWAA -Backup + Creates a backup of the agent installation before uninstalling. + .EXAMPLE + Uninstall-CWAA -Server "https://automate.company.com" + Uninstalls using the specified server URL to download uninstaller files. + .EXAMPLE + Uninstall-CWAA -Server "https://primary.company.com","https://backup.company.com" + Provides multiple server URLs with fallback. Tries each until uninstaller files download successfully. + .EXAMPLE + Uninstall-CWAA -Force + Forces uninstallation even if a probe agent is detected. + .EXAMPLE + Uninstall-CWAA -WhatIf + Simulates the uninstall process without making any actual changes. + .NOTES + Author: Chris Taylor + Alias: Uninstall-LTService + Requires: Administrator privileges + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Uninstall-LTService')] Param( @@ -7,35 +65,39 @@ function Uninstall-CWAA { [string[]]$Server, [Parameter(ValueFromPipelineByPropertyName = $true)] [switch]$Backup, - [switch]$Force + [switch]$Force, + [switch]$SkipCertificateCheck ) Begin { - Clear-Variable Executables, BasePath, reg, regs, installer, installerTest, installerResult, LTSI, uninstaller, uninstallerTest, uninstallerResult, xarg, Svr, SVer, SvrVer, SvrVerCheck, GoodServer, AlternateServer, Item -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($myInvocation.InvocationName)" + + # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. + # Only runs once per session, skips immediately on subsequent calls. + $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck if (-not ([bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544'))) { - Throw "Line $(LINENUM): Needs to be ran as Administrator" + Throw "Needs to be ran as Administrator" } - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if (($LTSI) -and ($LTSI | Select-Object -Expand Probe -EA 0) -eq '1') { + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { if ($Force -eq $True) { Write-Output 'Probe Agent Detected. UnInstall Forced.' } else { - Write-Error -Exception [System.OperationCanceledException]"Line $(LINENUM): Probe Agent Detected. UnInstall Denied." -ErrorAction Stop + Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. UnInstall Denied." -ErrorAction Stop } } if ($Backup) { - if ( $PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings') ) { + if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { New-CWAABackup } } - $BasePath = $(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0) - if (-not ($BasePath)) { $BasePath = "$env:windir\LTSVC" } + $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 + if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } New-PSDrive HKU Registry HKEY_USERS -ErrorAction SilentlyContinue -WhatIf:$False -Confirm:$False -Debug:$False | Out-Null $regs = @( 'Registry::HKEY_LOCAL_MACHINE\Software\LabTechMSP', @@ -73,128 +135,91 @@ function Uninstall-CWAA { ) if ($WhatIfPreference -ne $True) { - #Cleanup previous uninstallers Remove-Item 'Uninstall.exe', 'Uninstall.exe.config' -ErrorAction SilentlyContinue -Force -Confirm:$False - New-Item "$env:windir\temp\LabTech\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null + New-Item "$Script:CWAAInstallerTempPath\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null } - $xarg = "/x ""$($env:windir)\temp\LabTech\Installer\Agent_Uninstall.msi"" /qn" + $uninstallArguments = "/x ""$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi"" /qn" } Process { - if (-not ($Server)) { + if (-not $Server) { $Server = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'Server' -EA 0 } - if (-not ($Server)) { - $Server = Read-Host -Prompt 'Provide the URL to your LabTech server (https://automate.domain.com):' + if (-not $Server) { + $Server = Read-Host -Prompt 'Provide the URL to your Automate server (https://automate.domain.com):' } - $Server = ForEach ($Svr in $Server) { if ($Svr -notmatch 'https?://.+') { "https://$($Svr)" }; $Svr } - ForEach ($Svr in $Server) { - if (-not ($GoodServer)) { - if ($Svr -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { - Try { - if ($Svr -notmatch 'https?://.+') { $Svr = "http://$($Svr)" } - $SvrVerCheck = "$($Svr)/Labtech/Agent.aspx" - Write-Debug "Line $(LINENUM): Testing Server Response and Version: $SvrVerCheck" - $SvrVer = $Script:LTServiceNetWebClient.DownloadString($SvrVerCheck) - Write-Debug "Line $(LINENUM): Raw Response: $SvrVer" - $SVer = $SvrVer | Select-String -Pattern '(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}' | ForEach-Object { $_.matches } | Select-Object -Expand value -EA 0 - if ($Null -eq ($SVer)) { - Write-Verbose "Unable to test version response from $($Svr)." - Continue - } - $installer = "$($Svr)/LabTech/Service/LabTechRemoteAgent.msi" - $installerTest = [System.Net.WebRequest]::Create($installer) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Line $(LINENUM): Proxy Configuration Needed. Applying Proxy Settings to request." - $installerTest.Proxy = $Script:LTWebProxy - } - $installerTest.KeepAlive = $False - $installerTest.ProtocolVersion = '1.0' - $installerResult = $installerTest.GetResponse() - $installerTest.Abort() - if ($installerResult.StatusCode -ne 200) { - Write-Warning "WARNING: Line $(LINENUM): Unable to download Agent_Uninstall.msi from server $($Svr)." - Continue - } - else { - if ($PSCmdlet.ShouldProcess("$installer", 'DownloadFile')) { - Write-Debug "Line $(LINENUM): Downloading Agent_Uninstall.msi from $installer" - $Script:LTServiceNetWebClient.DownloadFile($installer, "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi") - if ((Test-Path "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi")) { - if (!((Get-Item "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi" -EA 0).length / 1KB -gt 1234)) { - Write-Warning "WARNING: Line $(LINENUM): Agent_Uninstall.msi size is below normal. Removing suspected corrupt file." - Remove-Item "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi" -ErrorAction SilentlyContinue -Force -Confirm:$False - Continue - } - else { - $AlternateServer = $Svr - } - } - } - } + # Resolve the first reachable server and its advertised version + $serverResult = Resolve-CWAAServer -Server $Server + if (-not $serverResult) { return } + $serverUrl = $serverResult.ServerUrl - #Using $SVer results gathered above. - if ([System.Version]$SVer -ge [System.Version]'110.374') { - #New Style Download Link starting with LT11 Patch 13 - The Agent Uninstaller URI has changed. - $uninstaller = "$($Svr)/LabTech/Service/LabUninstall.exe" - } - else { - #Original Uninstaller URL - $uninstaller = "$($Svr)/LabTech/Service/LabUninstall.exe" - } - $uninstallerTest = [System.Net.WebRequest]::Create($uninstaller) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Line $(LINENUM): Proxy Configuration Needed. Applying Proxy Settings to request." - $uninstallerTest.Proxy = $Script:LTWebProxy - } - $uninstallerTest.KeepAlive = $False - $uninstallerTest.ProtocolVersion = '1.0' - $uninstallerResult = $uninstallerTest.GetResponse() - $uninstallerTest.Abort() - if ($uninstallerResult.StatusCode -ne 200) { - Write-Warning "WARNING: Line $(LINENUM): Unable to download Agent_Uninstall from server." - Continue - } - else { - #Download Agent_Uninstall.exe - if ($PSCmdlet.ShouldProcess("$uninstaller", 'DownloadFile')) { - Write-Debug "Line $(LINENUM): Downloading Agent_Uninstall.exe from $uninstaller" - $Script:LTServiceNetWebClient.DownloadFile($uninstaller, "$($env:windir)\temp\Agent_Uninstall.exe") - if ((Test-Path "$($env:windir)\temp\Agent_Uninstall.exe") -and !((Get-Item "$($env:windir)\temp\Agent_Uninstall.exe" -EA 0).length / 1KB -gt 80)) { - Write-Warning "WARNING: Line $(LINENUM): Agent_Uninstall.exe size is below normal. Removing suspected corrupt file." - Remove-Item "$($env:windir)\temp\Agent_Uninstall.exe" -ErrorAction SilentlyContinue -Force -Confirm:$False - Continue - } - } - } - if ($WhatIfPreference -eq $True) { - $GoodServer = $Svr - } - Elseif ((Test-Path "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi") -and (Test-Path "$($env:windir)\temp\Agent_Uninstall.exe")) { - $GoodServer = $Svr - Write-Verbose "Successfully downloaded files from $($Svr)." - } - else { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr). Uninstall file(s) could not be received." - Continue - } - } - Catch { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr)." - Continue - } + Try { + # Download the uninstall MSI (same URL for all server versions) + $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" + $installerTest = [System.Net.WebRequest]::Create($installer) + if (($Script:LTProxy.Enabled) -eq $True) { + Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." + $installerTest.Proxy = $Script:LTWebProxy + } + $installerTest.KeepAlive = $False + $installerTest.ProtocolVersion = '1.0' + $installerResult = $installerTest.GetResponse() + $installerTest.Abort() + if ($installerResult.StatusCode -ne 200) { + Write-Warning "Unable to download Agent_Uninstall.msi from server $serverUrl." + return + } + + if ($PSCmdlet.ShouldProcess("$installer", 'DownloadFile')) { + Write-Debug "Downloading Agent_Uninstall.msi from $installer" + $Script:LTServiceNetWebClient.DownloadFile($installer, "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") + if (-not (Test-CWAADownloadIntegrity -FilePath "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi" -FileName 'Agent_Uninstall.msi')) { + return } - else { - Write-Verbose "Server address $($Svr) is not formatted correctly. Example: https://automate.domain.com" + $AlternateServer = $serverUrl + } + + # Download the uninstall EXE (same URI for all versions) + $uninstaller = "$serverUrl/LabTech/Service/LabUninstall.exe" + $uninstallerTest = [System.Net.WebRequest]::Create($uninstaller) + if (($Script:LTProxy.Enabled) -eq $True) { + Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." + $uninstallerTest.Proxy = $Script:LTWebProxy + } + $uninstallerTest.KeepAlive = $False + $uninstallerTest.ProtocolVersion = '1.0' + $uninstallerResult = $uninstallerTest.GetResponse() + $uninstallerTest.Abort() + if ($uninstallerResult.StatusCode -ne 200) { + Write-Warning "Unable to download Agent_Uninstall from server." + return + } + + 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 + if (-not (Test-CWAADownloadIntegrity -FilePath "${env:windir}\temp\Agent_Uninstall.exe" -FileName 'Agent_Uninstall.exe' -MinimumSizeKB 80)) { + return } } + + if ($WhatIfPreference -eq $True) { + $GoodServer = $serverUrl + } + Elseif ((Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") -and (Test-Path "${env:windir}\temp\Agent_Uninstall.exe")) { + $GoodServer = $serverUrl + Write-Verbose "Successfully downloaded files from $serverUrl." + } else { - Write-Debug "Line $(LINENUM): Server $($GoodServer) has been selected." - Write-Verbose "Server has already been selected - Skipping $($Svr)." + Write-Warning "Error encountered downloading from $serverUrl. Uninstall file(s) could not be received." } } + Catch { + Write-Warning "Error encountered downloading from $serverUrl." + } } End { @@ -202,140 +227,134 @@ function Uninstall-CWAA { Try { Write-Output 'Starting Uninstall.' - Try { Stop-CWAA -ErrorAction SilentlyContinue } Catch {} + Try { Stop-CWAA -ErrorAction SilentlyContinue } Catch { Write-Debug "Stop-CWAA encountered an error: $_" } - #Kill all running processes from %ltsvcdir% + # Kill all running processes from %ltsvcdir% if (Test-Path $BasePath) { $Executables = (Get-ChildItem $BasePath -Filter *.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -Expand FullName) if ($Executables) { - Write-Verbose "Terminating LabTech Processes from $($BasePath) if found running: $(($Executables) -replace [Regex]::Escape($BasePath),'' -replace '^\\','')" + Write-Verbose "Terminating Automate agent processes from $BasePath if found running: $(($Executables) -replace [Regex]::Escape($BasePath),'' -replace '^\\','')" Get-Process | Where-Object { $Executables -contains $_.Path } | ForEach-Object { - Write-Debug "Line $(LINENUM): Terminating Process $($_.ProcessName)" - $($_) | Stop-Process -Force -ErrorAction SilentlyContinue + Write-Debug "Terminating Process $($_.ProcessName)" + $_ | Stop-Process -Force -ErrorAction SilentlyContinue } Get-ChildItem $BasePath -Filter labvnc.exe -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction 0 } - if ($PSCmdlet.ShouldProcess("$($BasePath)\wodVPN.dll", 'Unregister DLL')) { - #Unregister DLL - Write-Debug "Line $(LINENUM): Executing Command ""regsvr32.exe /u $($BasePath)\wodVPN.dll /s""" - Try { & "$env:windir\system32\regsvr32.exe" /u "$($BasePath)\wodVPN.dll" /s 2>'' } + if ($PSCmdlet.ShouldProcess("$BasePath\wodVPN.dll", 'Unregister DLL')) { + Write-Debug "Executing Command ""regsvr32.exe /u $BasePath\wodVPN.dll /s""" + Try { & "$env:windir\system32\regsvr32.exe" /u "$BasePath\wodVPN.dll" /s 2>'' } Catch { Write-Output 'Error calling regsvr32.exe.' } } } - if ($PSCmdlet.ShouldProcess("msiexec.exe $($xarg)", 'Execute MSI Uninstall')) { - if ((Test-Path "$($env:windir)\temp\LabTech\Installer\Agent_Uninstall.msi")) { - #Run MSI uninstaller for current installation + if ($PSCmdlet.ShouldProcess("msiexec.exe $uninstallArguments", 'Execute MSI Uninstall')) { + if (Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") { Write-Verbose 'Launching MSI Uninstall.' - Write-Debug "Line $(LINENUM): Executing Command ""msiexec.exe $($xarg)""" - Start-Process -Wait -FilePath "$env:windir\system32\msiexec.exe" -ArgumentList $xarg -WorkingDirectory $env:TEMP + Write-Debug "Executing Command ""msiexec.exe $uninstallArguments""" + Start-Process -Wait -FilePath "$env:windir\system32\msiexec.exe" -ArgumentList $uninstallArguments -WorkingDirectory $env:TEMP Start-Sleep -Seconds 5 } else { - Write-Verbose "WARNING: $($env:windir)\temp\LabTech\Installer\Agent_Uninstall.msi was not found." + Write-Verbose "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi was not found." } } - if ($PSCmdlet.ShouldProcess("$($env:windir)\temp\Agent_Uninstall.exe", 'Execute Agent Uninstall')) { - if ((Test-Path "$($env:windir)\temp\Agent_Uninstall.exe")) { - #Run Agent_Uninstall.exe + if ($PSCmdlet.ShouldProcess("${env:windir}\temp\Agent_Uninstall.exe", 'Execute Agent Uninstall')) { + if (Test-Path "${env:windir}\temp\Agent_Uninstall.exe") { + # Remove previously extracted SFX files to prevent UnRAR overwrite prompts + Remove-Item "$env:TEMP\Uninstall.exe", "$env:TEMP\Uninstall.exe.config" -ErrorAction SilentlyContinue -Force -Confirm:$False Write-Verbose 'Launching Agent Uninstaller' - Write-Debug "Line $(LINENUM): Executing Command ""$($env:windir)\temp\Agent_Uninstall.exe""" - Start-Process -Wait -FilePath "$($env:windir)\temp\Agent_Uninstall.exe" -WorkingDirectory $env:TEMP + Write-Debug "Executing Command ""${env:windir}\temp\Agent_Uninstall.exe""" + Start-Process -Wait -FilePath "${env:windir}\temp\Agent_Uninstall.exe" -WorkingDirectory $env:TEMP Start-Sleep -Seconds 5 } else { - Write-Verbose "WARNING: $($env:windir)\temp\Agent_Uninstall.exe was not found." + Write-Verbose "${env:windir}\temp\Agent_Uninstall.exe was not found." } } Write-Verbose 'Removing Services if found.' - #Remove Services @('LTService', 'LTSvcMon', 'LabVNC') | ForEach-Object { if (Get-Service $_ -EA 0) { - if ( $PSCmdlet.ShouldProcess("$($_)", 'Remove Service') ) { - Write-Debug "Line $(LINENUM): Removing Service: $($_)" - Try { & "$env:windir\system32\sc.exe" delete "$($_)" 2>'' } + if ($PSCmdlet.ShouldProcess($_, 'Remove Service')) { + Write-Debug "Removing Service: $_" + Try { + & "$env:windir\system32\sc.exe" delete "$_" 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "sc.exe delete returned exit code $LASTEXITCODE for service '$_'." + } + } Catch { Write-Output 'Error calling sc.exe.' } } } } Write-Verbose 'Cleaning Files remaining if found.' - #Remove %ltsvcdir% - Depth First Removal, First by purging files, then Removing Folders, to get as much removed as possible if complete removal fails - @($BasePath, "$($env:windir)\temp\_ltupdate", "$($env:windir)\temp\_ltupdate") | ForEach-Object { - if ((Test-Path "$($_)" -EA 0)) { - if ( $PSCmdlet.ShouldProcess("$($_)", 'Remove Folder') ) { - Write-Debug "Line $(LINENUM): Removing Folder: $($_)" - Try { - Get-ChildItem -Path $_ -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { ($_.psiscontainer) } | ForEach-Object { Get-ChildItem -Path "$($_.FullName)" -EA 0 | Where-Object { -not ($_.psiscontainer) } | Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False } - Get-ChildItem -Path $_ -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { ($_.psiscontainer) } | Sort-Object { $_.fullname.length } -Descending | Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False - Remove-Item -Recurse -Force -Path $_ -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - } - Catch {} - } + # Depth-first removal to get as much removed as possible if complete removal fails + @($BasePath, "${env:windir}\temp\_ltupdate") | ForEach-Object { + if (Test-Path $_ -EA 0) { + Remove-CWAAFolderRecursive -Path $_ } } - Write-Verbose 'Removing agent installation msi file' + + Write-Verbose 'Removing agent installation msi file.' if ($PSCmdlet.ShouldProcess('Agent_Uninstall.msi', 'Remove File')) { - $MsiPath = "$env:windir\temp\LabTech\Installer\Agent_Uninstall.msi" - try { - do { - $MsiExists = Test-Path $MsiPath + $MsiPath = "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi" + $tries = 0 + Try { + Do { + $MsiExists = Test-Path $MsiPath Start-Sleep -Seconds 10 Remove-Item $MsiPath -ErrorAction SilentlyContinue $tries++ } - while (-not $MsiExists -or $tries -gt 4) + While ($MsiExists -and $tries -lt 4) } - catch { - Write-Verbose ('Unable to remove Agent_Uninstall.msi' -f $_.Exception.Message) + Catch { + Write-Verbose "Unable to remove Agent_Uninstall.msi: $($_.Exception.Message)" } } - Write-Verbose 'Cleaning Registry Keys if found.' - #Remove all registry keys - Depth First Value Removal, then Key Removal, to get as much removed as possible if complete removal fails + # Depth First Value Removal, then Key Removal Foreach ($reg in $regs) { - if ((Test-Path "$($reg)" -EA 0)) { - Write-Debug "Line $(LINENUM): Found Registry Key: $($reg)" - if ( $PSCmdlet.ShouldProcess("$($Reg)", 'Remove Registry Key') ) { + if (Test-Path $reg -EA 0) { + Write-Debug "Found Registry Key: $reg" + if ($PSCmdlet.ShouldProcess($reg, 'Remove Registry Key')) { Try { Get-ChildItem -Path $reg -Recurse -Force -ErrorAction SilentlyContinue | Sort-Object { $_.name.length } -Descending | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False Remove-Item -Recurse -Force -Path $reg -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False } - Catch {} + Catch { Write-Debug "Error removing registry key '$reg': $($_.Exception.Message)" } } } } } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the uninstall process. $($_.Exception.Message)" -ErrorAction Stop + Write-CWAAEventLog -EventId 1012 -EntryType Error -Message "Agent uninstall failed. Error: $($_.Exception.Message)" + Write-Error "There was an error during the uninstall process. $($_.Exception.Message)" -ErrorAction Stop } if ($WhatIfPreference -ne $True) { - if ($?) { - #Post Uninstall Check - If ((Test-Path "$env:windir\ltsvc") -or (Test-Path "$env:windir\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { - Start-Sleep -Seconds 10 - } - If ((Test-Path "$env:windir\ltsvc") -or (Test-Path "$env:windir\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { - Write-Error "ERROR: Line $(LINENUM): Remnants of previous install still detected after uninstall attempt. Please reboot and try again." - } - else { - Write-Output 'LabTech has been successfully uninstalled.' - } + # Post Uninstall Check + If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { + Start-Sleep -Seconds 10 + } + If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { + Write-Error "Remnants of previous install still detected after uninstall attempt. Please reboot and try again." + Write-CWAAEventLog -EventId 1011 -EntryType Warning -Message 'Remnants of previous install detected after uninstall. Reboot recommended.' } else { - $($Error[0]) + Write-Output 'Automate agent has been successfully uninstalled.' + Write-CWAAEventLog -EventId 1010 -EntryType Information -Message 'Agent uninstalled successfully.' } } } Elseif ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): No valid server was reached to use for the uninstall." -ErrorAction Stop + Write-Error "No valid server was reached to use for the uninstall." -ErrorAction Stop } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($myInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 index 02b5624..bfdbd28 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 @@ -1,137 +1,141 @@ function Update-CWAA { + <# + .SYNOPSIS + Manually updates the ConnectWise Automate Agent to a specified version. + .DESCRIPTION + Downloads and applies an agent update from the ConnectWise Automate server. The function + reads the current server configuration from the agent's registry settings, downloads the + appropriate update package, extracts it, and runs the updater. + + If no version is specified, the function uses the version advertised by the server. + The function validates that the requested version is higher than the currently installed + version and not higher than the server version before proceeding. + + The update process: + 1. Reads current agent settings and server information + 2. Downloads the LabtechUpdate.exe for the target version + 3. Stops agent services + 4. Extracts and runs the update + 5. Restarts agent services + .PARAMETER Version + The target agent version to update to. + Example: 120.240 + If omitted, the version advertised by the server will be used. + .EXAMPLE + Update-CWAA -Version 120.240 + Updates the agent to the specific version requested. + .EXAMPLE + Update-CWAA + Updates the agent to the current version advertised by the server. + .NOTES + Author: Darren White + Alias: Update-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Update-LTService')] Param( [parameter(Position = 0)] [AllowNull()] - [string]$Version + [string]$Version, + [switch]$SkipCertificateCheck ) Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - Clear-Variable Svr, GoodServer, Settings -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use + Write-Debug "Starting $($myInvocation.InvocationName)" + + # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. + # Only runs once per session, skips immediately on subsequent calls. + $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck + $Settings = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False $updaterPath = [System.Environment]::ExpandEnvironmentVariables('%windir%\temp\_LTUpdate') - $xarg = @("/o""$updaterPath""", '/y') - $uarg = @("""$updaterPath\Update.ini""") + $extractArguments = @("/o""$updaterPath""", '/y') + $updaterArguments = @("""$updaterPath\Update.ini""") } Process { - if (-not ($Server)) { + if (-not $Server) { if ($Settings) { $Server = $Settings | Select-Object -Expand 'Server' -EA 0 } } - $Server = ForEach ($Svr in $Server) { if ($Svr -notmatch 'https?://.+') { "https://$($Svr)" }; $Svr } - Foreach ($Svr in $Server) { - if (-not ($GoodServer)) { - if ($Svr -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { - if ($Svr -notmatch 'https?://.+') { $Svr = "http://$($Svr)" } - Try { - $SvrVerCheck = "$($Svr)/Labtech/Agent.aspx" - Write-Debug "Line $(LINENUM): Testing Server Response and Version: $SvrVerCheck" - $SvrVer = $Script:LTServiceNetWebClient.DownloadString($SvrVerCheck) - Write-Debug "Line $(LINENUM): Raw Response: $SvrVer" - $SVer = $SvrVer | Select-String -Pattern '(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}' | ForEach-Object { $_.matches } | Select-Object -Expand value -EA 0 - if ($Null -eq ($SVer)) { - Write-Verbose "Unable to test version response from $($Svr)." - Continue - } - if ($Version -match '[1-9][0-9]{2}\.[0-9]{1,3}') { - $updater = "$($Svr)/Labtech/Updates/LabtechUpdate_$($Version).zip" - } - Elseif ([System.Version]$SVer -ge [System.Version]'105.001') { - $Version = $SVer - Write-Verbose "Using detected version ($Version) from server: $($Svr)." - $updater = "$($Svr)/Labtech/Updates/LabtechUpdate_$($Version).zip" - } + # Resolve the first reachable server and its advertised version + if (-not $Server) { return } + $serverResult = Resolve-CWAAServer -Server $Server + if ($serverResult) { + $GoodServer = $serverResult.ServerUrl + $serverVersion = $serverResult.ServerVersion + } - #Kill all running processes from $updaterPath - if (Test-Path $updaterPath) { - $Executables = (Get-ChildItem $updaterPath -Filter *.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -Expand FullName) - if ($Executables) { - Write-Verbose "Terminating LabTech Processes from $($updaterPath) if found running: $(($Executables) -replace [Regex]::Escape($updaterPath),'' -replace '^\\','')" - Get-Process | Where-Object { $Executables -contains $_.Path } | ForEach-Object { - Write-Debug "Line $(LINENUM): Terminating Process $($_.ProcessName)" - $($_) | Stop-Process -Force -ErrorAction SilentlyContinue - } - } - } + if ($GoodServer) { + # Determine the target version and build the update download URL + if ($Version -match '[1-9][0-9]{2}\.[0-9]{1,3}') { + $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" + } + Elseif ([System.Version]$serverVersion -ge [System.Version]'105.001') { + $Version = $serverVersion + Write-Verbose "Using detected version ($Version) from server: $GoodServer." + $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" + } + + # Kill all running processes from $updaterPath before cleanup + if (Test-Path $updaterPath) { + $Executables = (Get-ChildItem $updaterPath -Filter *.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -Expand FullName) + if ($Executables) { + Write-Verbose "Terminating Automate agent processes from $updaterPath if found running: $(($Executables) -replace [Regex]::Escape($updaterPath),'' -replace '^\\','')" + Get-Process | Where-Object { $Executables -contains $_.Path } | ForEach-Object { + Write-Debug "Terminating Process $($_.ProcessName)" + $_ | Stop-Process -Force -ErrorAction SilentlyContinue + } + } + } - #Remove $updaterPath - Depth First Removal, First by purging files, then Removing Folders, to get as much removed as possible if complete removal fails - @("$updaterPath") | ForEach-Object { - if ((Test-Path "$($_)" -EA 0)) { - if ( $PSCmdlet.ShouldProcess("$($_)", 'Remove Folder') ) { - Write-Debug "Line $(LINENUM): Removing Folder: $($_)" - Try { - Get-ChildItem -Path $_ -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { ($_.psiscontainer) } | ForEach-Object { Get-ChildItem -Path "$($_.FullName)" -EA 0 | Where-Object { -not ($_.psiscontainer) } | Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False } - Get-ChildItem -Path $_ -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { ($_.psiscontainer) } | Sort-Object { $_.fullname.length } -Descending | Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False - Remove-Item -Recurse -Force -Path $_ -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - } - Catch {} - } - } + # Remove stale updater directory using depth-first removal + Remove-CWAAFolderRecursive -Path $updaterPath + + Try { + if (-not (Test-Path -PathType Container -Path $updaterPath)) { + New-Item $updaterPath -type directory -ErrorAction SilentlyContinue | Out-Null + } + $updaterTest = [System.Net.WebRequest]::Create($updater) + if (($Script:LTProxy.Enabled) -eq $True) { + Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." + $updaterTest.Proxy = $Script:LTWebProxy + } + $updaterTest.KeepAlive = $False + $updaterTest.ProtocolVersion = '1.0' + $updaterResult = $updaterTest.GetResponse() + $updaterTest.Abort() + if ($updaterResult.StatusCode -ne 200) { + Write-Warning "Unable to download LabtechUpdate.exe version $Version from server $GoodServer." + $GoodServer = $null + } + else { + if ($PSCmdlet.ShouldProcess($updater, 'DownloadFile')) { + Write-Debug "Downloading LabtechUpdate.exe from $updater" + $Script:LTServiceNetWebClient.DownloadFile($updater, "$updaterPath\LabtechUpdate.exe") + if (-not (Test-CWAADownloadIntegrity -FilePath "$updaterPath\LabtechUpdate.exe" -FileName 'LabtechUpdate.exe')) { + $GoodServer = $null } + } - Try { - if (-not (Test-Path -PathType Container -Path "$updaterPath" )) { - New-Item "$updaterPath" -type directory -ErrorAction SilentlyContinue | Out-Null - } - $updaterTest = [System.Net.WebRequest]::Create($updater) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Line $(LINENUM): Proxy Configuration Needed. Applying Proxy Settings to request." - $updaterTest.Proxy = $Script:LTWebProxy - } - $updaterTest.KeepAlive = $False - $updaterTest.ProtocolVersion = '1.0' - $updaterResult = $updaterTest.GetResponse() - $updaterTest.Abort() - if ($updaterResult.StatusCode -ne 200) { - Write-Warning "WARNING: Line $(LINENUM): Unable to download LabtechUpdate.exe version $Version from server $($Svr)." - Continue - } - else { - if ( $PSCmdlet.ShouldProcess($updater, 'DownloadFile') ) { - Write-Debug "Line $(LINENUM): Downloading LabtechUpdate.exe from $updater" - $Script:LTServiceNetWebClient.DownloadFile($updater, "$updaterPath\LabtechUpdate.exe") - If ((Test-Path "$updaterPath\LabtechUpdate.exe") -and !((Get-Item "$updaterPath\LabtechUpdate.exe" -EA 0).length / 1KB -gt 1234)) { - Write-Warning "WARNING: Line $(LINENUM): LabtechUpdate.exe size is below normal. Removing suspected corrupt file." - Remove-Item "$updaterPath\LabtechUpdate.exe" -ErrorAction SilentlyContinue -Force -Confirm:$False - Continue - } - } - - if ($WhatIfPreference -eq $True) { - $GoodServer = $Svr - } - Elseif (Test-Path "$updaterPath\LabtechUpdate.exe") { - $GoodServer = $Svr - Write-Verbose "LabtechUpdate.exe downloaded successfully from server $($Svr)." - } - else { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr). No update file was received." - Continue - } - } + if ($GoodServer) { + if ($WhatIfPreference -ne $True -and -not (Test-Path "$updaterPath\LabtechUpdate.exe")) { + Write-Warning "Error encountered downloading from $GoodServer. No update file was received." + $GoodServer = $null } - Catch { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading $updater." - Continue + else { + Write-Verbose "LabtechUpdate.exe downloaded successfully from server $GoodServer." } } - Catch { - Write-Warning "WARNING: Line $(LINENUM): Error encountered downloading from $($Svr)." - Continue - } - } - else { - Write-Warning "WARNING: Line $(LINENUM): Server address $($Svr) is not formatted correctly. Example: https://automate.domain.com" } } - else { - Write-Debug "Line $(LINENUM): Server $($GoodServer) has been selected." - Write-Verbose "Server has already been selected - Skipping $($Svr)." + Catch { + Write-Warning "Error encountered downloading $updater." + $GoodServer = $null } } } @@ -139,19 +143,19 @@ function Update-CWAA { End { $detectedVersion = $Settings | Select-Object -Expand 'Version' -EA 0 if ($Null -eq $detectedVersion) { - Write-Error "ERROR: Line $(LINENUM): No existing installation was found." -ErrorAction Stop + Write-Error "No existing installation was found." -ErrorAction Stop Return } if ([System.Version]$detectedVersion -ge [System.Version]$Version) { - Write-Warning "WARNING: Line $(LINENUM): Installed version detected ($detectedVersion) is higher than or equal to the requested version ($Version)." + Write-Warning "Installed version detected ($detectedVersion) is higher than or equal to the requested version ($Version)." Return } - if (-not ($GoodServer)) { - Write-Warning "WARNING: Line $(LINENUM): No valid server was detected." + if (-not $GoodServer) { + Write-Warning "No valid server was detected." Return } - if ([System.Version]$SVer -gt [System.Version]$Version) { - Write-Warning "WARNING: Line $(LINENUM): Server version detected ($SVer) is higher than the requested version ($Version)." + if ([System.Version]$serverVersion -gt [System.Version]$Version) { + Write-Warning "Server version detected ($serverVersion) is higher than the requested version ($Version)." Return } @@ -159,62 +163,58 @@ function Update-CWAA { Stop-CWAA } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error stopping the services. $($Error[0])" + Write-Error "There was an error stopping the services. $_" + Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update failed - unable to stop services. Error: $($_.Exception.Message)" Return } - Write-Output "Updating Agent with the following information: Server $($GoodServer), Version $Version" + Write-Output "Updating Agent with the following information: Server $GoodServer, Version $Version" Try { - if ($PSCmdlet.ShouldProcess("LabtechUpdate.exe $($xarg)", 'Extracting update files')) { - if ((Test-Path "$updaterPath\LabtechUpdate.exe")) { - #Extract Update Files + if ($PSCmdlet.ShouldProcess("LabtechUpdate.exe $extractArguments", 'Extracting update files')) { + if (Test-Path "$updaterPath\LabtechUpdate.exe") { Write-Verbose 'Launching LabtechUpdate Self-Extractor.' - Write-Debug "Line $(LINENUM): Executing Command ""LabtechUpdate.exe $($xarg)""" + Write-Debug "Executing Command ""LabtechUpdate.exe $extractArguments""" Try { Push-Location $updaterPath - & "$updaterPath\LabtechUpdate.exe" $($xarg) 2>'' + & "$updaterPath\LabtechUpdate.exe" $extractArguments 2>'' Pop-Location } Catch { Write-Output 'Error calling LabtechUpdate.exe.' } Start-Sleep -Seconds 5 } else { - Write-Verbose "WARNING: $updaterPath\LabtechUpdate.exe was not found." + Write-Verbose "$updaterPath\LabtechUpdate.exe was not found." } } - if ($PSCmdlet.ShouldProcess("Update.exe $($uarg)", 'Launching Updater')) { - if ((Test-Path "$updaterPath\Update.exe")) { - #Extract Update Files + if ($PSCmdlet.ShouldProcess("Update.exe $updaterArguments", 'Launching Updater')) { + if (Test-Path "$updaterPath\Update.exe") { Write-Verbose 'Launching Labtech Updater' - Write-Debug "Line $(LINENUM): Executing Command ""Update.exe $($uarg)""" - Try { & "$updaterPath\Update.exe" $($uarg) 2>'' } + Write-Debug "Executing Command ""Update.exe $updaterArguments""" + Try { & "$updaterPath\Update.exe" $updaterArguments 2>'' } Catch { Write-Output 'Error calling Update.exe.' } Start-Sleep -Seconds 5 } else { - Write-Verbose "WARNING: $updaterPath\Update.exe was not found." + Write-Verbose "$updaterPath\Update.exe was not found." } } - } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the update process $($Error[0])" -ErrorAction Continue + Write-Error "There was an error during the update process. $_" -ErrorAction Continue + Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update process failed. Error: $($_.Exception.Message)" } Try { Start-CWAA } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error starting the services. $($Error[0])" + Write-Error "There was an error starting the services. $_" + Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update completed but services failed to start. Error: $($_.Exception.Message)" Return } - if ($WhatIfPreference -ne $True) { - if ($?) {} - else { $Error[0] } - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-CWAAEventLog -EventId 1030 -EntryType Information -Message "Agent updated successfully to version $Version." + Write-Debug "Exiting $($myInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 b/ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 index 1dc8054..0bd257c 100644 --- a/ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 +++ b/ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 @@ -1,64 +1,105 @@ function Invoke-CWAACommand { - [CmdletBinding(SupportsShouldProcess=$True)] + <# + .SYNOPSIS + Sends a service command to the ConnectWise Automate agent. + .DESCRIPTION + Sends a control command to the LTService Windows service using sc.exe. + The agent supports a set of predefined commands (mapped to numeric IDs 128-145) + that trigger actions such as sending inventory, updating schedules, or killing processes. + .PARAMETER Command + One or more commands to send to the agent service. Valid values include + 'Update Schedule', 'Send Inventory', 'Send Drives', 'Send Processes', + 'Send Spyware List', 'Send Apps', 'Send Events', 'Send Printers', + 'Send Status', 'Send Screen', 'Send Services', 'Analyze Network', + 'Write Last Contact Date', 'Kill VNC', 'Kill Trays', 'Send Patch Reboot', + 'Run App Care Update', and 'Start App Care Daytime Patching'. + .EXAMPLE + Invoke-CWAACommand -Command 'Send Inventory' + Sends the 'Send Inventory' command to the agent service. + .EXAMPLE + 'Send Status', 'Send Apps' | Invoke-CWAACommand + Sends multiple commands to the agent service via pipeline. + .NOTES + Author: Chris Taylor + Alias: Invoke-LTServiceCommand + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Invoke-LTServiceCommand')] Param( - [Parameter(Mandatory=$True, Position=1, ValueFromPipeline=$True)] - [ValidateSet("Update Schedule", - "Send Inventory", - "Send Drives", - "Send Processes", - "Send Spyware List", - "Send Apps", - "Send Events", - "Send Printers", - "Send Status", - "Send Screen", - "Send Services", - "Analyze Network", - "Write Last Contact Date", - "Kill VNC", - "Kill Trays", - "Send Patch Reboot", - "Run App Care Update", - "Start App Care Daytime Patching")][string[]]$Command + [Parameter(Mandatory = $True, Position = 1, ValueFromPipeline = $True)] + [ValidateSet( + "Update Schedule", + "Send Inventory", + "Send Drives", + "Send Processes", + "Send Spyware List", + "Send Apps", + "Send Events", + "Send Printers", + "Send Status", + "Send Screen", + "Send Services", + "Analyze Network", + "Write Last Contact Date", + "Kill VNC", + "Kill Trays", + "Send Patch Reboot", + "Run App Care Update", + "Start App Care Daytime Patching" + )] + [string[]]$Command ) Begin { - $Service = Get-Service 'LTService' + Write-Debug "Starting $($MyInvocation.InvocationName)" + $Service = Get-Service 'LTService' -ErrorAction SilentlyContinue } Process { - if(-not ($Service)){Write-Warning "WARNING: Line $(LINENUM): Service 'LTService' was not found. Cannot send service command"; return} - if($Service.Status -ne 'Running'){Write-Warning "WARNING: Line $(LINENUM): Service 'LTService' is not running. Cannot send service command"; return} - Foreach ($Cmd in $Command){ - $CommandID=$Null - Try{ - switch($Cmd){ - 'Update Schedule' {$CommandID = 128} - 'Send Inventory' {$CommandID = 129} - 'Send Drives' {$CommandID = 130} - 'Send Processes' {$CommandID = 131} - 'Send Spyware List'{$CommandID = 132} - 'Send Apps' {$CommandID = 133} - 'Send Events' {$CommandID = 134} - 'Send Printers' {$CommandID = 135} - 'Send Status' {$CommandID = 136} - 'Send Screen' {$CommandID = 137} - 'Send Services' {$CommandID = 138} - 'Analyze Network' {$CommandID = 139} - 'Write Last Contact Date' {$CommandID = 140} - 'Kill VNC' {$CommandID = 141} - 'Kill Trays' {$CommandID = 142} - 'Send Patch Reboot' {$CommandID = 143} - 'Run App Care Update' {$CommandID = 144} - 'Start App Care Daytime Patching' {$CommandID = 145} - default {"Invalid entry"} + if (-not $Service) { + Write-Warning "Service 'LTService' was not found. Cannot send service command." + return + } + if ($Service.Status -ne 'Running') { + Write-Warning "Service 'LTService' is not running. Cannot send service command." + return + } + + foreach ($Cmd in $Command) { + $CommandID = $Null + Try { + switch ($Cmd) { + 'Update Schedule' { $CommandID = 128 } + 'Send Inventory' { $CommandID = 129 } + 'Send Drives' { $CommandID = 130 } + 'Send Processes' { $CommandID = 131 } + 'Send Spyware List' { $CommandID = 132 } + 'Send Apps' { $CommandID = 133 } + 'Send Events' { $CommandID = 134 } + 'Send Printers' { $CommandID = 135 } + 'Send Status' { $CommandID = 136 } + 'Send Screen' { $CommandID = 137 } + 'Send Services' { $CommandID = 138 } + 'Analyze Network' { $CommandID = 139 } + 'Write Last Contact Date' { $CommandID = 140 } + 'Kill VNC' { $CommandID = 141 } + 'Kill Trays' { $CommandID = 142 } + 'Send Patch Reboot' { $CommandID = 143 } + 'Run App Care Update' { $CommandID = 144 } + 'Start App Care Daytime Patching' { $CommandID = 145 } + default { Write-Debug "Unrecognized command: '$Cmd'" } } - if($PSCmdlet.ShouldProcess("LTService", "Send Service Command '$($Cmd)' ($($CommandID))")){ - if($Null -ne $CommandID){ - Write-Debug "Line $(LINENUM): Sending service command '$($Cmd)' ($($CommandID)) to 'LTService'" + + if ($PSCmdlet.ShouldProcess("LTService", "Send Service Command '$($Cmd)' ($($CommandID))")) { + if ($Null -ne $CommandID) { + Write-Debug "Sending service command '$($Cmd)' ($($CommandID)) to 'LTService'" Try { - $Null=& "$env:windir\system32\sc.exe" control LTService $($CommandID) 2>'' + $Null = & "$env:windir\system32\sc.exe" control LTService $($CommandID) 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "sc.exe control returned exit code $LASTEXITCODE for command '$Cmd' ($CommandID)." + } Write-Output "Sent Command '$($Cmd)' to 'LTService'" } Catch { @@ -67,13 +108,13 @@ function Invoke-CWAACommand { } } } - - Catch{ - Write-Warning ("WARNING: Line $(LINENUM)",$_.Exception) + Catch { + Write-Warning "Failed to process command '$Cmd'. $($_.Exception.Message)" } } } - End{} - + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } } diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 index aa1713a..09fd30b 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 @@ -1,44 +1,65 @@ function Get-CWAAError { + <# + .SYNOPSIS + Reads the ConnectWise Automate Agent error log into structured objects. + .DESCRIPTION + Parses the LTErrors.txt file from the agent install directory into objects with + ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, + and pipeline operations on agent error log entries. + + The log file location is determined from Get-CWAAInfo; if unavailable, falls back + to the default install path at C:\Windows\LTSVC. + .EXAMPLE + Get-CWAAError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} + Returns all agent errors from the last 24 hours. + .EXAMPLE + Get-CWAAError | Out-GridView + Opens the error log in a sortable, searchable grid view window. + .NOTES + Author: Chris Taylor + Alias: Get-LTErrors + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTErrors')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - $BasePath = $(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0) - if (!$BasePath) { $BasePath = "$env:windir\LTSVC" } + $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 + if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } } Process { - if ($(Test-Path -Path "$BasePath\LTErrors.txt") -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find lelog." + Write-Debug "Starting $($MyInvocation.InvocationName)" + $logFilePath = "$BasePath\LTErrors.txt" + + if (-not (Test-Path -Path $logFilePath)) { + Write-Error "Unable to find agent error log at '$logFilePath'." return } + Try { - $errors = Get-Content "$BasePath\LTErrors.txt" + $errors = Get-Content $logFilePath $errors = $errors -join ' ' -split '::: ' - foreach ($Line in $Errors) { - $items = $Line -split "`t" -replace ' - ', '' + + foreach ($line in $errors) { + $items = $line -split "`t" -replace ' - ', '' if ($items[1]) { - $object = New-Object -TypeName PSObject - $object | Add-Member -MemberType NoteProperty -Name ServiceVersion -Value $items[0] - $object | Add-Member -MemberType NoteProperty -Name Timestamp -Value $(Try { [datetime]::Parse($items[1]) } Catch {}) - $object | Add-Member -MemberType NoteProperty -Name Message -Value $items[2] - Write-Output $object + [PSCustomObject]@{ + ServiceVersion = $items[0] + Timestamp = $(Try { [datetime]::Parse($items[1]) } Catch { $null }) + Message = $items[2] + } } } - } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error reading the log. $($Error[0])" + Write-Error "Failed to read agent error log at '$logFilePath'. Error: $($_.Exception.Message)" } } End { - if ($?) { - } - else { $Error[0] } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 index 91ca980..7cd5be1 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 @@ -1,34 +1,55 @@ function Get-CWAALogLevel { + <# + .SYNOPSIS + Retrieves the current logging level for the ConnectWise Automate Agent. + .DESCRIPTION + Checks the agent's registry settings to determine the current logging verbosity level. + The ConnectWise Automate Agent supports two logging levels: Normal (value 1) for standard + operations, and Verbose (value 1000) for detailed diagnostic logging. + + The logging level is stored in the registry at HKLM:\SOFTWARE\LabTech\Service\Settings + under the "Debuging" value. + .EXAMPLE + Get-CWAALogLevel + Returns the current logging level (Normal or Verbose). + .EXAMPLE + Get-CWAALogLevel + Set-CWAALogLevel -Level Verbose + Get-CWAALogLevel + Typical troubleshooting workflow: check level, enable verbose, verify the change. + .NOTES + Author: Chris Taylor + Alias: Get-LTLogging + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTLogging')] Param () - Begin { - Write-Verbose 'Checking for registry keys.' - } - Process { + Write-Debug "Starting $($MyInvocation.InvocationName)" Try { - $Value = (Get-CWAASettings | Select-Object -Expand Debuging -EA 0) - } + # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. + $logLevel = Get-CWAASettings | Select-Object -Expand Debuging -EA 0 - Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem reading the registry key. $($Error[0])" - return - } - } - - End { - if ($?) { - if ($value -eq 1) { - Write-Output 'Current logging level: Normal' - } - elseif ($value -eq 1000) { + if ($logLevel -eq 1000) { Write-Output 'Current logging level: Verbose' } + elseif ($Null -eq $logLevel -or $logLevel -eq 1) { + # Fresh installs may not have the Debuging value yet; treat as Normal + Write-Output 'Current logging level: Normal' + } else { - Write-Error "ERROR: Line $(LINENUM): Unknown Logging level $($value)" + Write-Error "Unknown logging level value '$logLevel' in registry." } } + Catch { + Write-Error "Failed to read logging level from registry. Error: $($_.Exception.Message)" + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 index 59678bf..2464f54 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 @@ -1,41 +1,65 @@ function Get-CWAAProbeError { + <# + .SYNOPSIS + Reads the ConnectWise Automate Agent probe error log into structured objects. + .DESCRIPTION + Parses the LTProbeErrors.txt file from the agent install directory into objects with + ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, + and pipeline operations on agent probe error log entries. + + The log file location is determined from Get-CWAAInfo; if unavailable, falls back + to the default install path at C:\Windows\LTSVC. + .EXAMPLE + Get-CWAAProbeError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} + Returns all probe errors from the last 24 hours. + .EXAMPLE + Get-CWAAProbeError | Out-GridView + Opens the probe error log in a sortable, searchable grid view window. + .NOTES + Author: Chris Taylor + Alias: Get-LTProbeErrors + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTProbeErrors')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - $BasePath = $(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0) - if (!($BasePath)) { $BasePath = "$env:windir\LTSVC" } + $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 + if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } } Process { - if ($(Test-Path -Path "$BasePath\LTProbeErrors.txt") -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find log." + Write-Debug "Starting $($MyInvocation.InvocationName)" + $logFilePath = "$BasePath\LTProbeErrors.txt" + + if (-not (Test-Path -Path $logFilePath)) { + Write-Error "Unable to find probe error log at '$logFilePath'." return } - $errors = Get-Content "$BasePath\LTProbeErrors.txt" - $errors = $errors -join ' ' -split '::: ' + Try { - Foreach ($Line in $Errors) { - $items = $Line -split "`t" -replace ' - ', '' - $object = New-Object -TypeName PSObject - $object | Add-Member -MemberType NoteProperty -Name ServiceVersion -Value $items[0] - $object | Add-Member -MemberType NoteProperty -Name Timestamp -Value $(Try { [datetime]::Parse($items[1]) } Catch {}) - $object | Add-Member -MemberType NoteProperty -Name Message -Value $items[2] - Write-Output $object + $errors = Get-Content $logFilePath + $errors = $errors -join ' ' -split '::: ' + + foreach ($line in $errors) { + $items = $line -split "`t" -replace ' - ', '' + if ($items[1]) { + [PSCustomObject]@{ + ServiceVersion = $items[0] + Timestamp = $(Try { [datetime]::Parse($items[1]) } Catch { $null }) + Message = $items[2] + } + } } } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error reading the log. $($Error[0])" + Write-Error "Failed to read probe error log at '$logFilePath'. Error: $($_.Exception.Message)" } } End { - if ($?) { - } - else { $Error[0] } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 index 6cbd84a..f9b9ed2 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 @@ -1,33 +1,69 @@ function Set-CWAALogLevel { - [CmdletBinding()] + <# + .SYNOPSIS + Sets the logging level for the ConnectWise Automate Agent. + .DESCRIPTION + Configures the agent's logging verbosity by updating the registry and restarting the + agent services. Supports Normal (standard) and Verbose (detailed diagnostic) levels. + + The function stops the agent service, writes the new logging level to the registry at + HKLM:\SOFTWARE\LabTech\Service\Settings under the "Debuging" value, then restarts the + agent service. After applying the change, it outputs the current logging level. + .PARAMETER Level + The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. + Normal sets registry value 1; Verbose sets registry value 1000. + .EXAMPLE + Set-CWAALogLevel -Level Verbose + Enables verbose diagnostic logging on the agent. + .EXAMPLE + Set-CWAALogLevel -Level Normal + Returns the agent to standard logging. + .EXAMPLE + Set-CWAALogLevel -Level Verbose -WhatIf + Shows what changes would be made without applying them. + .NOTES + Author: Chris Taylor + Alias: Set-LTLogging + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTLogging')] Param ( [ValidateSet('Normal', 'Verbose')] $Level = 'Normal' ) - Begin {} - Process { + Write-Debug "Starting $($MyInvocation.InvocationName)" Try { - Stop-CWAA + # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. + $registryPath = "$Script:CWAARegistrySettings" + $registryName = 'Debuging' + if ($Level -eq 'Normal') { - Set-ItemProperty HKLM:\SOFTWARE\LabTech\Service\Settings -Name 'Debuging' -Value 1 + $registryValue = 1 } - if ($Level -eq 'Verbose') { - Set-ItemProperty HKLM:\SOFTWARE\LabTech\Service\Settings -Name 'Debuging' -Value 1000 + else { + $registryValue = 1000 } - Start-CWAA - } + if ($PSCmdlet.ShouldProcess("$registryPath\$registryName", "Set logging level to $Level (value: $registryValue)")) { + Stop-CWAA + Set-ItemProperty $registryPath -Name $registryName -Value $registryValue + Start-CWAA + } + + Get-CWAALogLevel + Write-CWAAEventLog -EventId 3030 -EntryType Information -Message "Agent log level set to $Level." + } Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem writing the registry key. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3032 -EntryType Error -Message "Failed to set agent log level to '$Level'. Error: $($_.Exception.Message)" + Write-Error "Failed to set logging level to '$Level'. Error: $($_.Exception.Message)" -ErrorAction Stop } } End { - if ($?) { - Get-CWAALogging - } + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 b/ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 index f7b3779..ddaf7e6 100644 --- a/ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 +++ b/ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 @@ -1,47 +1,87 @@ - -function Get-CWAAProxy { +function Get-CWAAProxy { + <# + .SYNOPSIS + Retrieves the current agent proxy settings for module operations. + .DESCRIPTION + Reads the current Automate agent proxy settings from the installed agent (if present) + and stores them in the module-scoped $Script:LTProxy object. The proxy URL, + username, and password are decrypted using the agent's password string. The + discovered settings are used by all module communication operations for the + duration of the session, and returned as the function result. + .EXAMPLE + Get-CWAAProxy + Retrieves and returns the current proxy configuration. + .EXAMPLE + $proxy = Get-CWAAProxy + if ($proxy.Enabled) { Write-Host "Proxy: $($proxy.ProxyServerURL)" } + Checks whether a proxy is configured and displays the URL. + .NOTES + Author: Darren White + Alias: Get-LTProxy + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTProxy')] - Param( - ) + Param() Begin { - Clear-Variable CustomProxyObject, LTSI, LTSS -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" Write-Verbose 'Discovering Proxy Settings used by the LT Agent.' - $Null = Initialize-CWAAKeys + + # Decrypt agent passwords from registry. The decrypted PasswordString is used + # below to decode proxy credentials. This logic was formerly in the private + # Initialize-CWAAKeys function — inlined here because Get-CWAAProxy is the only + # consumer, and key decryption is inherently the first step of proxy discovery. + # The $serviceInfo result is reused in Process to avoid a redundant registry read. + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { + Write-Debug "Decoding Server Password." + $Script:LTServiceKeys.ServerPasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.ServerPassword)" + if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'Password' })) { + Write-Debug "Decoding Agent Password." + $Script:LTServiceKeys.PasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.Password)" -Key "$($Script:LTServiceKeys.ServerPasswordString)" + } + else { + $Script:LTServiceKeys.PasswordString = '' + } + } + else { + $Script:LTServiceKeys.ServerPasswordString = '' + $Script:LTServiceKeys.PasswordString = '' + } } Process { Try { - $LTSI = Get-CWAAInfo -EA 0 -WA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($Null -ne $LTSI -and ($LTSI | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { - $LTSS = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False - if ($Null -ne $LTSS) { - if (($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' }) -and ($($LTSS | Select-Object -Expand ProxyServerURL -EA 0) -Match 'https?://.+')) { - Write-Debug "Line $(LINENUM): Proxy Detected. Setting ProxyServerURL to $($LTSS|Select-Object -Expand ProxyServerURL -EA 0)" + # Reuse $serviceInfo from Begin block — eliminates a redundant Get-CWAAInfo call. + if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { + $serviceSettings = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False + if ($Null -ne $serviceSettings) { + if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' }) -and ($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -Match 'https?://.+')) { + Write-Debug "Proxy Detected. Setting ProxyServerURL to $($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0)" $Script:LTProxy.Enabled = $True - $Script:LTProxy.ProxyServerURL = "$($LTSS|Select-Object -Expand ProxyServerURL -EA 0)" + $Script:LTProxy.ProxyServerURL = "$($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0)" } else { - Write-Debug "Line $(LINENUM): Setting ProxyServerURL to " + Write-Debug 'Setting ProxyServerURL to empty.' $Script:LTProxy.Enabled = $False $Script:LTProxy.ProxyServerURL = '' } - if ($Script:LTProxy.Enabled -eq $True -and ($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($LTSS | Select-Object -Expand ProxyUsername -EA 0)) { - $Script:LTProxy.ProxyUsername = "$(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" - Write-Debug "Line $(LINENUM): Setting ProxyUsername to $($Script:LTProxy.ProxyUsername)" + if ($Script:LTProxy.Enabled -eq $True -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)) { + $Script:LTProxy.ProxyUsername = "$(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" + Write-Debug "Setting ProxyUsername to $(Get-CWAARedactedValue $Script:LTProxy.ProxyUsername)" } else { - Write-Debug "Line $(LINENUM): Setting ProxyUsername to " + Write-Debug 'Setting ProxyUsername to empty.' $Script:LTProxy.ProxyUsername = '' } - if ($Script:LTProxy.Enabled -eq $True -and ($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($LTSS | Select-Object -Expand ProxyPassword -EA 0)) { - $Script:LTProxy.ProxyPassword = "$(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" - Write-Debug "Line $(LINENUM): Setting ProxyPassword to $($Script:LTProxy.ProxyPassword)" + if ($Script:LTProxy.Enabled -eq $True -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)) { + $Script:LTProxy.ProxyPassword = "$(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" + Write-Debug "Setting ProxyPassword to $(Get-CWAARedactedValue $Script:LTProxy.ProxyPassword)" } else { - Write-Debug "Line $(LINENUM): Setting ProxyPassword to " + Write-Debug 'Setting ProxyPassword to empty.' $Script:LTProxy.ProxyPassword = '' } } @@ -51,12 +91,12 @@ function Get-CWAAProxy { } } Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem retrieving Proxy Information. $($Error[0])" + Write-Error "There was a problem retrieving Proxy Information. $_" } } End { - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" return $Script:LTProxy } } diff --git a/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 b/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 index 071e763..8fa23da 100644 --- a/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 +++ b/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 @@ -1,5 +1,54 @@ - function Set-CWAAProxy { + <# + .SYNOPSIS + Configures module proxy settings for all operations during the current session. + .DESCRIPTION + Sets or clears Proxy settings needed for module function and agent operations. + If an agent is already installed, this function will update the ProxyUsername, + ProxyPassword, and ProxyServerURL values in the agent registry settings. + Agent services will be restarted for changes (if found) to be applied. + .PARAMETER ProxyServerURL + The URL and optional port to assign as the proxy server for module operations + and for the installed agent (if present). + Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' + May be used with ProxyUsername/ProxyPassword or EncodedProxyUsername/EncodedProxyPassword. + .PARAMETER ProxyUsername + Plain text username for proxy authentication. + Must be used with ProxyServerURL and ProxyPassword. + .PARAMETER ProxyPassword + Plain text password for proxy authentication. + Must be used with ProxyServerURL and ProxyUsername. + .PARAMETER EncodedProxyUsername + Encoded username for proxy authentication, encrypted with the agent password. + Will be decoded using the agent password. Must be used with ProxyServerURL + and EncodedProxyPassword. + .PARAMETER EncodedProxyPassword + Encoded password for proxy authentication, encrypted with the agent password. + Will be decoded using the agent password. Must be used with ProxyServerURL + and EncodedProxyUsername. + .PARAMETER DetectProxy + Automatically detect system proxy settings for module operations. + Discovered settings are applied to the installed agent (if present). + Cannot be used with other parameters. + .PARAMETER ResetProxy + Clears any currently defined proxy settings for module operations. + Changes are applied to the installed agent (if present). + Cannot be used with other parameters. + .EXAMPLE + Set-CWAAProxy -DetectProxy + Automatically detects and configures the system proxy. + .EXAMPLE + Set-CWAAProxy -ResetProxy + Clears all proxy settings. + .EXAMPLE + Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' + Sets the proxy server URL without authentication. + .NOTES + Author: Darren White + Alias: Set-LTProxy + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTProxy')] Param( @@ -21,18 +70,21 @@ function Set-CWAAProxy { [alias('Clear')] [alias('Reset')] [alias('ClearProxy')] - [switch]$ResetProxy + [switch]$ResetProxy, + [switch]$SkipCertificateCheck ) Begin { - Clear-Variable LTServiceSettingsChanged, LTSS, LTServiceRestartNeeded, proxyURL, proxyUser, proxyPass, passwd, Svr -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" + + # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. + # Only runs once per session, skips immediately on subsequent calls. + $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck try { - $LTSS = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False + $serviceSettings = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False } - catch {} - + catch { Write-Debug "Failed to retrieve service settings. $_" } } Process { @@ -43,10 +95,10 @@ function Set-CWAAProxy { ((($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword)) -and (($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) -or ((($ProxyUsername) -or ($ProxyPassword)) -and (-not ($ProxyServerURL) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword) -or ($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) -or ((($EncodedProxyUsername) -or ($EncodedProxyPassword)) -and (-not ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) - ) { Write-Error "ERROR: Line $(LINENUM): Set-CWAAProxy: Invalid Parameter specified" -ErrorAction Stop } + ) { Write-Error "Set-CWAAProxy: Invalid parameter combination specified." -ErrorAction Stop } if (-not (($ResetProxy -eq $True) -or ($DetectProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) { - if ($Args.Count -gt 0) { Write-Error "ERROR: Line $(LINENUM): Set-CWAAProxy: Unknown Parameter specified" -ErrorAction Stop } - else { Write-Error "ERROR: Line $(LINENUM): Set-CWAAProxy: Required Parameters Missing" -ErrorAction Stop } + if ($Args.Count -gt 0) { Write-Error "Set-CWAAProxy: Unknown parameter specified." -ErrorAction Stop } + else { Write-Error "Set-CWAAProxy: Required parameters missing." -ErrorAction Stop } } Try { @@ -67,24 +119,24 @@ function Set-CWAAProxy { $Script:LTWebProxy = [System.Net.WebRequest]::GetSystemWebProxy() $Script:LTProxy.Enabled = $False $Script:LTProxy.ProxyServerURL = '' - $Servers = @($("$($LTSS|Select-Object -Expand 'ServerAddress' -EA 0)|www.connectwise.com").Split('|') | ForEach-Object { $_.Trim() }) - Foreach ($Svr In $Servers) { + $Servers = @($("$($serviceSettings | Select-Object -Expand 'ServerAddress' -EA 0)|www.connectwise.com").Split('|') | ForEach-Object { $_.Trim() }) + Foreach ($serverUrl In $Servers) { if (-not ($Script:LTProxy.Enabled)) { - if ($Svr -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { - $Svr = $Svr -replace 'https?://', '' + if ($serverUrl -match $Script:CWAAServerValidationRegex) { + $serverUrl = $serverUrl -replace 'https?://', '' Try { - $Script:LTProxy.ProxyServerURL = $Script:LTWebProxy.GetProxy("http://$($Svr)").Authority + $Script:LTProxy.ProxyServerURL = $Script:LTWebProxy.GetProxy("http://$($serverUrl)").Authority } - catch {} - if (($Null -ne $Script:LTProxy.ProxyServerURL) -and ($Script:LTProxy.ProxyServerURL -ne '') -and ($Script:LTProxy.ProxyServerURL -notcontains "$($Svr)")) { - Write-Debug "Line $(LINENUM): Detected Proxy URL: $($Script:LTProxy.ProxyServerURL) on server $($Svr)" + catch { Write-Debug "Failed to get proxy for server $serverUrl. $_" } + if (($Null -ne $Script:LTProxy.ProxyServerURL) -and ($Script:LTProxy.ProxyServerURL -ne '') -and ($Script:LTProxy.ProxyServerURL -notcontains "$($serverUrl)")) { + Write-Debug "Detected Proxy URL: $($Script:LTProxy.ProxyServerURL) on server $($serverUrl)" $Script:LTProxy.Enabled = $True } } } } if (-not ($Script:LTProxy.Enabled)) { - if (($Script:LTProxy.ProxyServerURL -eq '') -or ($Script:LTProxy.ProxyServerURL -contains '$Svr')) { + if (($Script:LTProxy.ProxyServerURL -eq '') -or ($Script:LTProxy.ProxyServerURL -contains '$serverUrl')) { $Script:LTProxy.ProxyServerURL = netsh winhttp show proxy | Select-String -Pattern '(?i)(?<=Proxyserver.*http\=)([^;\r\n]*)' -EA 0 | ForEach-Object { $_.matches } | Select-Object -Expand value } if (($Null -eq $Script:LTProxy.ProxyServerURL) -or ($Script:LTProxy.ProxyServerURL -eq '')) { @@ -93,7 +145,7 @@ function Set-CWAAProxy { } else { $Script:LTProxy.Enabled = $True - Write-Debug "Line $(LINENUM): Detected Proxy URL: $($Script:LTProxy.ProxyServerURL)" + Write-Debug "Detected Proxy URL: $($Script:LTProxy.ProxyServerURL)" } } $Script:LTProxy.ProxyUsername = '' @@ -123,13 +175,13 @@ function Set-CWAAProxy { if (($ProxyPassword)) { foreach ($proxyPass in $ProxyPassword) { $Script:LTProxy.ProxyPassword = $proxyPass - $passwd = ConvertTo-SecureString $proxyPass -AsPlainText -Force; ## Website credentials + $passwd = ConvertTo-SecureString $proxyPass -AsPlainText -Force; } } if (($EncodedProxyPassword)) { foreach ($proxyPass in $EncodedProxyPassword) { $Script:LTProxy.ProxyPassword = $(ConvertFrom-CWAASecurity -InputString "$($proxyPass)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) - $passwd = ConvertTo-SecureString $Script:LTProxy.ProxyPassword -AsPlainText -Force; ## Website credentials + $passwd = ConvertTo-SecureString $Script:LTProxy.ProxyPassword -AsPlainText -Force; } } $Script:LTWebProxy.Credentials = New-Object System.Management.Automation.PSCredential ($Script:LTProxy.ProxyUsername, $passwd); @@ -137,64 +189,69 @@ function Set-CWAAProxy { $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy } } - } - - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the Proxy Configuration process. $($Error[0])" -ErrorAction Stop - } - } - End { - if ($?) { - $LTServiceSettingsChanged = $False - if ($Null -ne ($LTSS)) { - if (($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' })) { - if (($($LTSS | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne $Script:LTProxy.ProxyServerURL) -and (($($LTSS | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -eq '' -and $Script:LTProxy.Enabled -eq $True -and $Script:LTProxy.ProxyServerURL -match '.+\..+') -or ($($LTSS | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne '' -and ($Script:LTProxy.ProxyServerURL -ne '' -or $Script:LTProxy.Enabled -eq $False)))) { - Write-Debug "Line $(LINENUM): ProxyServerURL Changed: Old Value: $($LTSS|Select-Object -Expand ProxyServerURL -EA 0) New Value: $($Script:LTProxy.ProxyServerURL)" - $LTServiceSettingsChanged = $True + # Apply settings to agent registry if changes detected + $settingsChanged = $False + if ($Null -ne ($serviceSettings)) { + if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' })) { + if (($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne $Script:LTProxy.ProxyServerURL) -and (($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -eq '' -and $Script:LTProxy.Enabled -eq $True -and $Script:LTProxy.ProxyServerURL -match '.+\..+') -or ($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne '' -and ($Script:LTProxy.ProxyServerURL -ne '' -or $Script:LTProxy.Enabled -eq $False)))) { + Write-Debug "ProxyServerURL Changed: Old Value: $($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) New Value: $($Script:LTProxy.ProxyServerURL)" + $settingsChanged = $True } - if (($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($LTSS | Select-Object -Expand ProxyUsername -EA 0)) { - if ($(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyUsername) { - Write-Debug "Line $(LINENUM): ProxyUsername Changed: Old Value: $(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",'')) New Value: $($Script:LTProxy.ProxyUsername)" - $LTServiceSettingsChanged = $True + if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)) { + if ($(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyUsername) { + Write-Debug "ProxyUsername Changed: Old Value: $(Get-CWAARedactedValue (ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))) New Value: $(Get-CWAARedactedValue $Script:LTProxy.ProxyUsername)" + $settingsChanged = $True } } - if ($Null -ne ($LTSS) -and ($LTSS | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($LTSS | Select-Object -Expand ProxyPassword -EA 0)) { - if ($(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyPassword) { - Write-Debug "Line $(LINENUM): ProxyPassword Changed: Old Value: $(ConvertFrom-CWAASecurity -InputString "$($LTSS|Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",'')) New Value: $($Script:LTProxy.ProxyPassword)" - $LTServiceSettingsChanged = $True + if ($Null -ne ($serviceSettings) -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)) { + if ($(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyPassword) { + Write-Debug "ProxyPassword Changed: Old Value: $(Get-CWAARedactedValue (ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))) New Value: $(Get-CWAARedactedValue $Script:LTProxy.ProxyPassword)" + $settingsChanged = $True } } } Elseif ($Script:LTProxy.Enabled -eq $True -and $Script:LTProxy.ProxyServerURL -match '(https?://)?.+\..+') { - Write-Debug "Line $(LINENUM): ProxyServerURL Changed: Old Value: NOT SET New Value: $($Script:LTProxy.ProxyServerURL)" - $LTServiceSettingsChanged = $True + Write-Debug "ProxyServerURL Changed: Old Value: NOT SET New Value: $($Script:LTProxy.ProxyServerURL)" + $settingsChanged = $True } } else { - $svcRun = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - if (($svcRun -gt 0) -and ($($Script:LTProxy.ProxyServerURL) -match '.+')) { - $LTServiceSettingsChanged = $True + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count + if (($runningServiceCount -gt 0) -and ($($Script:LTProxy.ProxyServerURL) -match '.+')) { + $settingsChanged = $True } } - if ($LTServiceSettingsChanged -eq $True) { - if ((Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { $LTServiceRestartNeeded = $True; try { Stop-CWAA -EA 0 -WA 0 } catch {} } - Write-Verbose 'Updating LabTech\Service\Settings Proxy Configuration.' + if ($settingsChanged -eq $True) { + $serviceRestartNeeded = $False + if ((Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { + $serviceRestartNeeded = $True + try { Stop-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to stop services before proxy update. $_" } + } + Write-Verbose 'Updating Automate agent proxy configuration.' if ( $PSCmdlet.ShouldProcess('LTService Registry', 'Update') ) { - $Svr = $($Script:LTProxy.ProxyServerURL); if (($Svr -ne '') -and ($Svr -notmatch 'https?://')) { $Svr = "http://$($Svr)" } - @{'ProxyServerURL' = $Svr; + $serverUrl = $($Script:LTProxy.ProxyServerURL); if (($serverUrl -ne '') -and ($serverUrl -notmatch 'https?://')) { $serverUrl = "http://$($serverUrl)" } + @{'ProxyServerURL' = $serverUrl; 'ProxyUserName' = "$(ConvertTo-CWAASecurity -InputString "$($Script:LTProxy.ProxyUserName)" -Key "$($Script:LTServiceKeys.PasswordString)")"; 'ProxyPassword' = "$(ConvertTo-CWAASecurity -InputString "$($Script:LTProxy.ProxyPassword)" -Key "$($Script:LTServiceKeys.PasswordString)")" }.GetEnumerator() | ForEach-Object { - Write-Debug "Line $(LINENUM): Setting Registry value for $($_.Name) to `"$($_.Value)`"" - Set-ItemProperty -Path 'HKLM:Software\LabTech\Service\Settings' -Name $($_.Name) -Value $($_.Value) -EA 0 -Confirm:$False + Write-Debug "Setting Registry value for $($_.Name) to `"$($_.Value)`"" + Set-ItemProperty -Path $Script:CWAARegistrySettings -Name $($_.Name) -Value $($_.Value) -EA 0 -Confirm:$False } } - if ($LTServiceRestartNeeded -eq $True) { try { Start-CWAA -EA 0 -WA 0 } catch {} } + if ($serviceRestartNeeded -eq $True) { + try { Start-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to restart services after proxy update. $_" } + } + Write-CWAAEventLog -EventId 3020 -EntryType Information -Message "Proxy settings updated. Enabled: $($Script:LTProxy.Enabled), Server: $($Script:LTProxy.ProxyServerURL)" } } - else { $Error[0] } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Catch { + Write-CWAAEventLog -EventId 3022 -EntryType Error -Message "Proxy configuration failed. Error: $($_.Exception.Message)" + Write-Error "There was an error during the Proxy Configuration process. $_" -ErrorAction Stop + } } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } } diff --git a/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 b/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 new file mode 100644 index 0000000..ce095b8 --- /dev/null +++ b/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 @@ -0,0 +1,204 @@ +function Register-CWAAHealthCheckTask { + <# + .SYNOPSIS + Creates or updates a scheduled task for periodic ConnectWise Automate agent health checks. + .DESCRIPTION + Creates a Windows scheduled task that runs Repair-CWAA at a configurable interval + (default every 6 hours) to monitor agent health and automatically remediate issues. + + The task runs as SYSTEM with highest privileges, includes a random delay equal to the + interval to stagger execution across multiple machines, and has a 1-hour execution timeout. + + If the task already exists and the InstallerToken has changed, the task is recreated + with the new token. Use -Force to recreate unconditionally. + + A backup of the current agent configuration is created before task registration + via New-CWAABackup. + .PARAMETER InstallerToken + The installer token for authenticated agent deployment. Embedded in the scheduled + task action for use by Repair-CWAA. + .PARAMETER Server + Optional server URL. When provided, the scheduled task passes this to Repair-CWAA + in Install mode (with Server, LocationID, and InstallerToken). + .PARAMETER LocationID + Optional location ID. Required when Server is provided. + .PARAMETER TaskName + Name of the scheduled task. Default: 'CWAAHealthCheck'. + .PARAMETER IntervalHours + Hours between health check runs. Default: 6. + .PARAMETER Force + Force recreation of the task even if it already exists with the same token. + .EXAMPLE + Register-CWAAHealthCheckTask -InstallerToken 'abc123def456' + Creates a task that runs Repair-CWAA in Checkup mode every 6 hours. + .EXAMPLE + Register-CWAAHealthCheckTask -InstallerToken 'token' -Server 'https://automate.domain.com' -LocationID 42 + Creates a task that runs Repair-CWAA in Install mode (can install fresh if agent is missing). + .EXAMPLE + Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' + Creates a custom-named task running every 12 hours. + .NOTES + Author: Chris Taylor + Alias: Register-LTHealthCheckTask + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + [Alias('Register-LTHealthCheckTask')] + Param( + [Parameter(Mandatory = $True)] + [ValidatePattern('(?s:^[0-9a-z]+$)')] + [string]$InstallerToken, + + [ValidatePattern('^[a-zA-Z0-9\.\-\:\/]+$')] + [string]$Server, + + [int]$LocationID, + + [ValidatePattern('^[\w\-\. ]+$')] + [string]$TaskName = 'CWAAHealthCheck', + + [ValidateRange(1, 168)] + [int]$IntervalHours = 6, + + [switch]$Force + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + $created = $False + $updated = $False + + # Check if the task already exists + $existingTaskXml = $Null + Try { + [xml]$existingTaskXml = schtasks /QUERY /XML /TN $TaskName 2>$Null + } + Catch { Write-Debug "Task '$TaskName' not found or query failed: $($_.Exception.Message)" } + + # If the task exists and the token hasn't changed, skip recreation unless -Force + if ($existingTaskXml -and -not $Force) { + if ($existingTaskXml.Task.Actions.Exec.Arguments -match [regex]::Escape($InstallerToken)) { + Write-Verbose "Scheduled task '$TaskName' already exists with the same InstallerToken. Use -Force to recreate." + [PSCustomObject]@{ + TaskName = $TaskName + Created = $False + Updated = $False + } + return + } + $updated = $True + } + + if ($PSCmdlet.ShouldProcess("Scheduled Task '$TaskName'", 'Create health check task')) { + # Back up agent settings before creating/updating the task + Write-Verbose 'Backing up agent configuration.' + New-CWAABackup -ErrorAction SilentlyContinue + + # Build the PowerShell command for the scheduled task action + # Use Install mode if Server and LocationID are provided, otherwise Checkup mode + if ($Server -and $LocationID) { + $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server '$Server' -LocationID $LocationID -InstallerToken '$InstallerToken'" + } + else { + $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -InstallerToken '$InstallerToken'" + } + + # XML-escape special characters in the command for the task definition + $escapedCommand = $repairCommand -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' + + # Delete existing task if present + Try { + $Null = schtasks /DELETE /TN $TaskName /F 2>&1 + } + Catch { Write-Debug "Failed to delete existing task '$TaskName': $($_.Exception.Message)" } + + # Build the task XML definition + # Runs as SYSTEM (S-1-5-18) with highest privileges + # Repeats every $IntervalHours hours with randomized delay for staggering + $intervalIso = "PT${IntervalHours}H" + $startBoundary = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK' + + [xml]$taskXml = @" + + + + ConnectWise Automate agent health check and automatic remediation. + \$TaskName + + + + S-1-5-18 + HighestAvailable + + + + false + false + IgnoreNew + + PT10M + PT1H + false + false + + PT1H + + + + $startBoundary + + $intervalIso + P7300D + true + + $intervalIso + + + + + C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe + -NoProfile -WindowStyle Hidden -Command "$escapedCommand" + + + +"@ + + $taskFilePath = "$env:TEMP\CWAAHealthCheckTask.xml" + Try { + $taskXml.Save($taskFilePath) + + $schtasksOutput = schtasks /CREATE /TN $TaskName /XML $taskFilePath /RU SYSTEM 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "schtasks returned exit code $LASTEXITCODE. Output: $schtasksOutput" + } + + $created = -not $updated + $resultMessage = if ($updated) { "Scheduled task '$TaskName' updated." } else { "Scheduled task '$TaskName' created." } + Write-Output $resultMessage + Write-CWAAEventLog -EventId 4020 -EntryType Information -Message "$resultMessage Interval: every $IntervalHours hours." + } + Catch { + Write-Error "Failed to create scheduled task '$TaskName'. Error: $($_.Exception.Message)" + Write-CWAAEventLog -EventId 4022 -EntryType Error -Message "Failed to create scheduled task '$TaskName'. Error: $($_.Exception.Message)" + } + Finally { + # Clean up the temporary XML file + Remove-Item -Path $taskFilePath -Force -ErrorAction SilentlyContinue + } + } + + [PSCustomObject]@{ + TaskName = $TaskName + Created = $created + Updated = $updated + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 new file mode 100644 index 0000000..5fca6b3 --- /dev/null +++ b/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 @@ -0,0 +1,396 @@ +function Repair-CWAA { + <# + .SYNOPSIS + Performs escalating remediation of the ConnectWise Automate agent. + .DESCRIPTION + 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 + 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 + 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 + or from backup settings. + + All remediation actions are logged to the Windows Event Log (Application log, + source ConnectWiseAutomateAgent) for visibility in unattended scheduled task runs. + + Designed to be called periodically via Register-CWAAHealthCheckTask or any + external scheduler. + .PARAMETER Server + The ConnectWise Automate server URL for fresh installs or server mismatch correction. + Required when using the Install parameter set. + .PARAMETER LocationID + The LocationID for fresh agent installs. Required with the Install parameter set. + .PARAMETER InstallerToken + An installer token for authenticated agent deployment. Required for both parameter sets. + .PARAMETER HoursRestart + Hours since last check-in before a service restart is attempted. Expressed as a + negative number (e.g., -2 means 2 hours ago). Default: -2. + .PARAMETER HoursReinstall + Hours since last check-in before a full reinstall is attempted. Expressed as a + negative number (e.g., -120 means 120 hours / 5 days ago). Default: -120. + .EXAMPLE + Repair-CWAA -InstallerToken 'abc123def456' + Checks the installed agent and repairs if needed (Checkup mode). + .EXAMPLE + Repair-CWAA -Server 'https://automate.domain.com' -LocationID 42 -InstallerToken 'token' + Checks agent health. If the agent is missing or pointed at the wrong server, + installs or reinstalls with the specified settings. + .EXAMPLE + Repair-CWAA -InstallerToken 'token' -HoursRestart -4 -HoursReinstall -240 + Uses custom thresholds: restart after 4 hours offline, reinstall after 10 days. + .NOTES + Author: Chris Taylor + Alias: Repair-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + [Alias('Repair-LTService')] + Param( + [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [ValidateScript({ + if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } + else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } + })] + [string]$Server, + + [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [ValidateRange(1, [int]::MaxValue)] + [int]$LocationID, + + [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [Parameter(ParameterSetName = 'Checkup', Mandatory = $True)] + [ValidatePattern('(?s:^[0-9a-z]+$)')] + [string]$InstallerToken, + + [int]$HoursRestart = -2, + + [int]$HoursReinstall = -120 + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + + # Kill duplicate Repair-CWAA processes to prevent overlapping remediation + # Uses CIM for reliable command-line matching (Get-Process cannot filter by arguments) + if ($PSCmdlet.ShouldProcess('Duplicate Repair-CWAA processes', 'Terminate')) { + Try { + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.Name -eq 'powershell.exe' -and + $_.CommandLine -match 'Repair-CWAA' -and + $_.ProcessId -ne $PID + } | ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + } + Catch { + Write-Debug "Unable to check for duplicate processes: $($_.Exception.Message)" + } + } + } + + Process { + $actionTaken = 'None' + $success = $True + $resultMessage = '' + + # Determine if the agent service is installed + $agentServiceExists = [bool](Get-Service 'LTService' -ErrorAction SilentlyContinue) + + if ($agentServiceExists) { + #region Agent is installed — check health and remediate + + # Verify we can read agent configuration + $agentInfo = $Null + Try { + $agentInfo = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False + } + Catch { + # 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)" + + $backupSettings = Get-CWAAInfoBackup -EA 0 + Try { + Get-Process 'Agent_Uninstall' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + if ($PSCmdlet.ShouldProcess('LTService', 'Uninstall agent with unreadable config')) { + Uninstall-CWAA -Force -Server ($backupSettings.Server[0]) + } + } + Catch { + Write-Error "Failed to uninstall agent with unreadable config. Error: $($_.Exception.Message)" + Write-CWAAEventLog -EventId 4009 -EntryType Error -Message "Failed to uninstall agent with unreadable config. Error: $($_.Exception.Message)" + } + $resultMessage = 'Uninstalled agent with unreadable config. Restart machine and run again.' + $actionTaken = 'Uninstall' + $success = $False + + [PSCustomObject]@{ + ActionTaken = $actionTaken + Success = $success + Message = $resultMessage + } + return + } + + # If Server parameter was provided, check that it matches the installed agent + if ($Server) { + $currentServers = ($agentInfo | Select-Object -Expand 'Server' -EA 0) + $cleanExpectedServer = $Server -replace 'https?://', '' -replace '/$', '' + $serverMatches = $False + foreach ($currentServer in $currentServers) { + $cleanCurrent = $currentServer -replace 'https?://', '' -replace '/$', '' + if ($cleanCurrent -eq $cleanExpectedServer) { + $serverMatches = $True + break + } + } + + if (-not $serverMatches) { + Write-Warning "Wrong install server ($($currentServers -join ', ')). Expected '$Server'. Reinstalling." + Write-CWAAEventLog -EventId 4004 -EntryType Warning -Message "Server mismatch detected. Installed: $($currentServers -join ', '). Expected: $Server. Reinstalling." + + if ($PSCmdlet.ShouldProcess('LTService', "Reinstall agent for server mismatch (current: $($currentServers -join ', '), expected: $Server)")) { + Clear-CWAAInstallerArtifacts + Try { + Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken + $actionTaken = 'Reinstall' + $resultMessage = "Reinstalled agent to correct server: $Server" + Write-CWAAEventLog -EventId 4004 -EntryType Information -Message $resultMessage + } + Catch { + $actionTaken = 'Reinstall' + $success = $False + $resultMessage = "Failed to reinstall agent for server mismatch. Error: $($_.Exception.Message)" + Write-Error $resultMessage + Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage + } + } + + [PSCustomObject]@{ + ActionTaken = $actionTaken + Success = $success + Message = $resultMessage + } + return + } + } + + # Get last contact timestamp (try LastSuccessStatus, fall back to HeartbeatLastReceived) + $lastContact = $Null + Try { + [datetime]$lastContact = $agentInfo.LastSuccessStatus + } + Catch { + Try { + [datetime]$lastContact = $agentInfo.HeartbeatLastReceived + } + Catch { + # No valid contact timestamp — treat as very old + [datetime]$lastContact = (Get-Date).AddYears(-1) + } + } + + # Get last heartbeat timestamp + $lastHeartbeat = $Null + Try { + [datetime]$lastHeartbeat = $agentInfo.HeartbeatLastSent + } + Catch { + [datetime]$lastHeartbeat = (Get-Date).AddYears(-1) + } + + Write-Verbose "Last check-in: $lastContact" + Write-Verbose "Last heartbeat: $lastHeartbeat" + + # Determine the server address for connectivity checks + $activeServer = $Null + if ($Server) { + $activeServer = $Server + } + else { + Try { $activeServer = ($agentInfo | Select-Object -Expand 'Server' -EA 0)[0] } + Catch { + Try { $activeServer = (Get-CWAAInfoBackup -EA 0).Server[0] } + Catch { Write-Debug "Unable to retrieve server from backup settings: $($_.Exception.Message)" } + } + } + + # Check if the agent is offline beyond the restart threshold + $restartThreshold = (Get-Date).AddHours($HoursRestart) + $reinstallThreshold = (Get-Date).AddHours($HoursReinstall) + + if ($lastContact -lt $restartThreshold -or $lastHeartbeat -lt $restartThreshold) { + Write-Verbose "Agent has NOT checked in within the last $([Math]::Abs($HoursRestart)) hour(s)." + Write-CWAAEventLog -EventId 4001 -EntryType Warning -Message "Agent offline. Last contact: $lastContact. Last heartbeat: $lastHeartbeat. Threshold: $([Math]::Abs($HoursRestart)) hours." + + # Verify the server is reachable before attempting remediation + if ($activeServer) { + $serverAvailable = Test-CWAAServerConnectivity -Server $activeServer -Quiet + if (-not $serverAvailable) { + $resultMessage = "Server '$activeServer' is not reachable. Cannot remediate." + Write-Error $resultMessage + Write-CWAAEventLog -EventId 4008 -EntryType Error -Message $resultMessage + [PSCustomObject]@{ + ActionTaken = 'None' + Success = $False + Message = $resultMessage + } + return + } + } + + # Step 1: Restart services + if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart services to recover agent check-in')) { + Write-Verbose 'Restarting Automate agent services.' + Restart-CWAA + + # Wait up to 2 minutes for the agent to check in after restart + Write-Verbose 'Waiting for agent check-in after restart.' + $waitStart = Get-Date + while ($lastContact -lt $restartThreshold -and $waitStart.AddMinutes(2) -gt (Get-Date)) { + Start-Sleep -Seconds 2 + Try { + [datetime]$lastContact = (Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False).LastSuccessStatus + } + Catch { + Write-Debug "Unable to re-read LastSuccessStatus during wait loop: $($_.Exception.Message)" + } + } + } + + # Did the restart fix it? + if ($lastContact -ge $restartThreshold) { + $actionTaken = 'Restart' + $resultMessage = "Services restarted. Agent recovered. Last contact: $lastContact" + Write-Verbose $resultMessage + Write-CWAAEventLog -EventId 4001 -EntryType Information -Message $resultMessage + } + # Step 2: Reinstall if still offline beyond reinstall threshold + elseif ($lastContact -lt $reinstallThreshold) { + Write-Verbose "Agent still not connecting after restart. Offline beyond $([Math]::Abs($HoursReinstall))-hour threshold. Reinstalling." + Write-CWAAEventLog -EventId 4002 -EntryType Warning -Message "Agent still offline after restart. Last contact: $lastContact. Attempting reinstall." + + if ($PSCmdlet.ShouldProcess('LTService', 'Reinstall agent after failed restart recovery')) { + Clear-CWAAInstallerArtifacts + Try { + if ($InstallerToken -and $Server -and $LocationID) { + Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken -Hide + } + else { + Redo-CWAA -Hide -InstallerToken $InstallerToken + } + $actionTaken = 'Reinstall' + $resultMessage = 'Agent reinstalled after extended offline period.' + Write-CWAAEventLog -EventId 4002 -EntryType Information -Message $resultMessage + } + Catch { + $actionTaken = 'Reinstall' + $success = $False + $resultMessage = "Agent reinstall failed. Error: $($_.Exception.Message)" + Write-Error $resultMessage + Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage + } + } + } + else { + # Restart was attempted but agent hasn't recovered yet. Not yet at reinstall threshold. + $actionTaken = 'Restart' + $success = $True + $resultMessage = "Services restarted. Agent has not recovered yet but is within reinstall threshold ($([Math]::Abs($HoursReinstall)) hours)." + Write-Verbose $resultMessage + } + } + else { + # Agent is healthy + $resultMessage = "Agent is healthy. Last contact: $lastContact. Last heartbeat: $lastHeartbeat." + Write-Verbose $resultMessage + Write-CWAAEventLog -EventId 4000 -EntryType Information -Message $resultMessage + } + + #endregion + } + else { + #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.' + + Try { + if ($Server -and $LocationID -and $InstallerToken) { + # Full install parameters provided + if ($PSCmdlet.ShouldProcess('LTService', "Install agent (Server: $Server, LocationID: $LocationID)")) { + Write-Verbose "Installing agent with provided parameters (Server: $Server, LocationID: $LocationID)." + Clear-CWAAInstallerArtifacts + Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken + $actionTaken = 'Install' + $resultMessage = "Fresh agent install completed (Server: $Server, LocationID: $LocationID)." + Write-CWAAEventLog -EventId 4003 -EntryType Information -Message $resultMessage + } + } + else { + # Try to recover from existing settings or backup + $settings = $Null + $hasBackup = $False + Try { + $settings = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False + $hasBackup = $True + } + Catch { + $settings = Get-CWAAInfoBackup -EA 0 + $hasBackup = $False + } + + if ($settings) { + if ($hasBackup) { + Write-Verbose 'Backing up current settings before reinstall.' + New-CWAABackup -ErrorAction SilentlyContinue + } + $reinstallServer = ($settings | Select-Object -Expand 'Server' -EA 0)[0] + $reinstallLocationID = $settings | Select-Object -Expand 'LocationID' -EA 0 + + if ($PSCmdlet.ShouldProcess('LTService', "Reinstall from backup settings (Server: $reinstallServer)")) { + Write-Verbose "Reinstalling agent from backup settings (Server: $reinstallServer)." + Clear-CWAAInstallerArtifacts + Redo-CWAA -Server $reinstallServer -LocationID $reinstallLocationID -Hide -InstallerToken $InstallerToken + $actionTaken = 'Install' + $resultMessage = "Agent reinstalled from backup settings (Server: $reinstallServer)." + Write-CWAAEventLog -EventId 4003 -EntryType Information -Message $resultMessage + } + } + else { + $success = $False + $resultMessage = 'Unable to find install settings. Provide -Server, -LocationID, and -InstallerToken parameters.' + Write-Error $resultMessage + Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage + } + } + } + Catch { + $actionTaken = 'Install' + $success = $False + $resultMessage = "Agent installation failed. Error: $($_.Exception.Message)" + Write-Error $resultMessage + Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage + } + + #endregion + } + + [PSCustomObject]@{ + ActionTaken = $actionTaken + Success = $success + Message = $resultMessage + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 index 8409f6d..8e9690d 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 @@ -1,45 +1,65 @@ function Restart-CWAA { + <# + .SYNOPSIS + Restarts the ConnectWise Automate agent services. + .DESCRIPTION + Verifies that the Automate agent services (LTService, LTSvcMon) are present, then + calls Stop-CWAA followed by Start-CWAA to perform a full service restart. + .EXAMPLE + Restart-CWAA + Restarts the ConnectWise Automate agent services. + .EXAMPLE + Restart-CWAA -WhatIf + Shows what would happen without actually restarting the services. + .NOTES + Author: Chris Taylor + Alias: Restart-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Restart-LTService')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" } Process { if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { if ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): Services NOT Found $($Error[0])" - return + Write-Error "Services NOT Found." } else { - Write-Error "What-If: Line $(LINENUM): Stopping: Services NOT Found" - return + Write-Error "What If: Services NOT Found." } - } - Try { - Stop-CWAA - } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error stopping the services. $($Error[0])" return } + if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart Service')) { + Try { + Stop-CWAA + } + Catch { + Write-Error "There was an error stopping the services. $_" + Write-CWAAEventLog -EventId 2022 -EntryType Error -Message "Agent restart failed during stop phase. Error: $($_.Exception.Message)" + return + } - Try { - Start-CWAA - } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error starting the services. $($Error[0])" - return + Try { + Start-CWAA + } + Catch { + Write-Error "There was an error starting the services. $_" + Write-CWAAEventLog -EventId 2022 -EntryType Error -Message "Agent restart failed during start phase. Error: $($_.Exception.Message)" + return + } + + Write-Output 'Services restarted successfully.' + Write-CWAAEventLog -EventId 2020 -EntryType Information -Message 'Agent services restarted successfully.' } } End { - if ($WhatIfPreference -ne $True) { - if ($?) { Write-Output 'Services Restarted successfully.' } - else { $Error[0] } - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 index e3e699c..5a69fc1 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 @@ -1,11 +1,33 @@ function Start-CWAA { + <# + .SYNOPSIS + Starts the ConnectWise Automate agent services. + .DESCRIPTION + Verifies that the Automate agent services (LTService, LTSvcMon) are present. Checks + for any process using the LTTray port (default 42000) and kills it. If a + protected application holds the port, increments the TrayPort (wrapping from + 42009 back to 42000). Sets services to Automatic startup and starts them via + sc.exe. Waits up to one minute for LTService to reach the Running state, then + issues a Send Status command for immediate check-in. + .EXAMPLE + Start-CWAA + Starts the ConnectWise Automate agent services. + .EXAMPLE + Start-CWAA -WhatIf + Shows what would happen without actually starting the services. + .NOTES + Author: Chris Taylor + Alias: Start-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Start-LTService')] Param() Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - #Identify processes that are using the tray port + Write-Debug "Starting $($MyInvocation.InvocationName)" + # Identify processes that are using the tray port [array]$processes = @() $Port = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand TrayPort -EA 0) if (-not ($Port)) { $Port = '42000' } @@ -15,86 +37,87 @@ function Start-CWAA { Process { if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { if ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): Services NOT Found $($Error[0])" - return + Write-Error "Services NOT Found." } else { - Write-Error "What If: Line $(LINENUM): Stopping: Services NOT Found" - return + Write-Error "What If: Services NOT Found." } + return } Try { - If ((('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object | Select-Object -Expand Count) -gt 0) { + if ((('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object | Select-Object -Expand Count) -gt 0) { Try { $netstat = & "$env:windir\system32\netstat.exe" -a -o -n 2>'' | Select-String -Pattern " .*[0-9\.]+:$($Port).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 } - Catch { Write-Output 'Error calling netstat.exe.'; $netstat = $null } + Catch { Write-Debug 'Failed to call netstat.exe.'; $netstat = $null } Foreach ($line in $netstat) { $processes += ($line -split ' {4,}')[-1] } $processes = $processes | Where-Object { $_ -gt 0 -and $_ -match '^\d+$' } | Sort-Object | Get-Unique if ($processes) { - Foreach ($proc in $processes) { - Write-Output "Process ID:$proc is using port $Port. Killing process." - Try { Stop-Process -Id $proc -Force -Verbose -EA Stop } + Foreach ($processId in $processes) { + Write-Output "Process ID:$processId is using port $Port. Killing process." + Try { Stop-Process -Id $processId -Force -Verbose -EA Stop } Catch { - Write-Warning "WARNING: Line $(LINENUM): There was an issue killing the following process: $proc" - Write-Warning "WARNING: Line $(LINENUM): This generally means that a 'protected application' is using this port." - $newPort = [int]$port + 1 + Write-Warning "There was an issue killing process: $processId" + Write-Warning "This generally means that a 'protected application' is using this port." + # TrayPort wraps within the 42000-42009 range. If a protected process holds + # the current port, increment and wrap back to 42000 after 42009. + $newPort = [int]$Port + 1 if ($newPort -gt 42009) { $newPort = 42000 } - Write-Warning "WARNING: Line $(LINENUM): Setting tray port to $newPort." - New-ItemProperty -Path 'HKLM:\Software\Labtech\Service' -Name TrayPort -PropertyType String -Value $newPort -Force -WhatIf:$False -Confirm:$False | Out-Null + Write-Warning "Setting tray port to $newPort." + New-ItemProperty -Path $Script:CWAARegistryRoot -Name TrayPort -PropertyType String -Value $newPort -Force -WhatIf:$False -Confirm:$False | Out-Null } } } } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Start Service')) { - @('LTService', 'LTSvcMon') | ForEach-Object { + $Script:CWAAServiceNames | ForEach-Object { if (Get-Service $_ -EA 0) { Set-Service $_ -StartupType Automatic -EA 0 -Confirm:$False -WhatIf:$False $Null = & "$env:windir\system32\sc.exe" start "$($_)" 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "sc.exe start returned exit code $LASTEXITCODE for service '$_'." + } $startedSvcCount++ - Write-Debug "Line $(LINENUM): Executed Start Service for $($_)" + Write-Debug "Executed Start Service for $($_)" } } - } - } - - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error starting the LabTech services. $($Error[0])" - return - } - } - End { - if ($WhatIfPreference -ne $True) { - if ($?) { - $svcnotRunning = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - if ($svcnotRunning -gt 0 -and $startedSvcCount -eq 2) { + # Wait for services if we issued start commands + $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count + if ($stoppedServiceCount -gt 0 -and $startedSvcCount -eq 2) { $timeout = New-TimeSpan -Minutes 1 - $sw = [diagnostics.stopwatch]::StartNew() - Write-Host -NoNewline 'Waiting for Services to Start.' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + Write-Verbose 'Waiting for services to start...' Do { - Write-Host -NoNewline '.' Start-Sleep 2 - $svcnotRunning = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($sw.elapsed -gt $timeout -or $svcnotRunning -eq 0) - Write-Host '' - $sw.Stop() + $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count + } Until ($stopwatch.Elapsed -gt $timeout -or $stoppedServiceCount -eq 0) + $stopwatch.Stop() + Write-Verbose 'Service start wait completed.' } - if ($svcnotRunning -eq 0) { - Write-Output 'Services Started successfully.' + + # Report final state + if ($stoppedServiceCount -eq 0) { + Write-Output 'Services started successfully.' + Write-CWAAEventLog -EventId 2000 -EntryType Information -Message 'Agent services started successfully.' $Null = Invoke-CWAACommand 'Send Status' -EA 0 -Confirm:$False } - Elseif ($startedSvcCount -gt 0) { + elseif ($startedSvcCount -gt 0) { Write-Output 'Service Start was issued but LTService has not reached Running state.' + Write-CWAAEventLog -EventId 2001 -EntryType Warning -Message 'Agent services failed to reach Running state after start.' } else { Write-Output 'Service Start was not issued.' } } - Else { - $($Error[0]) - } } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Catch { + Write-Error "There was an error starting the Automate agent services. $_" + Write-CWAAEventLog -EventId 2002 -EntryType Error -Message "Agent service start failed. Error: $($_.Exception.Message)" + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 index 7de1f2c..744607f 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 @@ -1,67 +1,88 @@ function Stop-CWAA { + <# + .SYNOPSIS + Stops the ConnectWise Automate agent services. + .DESCRIPTION + Verifies that the Automate agent services (LTService, LTSvcMon) are present, then + attempts to stop them gracefully via sc.exe. Waits up to one minute for the + services to reach a Stopped state. If they do not stop in time, remaining + Automate agent processes (LTTray, LTSVC, LTSvcMon) are forcefully terminated. + .EXAMPLE + Stop-CWAA + Stops the ConnectWise Automate agent services. + .EXAMPLE + Stop-CWAA -WhatIf + Shows what would happen without actually stopping the services. + .NOTES + Author: Chris Taylor + Alias: Stop-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Stop-LTService')] Param() Begin { - Clear-Variable sw, timeout, svcRun -EA 0 -WhatIf:$False -Confirm:$False -Verbose:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" } Process { if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { if ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): Services NOT Found $($Error[0])" - return + Write-Error "Services NOT Found." } else { - Write-Error "What If: Line $(LINENUM): Stopping: Services NOT Found" - return + Write-Error "What If: Services NOT Found." } + return } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Stop-Service')) { $Null = Invoke-CWAACommand ('Kill VNC', 'Kill Trays') -EA 0 -WhatIf:$False -Confirm:$False - Write-Verbose 'Stopping Labtech Services' + Write-Verbose 'Stopping Automate agent services.' Try { - ('LTService', 'LTSvcMon') | ForEach-Object { - Try { $Null = & "$env:windir\system32\sc.exe" stop "$($_)" 2>'' } - Catch { Write-Output 'Error calling sc.exe.' } + $Script:CWAAServiceNames | ForEach-Object { + Try { + $Null = & "$env:windir\system32\sc.exe" stop "$($_)" 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "sc.exe stop returned exit code $LASTEXITCODE for service '$_'." + } + } + Catch { Write-Debug "Failed to call sc.exe stop for service $_." } } $timeout = New-TimeSpan -Minutes 1 - $sw = [diagnostics.stopwatch]::StartNew() - Write-Host -NoNewline 'Waiting for Services to Stop.' + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + Write-Verbose 'Waiting for services to stop...' Do { - Write-Host -NoNewline '.' Start-Sleep 2 - $svcRun = ('LTService', 'LTSvcMon') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count - } Until ($sw.elapsed -gt $timeout -or $svcRun -eq 0) - Write-Host '' - $sw.Stop() - if ($svcRun -gt 0) { - Write-Verbose "Services did not stop. Terminating Processes after $(([int32]$sw.Elapsed.TotalSeconds).ToString()) seconds." + $runningServiceCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count + } Until ($stopwatch.Elapsed -gt $timeout -or $runningServiceCount -eq 0) + $stopwatch.Stop() + Write-Verbose 'Service stop wait completed.' + if ($runningServiceCount -gt 0) { + Write-Verbose "Services did not stop. Terminating processes after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." } Get-Process | Where-Object { @('LTTray', 'LTSVC', 'LTSvcMon') -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False - } + # Verify final state and report + $remainingCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count + if ($remainingCount -eq 0) { + Write-Output 'Services stopped successfully.' + Write-CWAAEventLog -EventId 2010 -EntryType Information -Message 'Agent services stopped successfully.' + } + else { + Write-Warning 'Services have not stopped completely.' + Write-CWAAEventLog -EventId 2011 -EntryType Warning -Message 'Agent services did not stop completely.' + } + } Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error stopping the LabTech processes. $($Error[0])" - return + Write-Error "There was an error stopping the Automate agent processes. $_" + Write-CWAAEventLog -EventId 2012 -EntryType Error -Message "Agent service stop failed. Error: $($_.Exception.Message)" } } } End { - if ($WhatIfPreference -ne $True) { - if ($?) { - If ((('LTService', 'LTSvcMon') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count) -eq 0) { - Write-Output 'Services Stopped successfully.' - } - else { - Write-Warning "WARNING: Line $(LINENUM): Services have not stopped completely." - } - } - else { $Error[0] } - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 b/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 new file mode 100644 index 0000000..eac537f --- /dev/null +++ b/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 @@ -0,0 +1,154 @@ +function Test-CWAAHealth { + <# + .SYNOPSIS + Performs a read-only health assessment of the ConnectWise Automate agent. + .DESCRIPTION + Checks the overall health of the installed Automate agent without taking any + remediation action. Returns a status object with details about the agent's + installation state, service status, last check-in times, and server connectivity. + + This function never modifies the agent, services, or registry. It is safe to call + at any time for monitoring or diagnostic purposes. + + Health assessment criteria: + - Agent is installed (LTService exists) + - Services are running (LTService and LTSvcMon) + - Agent has checked in recently (LastSuccessStatus or HeartbeatLastSent within threshold) + - Server is reachable (optional, tested when Server param is provided or auto-discovered) + + The Healthy property is True only when the agent is installed, services are running, + and LastContact is not null. + .PARAMETER Server + An Automate server URL to validate against the installed agent's configured server. + If provided, the ServerMatch property indicates whether the installed agent points + to this server. If omitted, ServerMatch is null. + .PARAMETER TestServerConnectivity + When specified, tests whether the agent's server is reachable via the agent.aspx + endpoint. Adds a brief network call. The ServerReachable property is null when + this switch is not used. + .EXAMPLE + Test-CWAAHealth + Returns a health status object for the installed agent. + .EXAMPLE + Test-CWAAHealth -Server 'https://automate.domain.com' -TestServerConnectivity + Checks agent health, validates the server address matches, and tests server connectivity. + .EXAMPLE + if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } + Uses the Healthy boolean for conditional logic. + .NOTES + Author: Chris Taylor + Alias: Test-LTHealth + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + [Alias('Test-LTHealth')] + Param( + [Parameter(ValueFromPipelineByPropertyName = $True)] + [string]$Server, + + [switch]$TestServerConnectivity + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + # Defaults — populated progressively as checks succeed + $agentInstalled = $False + $servicesRunning = $False + $lastContact = $Null + $lastHeartbeat = $Null + $serverAddress = $Null + $serverMatch = $Null + $serverReachable = $Null + $healthy = $False + + # Check if the agent service exists + $ltService = Get-Service 'LTService' -ErrorAction SilentlyContinue + if ($ltService) { + $agentInstalled = $True + + # Check if both services are running + $ltSvcMon = Get-Service 'LTSvcMon' -ErrorAction SilentlyContinue + $servicesRunning = ( + $ltService.Status -eq 'Running' -and + $ltSvcMon -and $ltSvcMon.Status -eq 'Running' + ) + + # Read agent configuration from registry + $agentInfo = $Null + Try { + $agentInfo = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + } + Catch { + Write-Verbose "Unable to read agent info from registry: $($_.Exception.Message)" + } + + if ($agentInfo) { + # Extract server address + $serverAddress = ($agentInfo | Select-Object -Expand 'Server' -EA 0) -join '|' + + # Parse last contact timestamp + Try { + [datetime]$lastContact = $agentInfo.LastSuccessStatus + } + Catch { + Write-Verbose 'LastSuccessStatus not available or not a valid datetime.' + } + + # Parse last heartbeat timestamp + Try { + [datetime]$lastHeartbeat = $agentInfo.HeartbeatLastSent + } + Catch { + Write-Verbose 'HeartbeatLastSent not available or not a valid datetime.' + } + + # If a Server was provided, check if it matches the installed configuration + if ($Server) { + $installedServers = @($agentInfo | Select-Object -Expand 'Server' -EA 0) + $cleanServer = $Server -replace 'https?://', '' -replace '/$', '' + $serverMatch = $False + foreach ($installedServer in $installedServers) { + $cleanInstalled = $installedServer -replace 'https?://', '' -replace '/$', '' + if ($cleanInstalled -eq $cleanServer) { + $serverMatch = $True + break + } + } + } + + # Optionally test server connectivity + if ($TestServerConnectivity) { + $serversToTest = @($agentInfo | Select-Object -Expand 'Server' -EA 0) + if ($serversToTest) { + $serverReachable = Test-CWAAServerConnectivity -Server $serversToTest[0] -Quiet + } + else { + $serverReachable = $False + } + } + } + + # Overall health: installed, running, and has a recent contact timestamp + $healthy = $agentInstalled -and $servicesRunning -and ($Null -ne $lastContact) + } + + [PSCustomObject]@{ + AgentInstalled = $agentInstalled + ServicesRunning = $servicesRunning + LastContact = $lastContact + LastHeartbeat = $lastHeartbeat + ServerAddress = $serverAddress + ServerMatch = $serverMatch + ServerReachable = $serverReachable + Healthy = $healthy + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 b/ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 new file mode 100644 index 0000000..0495789 --- /dev/null +++ b/ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 @@ -0,0 +1,79 @@ +function Unregister-CWAAHealthCheckTask { + <# + .SYNOPSIS + Removes the ConnectWise Automate agent health check scheduled task. + .DESCRIPTION + Deletes the Windows scheduled task created by Register-CWAAHealthCheckTask. + If the task does not exist, writes a warning and returns gracefully. + .PARAMETER TaskName + Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. + .EXAMPLE + Unregister-CWAAHealthCheckTask + Removes the default CWAAHealthCheck scheduled task. + .EXAMPLE + Unregister-CWAAHealthCheckTask -TaskName 'MyHealthCheck' + Removes a custom-named health check task. + .NOTES + Author: Chris Taylor + Alias: Unregister-LTHealthCheckTask + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + [Alias('Unregister-LTHealthCheckTask')] + Param( + [string]$TaskName = 'CWAAHealthCheck' + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + $removed = $False + + # Check if the task exists + $taskExists = $False + Try { + $Null = schtasks /QUERY /TN $TaskName 2>$Null + if ($LASTEXITCODE -eq 0) { + $taskExists = $True + } + } + Catch { Write-Debug "Task '$TaskName' query failed: $($_.Exception.Message)" } + + if (-not $taskExists) { + Write-Warning "Scheduled task '$TaskName' does not exist." + [PSCustomObject]@{ + TaskName = $TaskName + Removed = $False + } + return + } + + if ($PSCmdlet.ShouldProcess("Scheduled Task '$TaskName'", 'Remove health check task')) { + Try { + $schtasksOutput = schtasks /DELETE /TN $TaskName /F 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "schtasks returned exit code $LASTEXITCODE. Output: $schtasksOutput" + } + $removed = $True + Write-Output "Scheduled task '$TaskName' has been removed." + Write-CWAAEventLog -EventId 4030 -EntryType Information -Message "Scheduled task '$TaskName' removed." + } + Catch { + Write-Error "Failed to remove scheduled task '$TaskName'. Error: $($_.Exception.Message)" + Write-CWAAEventLog -EventId 4032 -EntryType Error -Message "Failed to remove scheduled task '$TaskName'. Error: $($_.Exception.Message)" + } + } + + [PSCustomObject]@{ + TaskName = $TaskName + Removed = $removed + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 index 538c089..998ec45 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 @@ -1,59 +1,87 @@ function Get-CWAAInfo { + <# + .SYNOPSIS + Retrieves ConnectWise Automate agent configuration from the registry. + .DESCRIPTION + Reads all agent configuration values from the Automate agent service registry key and + returns them as a single object. Resolves the BasePath from the service image path + if not present in the registry, expands environment variables in BasePath, and parses + the pipe-delimited Server Address into a clean Server array. + + This function supports ShouldProcess because many internal callers pass + -WhatIf:$False -Confirm:$False to suppress prompts during automated operations. + .EXAMPLE + Get-CWAAInfo + Returns an object containing all agent registry properties including ID, Server, + LocationID, BasePath, and other configuration values. + .EXAMPLE + Get-CWAAInfo -WhatIf:$False -Confirm:$False + Retrieves agent info with ShouldProcess suppressed, as used by internal callers. + .NOTES + Author: Chris Taylor + Alias: Get-LTServiceInfo + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True, ConfirmImpact = 'Low')] [Alias('Get-LTServiceInfo')] Param () Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - Clear-Variable key, BasePath, exclude, Servers -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use + Write-Debug "Starting $($MyInvocation.InvocationName)" $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' - $key = $Null } Process { - if ((Test-Path 'HKLM:\SOFTWARE\LabTech\Service') -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find information on LTSvc. Make sure the agent is installed." - Return $Null + if (-not (Test-Path $Script:CWAARegistryRoot)) { + Write-Error "Unable to find information on LTSvc. Make sure the agent is installed." + return $Null } if ($PSCmdlet.ShouldProcess('LTService', 'Retrieving Service Registry Values')) { Write-Verbose 'Checking for LT Service registry keys.' Try { - $key = Get-ItemProperty 'HKLM:\SOFTWARE\LabTech\Service' -ErrorAction Stop | Select-Object * -exclude $exclude + $key = Get-ItemProperty $Script:CWAARegistryRoot -ErrorAction Stop | Select-Object * -Exclude $exclude + if ($Null -ne $key -and -not ($key | Get-Member -EA 0 | Where-Object { $_.Name -match 'BasePath' })) { - if ((Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService') -eq $True) { + $BasePath = $Script:CWAAInstallPath + if (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService') { Try { - $BasePath = Get-Item $( Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' -ErrorAction Stop | Select-Object -Expand ImagePath | Select-String -Pattern '^[^"][^ ]+|(?<=^")[^"]+' | Select-Object -Expand Matches -First 1 | Select-Object -Expand Value -EA 0 -First 1 ) | Select-Object -Expand DirectoryName -EA 0 + $BasePath = Get-Item $( + Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' -ErrorAction Stop | + Select-Object -Expand ImagePath | + Select-String -Pattern '^[^"][^ ]+|(?<=^")[^"]+' | + Select-Object -Expand Matches -First 1 | + Select-Object -Expand Value -EA 0 -First 1 + ) | Select-Object -Expand DirectoryName -EA 0 } Catch { - $BasePath = "${env:windir}\LTSVC" + Write-Debug "Could not resolve BasePath from service ImagePath, using default: $_" } } - else { - $BasePath = "${env:windir}\LTSVC" - } Add-Member -InputObject $key -MemberType NoteProperty -Name BasePath -Value $BasePath } - $key.BasePath = [System.Environment]::ExpandEnvironmentVariables($($key | Select-Object -Expand BasePath -EA 0)) -replace '\\\\', '\' + + $key.BasePath = [System.Environment]::ExpandEnvironmentVariables( + $($key | Select-Object -Expand BasePath -EA 0) + ) -replace '\\\\', '\' + if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'Server Address' })) { - $Servers = ($Key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | ForEach-Object { $_.Trim() -replace '~', '' } | Where-Object { $_ -match '.+' } + $Servers = ($key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | + ForEach-Object { $_.Trim() -replace '~', '' } | + Where-Object { $_ -match '.+' } Add-Member -InputObject $key -MemberType NoteProperty -Name 'Server' -Value $Servers -Force } - } + return $key + } Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem reading the registry keys. $($Error[0])" + Write-Error "There was a problem reading the registry keys. $_" } } } End { - if ($?) { - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" - return $key - } - else { - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" - } + Write-Debug "Exiting $($MyInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 index aedcfe6..5ea5b54 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 @@ -1,37 +1,63 @@ function Get-CWAAInfoBackup { + <# + .SYNOPSIS + Retrieves backed-up ConnectWise Automate agent configuration from the registry. + .DESCRIPTION + Reads all agent configuration values from the LabTechBackup registry key + and returns them as a single object. This backup is created by New-CWAABackup and + stores a snapshot of the agent configuration at the time of backup. + + Expands environment variables in BasePath and parses the pipe-delimited Server + Address into a clean Server array, matching the behavior of Get-CWAAInfo. + .EXAMPLE + Get-CWAAInfoBackup + Returns an object containing all backed-up agent registry properties. + .EXAMPLE + Get-CWAAInfoBackup | Select-Object -ExpandProperty Server + Returns only the server addresses from the backup configuration. + .NOTES + Author: Chris Taylor + Alias: Get-LTServiceInfoBackup + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTServiceInfoBackup')] Param () Begin { - Write-Verbose 'Checking for registry keys.' + Write-Debug "Starting $($MyInvocation.InvocationName)" $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' } Process { - if ((Test-Path 'HKLM:\SOFTWARE\LabTechBackup\Service') -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find backup information on LTSvc. Use New-CWAABackup to create a settings backup." + if (-not (Test-Path $Script:CWAARegistryBackup)) { + Write-Error "Unable to find backup information on LTSvc. Use New-CWAABackup to create a settings backup." return } + Try { - $key = Get-ItemProperty HKLM:\SOFTWARE\LabTechBackup\Service -ErrorAction Stop | Select-Object * -exclude $exclude + $key = Get-ItemProperty $Script:CWAARegistryBackup -ErrorAction Stop | Select-Object * -Exclude $exclude + if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'BasePath' })) { $key.BasePath = [System.Environment]::ExpandEnvironmentVariables($key.BasePath) -replace '\\\\', '\' } + if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'Server Address' })) { - $Servers = ($Key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | ForEach-Object { $_.Trim() } + $Servers = ($key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | + ForEach-Object { $_.Trim() -replace '~', '' } | + Where-Object { $_ -match '.+' } Add-Member -InputObject $key -MemberType NoteProperty -Name 'Server' -Value $Servers -Force } + + return $key } Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem reading the backup registry keys. $($Error[0])" - return + Write-Error "There was a problem reading the backup registry keys. $_" } } End { - if ($?) { - return $key - } + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 index a5e247e..073066c 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 @@ -1,29 +1,50 @@ function Get-CWAASettings { + <# + .SYNOPSIS + Retrieves ConnectWise Automate agent settings from the registry. + .DESCRIPTION + Reads agent settings from the Automate agent service Settings registry subkey + (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. + These settings are separate from the main agent configuration returned by + Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, + ProxyPassword), logging level, and other operational parameters written by + the agent or Set-CWAAProxy. + .EXAMPLE + Get-CWAASettings + Returns an object containing all agent settings registry properties. + .EXAMPLE + (Get-CWAASettings).ProxyServerURL + Returns just the configured proxy URL, if any. + .NOTES + Author: Chris Taylor + Alias: Get-LTServiceSettings + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Get-LTServiceSettings')] Param () Begin { - Write-Verbose 'Checking for registry keys.' - if ((Test-Path 'HKLM:\SOFTWARE\LabTech\Service\Settings') -eq $False) { - Write-Error 'ERROR: Unable to find LTSvc settings. Make sure the agent is installed.' - } + Write-Debug "Starting $($MyInvocation.InvocationName)" $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' } Process { - Try { - Get-ItemProperty HKLM:\SOFTWARE\LabTech\Service\Settings -ErrorAction Stop | Select-Object * -exclude $exclude + if (-not (Test-Path $Script:CWAARegistrySettings)) { + Write-Error "Unable to find LTSvc settings. Make sure the agent is installed." + return } + Try { + return Get-ItemProperty $Script:CWAARegistrySettings -ErrorAction Stop | Select-Object * -Exclude $exclude + } Catch { - Write-Error "ERROR: There was a problem reading the registry keys. $($Error[0])" + Write-Error "There was a problem reading the registry keys. $_" } } End { - if ($?) { - $key - } + Write-Debug "Exiting $($MyInvocation.InvocationName)" } -} \ No newline at end of file +} diff --git a/ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 b/ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 index 8a1d225..75708ab 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 @@ -1,67 +1,109 @@ function New-CWAABackup { - [CmdletBinding()] + <# + .SYNOPSIS + Creates a complete backup of the ConnectWise Automate agent installation. + .DESCRIPTION + Creates a comprehensive backup of the currently installed ConnectWise Automate agent + by copying all files from the agent installation directory and exporting all related + registry keys. This backup can be used to restore the agent configuration if needed, + or to preserve settings before performing maintenance operations. + + The backup process performs the following operations: + 1. Locates the agent installation directory (typically C:\Windows\LTSVC) + 2. Creates a Backup subdirectory within the agent installation path + 3. Copies all files from the installation directory to the Backup folder + 4. Exports registry keys from HKLM\SOFTWARE\LabTech to a .reg file + 5. Modifies the exported registry data to use the LabTechBackup key name + 6. Imports the modified registry data to HKLM\SOFTWARE\LabTechBackup + .EXAMPLE + New-CWAABackup + Creates a complete backup of the agent installation files and registry settings. + .EXAMPLE + New-CWAABackup -WhatIf + Shows what the backup operation would do without actually creating the backup. + .NOTES + Author: Chris Taylor + Alias: New-LTServiceBackup + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] [Alias('New-LTServiceBackup')] Param () Begin { - Clear-Variable LTPath, BackupPath, Keys, Path, Result, Reg, RegPath -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" - $LTPath = "$(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False|Select-Object -Expand BasePath -EA 0)" - if (-not ($LTPath)) { - Write-Error "ERROR: Line $(LINENUM): Unable to find LTSvc folder path." -ErrorAction Stop + $agentPath = "$(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0)" + if (-not $agentPath) { + Write-Error "Unable to find LTSvc folder path." -ErrorAction Stop } - $BackupPath = "$($LTPath)Backup" + $BackupPath = Join-Path $agentPath 'Backup' $Keys = 'HKLM\SOFTWARE\LabTech' $RegPath = "$BackupPath\LTBackup.reg" Write-Verbose 'Checking for registry keys.' - if ((Test-Path ($Keys -replace '^(H[^\\]*)', '$1:')) -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find registry information on LTSvc. Make sure the agent is installed." -ErrorAction Stop - } - if ($(Test-Path -Path $LTPath -PathType Container) -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to find LTSvc folder path $LTPath" -ErrorAction Stop + if (-not (Test-Path ($Keys -replace '^(H[^\\]*)', '$1:'))) { + Write-Error "Unable to find registry information on LTSvc. Make sure the agent is installed." -ErrorAction Stop } - New-Item $BackupPath -type directory -ErrorAction SilentlyContinue | Out-Null - if ($(Test-Path -Path $BackupPath -PathType Container) -eq $False) { - Write-Error "ERROR: Line $(LINENUM): Unable to create backup folder path $BackupPath" -ErrorAction Stop + if (-not (Test-Path -Path $agentPath -PathType Container)) { + Write-Error "Unable to find LTSvc folder path $agentPath" -ErrorAction Stop } } Process { - Try { - Copy-Item $LTPath $BackupPath -Recurse -Force + if ($PSCmdlet.ShouldProcess($BackupPath, 'Create backup directory')) { + New-Item $BackupPath -Type Directory -ErrorAction SilentlyContinue | Out-Null + if (-not (Test-Path -Path $BackupPath -PathType Container)) { + Write-Error "Unable to create backup folder path $BackupPath" -ErrorAction Stop + } } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem backing up the LTSvc Folder. $($Error[0])" + if ($PSCmdlet.ShouldProcess($agentPath, 'Copy agent files to backup')) { + Try { + # Copy each top-level item individually, excluding the Backup directory + # itself to prevent recursive copy loop (Backup is inside the agent path) + Get-ChildItem $agentPath -Exclude 'Backup' | Copy-Item -Destination $BackupPath -Recurse -Force + } + Catch { + Write-Error "There was a problem backing up the LTSvc folder. $_" + Write-CWAAEventLog -EventId 3012 -EntryType Error -Message "Agent backup failed (file copy). Error: $($_.Exception.Message)" + } } - Try { - Write-Debug "Line $(LINENUM): Exporting Registry Data" - $Null = & "$env:windir\system32\reg.exe" export "$Keys" "$RegPath" /y 2>'' - Write-Debug "Line $(LINENUM): Loading and modifying registry key name" - $Reg = Get-Content $RegPath - $Reg = $Reg -replace [Regex]::Escape('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech'), '[HKEY_LOCAL_MACHINE\SOFTWARE\LabTechBackup' - Write-Debug "Line $(LINENUM): Writing output information" - $Reg | Out-File $RegPath - Write-Debug "Line $(LINENUM): Importing Registry data to Backup Path" - $Null = & "$env:windir\system32\reg.exe" import "$RegPath" 2>'' - $True | Out-Null #Protection to prevent exit status error - } + if ($PSCmdlet.ShouldProcess($Keys, 'Export and backup registry keys')) { + Try { + Write-Debug 'Exporting registry data' + $Null = & "$env:windir\system32\reg.exe" export "$Keys" "$RegPath" /y 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "reg.exe export returned exit code $LASTEXITCODE. Registry backup may be incomplete." + } + + Write-Debug 'Loading and modifying registry key name' + $Reg = Get-Content $RegPath + $Reg = $Reg -replace [Regex]::Escape('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech'), '[HKEY_LOCAL_MACHINE\SOFTWARE\LabTechBackup' - Catch { - Write-Error "ERROR: Line $(LINENUM): There was a problem backing up the LTSvc Registry keys. $($Error[0])" + Write-Debug 'Writing modified registry data' + $Reg | Out-File $RegPath + + Write-Debug 'Importing registry data to backup path' + $Null = & "$env:windir\system32\reg.exe" import "$RegPath" 2>'' + if ($LASTEXITCODE -ne 0) { + Write-Warning "reg.exe import returned exit code $LASTEXITCODE. Registry backup restoration may have failed." + } + $True | Out-Null + } + Catch { + Write-Error "There was a problem backing up the LTSvc registry keys. $_" + Write-CWAAEventLog -EventId 3012 -EntryType Error -Message "Agent backup failed (registry export). Error: $($_.Exception.Message)" + } } + + Write-Output 'The Automate agent backup has been created.' + Write-CWAAEventLog -EventId 3010 -EntryType Information -Message "Agent backup created at $BackupPath." } End { - if ($?) { - Write-Output 'The LabTech Backup has been created.' - } - else { - Write-Error "ERROR: Line $(LINENUM): There was a problem completing the LTSvc Backup. $($Error[0])" - } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 index 2440d4c..69151fd 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 @@ -1,4 +1,42 @@ function Reset-CWAA { + <# + .SYNOPSIS + Removes local agent identity settings to force re-registration. + .DESCRIPTION + Removes some of the agent's local settings: ID, MAC, and/or LocationID. The function + stops the services, removes the specified registry values, then restarts the services. + Resetting all three values forces the agent to check in as a new agent. If MAC filtering + is enabled on the server, the agent should check back in with the same ID. + + This function is useful for resolving duplicate agent entries. If no switches are + specified, all three values (ID, Location, MAC) are reset. + + Probe agents are protected from reset unless the -Force switch is used. + .PARAMETER ID + Resets the AgentID of the computer. + .PARAMETER Location + Resets the LocationID of the computer. + .PARAMETER MAC + Resets the MAC address of the computer. + .PARAMETER Force + Forces the reset operation on an agent detected as a probe. + .PARAMETER NoWait + Skips the post-reset health check that waits for the agent to re-register. + .EXAMPLE + Reset-CWAA + Resets the ID, MAC, and LocationID on the agent, then waits for re-registration. + .EXAMPLE + Reset-CWAA -ID + Resets only the AgentID of the agent. + .EXAMPLE + Reset-CWAA -Force -NoWait + Resets all values on a probe agent without waiting for re-registration. + .NOTES + Author: Chris Taylor + Alias: Reset-LTService + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Reset-LTService')] Param( @@ -10,41 +48,39 @@ function Reset-CWAA { ) Begin { - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Starting $($MyInvocation.InvocationName)" - $Reg = 'HKLM:\Software\LabTech\Service' - if (!$PsBoundParameters.ContainsKey('ID') -and !$PsBoundParameters.ContainsKey('Location') -and !$PsBoundParameters.ContainsKey('MAC')) { + if (-not $PSBoundParameters.ContainsKey('ID') -and -not $PSBoundParameters.ContainsKey('Location') -and -not $PSBoundParameters.ContainsKey('MAC')) { $ID = $True $Location = $True $MAC = $True } - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if (($LTSI) -and ($LTSI | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { + if ($Force) { Write-Output 'Probe Agent Detected. Reset Forced.' } else { if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"ERROR: Line $(LINENUM): Probe Agent Detected. Reset Denied." -ErrorAction Stop + Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Reset Denied." -ErrorAction Stop } else { - Write-Error -Exception [System.OperationCanceledException]"What If: Line $(LINENUM): Probe Agent Detected. Reset Denied." -ErrorAction Stop + Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Reset Denied." -ErrorAction Stop } } } - Write-Output "OLD ID: $($LTSI|Select-Object -Expand ID -EA 0) LocationID: $($LTSI|Select-Object -Expand LocationID -EA 0) MAC: $($LTSI|Select-Object -Expand MAC -EA 0)" - $LTSI = $Null + Write-Output "OLD ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" } Process { - if (!(Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { + if (-not (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue)) { if ($WhatIfPreference -ne $True) { - Write-Error "ERROR: Line $(LINENUM): LabTech Services NOT Found $($Error[0])" + Write-Error "Automate agent services NOT Found." return } else { - Write-Error "What If: Line $(LINENUM): Stopping: LabTech Services NOT Found" + Write-Error "What If: Stopping: Automate agent services NOT Found." return } } @@ -54,43 +90,45 @@ function Reset-CWAA { Stop-CWAA if ($ID) { Write-Output '.Removing ID' - Remove-ItemProperty -Name ID -Path $Reg -ErrorAction SilentlyContinue + Remove-ItemProperty -Name ID -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue } if ($Location) { Write-Output '.Removing LocationID' - Remove-ItemProperty -Name LocationID -Path $Reg -ErrorAction SilentlyContinue + Remove-ItemProperty -Name LocationID -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue } if ($MAC) { Write-Output '.Removing MAC' - Remove-ItemProperty -Name MAC -Path $Reg -ErrorAction SilentlyContinue + Remove-ItemProperty -Name MAC -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue } Start-CWAA } } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error during the reset process. $($Error[0])" -ErrorAction Stop + Write-CWAAEventLog -EventId 3002 -EntryType Error -Message "Agent reset failed. Error: $($_.Exception.Message)" + Write-Error "There was an error during the reset process. $_" -ErrorAction Stop } } End { - if ($?) { - if (-NOT $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Discover new settings after Service Start')) { - $timeout = New-TimeSpan -Minutes 1 - $sw = [diagnostics.stopwatch]::StartNew() - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Write-Host -NoNewline 'Waiting for agent to register.' - While (!($LTSI | Select-Object -Expand ID -EA 0) -or !($LTSI | Select-Object -Expand LocationID -EA 0) -or !($LTSI | Select-Object -Expand MAC -EA 0) -and $($sw.elapsed) -lt $timeout) { - Write-Host -NoNewline '.' - Start-Sleep 2 - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - } - Write-Host '' - $LTSI = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Write-Output "NEW ID: $($LTSI|Select-Object -Expand ID -EA 0) LocationID: $($LTSI|Select-Object -Expand LocationID -EA 0) MAC: $($LTSI|Select-Object -Expand MAC -EA 0)" + if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Discover new settings after Service Start')) { + $timeout = New-TimeSpan -Minutes 1 + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + Write-Verbose 'Waiting for agent to register...' + while ( + (-not ($serviceInfo | Select-Object -Expand ID -EA 0) -or + -not ($serviceInfo | Select-Object -Expand LocationID -EA 0) -or + -not ($serviceInfo | Select-Object -Expand MAC -EA 0)) -and + $stopwatch.Elapsed -lt $timeout + ) { + Start-Sleep 2 + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False } + Write-Verbose 'Agent registration wait complete.' + $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + Write-Output "NEW ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" + Write-CWAAEventLog -EventId 3000 -EntryType Information -Message "Agent reset successfully. New ID: $($serviceInfo | Select-Object -Expand ID -EA 0), LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0)" } - else { $Error[0] } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 b/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 index 427a36e..8f29401 100644 --- a/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 +++ b/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 @@ -1,5 +1,33 @@ - function Test-CWAAPort { + <# + .SYNOPSIS + Tests connectivity to TCP ports required by the ConnectWise Automate agent. + .DESCRIPTION + Verifies that the local LTTray port is available and tests connectivity to + the required TCP ports (70, 80, 443) on the Automate server, plus port 8002 + on the Automate mediator server. + If no server is provided, the function attempts to detect it from the installed + agent configuration or backup info. + .PARAMETER Server + The URL of the Automate server (e.g., https://automate.domain.com). + If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + .PARAMETER TrayPort + The local port LTSvc.exe listens on for LTTray communication. + Defaults to 42000 if not provided or not found in agent configuration. + .PARAMETER Quiet + Returns a boolean connectivity result instead of verbose output. + .EXAMPLE + Test-CWAAPort -Server 'https://automate.domain.com' + Tests all required ports against the specified server. + .EXAMPLE + Test-CWAAPort -Quiet + Returns $True if the TrayPort is available, $False otherwise. + .NOTES + Author: Chris Taylor + Alias: Test-LTPorts + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> [CmdletBinding()] [Alias('Test-LTPorts')] Param( @@ -12,44 +40,40 @@ function Test-CWAAPort { ) Begin { - $Mediator = 'mediator.labtechsoftware.com' + Write-Debug "Starting $($MyInvocation.InvocationName)" + $MediatorServer = 'mediator.labtechsoftware.com' + function Private:TestPort { Param( [parameter(Position = 0)] - [string] - $ComputerName, + [string]$ComputerName, [parameter(Mandatory = $False)] - [System.Net.IPAddress] - $IPAddress, + [System.Net.IPAddress]$IPAddress, - [parameter(Mandatory = $True , Position = 1)] - [int] - $Port + [parameter(Mandatory = $True, Position = 1)] + [int]$Port ) - $RemoteServer = if ([string]::IsNullOrEmpty($ComputerName)) { $IPAddress } else { $ComputerName }; - if ([string]::IsNullOrEmpty($RemoteServer)) { Write-Error "ERROR: Line $(LINENUM): No ComputerName or IPAddress was provided to test."; return } + $RemoteServer = if ([string]::IsNullOrEmpty($ComputerName)) { $IPAddress } else { $ComputerName } + if ([string]::IsNullOrEmpty($RemoteServer)) { + Write-Error "No ComputerName or IPAddress was provided to test." + return + } - $test = New-Object System.Net.Sockets.TcpClient; + $tcpClient = New-Object System.Net.Sockets.TcpClient Try { - Write-Output "Connecting to $($RemoteServer):$Port (TCP).."; - $test.Connect($RemoteServer, $Port); - Write-Output 'Connection successful'; + Write-Output "Connecting to $($RemoteServer):$Port (TCP).." + $tcpClient.Connect($RemoteServer, $Port) + Write-Output 'Connection successful' } Catch { - Write-Output 'ERROR: Connection failed'; - $Global:PortTestError = 1 + Write-Output 'Connection failed' } Finally { - $test.Close(); + $tcpClient.Close() } - } - - Clear-Variable CleanSvr, svr, proc, processes, port, netstat, line -EA 0 -WhatIf:$False -Confirm:$False #Clearing Variables for use - Write-Debug "Starting $($myInvocation.InvocationName) at line $(LINENUM)" - } Process { @@ -64,33 +88,39 @@ function Test-CWAAPort { if (-not ($Quiet) -or (($TrayPort) -ge 1 -and ($TrayPort) -le 65530)) { if (-not ($TrayPort) -or -not (($TrayPort) -ge 1 -and ($TrayPort) -le 65530)) { - #Learn LTTrayPort if available. + # Discover TrayPort from agent configuration if not provided $TrayPort = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand TrayPort -EA 0) } if (-not ($TrayPort) -or $TrayPort -notmatch '^\d+$') { $TrayPort = 42000 } [array]$processes = @() - #Get all processes that are using LTTrayPort (Default 42000) - Try { $netstat = & "$env:windir\system32\netstat.exe" -a -o -n | Select-String -Pattern " .*[0-9\.]+:$($TrayPort).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 } - Catch { Write-Output 'Error calling netstat.exe.'; $netstat = $null } - Foreach ($line In $netstat) { - $processes += ($line -split ' {4,}')[-1] + # Get all processes using the TrayPort (default 42000) + Try { + $netstatOutput = & "$env:windir\system32\netstat.exe" -a -o -n | Select-String -Pattern " .*[0-9\.]+:$($TrayPort).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 + } + Catch { + Write-Output 'Error calling netstat.exe.' + $netstatOutput = $null + } + foreach ($netstatLine in $netstatOutput) { + $processes += ($netstatLine -split ' {4,}')[-1] } $processes = $processes | Where-Object { $_ -gt 0 -and $_ -match '^\d+$' } | Sort-Object | Get-Unique + if (($processes)) { if (-not ($Quiet)) { - Foreach ($proc In $processes) { - if ((Get-Process -Id $proc -EA 0 | Select-Object -Expand ProcessName -EA 0) -eq 'LTSvc') { + foreach ($processId in $processes) { + if ((Get-Process -Id $processId -EA 0 | Select-Object -Expand ProcessName -EA 0) -eq 'LTSvc') { Write-Output "TrayPort Port $TrayPort is being used by LTSvc." } else { - Write-Output "Error: TrayPort Port $TrayPort is being used by $(Get-Process -Id $proc|Select-Object -Expand ProcessName -EA 0)." + Write-Output "Error: TrayPort Port $TrayPort is being used by $(Get-Process -Id $processId | Select-Object -Expand ProcessName -EA 0)." } } } else { return $False } } - Elseif (($Quiet) -eq $True) { + elseif (($Quiet) -eq $True) { return $True } else { @@ -98,41 +128,36 @@ function Test-CWAAPort { } } - foreach ($svr in $Server) { + foreach ($serverEntry in $Server) { if ($Quiet) { - $CleanSvr = ($Svr -replace 'https?://', '' | ForEach-Object { $_.Trim() }) - Test-Connection $CleanSvr -Quiet + $cleanServerAddress = ($serverEntry -replace 'https?://', '' | ForEach-Object { $_.Trim() }) + Test-Connection $cleanServerAddress -Quiet return } - if ($Svr -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { + if ($serverEntry -match $Script:CWAAServerValidationRegex) { Try { - $CleanSvr = ($Svr -replace 'https?://', '' | ForEach-Object { $_.Trim() }) + $cleanServerAddress = ($serverEntry -replace 'https?://', '' | ForEach-Object { $_.Trim() }) Write-Output 'Testing connectivity to required TCP ports:' - TestPort -ComputerName $CleanSvr -Port 70 - TestPort -ComputerName $CleanSvr -Port 80 - TestPort -ComputerName $CleanSvr -Port 443 - TestPort -ComputerName $Mediator -Port 8002 - + TestPort -ComputerName $cleanServerAddress -Port 70 + TestPort -ComputerName $cleanServerAddress -Port 80 + TestPort -ComputerName $cleanServerAddress -Port 443 + TestPort -ComputerName $MediatorServer -Port 8002 } - Catch { - Write-Error "ERROR: Line $(LINENUM): There was an error testing the ports. $($Error[0])" -ErrorAction Stop + Write-Error "There was an error testing the ports for '$serverEntry'. $($_)" -ErrorAction Stop } } else { - Write-Warning "WARNING: Line $(LINENUM): Server address $($Svr) is not a valid address or is not formatted correctly. Example: https://automate.domain.com" + Write-Warning "Server address '$($serverEntry)' is not valid or not formatted correctly. Example: https://automate.domain.com" } } } End { - if ($?) { - if (-not ($Quiet)) { - Write-Output 'Test-CWAAPorts Finished' - } + if (-not ($Quiet)) { + Write-Output 'Test-CWAAPort Finished' } - Else { $Error[0] } - Write-Debug "Exiting $($myInvocation.InvocationName) at line $(LINENUM)" + Write-Debug "Exiting $($MyInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 b/ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 new file mode 100644 index 0000000..3aa3432 --- /dev/null +++ b/ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 @@ -0,0 +1,143 @@ +function Test-CWAAServerConnectivity { + <# + .SYNOPSIS + Tests connectivity to a ConnectWise Automate server's agent endpoint. + .DESCRIPTION + Verifies that an Automate server is online and responding by querying the + agent.aspx endpoint. Validates that the response matches the expected version + format (pipe-delimited string ending with a version number). + + If no server is provided, the function attempts to discover it from the + installed agent configuration or backup settings. + + Returns a result object per server with availability status and version info, + or a simple boolean in Quiet mode. + .PARAMETER Server + One or more ConnectWise Automate server URLs (e.g., https://automate.domain.com). + If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + .PARAMETER Quiet + Returns $True if all servers are reachable, $False otherwise. + .EXAMPLE + Test-CWAAServerConnectivity -Server 'https://automate.domain.com' + Tests connectivity and returns a result object with Server, Available, Version, and ErrorMessage. + .EXAMPLE + Test-CWAAServerConnectivity -Quiet + Returns $True if the discovered server is reachable, $False otherwise. + .EXAMPLE + Get-CWAAInfo | Test-CWAAServerConnectivity + Tests connectivity to the server configured on the installed agent via pipeline. + .NOTES + Author: Chris Taylor + Alias: Test-LTServerConnectivity + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + [Alias('Test-LTServerConnectivity')] + Param( + [Parameter(ValueFromPipelineByPropertyName = $True, ValueFromPipeline = $True)] + [string[]]$Server, + + [switch]$Quiet + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + + # Enable TLS 1.2 for the web request without full Initialize-CWAANetworking + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + # Expected response pattern from agent.aspx: pipe-delimited string ending with version + $agentResponsePattern = '\|\|\|\|\|\|\d+\.\d+' + $versionExtractPattern = '(\d+\.\d+)\s*$' + + $allAvailable = $True + } + + Process { + if (-not $Server) { + Write-Verbose 'No Server provided - checking installed agent configuration.' + $Server = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | + Select-Object -Expand 'Server' -EA 0 + if (-not $Server) { + Write-Verbose 'No Server found in agent config. Checking backup settings.' + $Server = Get-CWAAInfoBackup -EA 0 -Verbose:$False | + Select-Object -Expand 'Server' -EA 0 + } + if (-not $Server) { + Write-Error "No server could be determined. Provide a -Server parameter or ensure the agent is installed." + return + } + } + + foreach ($serverEntry in $Server) { + # Normalize: ensure the URL has a scheme + $serverUrl = $serverEntry.Trim() + if ($serverUrl -notmatch '^https?://') { + $serverUrl = "https://$serverUrl" + } + + # Validate server address format + $cleanAddress = $serverUrl -replace 'https?://', '' + if ($cleanAddress -notmatch $Script:CWAAServerValidationRegex) { + Write-Warning "Server address '$serverEntry' is not valid or not formatted correctly. Example: https://automate.domain.com" + $allAvailable = $False + if (-not $Quiet) { + [PSCustomObject]@{ + Server = $serverEntry + Available = $False + Version = $Null + ErrorMessage = 'Invalid server address format' + } + } + continue + } + + $endpointUrl = "$serverUrl/LabTech/agent.aspx" + $available = $False + $version = $Null + $errorMessage = $Null + + Try { + Write-Verbose "Testing connectivity to $endpointUrl" + $response = Invoke-RestMethod -Uri $endpointUrl -TimeoutSec 10 -ErrorAction Stop + + if ($response -match $agentResponsePattern) { + $available = $True + if ($response -match $versionExtractPattern) { + $version = $Matches[1] + } + Write-Verbose "Server '$serverEntry' is available (version $version)." + } + else { + $errorMessage = 'Server responded but with unexpected format' + Write-Verbose "Server '$serverEntry' responded but response did not match expected agent pattern." + } + } + Catch { + $errorMessage = $_.Exception.Message + Write-Verbose "Server '$serverEntry' is not available: $errorMessage" + } + + if (-not $available) { + $allAvailable = $False + } + + if (-not $Quiet) { + [PSCustomObject]@{ + Server = $serverEntry + Available = $available + Version = $version + ErrorMessage = $errorMessage + } + } + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + if ($Quiet) { + return $allAvailable + } + } +} diff --git a/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml b/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml index 6660fa8..5bb4df4 100644 --- a/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml +++ b/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml @@ -6,11 +6,11 @@ ConvertFrom CWAASecurity - This function decodes an encoded Base64 value + Decodes a Base64-encoded string using TripleDES decryption. - This function decodes the provided string using the specified or default key. + This function decodes the provided string using the specified or default key. It uses TripleDES with an MD5-derived key and a fixed initialization vector. If decoding fails with the provided key and Force is enabled, alternate key values are attempted automatically. @@ -18,7 +18,7 @@ InputString - This is the string to be decoded. + The Base64-encoded string to be decoded. String[] @@ -27,10 +27,21 @@ None + + Force + + Forces the function to try alternate key values if decoding fails using the provided key. Enabled by default. + + + SwitchParameter + + + True + Key - This is the key used for decoding. If not provided, default values will be tried. + The key used for decoding. If not provided, default values will be tried. String[] @@ -39,24 +50,37 @@ None - - Force + + ProgressAction - This forces the function to try alternate key values if decoding fails using provided key. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - True + None + + Force + + Forces the function to try alternate key values if decoding fails using the provided key. Enabled by default. + + SwitchParameter + + SwitchParameter + + + True + InputString - This is the string to be decoded. + The Base64-encoded string to be decoded. String[] @@ -68,7 +92,7 @@ Key - This is the key used for decoding. If not provided, default values will be tried. + The key used for decoding. If not provided, default values will be tried. String[] @@ -77,39 +101,46 @@ None - - Force + + ProgressAction - This forces the function to try alternate key values if decoding fails using provided key. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - True + None - Version: 1.1 Author: Darren White Creation Date: 1/25/2018 Purpose/Change: Initial function development + Author: Chris Taylor Alias: ConvertFrom-LTSecurity - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + ConvertFrom-CWAASecurity -InputString 'EncodedValue' + + Decodes the string using the default key. + + + + -------------------------- EXAMPLE 2 -------------------------- + ConvertFrom-CWAASecurity -InputString 'EncodedValue' -Key 'MyCustomKey' - {{ Add example description here }} + Decodes the string using a custom key. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -119,11 +150,11 @@ ConvertTo CWAASecurity - This function encodes a value compatible with LT operations. + Encodes a string using TripleDES encryption compatible with Automate operations. - This function encodes the provided string using the specified or default key. + This function encodes the provided string using the specified or default key. It uses TripleDES with an MD5-derived key and a fixed initialization vector, returning a Base64-encoded result. @@ -131,7 +162,7 @@ InputString - This is the string to be encoded. + The string to be encoded. String @@ -143,7 +174,7 @@ Key - This is the key used for encoding. If not provided, a default value will be used. + The key used for encoding. If not provided, a default value will be used. Object @@ -152,13 +183,25 @@ None + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + InputString - This is the string to be encoded. + The string to be encoded. String @@ -170,7 +213,7 @@ Key - This is the key used for encoding. If not provided, a default value will be used. + The key used for encoding. If not provided, a default value will be used. Object @@ -179,27 +222,46 @@ None + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - Version: 1.1 Author: Darren White Creation Date: 1/25/2018 Purpose/Change: Initial function development + Author: Chris Taylor Alias: ConvertTo-LTSecurity - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + ConvertTo-CWAASecurity -InputString 'PlainTextValue' + + Encodes the string using the default key. + + + + -------------------------- EXAMPLE 2 -------------------------- + ConvertTo-CWAASecurity -InputString 'PlainTextValue' -Key 'MyCustomKey' - {{ Add example description here }} + Encodes the string using a custom key. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -209,48 +271,71 @@ Get CWAAError - This will pull the %ltsvcdir%\LTErrors.txt file into an object. + Reads the ConnectWise Automate Agent error log into structured objects. - {{ Fill in the Description }} + Parses the LTErrors.txt file from the agent install directory into objects with ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, and pipeline operations on agent error log entries. + The log file location is determined from Get-CWAAInfo; if unavailable, falls back to the default install path at C:\Windows\LTSVC. Get-CWAAError + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + - Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/18/2018 Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. - Update Date: 1/26/2019 Purpose/Change: Update for better international date parsing support. Function rename. + Author: Chris Taylor Alias: Get-LTErrors -------------------------- EXAMPLE 1 -------------------------- - Get-CWAAErrors | where {(Get-date $_.Time) -gt (get-date).AddHours(-24)} + Get-CWAAError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} - Get a list of all errors in the last 24hr + Returns all agent errors from the last 24 hours. -------------------------- EXAMPLE 2 -------------------------- - Get-CWAAErrors | Out-Gridview + Get-CWAAError | Out-GridView - Open the log file in a sortable searchable window. + Opens the error log in a sortable, searchable grid view window. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -260,25 +345,27 @@ Get CWAAInfo - This function will pull all of the registry data into an object. + Retrieves ConnectWise Automate agent configuration from the registry. - {{ Fill in the Description }} + Reads all agent configuration values from the Automate agent service registry key and returns them as a single object. Resolves the BasePath from the service image path if not present in the registry, expands environment variables in BasePath, and parses the pipe-delimited Server Address into a clean Server array. + This function supports ShouldProcess because many internal callers pass -WhatIf:$False -Confirm:$False to suppress prompts during automated operations. Get-CWAAInfo - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -291,20 +378,31 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -318,32 +416,46 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + - Version: 1.5 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 8/24/2017 Purpose/Change: Update to use Clear-Variable. - Update Date: 3/12/2018 Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. - Update Date: 8/28/2018 Purpose/Change: Remove '~' from server addresses. - Update Date: 1/19/2019 Purpose/Change: Improved BasePath value assignment + Author: Chris Taylor Alias: Get-LTServiceInfo - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Get-CWAAInfo + + Returns an object containing all agent registry properties including ID, Server, LocationID, BasePath, and other configuration values. + + + + -------------------------- EXAMPLE 2 -------------------------- + Get-CWAAInfo -WhatIf:$False -Confirm:$False - {{ Add example description here }} + Retrieves agent info with ShouldProcess suppressed, as used by internal callers. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -353,40 +465,71 @@ Get CWAAInfoBackup - This function will pull all of the backed up registry data into an object. + Retrieves backed-up ConnectWise Automate agent configuration from the registry. - {{ Fill in the Description }} + Reads all agent configuration values from the LabTechBackup registry key and returns them as a single object. This backup is created by New-CWAABackup and stores a snapshot of the agent configuration at the time of backup. + Expands environment variables in BasePath and parses the pipe-delimited Server Address into a clean Server array, matching the behavior of Get-CWAAInfo. Get-CWAAInfoBackup + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + - Version: 1.1 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 5/11/2017 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/18/2018 Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. + Author: Chris Taylor Alias: Get-LTServiceInfoBackup - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Get-CWAAInfoBackup + + Returns an object containing all backed-up agent registry properties. + + + + -------------------------- EXAMPLE 2 -------------------------- + Get-CWAAInfoBackup | Select-Object -ExpandProperty Server - {{ Add example description here }} + Returns only the server addresses from the backup configuration. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -396,56 +539,71 @@ Get CWAALogLevel - {{ Fill in the Synopsis }} + Retrieves the current logging level for the ConnectWise Automate Agent. - {{ Fill in the Description }} + Checks the agent's registry settings to determine the current logging verbosity level. The ConnectWise Automate Agent supports two logging levels: Normal (value 1) for standard operations, and Verbose (value 1000) for detailed diagnostic logging. + The logging level is stored in the registry at HKLM:\SOFTWARE\LabTech\Service\Settings under the "Debuging" value. Get-CWAALogLevel + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - - - - - None - + + + ProgressAction - + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - - - + ActionPreference - System.Object + ActionPreference + - - - - - + None + + + + - + Author: Chris Taylor Alias: Get-LTLogging - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Get-CWAALogLevel + + Returns the current logging level (Normal or Verbose). + + + + -------------------------- EXAMPLE 2 -------------------------- + Get-CWAALogLevel - {{ Add example description here }} + Set-CWAALogLevel -Level Verbose Get-CWAALogLevel Typical troubleshooting workflow: check level, enable verbose, verify the change. - Online Version: - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -455,48 +613,71 @@ Get CWAAProbeError - This will pull the %ltsvcdir%\LTProbeErrors.txt file into an object. + Reads the ConnectWise Automate Agent probe error log into structured objects. - {{ Fill in the Description }} + Parses the LTProbeErrors.txt file from the agent install directory into objects with ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, and pipeline operations on agent probe error log entries. + The log file location is determined from Get-CWAAInfo; if unavailable, falls back to the default install path at C:\Windows\LTSVC. Get-CWAAProbeError + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + - Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/18/2018 Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. - Update Date: 1/26/2019 Purpose/Change: Update for better international date parsing support + Author: Chris Taylor Alias: Get-LTProbeErrors -------------------------- EXAMPLE 1 -------------------------- - Get-CWAAProbeErrors | where {(Get-date $_.Time) -gt (get-date).AddHours(-24)} + Get-CWAAProbeError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} - Get a list of all errors in the last 24hr + Returns all probe errors from the last 24 hours. -------------------------- EXAMPLE 2 -------------------------- - Get-CWAAProbeErrors | Out-Gridview + Get-CWAAProbeError | Out-GridView - Open the log file in a sortable searchable window. + Opens the probe error log in a sortable, searchable grid view window. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -506,39 +687,70 @@ Get CWAAProxy - This function retrieves the current agent proxy settings for module functions to use the specified proxy configuration for all communication operations as long as the module remains loaded. + Retrieves the current agent proxy settings for module operations. - This function will get the current LabTech Proxy settings from the installed agent (if present). If no agent settings are found, the function will attempt to discover the current proxy settings for the system. The Proxy Settings determined will be stored in memory for internal use, and returned as the function result. + Reads the current Automate agent proxy settings from the installed agent (if present) and stores them in the module-scoped $Script:LTProxy object. The proxy URL, username, and password are decrypted using the agent's password string. The discovered settings are used by all module communication operations for the duration of the session, and returned as the function result. Get-CWAAProxy + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + - Version: 1.1 Author: Darren White Creation Date: 1/24/2018 Purpose/Change: Initial function development - Update Date: 3/18/2018 Purpose/Change: Ensure ProxyUser and ProxyPassword are set correctly when proxy is not configured. + Author: Darren White Alias: Get-LTProxy - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Get-CWAAProxy + + Retrieves and returns the current proxy configuration. + + + + -------------------------- EXAMPLE 2 -------------------------- + $proxy = Get-CWAAProxy - {{ Add example description here }} + if ($proxy.Enabled) { Write-Host "Proxy: $($proxy.ProxyServerURL)" } Checks whether a proxy is configured and displays the URL. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -548,56 +760,70 @@ Get CWAASettings - Pulls information about the agent install. + Retrieves ConnectWise Automate agent settings from the registry. - {{ Fill in the Description }} + Reads agent settings from the Automate agent service settings registry subkey (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. These settings are separate from the main agent configuration returned by Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, ProxyPassword), logging level, and other operational parameters written by the agent or Set-CWAAProxy. Get-CWAASettings + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + - - - - - None - + + + ProgressAction - + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - - - + ActionPreference - System.Object + ActionPreference + - - - - - + None + + + + - + Author: Chris Taylor Alias: Get-LTServiceSettings - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Get-CWAASettings + + Returns an object containing all agent settings registry properties. + + + + -------------------------- EXAMPLE 2 -------------------------- + (Get-CWAASettings).ProxyServerURL - {{ Add example description here }} + Returns just the configured proxy URL, if any. - Online Version: - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -607,25 +833,26 @@ Hide CWAAAddRemove - This function hides the LabTech install from the Add/Remove Programs list. + Hides the Automate agent from the Add/Remove Programs list. - This function will rename the DisplayName registry key to hide it from the Add/Remove Programs list. + Sets the SystemComponent registry value to 1 on Automate agent uninstall keys, which hides the agent from the Windows Add/Remove Programs (Programs and Features) list. Also cleans up any leftover HiddenProductName registry values from older hiding methods. Hide-CWAAAddRemove - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -638,20 +865,31 @@ False - - + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -665,29 +903,46 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + - Version: 1.2 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/12/2018 Purpose/Change: Support for ShouldProcess. Added Registry Paths to be checked. Modified hiding method to be compatible with standard software controls. + Author: Chris Taylor Alias: Hide-LTAddRemove - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Hide-CWAAAddRemove + + Hides the Automate agent entry from Add/Remove Programs. + + + + -------------------------- EXAMPLE 2 -------------------------- + Hide-CWAAAddRemove -WhatIf - {{ Add example description here }} + Shows what registry changes would be made without applying them. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -697,31 +952,42 @@ Install CWAA - This function will install the LabTech agent on the machine. + Installs the ConnectWise Automate Agent on the local computer. - This function will install the LabTech agent on the machine with the specified server/password/location. + Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. + If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. The -Force parameter allows installation even when services are already present or when only .NET 4.0+ is available without 3.5. Install-CWAA - - Server + + Force - This is the URL to your LabTech server. example: https://automate.domain.com This is used to download the installation files. (Get-CWAAInfo|Select-Object -Expand 'Server Address' -ErrorAction SilentlyContinue) + Disables safety checks including existing service detection and .NET version requirements. - String[] - String[] + SwitchParameter - None + False - - ServerPassword + + Hide + + Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. + + + SwitchParameter + + + False + + + InstallerToken - This is the server password that agents use to authenticate with the LabTech server. SELECT SystemPassword FROM config; + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. String @@ -730,34 +996,45 @@ None - + LocationID - This is the LocationID of the location that the agent will be put into. (Get-CWAAInfo).LocationID + The LocationID of the location the agent will be assigned to. Int32 Int32 - 0 + None - - TrayPort + + NoWait - This is the port LTSvc.exe listens on for communication with LTTray processes. + Skips the post-install health check that waits for agent registration. The function exits immediately after the installer completes. - Int32 - Int32 + SwitchParameter - 0 + False - + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + Rename - This will call Rename-CWAAAddRemove after the install. + Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. String @@ -767,31 +1044,33 @@ None - Hide + Server - This will call Hide-CWAAAddRemove after the install. + One or more ConnectWise Automate server URLs to download the installer from. Example: https://automate.domain.com The function tries each server in order until a successful download occurs. + String[] - SwitchParameter + String[] - False + None - - SkipDotNet + + ServerPassword - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + The server password that agents use to authenticate with the Automate server. Used for legacy deployment method. InstallerToken is preferred. + String - SwitchParameter + String - False + None - Force + SkipCertificateCheck - This will disable some of the error checking on the install process. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -800,9 +1079,9 @@ False - NoWait + SkipDotNet - This will skip the ending health check for the install process. The function will exit once the installer has completed. + Skips .NET Framework 3.5 and 2.0 prerequisite checks. Use when .NET 4.0+ is already installed. SwitchParameter @@ -810,16 +1089,17 @@ False - - WhatIf + + TrayPort - Shows what would happen if the cmdlet runs. The cmdlet is not run. + The local port LTSvc.exe listens on for communication with LTTray processes. Defaults to 42000. If the port is in use, the function auto-selects the next available port. + Int32 - SwitchParameter + Int32 - False + None Confirm @@ -832,61 +1112,81 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + Install-CWAA - - Server + + Force - This is the URL to your LabTech server. example: https://automate.domain.com This is used to download the installation files. (Get-CWAAInfo|Select-Object -Expand 'Server Address' -ErrorAction SilentlyContinue) + Disables safety checks including existing service detection and .NET version requirements. - String[] - String[] + SwitchParameter - None + False - - ServerPassword + + Hide - This is the server password that agents use to authenticate with the LabTech server. SELECT SystemPassword FROM config; + Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. - String - String + SwitchParameter - None + False - + LocationID - This is the LocationID of the location that the agent will be put into. (Get-CWAAInfo).LocationID + The LocationID of the location the agent will be assigned to. Int32 Int32 - 0 + None - - TrayPort + + NoWait - This is the port LTSvc.exe listens on for communication with LTTray processes. + Skips the post-install health check that waits for agent registration. The function exits immediately after the installer completes. - Int32 - Int32 + SwitchParameter - 0 + False - + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + Rename - This will call Rename-CWAAAddRemove after the install. + Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. String @@ -896,31 +1196,33 @@ None - Hide + Server - This will call Hide-CWAAAddRemove after the install. + One or more ConnectWise Automate server URLs to download the installer from. Example: https://automate.domain.com The function tries each server in order until a successful download occurs. + String[] - SwitchParameter + String[] - False + None - - SkipDotNet + + ServerPassword - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + The server password that agents use to authenticate with the Automate server. Used for legacy deployment method. InstallerToken is preferred. + String - SwitchParameter + String - False + None - Force + SkipCertificateCheck - This will disable some of the error checking on the install process. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -929,9 +1231,9 @@ False - NoWait + SkipDotNet - This will skip the ending health check for the install process. The function will exit once the installer has completed. + Skips .NET Framework 3.5 and 2.0 prerequisite checks. Use when .NET 4.0+ is already installed. SwitchParameter @@ -939,16 +1241,17 @@ False - - WhatIf + + TrayPort - Shows what would happen if the cmdlet runs. The cmdlet is not run. + The local port LTSvc.exe listens on for communication with LTTray processes. Defaults to 42000. If the port is in use, the function auto-selects the next available port. + Int32 - SwitchParameter + Int32 - False + None Confirm @@ -961,38 +1264,48 @@ False - - InstallerToken + + WhatIf - An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + Shows what would happen if the cmdlet runs. The cmdlet is not run. - String - String + SwitchParameter - None + False - - Server + + Force - This is the URL to your LabTech server. example: https://automate.domain.com This is used to download the installation files. (Get-CWAAInfo|Select-Object -Expand 'Server Address' -ErrorAction SilentlyContinue) + Disables safety checks including existing service detection and .NET version requirements. - String[] + SwitchParameter - String[] + SwitchParameter - None + False - - ServerPassword + + Hide + + Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. + + SwitchParameter + + SwitchParameter + + + False + + + InstallerToken - This is the server password that agents use to authenticate with the LabTech server. SELECT SystemPassword FROM config; + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. String @@ -1001,82 +1314,82 @@ None - + LocationID - This is the LocationID of the location that the agent will be put into. (Get-CWAAInfo).LocationID + The LocationID of the location the agent will be assigned to. Int32 Int32 - 0 + None - - TrayPort + + NoWait - This is the port LTSvc.exe listens on for communication with LTTray processes. + Skips the post-install health check that waits for agent registration. The function exits immediately after the installer completes. - Int32 + SwitchParameter - Int32 + SwitchParameter - 0 + False - - Rename + + ProgressAction - This will call Rename-CWAAAddRemove after the install. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - String + ActionPreference - String + ActionPreference None - Hide + Rename - This will call Hide-CWAAAddRemove after the install. + Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. - SwitchParameter + String - SwitchParameter + String - False + None - SkipDotNet + Server - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + One or more ConnectWise Automate server URLs to download the installer from. Example: https://automate.domain.com The function tries each server in order until a successful download occurs. - SwitchParameter + String[] - SwitchParameter + String[] - False + None - - Force + + ServerPassword - This will disable some of the error checking on the install process. + The server password that agents use to authenticate with the Automate server. Used for legacy deployment method. InstallerToken is preferred. - SwitchParameter + String - SwitchParameter + String - False + None - NoWait + SkipCertificateCheck - This will skip the ending health check for the install process. The function will exit once the installer has completed. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -1085,10 +1398,10 @@ False - - WhatIf + + SkipDotNet - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Skips .NET Framework 3.5 and 2.0 prerequisite checks. Use when .NET 4.0+ is already installed. SwitchParameter @@ -1097,6 +1410,18 @@ False + + TrayPort + + The local port LTSvc.exe listens on for communication with LTTray processes. Defaults to 42000. If the port is in use, the function auto-selects the next available port. + + Int32 + + Int32 + + + None + Confirm @@ -1109,40 +1434,58 @@ False - - InstallerToken + + WhatIf - An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + Shows what would happen if the cmdlet runs. The cmdlet is not run. - String + SwitchParameter - String + SwitchParameter - None + False - - + + + + System.String[] + + + + + + + + System.String + + + + + + + + System.Int32 + + + + + + + + + + System.Object + + + + + + - Version: 2.0 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 6/10/2017 Purpose/Change: Updates for pipeline input, support for multiple servers - Update Date: 6/24/2017 Purpose/Change: Update to detect Server Version and use updated URL format for LabTech 11 Patch 13. - Update Date: 8/24/2017 Purpose/Change: Update to use Clear-Variable. Additional Debugging. - Update Date: 8/29/2017 Purpose/Change: Additional Debugging. - Update Date: 9/7/2017 Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. - Update Date: 1/26/2018 Purpose/Change: Added support for Proxy Server for Download and Installation steps. - Update Date: 2/13/2018 Purpose/Change: Added -TrayPort parameter. - Update Date: 3/13/2018 Purpose/Change: Added -NoWait parameter. Added minimum size requirement for agent installer to detect and skip a bad file download. - Update Date: 6/5/2018 Purpose/Change: Added -SkipDotNet parameter. Allows for skipping of .NET 3.5 and 2.0 framework checks for installing on OS with .NET 4.0+ already installed - Update Date: 1/21/2019 Purpose/Change: Minor bugfixes/adjustments. Allow single label server name, accept Agent ID 1 as valid. - Update Date: 2/28/2019 Purpose/Change: Update to try both http and https methods if not specified for Server - Update Date: 12/28/2019 Purpose/Change: Handle .NET 3.5 in pending state, accept .NET 4.0+ or higher with -Force parameter - Update Date: 6/10/2020 Purpose/Change: Remove Deployment.aspx dependance - Update Date: 6/11/2020 Purpose/Change: Update to work with or without Deployment.aspx + Author: Chris Taylor Alias: Install-LTService @@ -1150,14 +1493,28 @@ -------------------------- EXAMPLE 1 -------------------------- Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 - This will install the LabTech agent using the provided Server URL, InstallerToken, and LocationID. + Installs the agent using an InstallerToken for authentication. + + + + -------------------------- EXAMPLE 2 -------------------------- + Install-CWAA -Server https://automate.domain.com -ServerPassword 'encryptedpass' -LocationID 1 + + Installs the agent using a legacy server password. + + + + -------------------------- EXAMPLE 3 -------------------------- + Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait + + Installs the agent without waiting for registration to complete. - http://labtechconsulting.com - http://labtechconsulting.com + Online Version: + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -1167,11 +1524,11 @@ Invoke CWAACommand - This function tells the agent to execute the desired command. + Sends a service command to the ConnectWise Automate agent. - This function will allow you to execute all known commands against an agent. + Sends a control command to the LTService Windows service using sc.exe. The agent supports a set of predefined commands (mapped to numeric IDs 128-145) that trigger actions such as sending inventory, updating schedules, or killing processes. @@ -1179,7 +1536,7 @@ Command - {{ Fill Command Description }} + One or more commands to send to the agent service. Valid values include 'Update Schedule', 'Send Inventory', 'Send Drives', 'Send Processes', 'Send Spyware List', 'Send Apps', 'Send Events', 'Send Printers', 'Send Status', 'Send Screen', 'Send Services', 'Analyze Network', 'Write Last Contact Date', 'Kill VNC', 'Kill Trays', 'Send Patch Reboot', 'Run App Care Update', and 'Start App Care Daytime Patching'. String[] @@ -1188,16 +1545,17 @@ None - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -1210,13 +1568,24 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + Command - {{ Fill Command Description }} + One or more commands to send to the agent service. Valid values include 'Update Schedule', 'Send Inventory', 'Send Drives', 'Send Processes', 'Send Spyware List', 'Send Apps', 'Send Events', 'Send Printers', 'Send Status', 'Send Screen', 'Send Services', 'Analyze Network', 'Write Last Contact Date', 'Kill VNC', 'Kill Trays', 'Send Patch Reboot', 'Run App Care Update', and 'Start App Care Daytime Patching'. String[] @@ -1225,17 +1594,17 @@ None - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -1249,29 +1618,46 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + - Version: 1.2 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 2/2/2018 Purpose/Change: Initial script development Thanks: Gavin Stone, for finding the command list - Update Date: 2/8/2018 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override + Author: Chris Taylor Alias: Invoke-LTServiceCommand - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Invoke-CWAACommand -Command 'Send Inventory' + + Sends the 'Send Inventory' command to the agent service. + + + + -------------------------- EXAMPLE 2 -------------------------- + 'Send Status', 'Send Apps' | Invoke-CWAACommand - {{ Add example description here }} + Sends multiple commands to the agent service via pipeline. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -1281,56 +1667,117 @@ New CWAABackup - {{ Fill in the Synopsis }} + Creates a complete backup of the ConnectWise Automate agent installation. - {{ Fill in the Description }} + Creates a comprehensive backup of the currently installed ConnectWise Automate agent by copying all files from the agent installation directory and exporting all related registry keys. This backup can be used to restore the agent configuration if needed, or to preserve settings before performing maintenance operations. + The backup process performs the following operations: 1. Locates the agent installation directory (typically C:\Windows\LTSVC) 2. Creates a Backup subdirectory within the agent installation path 3. Copies all files from the installation directory to the Backup folder 4. Exports registry keys from HKLM\SOFTWARE\LabTech to a .reg file 5. Modifies the exported registry data to use the LabTechBackup key name 6. Imports the modified registry data to HKLM\SOFTWARE\LabTechBackup New-CWAABackup + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + - - - + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference - None + ActionPreference + + None + + + Confirm - + Prompts you for confirmation before running the cmdlet. - - - - + SwitchParameter - System.Object + SwitchParameter + + False + + + WhatIf - + Shows what would happen if the cmdlet runs. The cmdlet is not run. - - + SwitchParameter + + SwitchParameter + + + False + + + + - + Author: Chris Taylor Alias: New-LTServiceBackup - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + New-CWAABackup + + Creates a complete backup of the agent installation files and registry settings. + + + + -------------------------- EXAMPLE 2 -------------------------- + New-CWAABackup -WhatIf - {{ Add example description here }} + Shows what the backup operation would do without actually creating the backup. - Online Version: - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -1340,55 +1787,53 @@ Redo CWAA - This function will reinstall the LabTech agent from the machine. + Reinstalls the ConnectWise Automate Agent on the local computer. - This script will attempt to pull all current settings from machine and issue an 'Uninstall-CWAA', 'Install-CWAA' with gathered information. If the function is unable to find the settings it will ask for needed parameters. + Performs a complete reinstall of the ConnectWise Automate Agent by uninstalling and then reinstalling the agent. The function attempts to retrieve current settings (server, location, etc.) from the existing installation or from a backup. If settings cannot be determined automatically, the function will prompt for the required parameters. + The reinstall process: 1. Reads current agent settings from registry or backup 2. Uninstalls the existing agent via Uninstall-CWAA 3. Waits 20 seconds for the uninstall to settle 4. Installs a fresh agent via Install-CWAA with the gathered settings Redo-CWAA - - Server + + Backup - This is the URL to your LabTech server. Example: https://automate.domain.com This is used to download the installation and removal utilities. If no server is provided the uninstaller will use Get-CWAAInfo to get the server address. If it is unable to find LT currently installed it will try Get-CWAAInfoBackup + Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. - String[] - String[] + SwitchParameter - None + False - - ServerPassword + + Force - {{ Fill ServerPassword Description }} + Forces reinstallation even when a probe agent is detected. - SecureString - SecureString + SwitchParameter - None + False - - LocationID + + Hide - The LocationID of the location that you want the agent in example: 555 + Hides the agent entry from Add/Remove Programs after reinstallation. - String - String + SwitchParameter - None + False - - Rename + + InstallerToken - This will call Rename-CWAAAddRemove to rename the install in Add/Remove Programs + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken String @@ -1397,54 +1842,70 @@ None - - Backup + + LocationID - This will run a New-CWAABackup command before uninstalling. + The LocationID of the location the agent will be assigned to. If not provided, reads from the current agent configuration or prompts interactively. + Int32 - SwitchParameter + Int32 - False + None - - Hide + + ProgressAction - Will remove from add-remove programs + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None - SkipDotNet + Rename - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + Renames the agent entry in Add/Remove Programs after reinstallation. + String - SwitchParameter + String - False + None - - Force + + Server - This will force operation on an agent detected as a probe. + One or more ConnectWise Automate server URLs. Example: https://automate.domain.com If not provided, the function reads the server URL from the current agent configuration or backup settings. If neither is available, prompts interactively. + String[] - SwitchParameter + String[] - False + None - - WhatIf + + ServerPassword - Shows what would happen if the cmdlet runs. The cmdlet is not run. + The server password for agent authentication. InstallerToken is preferred. + + String + + String + + + None + + + SkipDotNet + + Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. SwitchParameter @@ -1463,105 +1924,117 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + Redo-CWAA - - Server + + Backup - This is the URL to your LabTech server. Example: https://automate.domain.com This is used to download the installation and removal utilities. If no server is provided the uninstaller will use Get-CWAAInfo to get the server address. If it is unable to find LT currently installed it will try Get-CWAAInfoBackup + Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. - String[] - String[] + SwitchParameter - None + False - - ServerPassword + + Force - {{ Fill ServerPassword Description }} + Forces reinstallation even when a probe agent is detected. - SecureString - SecureString + SwitchParameter - None + False - - LocationID + + Hide - The LocationID of the location that you want the agent in example: 555 + Hides the agent entry from Add/Remove Programs after reinstallation. - String - String + SwitchParameter - None + False - - Rename + + LocationID - This will call Rename-CWAAAddRemove to rename the install in Add/Remove Programs + The LocationID of the location the agent will be assigned to. If not provided, reads from the current agent configuration or prompts interactively. - String + Int32 - String + Int32 None - - Backup + + ProgressAction - This will run a New-CWAABackup command before uninstalling. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None - Hide + Rename - Will remove from add-remove programs + Renames the agent entry in Add/Remove Programs after reinstallation. + String - SwitchParameter + String - False + None - - SkipDotNet + + Server - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + One or more ConnectWise Automate server URLs. Example: https://automate.domain.com If not provided, the function reads the server URL from the current agent configuration or backup settings. If neither is available, prompts interactively. + String[] - SwitchParameter + String[] - False + None - - Force + + ServerPassword - This will force operation on an agent detected as a probe. + The server password for agent authentication. InstallerToken is preferred. + String - SwitchParameter + String - False + None - - WhatIf + + SkipDotNet - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. SwitchParameter @@ -1580,50 +2053,60 @@ False - - InstallerToken + + WhatIf - An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + Shows what would happen if the cmdlet runs. The cmdlet is not run. - String - String + SwitchParameter - None + False - - Server + + Backup - This is the URL to your LabTech server. Example: https://automate.domain.com This is used to download the installation and removal utilities. If no server is provided the uninstaller will use Get-CWAAInfo to get the server address. If it is unable to find LT currently installed it will try Get-CWAAInfoBackup + Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. - String[] + SwitchParameter - String[] + SwitchParameter - None + False - - ServerPassword + + Force - {{ Fill ServerPassword Description }} + Forces reinstallation even when a probe agent is detected. - SecureString + SwitchParameter - SecureString + SwitchParameter - None + False - - LocationID + + Hide + + Hides the agent entry from Add/Remove Programs after reinstallation. + + SwitchParameter + + SwitchParameter + + + False + + + InstallerToken - The LocationID of the location that you want the agent in example: 555 + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken String @@ -1632,34 +2115,34 @@ None - - Backup + + LocationID - This will run a New-CWAABackup command before uninstalling. + The LocationID of the location the agent will be assigned to. If not provided, reads from the current agent configuration or prompts interactively. - SwitchParameter + Int32 - SwitchParameter + Int32 - False + None - - Hide + + ProgressAction - Will remove from add-remove programs + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - + Rename - This will call Rename-CWAAAddRemove to rename the install in Add/Remove Programs + Renames the agent entry in Add/Remove Programs after reinstallation. String @@ -1668,34 +2151,34 @@ None - - SkipDotNet + + Server - This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. + One or more ConnectWise Automate server URLs. Example: https://automate.domain.com If not provided, the function reads the server URL from the current agent configuration or backup settings. If neither is available, prompts interactively. - SwitchParameter + String[] - SwitchParameter + String[] - False + None - - Force + + ServerPassword - This will force operation on an agent detected as a probe. + The server password for agent authentication. InstallerToken is preferred. - SwitchParameter + String - SwitchParameter + String - False + None - - WhatIf + + SkipDotNet - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. SwitchParameter @@ -1716,31 +2199,24 @@ False - - InstallerToken + + WhatIf - An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + Shows what would happen if the cmdlet runs. The cmdlet is not run. - String + SwitchParameter - String + SwitchParameter - None + False - Version: 1.5 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 6/8/2017 Purpose/Change: Update to support user provided settings for -Server, -Password, -LocationID. - Update Date: 6/10/2017 Purpose/Change: Updates for pipeline input, support for multiple servers - Update Date: 8/24/2017 Purpose/Change: Update to use Clear-Variable. - Update Date: 3/12/2018 Purpose/Change: Added detection of "Probe" enabled agent. Added support for -Force parameter to override probe detection. Updated support of -WhatIf parameter. - Update Date: 2/22/2019 Purpose/Change: Added -SkipDotNet parameter. Allows for skipping of .NET 3.5 and 2.0 framework checks for installing on OS with .NET 4.0+ already installed + Author: Chris Taylor Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService @@ -1748,55 +2224,65 @@ -------------------------- EXAMPLE 1 -------------------------- Redo-CWAA - This will ReInstall the LabTech agent using the server address in the registry. + Reinstalls the agent using settings from the current installation registry. -------------------------- EXAMPLE 2 -------------------------- - Redo-CWAA -Server https://automate.domain.com -Password sQWZzEDYKFFnTT0yP56vgA== -LocationID 42 + Redo-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 + + Reinstalls the agent with explicitly provided settings. + + + + -------------------------- EXAMPLE 3 -------------------------- + Redo-CWAA -Backup -Force - This will ReInstall the LabTech agent using the provided server URL to download the installation files. + Backs up settings, then forces reinstallation even if a probe agent is detected. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Rename-CWAAAddRemove - Rename - CWAAAddRemove + Register-CWAAHealthCheckTask + Register + CWAAHealthCheckTask - This function renames the LabTech install as shown in the Add/Remove Programs list. + Creates or updates a scheduled task for periodic ConnectWise Automate agent health checks. - This function will change the value of the DisplayName registry key to effect Add/Remove Programs list. + Creates a Windows scheduled task that runs Repair-CWAA at a configurable interval (default every 6 hours) to monitor agent health and automatically remediate issues. + The task runs as SYSTEM with highest privileges, includes a random delay equal to the interval to stagger execution across multiple machines, and has a 1-hour execution timeout. + If the task already exists and the InstallerToken has changed, the task is recreated with the new token. Use -Force to recreate unconditionally. + A backup of the current agent configuration is created before task registration via New-CWAABackup. - Rename-CWAAAddRemove + Register-CWAAHealthCheckTask - Name + InstallerToken - This is the Name for the LabTech Agent as displayed in the list of installed software. + The installer token for authenticated agent deployment. Embedded in the scheduled task action for use by Repair-CWAA. - Object + String - Object + String None - PublisherName + Server - This is the Name for the Publisher of the LabTech Agent as displayed in the list of installed software. + Optional server URL. When provided, the scheduled task passes this to Repair-CWAA in Install mode (with Server, LocationID, and InstallerToken). String @@ -1805,10 +2291,46 @@ None - - WhatIf + + LocationID - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Optional location ID. Required when Server is provided. + + Int32 + + Int32 + + + 0 + + + TaskName + + Name of the scheduled task. Default: 'CWAAHealthCheck'. + + String + + String + + + CWAAHealthCheck + + + IntervalHours + + Hours between health check runs. Default: 6. + + Int32 + + Int32 + + + 6 + + + Force + + Force recreation of the task even if it already exists with the same token. SwitchParameter @@ -1816,6 +2338,18 @@ False + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -1827,25 +2361,84 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + - - Name + + Force - This is the Name for the LabTech Agent as displayed in the list of installed software. + Force recreation of the task even if it already exists with the same token. - Object + SwitchParameter - Object + SwitchParameter + + + False + + + InstallerToken + + The installer token for authenticated agent deployment. Embedded in the scheduled task action for use by Repair-CWAA. + + String + + String + + + None + + + IntervalHours + + Hours between health check runs. Default: 6. + + Int32 + + Int32 + + + 6 + + + LocationID + + Optional location ID. Required when Server is provided. + + Int32 + + Int32 + + + 0 + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference None - PublisherName + Server - This is the Name for the Publisher of the LabTech Agent as displayed in the list of installed software. + Optional server URL. When provided, the scheduled task passes this to Repair-CWAA in Install mode (with Server, LocationID, and InstallerToken). String @@ -1854,17 +2447,17 @@ None - - WhatIf + + TaskName - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Name of the scheduled task. Default: 'CWAAHealthCheck'. - SwitchParameter + String - SwitchParameter + String - False + CWAAHealthCheck Confirm @@ -1878,95 +2471,111 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + - Version: 1.2 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 5/14/2017 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/12/2018 Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. + Author: Chris Taylor Alias: Register-LTHealthCheckTask - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Register-CWAAHealthCheckTask -InstallerToken 'abc123def456' + + Creates a task that runs Repair-CWAA in Checkup mode every 6 hours. + + + + -------------------------- EXAMPLE 2 -------------------------- + Register-CWAAHealthCheckTask -InstallerToken 'token' -Server 'https://automate.domain.com' -LocationID 42 + + Creates a task that runs Repair-CWAA in Install mode (can install fresh if agent is missing). + + + + -------------------------- EXAMPLE 3 -------------------------- + Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' - {{ Add example description here }} + Creates a custom-named task running every 12 hours. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Reset-CWAA - Reset - CWAA + Rename-CWAAAddRemove + Rename + CWAAAddRemove - This function will remove local settings on the agent. + Renames the Automate agent entry in the Add/Remove Programs list. - This function can remove some of the agents local settings. ID, MAC, LocationID The function will stop the services, make the change, then start the services. Resetting all of these will force the agent to check in as a new agent. If you have MAC filtering enabled it should check back in with the same ID. This function is useful for duplicate agents. + Changes the DisplayName (and optionally Publisher) registry values for the Automate agent uninstall keys, which controls how the agent appears in the Windows Add/Remove Programs (Programs and Features) list. - Reset-CWAA - - ID - - This will reset the AgentID of the computer - - - SwitchParameter - - - False - - - Location + Rename-CWAAAddRemove + + Name - This will reset the LocationID of the computer + The display name for the Automate agent as shown in the list of installed software. + Object - SwitchParameter + Object - False + None - - MAC + + PublisherName - This will reset the MAC of the computer + The publisher name for the Automate agent as shown in the list of installed software. + String - SwitchParameter + String - False + None - - Force + + ProgressAction - This will force operation on an agent detected as a probe. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None - - NoWait + + Confirm - This will skip the ending health check for the reset process. The function will exit once the values specified have been reset. + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -1985,72 +2594,49 @@ False - - Confirm - - Prompts you for confirmation before running the cmdlet. - - - SwitchParameter - - - False - - - ID - - This will reset the AgentID of the computer - - SwitchParameter - - SwitchParameter - - - False - - - Location + + Name - This will reset the LocationID of the computer + The display name for the Automate agent as shown in the list of installed software. - SwitchParameter + Object - SwitchParameter + Object - False + None - - MAC + + ProgressAction - This will reset the MAC of the computer + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - Force + + PublisherName - This will force operation on an agent detected as a probe. + The publisher name for the Automate agent as shown in the list of installed software. - SwitchParameter + String - SwitchParameter + String - False + None - - NoWait + + Confirm - This will skip the ending health check for the reset process. The function will exit once the values specified have been reset. + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -2071,78 +2657,126 @@ False - - Confirm - - Prompts you for confirmation before running the cmdlet. - - SwitchParameter - - SwitchParameter - - - False - - Version: 1.4 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/12/2018 Purpose/Change: Added detection of "Probe" enabled agent. Added support for -Force parameter to override probe detection. Added support for -WhatIf. Added support for -NoWait paramter to bypass agent health check. - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override - Update Date: 8/5/2019 Purpose/Change: Bugfixes for -Location parameter + Author: Chris Taylor Alias: Rename-LTAddRemove -------------------------- EXAMPLE 1 -------------------------- - Reset-CWAA + Rename-CWAAAddRemove -Name 'My Remote Agent' - This resets the ID, MAC and LocationID on the agent. + Renames the Automate agent display name to 'My Remote Agent'. -------------------------- EXAMPLE 2 -------------------------- - Reset-CWAA -ID + Rename-CWAAAddRemove -Name 'My Remote Agent' -PublisherName 'My Company' - This resets only the ID of the agent. + Renames both the display name and publisher name in Add/Remove Programs. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Restart-CWAA - Restart + Repair-CWAA + Repair CWAA - This function will restart the LabTech Services. + Performs escalating remediation of the ConnectWise Automate agent. - {{ Fill in the Description }} + 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 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 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 or from backup settings. + All remediation actions are logged to the Windows Event Log (Application log, source ConnectWiseAutomateAgent) for visibility in unattended scheduled task runs. + Designed to be called periodically via Register-CWAAHealthCheckTask or any external scheduler. - Restart-CWAA - - WhatIf + Repair-CWAA + + HoursReinstall - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Hours since last check-in before a full reinstall is attempted. Expressed as a negative number (e.g., -120 means 120 hours / 5 days ago). Default: -120. + Int32 - SwitchParameter + Int32 - False + -120 + + + HoursRestart + + Hours since last check-in before a service restart is attempted. Expressed as a negative number (e.g., -2 means 2 hours ago). Default: -2. + + Int32 + + Int32 + + + -2 + + + InstallerToken + + An installer token for authenticated agent deployment. Required for both parameter sets. + + String + + String + + + None + + + LocationID + + The LocationID for fresh agent installs. Required with the Install parameter set. + + Int32 + + Int32 + + + 0 + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Server + + The ConnectWise Automate server URL for fresh installs or server mismatch correction. Required when using the Install parameter set. + + String + + String + + + None Confirm @@ -2155,241 +2789,242 @@ False - - - - - WhatIf - - Shows what would happen if the cmdlet runs. The cmdlet is not run. - - SwitchParameter + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + HoursReinstall + + Hours since last check-in before a full reinstall is attempted. Expressed as a negative number (e.g., -120 means 120 hours / 5 days ago). Default: -120. + + Int32 - SwitchParameter + Int32 - False + -120 - - Confirm + + HoursRestart - Prompts you for confirmation before running the cmdlet. + Hours since last check-in before a service restart is attempted. Expressed as a negative number (e.g., -2 means 2 hours ago). Default: -2. - SwitchParameter + Int32 - SwitchParameter + Int32 - False + -2 - - - - - - Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/13/2018 Purpose/Change: Added additional debugging output, support for ShouldProcess (-Confirm, -WhatIf) - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override - - - - - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} - - {{ Add example description here }} - - - - - - http://labtechconsulting.com - http://labtechconsulting.com - - - - - - Set-CWAALogLevel - Set - CWAALogLevel - - This function will restart the LabTech Services. - - - - {{ Fill in the Description }} - - - - Set-CWAALogLevel - - Level - - {{ Fill Level Description }} - - - Normal - Verbose - - Object - - Object - - - None - - - - - - Level + + InstallerToken - {{ Fill Level Description }} + An installer token for authenticated agent deployment. Required for both parameter sets. - Object + String - Object + String None - - - + + LocationID + + The LocationID for fresh agent installs. Required with the Install parameter set. + + Int32 - None + Int32 + + 0 + + + ProgressAction - + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - - - + ActionPreference - System.Object + ActionPreference + + None + + + Server - + The ConnectWise Automate server URL for fresh installs or server mismatch correction. Required when using the Install parameter set. - - + String + + String + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + - Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/13/2018 Purpose/Change: Added additional debugging output, support for ShouldProcess (-Confirm, -WhatIf) - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override + Author: Chris Taylor Alias: Repair-LTService - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Repair-CWAA -InstallerToken 'abc123def456' + + Checks the installed agent and repairs if needed (Checkup mode). + + + + -------------------------- EXAMPLE 2 -------------------------- + Repair-CWAA -Server 'https://automate.domain.com' -LocationID 42 -InstallerToken 'token' + + Checks agent health. If the agent is missing or pointed at the wrong server, installs or reinstalls with the specified settings. + + + + -------------------------- EXAMPLE 3 -------------------------- + Repair-CWAA -InstallerToken 'token' -HoursRestart -4 -HoursReinstall -240 - {{ Add example description here }} + Uses custom thresholds: restart after 4 hours offline, reinstall after 10 days. - Online Version: - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Set-CWAAProxy - Set - CWAAProxy + Reset-CWAA + Reset + CWAA - This function configures module functions to use the specified proxy configuration for all operations as long as the module remains loaded. + Removes local agent identity settings to force re-registration. - This function will set or clear Proxy settings needed for function and agent operations. If an agent is already installed, this function will set the ProxyUsername, ProxyPassword, and ProxyServerURL values for the Agent. NOTE: Agent Services will be restarted for changes (if found) to be applied. + Removes some of the agent's local settings: ID, MAC, and/or LocationID. The function stops the services, removes the specified registry values, then restarts the services. Resetting all three values forces the agent to check in as a new agent. If MAC filtering is enabled on the server, the agent should check back in with the same ID. + This function is useful for resolving duplicate agent entries. If no switches are specified, all three values (ID, Location, MAC) are reset. + Probe agents are protected from reset unless the -Force switch is used. - Set-CWAAProxy - - ProxyServerURL + Reset-CWAA + + Force - This is the URL and Port to assign as the ProxyServerURL for Module operations during this session and for the Installed Agent (if present). Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com' Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' This parameter may be used with the additional following parameters: ProxyUsername, ProxyPassword, EncodedProxyUsername, EncodedProxyPassword + Forces the reset operation on an agent detected as a probe. - String - String + SwitchParameter - None + False - - ProxyUsername + + ID - This is the plain text Username for Proxy operations. Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -ProxyUsername 'Test-User' -ProxyPassword 'SomeFancyPassword' + Resets the AgentID of the computer. - String - String + SwitchParameter - None + False - - ProxyPassword + + Location - This is the plain text Password for Proxy operations. + Resets the LocationID of the computer. - SecureString - SecureString + SwitchParameter - None + False - - EncodedProxyUsername + + MAC - This is the encoded Username for Proxy operations. The parameter must be encoded with the Agent Password. This Parameter will be decoded using the Agent Password, and the decoded string will be configured. NOTE: Reinstallation of the Agent will generate a new agent password. Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -EncodedProxyUsername '1GzhlerwMy0ElG9XNgiIkg==' -EncodedProxyPassword 'Duft4r7fekTp5YnQL9F0V9TbP7sKzm0n' + Resets the MAC address of the computer. - String - String + SwitchParameter - None + False - - EncodedProxyPassword + + NoWait - This is the encoded Password for Proxy operations. The parameter must be encoded with the Agent Password. This Parameter will be decoded using the Agent Password, and the decoded string will be configured. NOTE: Reinstallation of the Agent will generate a new password. + Skips the post-reset health check that waits for the agent to re-register. - SecureString - SecureString + SwitchParameter - None + False - - DetectProxy + + ProgressAction - This parameter attempts to automatically detect the system Proxy settings for Module operations during this session. Discovered settings will be assigned to the Installed Agent (if present). Example: Set-CWAAProxy -DetectProxy This parameter may not be used with other parameters. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None - - ResetProxy + + Confirm - This parameter clears any currently defined Proxy Settings for Module operations during this session. Discovered settings will be assigned to the Installed Agent (if present). Example: Set-CWAAProxy -ResetProxy This parameter may not be used with other parameters. + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -2408,96 +3043,1034 @@ False - - Confirm - - Prompts you for confirmation before running the cmdlet. - - - SwitchParameter - - - False - - - ProxyServerURL + + Force - This is the URL and Port to assign as the ProxyServerURL for Module operations during this session and for the Installed Agent (if present). Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com' Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' This parameter may be used with the additional following parameters: ProxyUsername, ProxyPassword, EncodedProxyUsername, EncodedProxyPassword + Forces the reset operation on an agent detected as a probe. - String + SwitchParameter - String + SwitchParameter - None + False - - ProxyUsername + + ID - This is the plain text Username for Proxy operations. Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -ProxyUsername 'Test-User' -ProxyPassword 'SomeFancyPassword' + Resets the AgentID of the computer. - String + SwitchParameter - String + SwitchParameter - None + False - - ProxyPassword + + Location - This is the plain text Password for Proxy operations. + Resets the LocationID of the computer. - SecureString + SwitchParameter - SecureString + SwitchParameter - None + False - - EncodedProxyUsername - - This is the encoded Username for Proxy operations. The parameter must be encoded with the Agent Password. This Parameter will be decoded using the Agent Password, and the decoded string will be configured. NOTE: Reinstallation of the Agent will generate a new agent password. Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -EncodedProxyUsername '1GzhlerwMy0ElG9XNgiIkg==' -EncodedProxyPassword 'Duft4r7fekTp5YnQL9F0V9TbP7sKzm0n' + + MAC + + Resets the MAC address of the computer. + + SwitchParameter + + SwitchParameter + + + False + + + NoWait + + Skips the post-reset health check that waits for the agent to re-register. + + SwitchParameter + + SwitchParameter + + + False + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Chris Taylor Alias: Reset-LTService + + + + + -------------------------- EXAMPLE 1 -------------------------- + Reset-CWAA + + Resets the ID, MAC, and LocationID on the agent, then waits for re-registration. + + + + -------------------------- EXAMPLE 2 -------------------------- + Reset-CWAA -ID + + Resets only the AgentID of the agent. + + + + -------------------------- EXAMPLE 3 -------------------------- + Reset-CWAA -Force -NoWait + + Resets all values on a probe agent without waiting for re-registration. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Restart-CWAA + Restart + CWAA + + Restarts the ConnectWise Automate agent services. + + + + Verifies that the Automate agent services (LTService, LTSvcMon) are present, then calls Stop-CWAA followed by Start-CWAA to perform a full service restart. + + + + Restart-CWAA + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Chris Taylor Alias: Restart-LTService + + + + + -------------------------- EXAMPLE 1 -------------------------- + Restart-CWAA + + Restarts the ConnectWise Automate agent services. + + + + -------------------------- EXAMPLE 2 -------------------------- + Restart-CWAA -WhatIf + + Shows what would happen without actually restarting the services. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Set-CWAALogLevel + Set + CWAALogLevel + + Sets the logging level for the ConnectWise Automate Agent. + + + + Configures the agent's logging verbosity by updating the registry and restarting the agent services. Supports Normal (standard) and Verbose (detailed diagnostic) levels. + The function stops the agent service, writes the new logging level to the registry at HKLM:\SOFTWARE\LabTech\Service\Settings under the "Debuging" value, then restarts the agent service. After applying the change, it outputs the current logging level. + + + + Set-CWAALogLevel + + Level + + The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. Normal sets registry value 1; Verbose sets registry value 1000. + + Object + + Object + + + Normal + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + Level + + The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. Normal sets registry value 1; Verbose sets registry value 1000. + + Object + + Object + + + Normal + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Chris Taylor Alias: Set-LTLogging + + + + + -------------------------- EXAMPLE 1 -------------------------- + Set-CWAALogLevel -Level Verbose + + Enables verbose diagnostic logging on the agent. + + + + -------------------------- EXAMPLE 2 -------------------------- + Set-CWAALogLevel -Level Normal + + Returns the agent to standard logging. + + + + -------------------------- EXAMPLE 3 -------------------------- + Set-CWAALogLevel -Level Verbose -WhatIf + + Shows what changes would be made without applying them. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Set-CWAAProxy + Set + CWAAProxy + + Configures module proxy settings for all operations during the current session. + + + + Sets or clears Proxy settings needed for module function and agent operations. If an agent is already installed, this function will update the ProxyUsername, ProxyPassword, and ProxyServerURL values in the agent registry settings. Agent services will be restarted for changes (if found) to be applied. + + + + Set-CWAAProxy + + ProxyServerURL + + The URL and optional port to assign as the proxy server for module operations and for the installed agent (if present). Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' May be used with ProxyUsername/ProxyPassword or EncodedProxyUsername/EncodedProxyPassword. + + String + + String + + + None + + + ProxyUsername + + Plain text username for proxy authentication. Must be used with ProxyServerURL and ProxyPassword. + + String + + String + + + None + + + ProxyPassword + + Plain text password for proxy authentication. Must be used with ProxyServerURL and ProxyUsername. + + SecureString + + SecureString + + + None + + + DetectProxy + + Automatically detect system proxy settings for module operations. Discovered settings are applied to the installed agent (if present). Cannot be used with other parameters. + + + SwitchParameter + + + False + + + EncodedProxyPassword + + Encoded password for proxy authentication, encrypted with the agent password. Will be decoded using the agent password. Must be used with ProxyServerURL and EncodedProxyUsername. + + SecureString + + SecureString + + + None + + + EncodedProxyUsername + + Encoded username for proxy authentication, encrypted with the agent password. Will be decoded using the agent password. Must be used with ProxyServerURL and EncodedProxyPassword. + + String + + String + + + None + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + ResetProxy + + Clears any currently defined proxy settings for module operations. Changes are applied to the installed agent (if present). Cannot be used with other parameters. + + + SwitchParameter + + + False + + + SkipCertificateCheck + + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + + + SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + DetectProxy + + Automatically detect system proxy settings for module operations. Discovered settings are applied to the installed agent (if present). Cannot be used with other parameters. + + SwitchParameter + + SwitchParameter + + + False + + + EncodedProxyPassword + + Encoded password for proxy authentication, encrypted with the agent password. Will be decoded using the agent password. Must be used with ProxyServerURL and EncodedProxyUsername. + + SecureString + + SecureString + + + None + + + EncodedProxyUsername + + Encoded username for proxy authentication, encrypted with the agent password. Will be decoded using the agent password. Must be used with ProxyServerURL and EncodedProxyPassword. + + String + + String + + + None + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + ProxyPassword + + Plain text password for proxy authentication. Must be used with ProxyServerURL and ProxyUsername. + + SecureString + + SecureString + + + None + + + ProxyServerURL + + The URL and optional port to assign as the proxy server for module operations and for the installed agent (if present). Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' May be used with ProxyUsername/ProxyPassword or EncodedProxyUsername/EncodedProxyPassword. + + String + + String + + + None + + + ProxyUsername + + Plain text username for proxy authentication. Must be used with ProxyServerURL and ProxyPassword. + + String + + String + + + None + + + ResetProxy + + Clears any currently defined proxy settings for module operations. Changes are applied to the installed agent (if present). Cannot be used with other parameters. + + SwitchParameter + + SwitchParameter + + + False + + + SkipCertificateCheck + + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + + SwitchParameter + + SwitchParameter + + + False + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Darren White Alias: Set-LTProxy + + + + + -------------------------- EXAMPLE 1 -------------------------- + Set-CWAAProxy -DetectProxy + + Automatically detects and configures the system proxy. + + + + -------------------------- EXAMPLE 2 -------------------------- + Set-CWAAProxy -ResetProxy + + Clears all proxy settings. + + + + -------------------------- EXAMPLE 3 -------------------------- + Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' + + Sets the proxy server URL without authentication. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Show-CWAAAddRemove + Show + CWAAAddRemove + + Shows the Automate agent in the Add/Remove Programs list. + + + + Sets the SystemComponent registry value to 0 on Automate agent uninstall keys, which makes the agent visible in the Windows Add/Remove Programs (Programs and Features) list. Also cleans up any leftover HiddenProductName registry values from older hiding methods. + + + + Show-CWAAAddRemove + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - String + ActionPreference - String + ActionPreference None - - EncodedProxyPassword + + Confirm - This is the encoded Password for Proxy operations. The parameter must be encoded with the Agent Password. This Parameter will be decoded using the Agent Password, and the decoded string will be configured. NOTE: Reinstallation of the Agent will generate a new password. + Prompts you for confirmation before running the cmdlet. - SecureString + SwitchParameter - SecureString + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Chris Taylor Alias: Show-LTAddRemove + + + + + -------------------------- EXAMPLE 1 -------------------------- + Show-CWAAAddRemove + + Makes the Automate agent entry visible in Add/Remove Programs. + + + + -------------------------- EXAMPLE 2 -------------------------- + Show-CWAAAddRemove -WhatIf + + Shows what registry changes would be made without applying them. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Start-CWAA + Start + CWAA + + Starts the ConnectWise Automate agent services. + + + + Verifies that the Automate agent services (LTService, LTSvcMon) are present. Checks for any process using the LTTray port (default 42000) and kills it. If a protected application holds the port, increments the TrayPort (wrapping from 42009 back to 42000). Sets services to Automatic startup and starts them via sc.exe. Waits up to one minute for LTService to reach the Running state, then issues a Send Status command for immediate check-in. + + + + Start-CWAA + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference None - - DetectProxy + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + SwitchParameter + + SwitchParameter + + + False + + + + + + + Author: Chris Taylor Alias: Start-LTService + + + + + -------------------------- EXAMPLE 1 -------------------------- + Start-CWAA + + Starts the ConnectWise Automate agent services. + + + + -------------------------- EXAMPLE 2 -------------------------- + Start-CWAA -WhatIf + + Shows what would happen without actually starting the services. + + + + + + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + + + + + + Stop-CWAA + Stop + CWAA + + Stops the ConnectWise Automate agent services. + + + + Verifies that the Automate agent services (LTService, LTSvcMon) are present, then attempts to stop them gracefully via sc.exe. Waits up to one minute for the services to reach a Stopped state. If they do not stop in time, remaining Automate agent processes (LTTray, LTSVC, LTSvcMon) are forcefully terminated. + + + + Stop-CWAA + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + + + + ProgressAction - This parameter attempts to automatically detect the system Proxy settings for Module operations during this session. Discovered settings will be assigned to the Installed Agent (if present). Example: Set-CWAAProxy -DetectProxy This parameter may not be used with other parameters. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - ResetProxy + + Confirm - This parameter clears any currently defined Proxy Settings for Module operations during this session. Discovered settings will be assigned to the Installed Agent (if present). Example: Set-CWAAProxy -ResetProxy This parameter may not be used with other parameters. + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -2518,72 +4091,87 @@ False - - Confirm - - Prompts you for confirmation before running the cmdlet. - - SwitchParameter - - SwitchParameter - - - False - - Version: 1.1 Author: Darren White Creation Date: 1/24/2018 Purpose/Change: Initial function development + Author: Chris Taylor Alias: Stop-LTService - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Stop-CWAA + + Stops the ConnectWise Automate agent services. + + + + -------------------------- EXAMPLE 2 -------------------------- + Stop-CWAA -WhatIf - {{ Add example description here }} + Shows what would happen without actually stopping the services. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Show-CWAAAddRemove - Show - CWAAAddRemove + Test-CWAAHealth + Test + CWAAHealth - This function shows the LabTech install in the add/remove programs list. + Performs a read-only health assessment of the ConnectWise Automate agent. - This function will rename the HiddenDisplayName registry key to show it in the add/remove programs list. If there is not HiddenDisplayName key the function will import a new entry. + Checks the overall health of the installed Automate agent without taking any remediation action. Returns a status object with details about the agent's installation state, service status, last check-in times, and server connectivity. + This function never modifies the agent, services, or registry. It is safe to call at any time for monitoring or diagnostic purposes. + Health assessment criteria: - Agent is installed (LTService exists) + - Services are running (LTService and LTSvcMon) + - Agent has checked in recently (LastSuccessStatus or HeartbeatLastSent within threshold) + - Server is reachable (optional, tested when Server param is provided or auto-discovered) + + The Healthy property is True only when the agent is installed, services are running, and LastContact is not null. - Show-CWAAAddRemove - - WhatIf + Test-CWAAHealth + + Server - Shows what would happen if the cmdlet runs. The cmdlet is not run. + An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points to this server. If omitted, ServerMatch is null. + String - SwitchParameter + String - False + None - - Confirm + + ProgressAction - Prompts you for confirmation before running the cmdlet. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + TestServerConnectivity + + When specified, tests whether the agent's server is reachable via the agent.aspx endpoint. Adds a brief network call. The ServerReachable property is null when this switch is not used. SwitchParameter @@ -2594,22 +4182,34 @@ - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - Confirm + + Server - Prompts you for confirmation before running the cmdlet. + An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points to this server. If omitted, ServerMatch is null. + + String + + String + + + None + + + TestServerConnectivity + + When specified, tests whether the agent's server is reachable via the agent.aspx endpoint. Adds a brief network call. The ServerReachable property is null when this switch is not used. SwitchParameter @@ -2623,57 +4223,94 @@ - Version: 1.2 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/12/2018 Purpose/Change: Support for ShouldProcess. Added Registry Paths to be checked. Modified hiding method to be compatible with standard software controls. + Author: Chris Taylor Alias: Test-LTHealth - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Test-CWAAHealth + + Returns a health status object for the installed agent. + + + + -------------------------- EXAMPLE 2 -------------------------- + Test-CWAAHealth -Server 'https://automate.domain.com' -TestServerConnectivity + + Checks agent health, validates the server address matches, and tests server connectivity. + + + + -------------------------- EXAMPLE 3 -------------------------- + if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } - {{ Add example description here }} + Uses the Healthy boolean for conditional logic. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Start-CWAA - Start - CWAA + Test-CWAAPort + Test + CWAAPort - This function will start the LabTech Services. + Tests connectivity to TCP ports required by the ConnectWise Automate agent. - This function will verify that the LabTech services are present. It will then check for any process that is using the LTTray port (Default 42000) and kill it. Next it will start the services. + Verifies that the local LTTray port is available and tests connectivity to the required TCP ports (70, 80, 443) on the Automate server, plus port 8002 on the Automate mediator server. If no server is provided, the function attempts to detect it from the installed agent configuration or backup info. - Start-CWAA - - WhatIf + Test-CWAAPort + + Server - Shows what would happen if the cmdlet runs. The cmdlet is not run. + The URL of the Automate server (e.g., https://automate.domain.com). If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + String[] - SwitchParameter + String[] - False + None - - Confirm + + TrayPort - Prompts you for confirmation before running the cmdlet. + The local port LTSvc.exe listens on for LTTray communication. Defaults to 42000 if not provided or not found in agent configuration. + + Int32 + + Int32 + + + 0 + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Quiet + + Returns a boolean connectivity result instead of verbose output. SwitchParameter @@ -2684,22 +4321,22 @@ - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - Confirm + + Quiet - Prompts you for confirmation before running the cmdlet. + Returns a boolean connectivity result instead of verbose output. SwitchParameter @@ -2708,65 +4345,106 @@ False + + Server + + The URL of the Automate server (e.g., https://automate.domain.com). If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + + String[] + + String[] + + + None + + + TrayPort + + The local port LTSvc.exe listens on for LTTray communication. Defaults to 42000 if not provided or not found in agent configuration. + + Int32 + + Int32 + + + 0 + - Version: 1.5 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 5/11/2017 Purpose/Change: added check for non standard port number and set services to auto start - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 12/14/2017 Purpose/Change: Will increment the tray port if a conflict is detected. - Update Date: 2/1/2018 Purpose/Change: Added support for -WhatIf. Added Service Control Command to request agent check-in immediately after startup. - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override + Author: Chris Taylor Alias: Test-LTPorts - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Test-CWAAPort -Server 'https://automate.domain.com' + + Tests all required ports against the specified server. + + + + -------------------------- EXAMPLE 2 -------------------------- + Test-CWAAPort -Quiet - {{ Add example description here }} + Returns $True if the TrayPort is available, $False otherwise. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Stop-CWAA - Stop - CWAA + Test-CWAAServerConnectivity + Test + CWAAServerConnectivity - This function will stop the LabTech Services. + Tests connectivity to a ConnectWise Automate server's agent endpoint. - This function will verify that the LabTech services are present then attempt to stop them. It will then check for any remaining LabTech processes and kill them. + Verifies that an Automate server is online and responding by querying the agent.aspx endpoint. Validates that the response matches the expected version format (pipe-delimited string ending with a version number). + If no server is provided, the function attempts to discover it from the installed agent configuration or backup settings. + Returns a result object per server with availability status and version info, or a simple boolean in Quiet mode. - Stop-CWAA - - WhatIf + Test-CWAAServerConnectivity + + Server - Shows what would happen if the cmdlet runs. The cmdlet is not run. + One or more ConnectWise Automate server URLs (e.g., https://automate.domain.com). If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + String[] - SwitchParameter + String[] - False + None - - Confirm + + ProgressAction - Prompts you for confirmation before running the cmdlet. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + Quiet + + Returns $True if all servers are reachable, $False otherwise. SwitchParameter @@ -2777,22 +4455,22 @@ - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - Confirm + + Quiet - Prompts you for confirmation before running the cmdlet. + Returns $True if all servers are reachable, $False otherwise. SwitchParameter @@ -2801,76 +4479,145 @@ False + + Server + + One or more ConnectWise Automate server URLs (e.g., https://automate.domain.com). If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + + String[] + + String[] + + + None + - Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 3/12/2018 Purpose/Change: Updated Support for ShouldProcess to enable -Confirm and -WhatIf parameters. - Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override + Author: Chris Taylor Alias: Test-LTServerConnectivity - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Test-CWAAServerConnectivity -Server 'https://automate.domain.com' + + Tests connectivity and returns a result object with Server, Available, Version, and ErrorMessage. + + + + -------------------------- EXAMPLE 2 -------------------------- + Test-CWAAServerConnectivity -Quiet + + Returns $True if the discovered server is reachable, $False otherwise. + + + + -------------------------- EXAMPLE 3 -------------------------- + Get-CWAAInfo | Test-CWAAServerConnectivity - {{ Add example description here }} + Tests connectivity to the server configured on the installed agent via pipeline. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Test-CWAAPort - Test - CWAAPort + Uninstall-CWAA + Uninstall + CWAA - This function will attempt to connect to all required TCP ports. + Completely uninstalls the ConnectWise Automate Agent from the local computer. - The function will confirm the LTTray port is available locally. It will then test required TCP ports to the Server. + Performs a comprehensive removal of the ConnectWise Automate Agent from a Windows computer. This function is more thorough than a standard MSI uninstall, as it also removes residual files, registry keys, and services that may not be cleaned up by the normal uninstall process. + The uninstall process performs the following operations: 1. Downloads official uninstaller files (Agent_Uninstall.msi and Agent_Uninstall.exe) from the server 2. Optionally creates a backup of the current agent installation (if -Backup is specified) 3. Stops all running agent services (LTService, LTSvcMon, LabVNC) 4. Terminates any running agent processes 5. Unregisters the wodVPN.dll component 6. Runs the MSI uninstaller (Agent_Uninstall.msi) 7. Runs the agent uninstaller executable (Agent_Uninstall.exe) 8. Removes agent Windows services 9. Removes all agent files from the installation directory 10. Removes all agent-related registry keys (over 30 different registry locations) 11. Verifies the uninstall was successful + Probe Agent Protection: By default, this function will refuse to uninstall probe agents to prevent accidental removal of critical infrastructure. Use -Force to override this protection. - Test-CWAAPort - + Uninstall-CWAA + Server - This is the URL to your LabTech server. Example: https://automate.domain.com If no server is provided the function will use Get-CWAAInfo to get the server address. If it is unable to find LT currently installed it will try calling Get-CWAAInfoBackup. + One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. If that fails, prompts interactively for a server URL. Example: https://automate.domain.com + + String[] + + String[] + + + None + + + Backup + + Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. + + + SwitchParameter + + + False + + + Force + + Forces uninstallation even when a probe agent is detected. Use with extreme caution, as probe agents are typically critical infrastructure components. - String[] - String[] + SwitchParameter + + + False + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference None - - TrayPort + + SkipCertificateCheck - This is the port LTSvc.exe listens on for communication with LTTray. It will be checked to verify it is available. If not provided the default port will be used (42000). + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. - Int32 - Int32 + SwitchParameter - 0 + False - - Quiet + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + SwitchParameter + + + False + + + WhatIf - This will return a boolean for connectivity status to the Server + Shows what would happen if the cmdlet runs. The cmdlet is not run. SwitchParameter @@ -2881,10 +4628,46 @@ - + + Backup + + Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. + + SwitchParameter + + SwitchParameter + + + False + + + Force + + Forces uninstallation even when a probe agent is detected. Use with extreme caution, as probe agents are typically critical infrastructure components. + + SwitchParameter + + SwitchParameter + + + False + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + Server - This is the URL to your LabTech server. Example: https://automate.domain.com If no server is provided the function will use Get-CWAAInfo to get the server address. If it is unable to find LT currently installed it will try calling Get-CWAAInfoBackup. + One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. If that fails, prompts interactively for a server URL. Example: https://automate.domain.com String[] @@ -2893,22 +4676,34 @@ None - - TrayPort + + SkipCertificateCheck - This is the port LTSvc.exe listens on for communication with LTTray. It will be checked to verify it is available. If not provided the default port will be used (42000). + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. - Int32 + SwitchParameter - Int32 + SwitchParameter - 0 + False - - Quiet + + Confirm + + Prompts you for confirmation before running the cmdlet. + + SwitchParameter + + SwitchParameter + + + False + + + WhatIf - This will return a boolean for connectivity status to the Server + Shows what would happen if the cmdlet runs. The cmdlet is not run. SwitchParameter @@ -2922,68 +4717,98 @@ - Version: 1.6 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - Update Date: 5/11/2017 Purpose/Change: Quiet feature - Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - Update Date: 6/10/2017 Purpose/Change: Updates for pipeline input, support for multiple servers - Update Date: 8/24/2017 Purpose/Change: Update to use Clear-Variable. - Update Date: 8/29/2017 Purpose/Change: Added Server Address Format Check - Update Date: 2/13/2018 Purpose/Change: Added -TrayPort parameter. + Author: Chris Taylor Alias: Uninstall-LTService Requires: Administrator privileges - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Uninstall-CWAA + + Uninstalls the agent using the server URL from the agent's registry settings. + + + + -------------------------- EXAMPLE 2 -------------------------- + Uninstall-CWAA -Backup + + Creates a backup of the agent installation before uninstalling. + + + + -------------------------- EXAMPLE 3 -------------------------- + Uninstall-CWAA -Server "https://automate.company.com" + + Uninstalls using the specified server URL to download uninstaller files. + + + + -------------------------- EXAMPLE 4 -------------------------- + Uninstall-CWAA -Server "https://primary.company.com","https://backup.company.com" + + Provides multiple server URLs with fallback. Tries each until uninstaller files download successfully. + + + + -------------------------- EXAMPLE 5 -------------------------- + Uninstall-CWAA -Force + + Forces uninstallation even if a probe agent is detected. + + + + -------------------------- EXAMPLE 6 -------------------------- + Uninstall-CWAA -WhatIf - {{ Add example description here }} + Simulates the uninstall process without making any actual changes. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent - Uninstall-CWAA - Uninstall - CWAA + Unregister-CWAAHealthCheckTask + Unregister + CWAAHealthCheckTask - {{ Fill in the Synopsis }} + Removes the ConnectWise Automate agent health check scheduled task. - {{ Fill in the Description }} + Deletes the Windows scheduled task created by Register-CWAAHealthCheckTask. If the task does not exist, writes a warning and returns gracefully. - Uninstall-CWAA - - Server + Unregister-CWAAHealthCheckTask + + TaskName - {{ Fill Server Description }} + Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. - String[] + String - String[] + String - None + CWAAHealthCheck - - Backup + + ProgressAction - {{ Fill Backup Description }} + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + ActionPreference - SwitchParameter + ActionPreference - False + None Confirm @@ -2996,17 +4821,6 @@ False - - Force - - {{ Fill Force Description }} - - - SwitchParameter - - - False - WhatIf @@ -3021,34 +4835,34 @@ - - Backup + + ProgressAction - {{ Fill Backup Description }} + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - SwitchParameter + ActionPreference - SwitchParameter + ActionPreference - False + None - - Confirm + + TaskName - Prompts you for confirmation before running the cmdlet. + Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. - SwitchParameter + String - SwitchParameter + String - False + CWAAHealthCheck - - Force + + Confirm - {{ Fill Force Description }} + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -3057,18 +4871,6 @@ False - - Server - - {{ Fill Server Description }} - - String[] - - String[] - - - None - WhatIf @@ -3082,52 +4884,33 @@ False - - - - System.String[] - - - - - - - - System.Management.Automation.SwitchParameter - - - - - - - - - - System.Object - - - - - - + + - + Author: Chris Taylor Alias: Unregister-LTHealthCheckTask - -------------------------- Example 1 -------------------------- - PS C:\> {{ Add example code here }} + -------------------------- EXAMPLE 1 -------------------------- + Unregister-CWAAHealthCheckTask + + Removes the default CWAAHealthCheck scheduled task. + + + + -------------------------- EXAMPLE 2 -------------------------- + Unregister-CWAAHealthCheckTask -TaskName 'MyHealthCheck' - {{ Add example description here }} + Removes a custom-named health check task. - Online Version: - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -3137,11 +4920,13 @@ Update CWAA - This function will manually update the LabTech agent to the requested version. + Manually updates the ConnectWise Automate Agent to a specified version. - This script will attempt to pull current server settings from machine, then download and run the agent updater. + Downloads and applies an agent update from the ConnectWise Automate server. The function reads the current server configuration from the agent's registry settings, downloads the appropriate update package, extracts it, and runs the updater. + If no version is specified, the function uses the version advertised by the server. The function validates that the requested version is higher than the currently installed version and not higher than the server version before proceeding. + The update process: 1. Reads current agent settings and server information 2. Downloads the LabtechUpdate.exe for the target version 3. Stops agent services 4. Extracts and runs the update 5. Restarts agent services @@ -3149,7 +4934,7 @@ Version - This is the agent version to install. Example: 120.240 This is needed to download the update file. If omitted, the version advertised by the server will be used. + The target agent version to update to. Example: 120.240 If omitted, the version advertised by the server will be used. String @@ -3158,10 +4943,22 @@ None - - WhatIf + + ProgressAction - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + SkipCertificateCheck + + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. SwitchParameter @@ -3180,13 +4977,48 @@ False + + WhatIf + + Shows what would happen if the cmdlet runs. The cmdlet is not run. + + + SwitchParameter + + + False + + + ProgressAction + + Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + + ActionPreference + + ActionPreference + + + None + + + SkipCertificateCheck + + Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + + SwitchParameter + + SwitchParameter + + + False + Version - This is the agent version to install. Example: 120.240 This is needed to download the update file. If omitted, the version advertised by the server will be used. + The target agent version to update to. Example: 120.240 If omitted, the version advertised by the server will be used. String @@ -3195,10 +5027,10 @@ None - - WhatIf + + Confirm - Shows what would happen if the cmdlet runs. The cmdlet is not run. + Prompts you for confirmation before running the cmdlet. SwitchParameter @@ -3207,10 +5039,10 @@ False - - Confirm + + WhatIf - Prompts you for confirmation before running the cmdlet. + Shows what would happen if the cmdlet runs. The cmdlet is not run. SwitchParameter @@ -3224,8 +5056,7 @@ - Version: 1.1 Author: Darren White Creation Date: 8/28/2018 Purpose/Change: Initial function development - Update Date: 1/21/2019 Purpose/Change: Minor bugfixes/adjustments. Allow single label server name, accept less digits for Agent Minor version number + Author: Darren White Alias: Update-LTService @@ -3233,21 +5064,21 @@ -------------------------- EXAMPLE 1 -------------------------- Update-CWAA -Version 120.240 - This will update the Automate agent to the specific version requested, using the server address in the registry. + Updates the agent to the specific version requested. -------------------------- EXAMPLE 2 -------------------------- Update-CWAA - This will update the Automate agent to the current version advertised, using the server address in the registry. + Updates the agent to the current version advertised by the server. - http://labtechconsulting.com - http://labtechconsulting.com + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + https://github.com/christaylorcodes/ConnectWiseAutomateAgent diff --git a/ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt b/ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt index e69de29..a00d3a0 100644 --- a/ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt +++ b/ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt @@ -0,0 +1,64 @@ +TOPIC + about_ConnectWiseAutomateAgent + +SHORT DESCRIPTION + PowerShell module for managing the ConnectWise Automate (formerly LabTech) + Windows agent. + +LONG DESCRIPTION + The ConnectWiseAutomateAgent module provides cmdlets for installing, + configuring, troubleshooting, and managing the ConnectWise Automate agent + on Windows systems. + + This module is primarily used by Managed Service Providers (MSPs) who use + ConnectWise Automate as their Remote Monitoring and Management (RMM) + platform. + +DUAL NAMING CONVENTION + All functions use the CWAA prefix (e.g., Install-CWAA) and also declare + backward-compatible aliases using the legacy LabTech naming convention + (e.g., Install-LTService). Both names work identically. + + Current Name Legacy Alias + ------------ ------------ + Install-CWAA Install-LTService + Uninstall-CWAA Uninstall-LTService + Get-CWAAInfo Get-LTServiceInfo + Restart-CWAA Restart-LTService + +GETTING STARTED + # Import the module + Import-Module ConnectWiseAutomateAgent + + # View agent configuration + Get-CWAAInfo + + # Check agent service status + Get-CWAAInfo | Select-Object -Property ID, 'Server Address', LastSuccessStatus + + # Install agent on a new machine + Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 + + # Test connectivity to the Automate server + Test-CWAAPort -Server https://automate.domain.com + +SYSTEM REQUIREMENTS + - Windows operating system + - PowerShell 3.0 or later (2.0 with limitations) + - Administrator privileges for most operations + - Network access to the ConnectWise Automate server (ports 70, 80, 443, 8002) + +KEY LOCATIONS + Registry: HKLM:\SOFTWARE\LabTech\Service + Files: C:\Windows\LTSVC + Services: LTService (main), LTSvcMon (monitor), LabVNC (remote control) + Temp: C:\Windows\Temp\LabTech\Installer + TrayPort: 42000-42009 (local agent communication) + +SEE ALSO + Get-CWAAInfo + Install-CWAA + Uninstall-CWAA + Update-CWAA + Test-CWAAPort + https://github.com/christaylorcodes/ConnectWiseAutomateAgent diff --git a/ConnectWiseAutomateAgent_Functions.md b/ConnectWiseAutomateAgent_Functions.md deleted file mode 100644 index 5f6a3bf..0000000 --- a/ConnectWiseAutomateAgent_Functions.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -Module Name: ConnectWiseAutomateAgent -Module Guid: 37424fc5-48d4-4d15-8b19-e1c2bf4bab67 -Download Help Link: https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml -Help Version: 0.1.0.0 -Locale: en-US ---- - -# ConnectWiseAutomateAgent Module -## Description -This is a powershell module for interacting with the ConnectWise Automate agent. - -## ConnectWiseAutomateAgent Cmdlets -### [ConvertFrom-CWAASecurity](Docs/ConvertFrom-CWAASecurity.md) -This function decodes an encoded Base64 value - -### [ConvertTo-CWAASecurity](Docs/ConvertTo-CWAASecurity.md) -This function encodes a value compatible with LT operations. - -### [Get-CWAAError](Docs/Get-CWAAError.md) -This will pull the %ltsvcdir%\LTErrors.txt file into an object. - -### [Get-CWAAInfo](Docs/Get-CWAAInfo.md) -This function will pull all of the registry data into an object. - -### [Get-CWAAInfoBackup](Docs/Get-CWAAInfoBackup.md) -This function will pull all of the backed up registry data into an object. - -### [Get-CWAALogLevel](Docs/Get-CWAALogLevel.md) -{{ Fill in the Synopsis }} - -### [Get-CWAAProbeError](Docs/Get-CWAAProbeError.md) -This will pull the %ltsvcdir%\LTProbeErrors.txt file into an object. - -### [Get-CWAAProxy](Docs/Get-CWAAProxy.md) -This function retrieves the current agent proxy settings for module functions -to use the specified proxy configuration for all communication operations as -long as the module remains loaded. - -### [Get-CWAASettings](Docs/Get-CWAASettings.md) -Pulls information about the agent install. - -### [Hide-CWAAAddRemove](Docs/Hide-CWAAAddRemove.md) -This function hides the LabTech install from the Add/Remove Programs list. - -### [Install-CWAA](Docs/Install-CWAA.md) -This function will install the LabTech agent on the machine. - -### [Invoke-CWAACommand](Docs/Invoke-CWAACommand.md) -This function tells the agent to execute the desired command. - -### [New-CWAABackup](Docs/New-CWAABackup.md) -{{ Fill in the Synopsis }} - -### [Redo-CWAA](Docs/Redo-CWAA.md) -This function will reinstall the LabTech agent from the machine. - -### [Rename-CWAAAddRemove](Docs/Rename-CWAAAddRemove.md) -This function renames the LabTech install as shown in the Add/Remove Programs list. - -### [Reset-CWAA](Docs/Reset-CWAA.md) -This function will remove local settings on the agent. - -### [Restart-CWAA](Docs/Restart-CWAA.md) -This function will restart the LabTech Services. - -### [Set-CWAALogLevel](Docs/Set-CWAALogLevel.md) -This function will restart the LabTech Services. - -### [Set-CWAAProxy](Docs/Set-CWAAProxy.md) -This function configures module functions to use the specified proxy -configuration for all operations as long as the module remains loaded. - -### [Show-CWAAAddRemove](Docs/Show-CWAAAddRemove.md) -This function shows the LabTech install in the add/remove programs list. - -### [Start-CWAA](Docs/Start-CWAA.md) -This function will start the LabTech Services. - -### [Stop-CWAA](Docs/Stop-CWAA.md) -This function will stop the LabTech Services. - -### [Test-CWAAPort](Docs/Test-CWAAPort.md) -This function will attempt to connect to all required TCP ports. - -### [Uninstall-CWAA](Docs/Uninstall-CWAA.md) -{{ Fill in the Synopsis }} - -### [Update-CWAA](Docs/Update-CWAA.md) -This function will manually update the LabTech agent to the requested version. - diff --git a/Docs/Architecture.md b/Docs/Architecture.md new file mode 100644 index 0000000..da4cdc4 --- /dev/null +++ b/Docs/Architecture.md @@ -0,0 +1,321 @@ +# ConnectWiseAutomateAgent Architecture + +Visual reference for the module's internal structure, initialization flow, and system interactions. + +## Module Initialization (Two-Phase) + +Module import is fast with no side effects. Networking is deferred until first use. + +```mermaid +flowchart TD + A[Import-Module ConnectWiseAutomateAgent] --> B[PSM1: Dot-source all .ps1 files
from Public/ and Private/] + B --> C{Running 32-bit PS
on 64-bit OS?} + C -->|Yes, module mode| D[Emit WOW64 warning] + C -->|Yes, single-file mode| E[Relaunch under 64-bit PowerShell] + C -->|No| F[Initialize-CWAA] + D --> F + F --> G[Create Script constants
CWAARegistryRoot, CWAAInstallPath,
CWAAServiceNames, etc.] + G --> H[Create empty state objects
LTServiceKeys, LTProxy] + H --> I[Set CWAANetworkInitialized = false] + I --> J[Module ready — no network, no registry reads] + + J -.->|First networking call| K[Initialize-CWAANetworking] + + K --> L{CWAACertCallbackRegistered?} + L -->|No| M[Compile C# SSL callback via Add-Type
Graduated trust: IP bypass,
name mismatch tolerate, chain reject] + L -->|Yes| N{SkipCertificateCheck?} + M --> N + N -->|Yes| O[Set SkipAll = true
Full certificate bypass] + N -->|No| P{CWAANetworkInitialized?} + O --> P + P -->|Yes| Q[Return — already initialized] + P -->|No| R[Enable TLS 1.2 + 1.3] + R --> S[Create LTWebProxy + LTServiceNetWebClient] + S --> T[Get-CWAAProxy — discover agent proxy settings] + T --> U[Set CWAANetworkInitialized = true] + U --> V[Networking ready] + + style J fill:#d4edda,stroke:#28a745 + style V fill:#d4edda,stroke:#28a745 + style E fill:#fff3cd,stroke:#ffc107 + style D fill:#fff3cd,stroke:#ffc107 +``` + +## Agent Installation Workflow + +`Install-CWAA` end-to-end flow from parameter validation through post-install verification. + +```mermaid +flowchart TD + A[Install-CWAA called] --> B[Begin Block] + B --> B1[Initialize-CWAANetworking] + B1 --> B2{Running as Administrator?} + B2 -->|No| B3[Throw: Needs Administrator] + B2 -->|Yes| B4{Agent already installed?} + B4 -->|Yes, no -Force| B5[Throw: Already installed] + B4 -->|Yes, -Force| B6[Continue to Process] + B4 -->|No| B7[Validate .NET 3.5+ installed] + B7 -->|Missing| B8[Throw: .NET required] + B7 -->|Present| B6 + + B6 --> C[Process Block] + C --> C1[Resolve-CWAAServer
Validate URLs, test /Agent.aspx,
parse version string] + C1 -->|No server reachable| C2[Return — no GoodServer] + C1 -->|Server found| C3{Auth method?} + + C3 -->|InstallerToken| C4[URL: Deployment.aspx?InstallerToken=...] + C3 -->|ServerPassword| C5[URL: Service/LabTechRemoteAgent.msi] + C3 -->|Anonymous, v110.374+| C6[URL: Deployment.aspx?Probe=1] + C3 -->|Legacy| C7[URL: Deployment.aspx?MSILocations=LocationID] + + C4 --> C8{Server v240.331+?} + C8 -->|Yes| C9[Download ZIP, extract MSI+MST] + C8 -->|No| C10[Download MSI directly] + C5 --> C10 + C6 --> C10 + C7 --> C10 + + C9 --> C11[Test-CWAADownloadIntegrity
Verify file > 1234 KB] + C10 --> C11 + C11 -->|Failed| C12[Remove corrupt file, abort] + C11 -->|Passed| D + + D[End Block] --> D1{Previous install detected?} + D1 -->|Yes| D2[Uninstall-CWAA first] + D1 -->|No| D3[Clear-CWAAInstallerArtifacts] + D2 --> D3 + D3 --> D4[Resolve TrayPort 42000-42009] + D4 --> D5[Build MSI arguments:
SERVERADDRESS, SERVERPASS/TOKEN,
LOCATION, TRAYPORT] + D5 --> D6[Execute msiexec /i — up to 3 attempts] + D6 -->|All attempts failed| D7[Write-Error, log event] + D6 -->|Success| D8{Proxy configured?} + D8 -->|Yes| D9[Wait for LTService running
then Set-CWAAProxy] + D8 -->|No| D10[Wait for agent registration
poll every 2s, up to 120s] + D9 --> D10 + D10 --> D11[Redact passwords in install log] + D11 --> D12[Write-CWAAEventLog success] + + style B3 fill:#f8d7da,stroke:#dc3545 + style B5 fill:#f8d7da,stroke:#dc3545 + style B8 fill:#f8d7da,stroke:#dc3545 + style C12 fill:#f8d7da,stroke:#dc3545 + style D7 fill:#f8d7da,stroke:#dc3545 + style D12 fill:#d4edda,stroke:#28a745 +``` + +## Health Check Escalation Flow + +`Test-CWAAHealth` performs read-only assessment. `Repair-CWAA` uses those results to escalate remediation. + +```mermaid +flowchart TD + A[Repair-CWAA called] --> B{Agent installed?
LTService exists?} + + B -->|No| C[Stage 4: Fresh Install] + C --> C1{Server + LocationID +
InstallerToken provided?} + C1 -->|Yes| C2[Install-CWAA with params] + C1 -->|No| C3[Recover from Get-CWAAInfoBackup] + C3 --> C4{Backup has Server?} + C4 -->|Yes| C5[Redo-CWAA from backup] + C4 -->|No| C6[Error: No install settings available] + + B -->|Yes| D{Config readable?
Get-CWAAInfo succeeds?} + D -->|No| D1[Stage 1: Uninstall corrupt agent] + D1 --> D2[Return Success=false
for clean reinstall next cycle] + + D -->|Yes| E{Server parameter provided
AND server mismatch?} + E -->|Yes| E1[Stage 2: Redo-CWAA
Reinstall with correct server] + + E -->|No| F{LastContact or LastHeartbeat
older than HoursRestart?
default: 2 hours} + F -->|No| G[Agent healthy — no action
Log event 4000] + + F -->|Yes| H[Stage 3: Restart-CWAA] + H --> I[Wait up to 120s
Poll LastSuccessStatus every 2s] + I --> J{LastContact recovered?} + J -->|Yes| K[Restart succeeded
Log event 4001] + + J -->|No| L{LastContact older than
HoursReinstall?
default: 120 hours / 5 days} + L -->|No| M[Wait for next cycle
Return current status] + L -->|Yes| N{Server reachable?
Test-CWAAServerConnectivity} + N -->|No| O[Error: Server unreachable
Log event 4008] + N -->|Yes| P[Stage 3b: Redo-CWAA
Full reinstall
Log event 4002] + + style G fill:#d4edda,stroke:#28a745 + style K fill:#d4edda,stroke:#28a745 + style C6 fill:#f8d7da,stroke:#dc3545 + style D2 fill:#f8d7da,stroke:#dc3545 + style O fill:#f8d7da,stroke:#dc3545 + style E1 fill:#fff3cd,stroke:#ffc107 + style P fill:#fff3cd,stroke:#ffc107 + style C2 fill:#cce5ff,stroke:#004085 + style C5 fill:#cce5ff,stroke:#004085 +``` + +## Registry & File System Interaction Map + +Which functions read and write the key system locations. + +```mermaid +flowchart LR + subgraph Registry["Registry Keys"] + REG_ROOT["HKLM:\SOFTWARE\LabTech\Service
CWAARegistryRoot"] + REG_SETTINGS["...\Service\Settings
CWAARegistrySettings"] + REG_BACKUP["HKLM:\SOFTWARE\LabTechBackup\Service
CWAARegistryBackup"] + REG_UNINSTALL["HKLM:\...\Uninstall\{GUID}
CWAAUninstallKeys"] + end + + subgraph FileSystem["File System"] + FS_INSTALL["C:\Windows\LTSVC\
CWAAInstallPath"] + FS_TEMP["C:\Windows\Temp\LabTech\
CWAAInstallerTempPath"] + FS_ERRORS["C:\Windows\LTSVC\errors.txt"] + FS_PROBES["C:\Windows\LTSVC\Probes\*.txt"] + FS_BACKUP["C:\Windows\LTSVC\Backup\"] + end + + subgraph Readers["Read Operations"] + direction TB + R1[Get-CWAAInfo] + R2[Get-CWAASettings] + R3[Get-CWAAInfoBackup] + R4[Get-CWAAError] + R5[Get-CWAAProbeError] + R6[Get-CWAAProxy] + R7[Test-CWAAHealth] + end + + subgraph Writers["Write Operations"] + direction TB + W1[Install-CWAA] + W2[Uninstall-CWAA] + W3[Set-CWAALogLevel] + W4[Set-CWAAProxy] + W5[Reset-CWAA] + W6[New-CWAABackup] + W7[Start-CWAA] + W8["Hide/Show/Rename-
CWAAAddRemove"] + end + + R1 -->|read| REG_ROOT + R2 -->|read| REG_SETTINGS + R3 -->|read| REG_BACKUP + R4 -->|read| FS_ERRORS + R5 -->|read| FS_PROBES + R6 -->|read| REG_SETTINGS + R7 -->|read| REG_ROOT + + W1 -->|write| FS_INSTALL + W1 -->|write| FS_TEMP + W2 -->|delete| REG_ROOT + W2 -->|delete| FS_INSTALL + W3 -->|write| REG_SETTINGS + W4 -->|write| REG_SETTINGS + W5 -->|delete values| REG_ROOT + W6 -->|write| REG_BACKUP + W6 -->|write| FS_BACKUP + W7 -->|write TrayPort| REG_ROOT + W8 -->|write| REG_UNINSTALL + + style REG_ROOT fill:#e2e3f1,stroke:#6c63ff + style REG_SETTINGS fill:#e2e3f1,stroke:#6c63ff + style REG_BACKUP fill:#e2e3f1,stroke:#6c63ff + style REG_UNINSTALL fill:#e2e3f1,stroke:#6c63ff + style FS_INSTALL fill:#fce4d6,stroke:#ed7d31 + style FS_TEMP fill:#fce4d6,stroke:#ed7d31 + style FS_ERRORS fill:#fce4d6,stroke:#ed7d31 + style FS_PROBES fill:#fce4d6,stroke:#ed7d31 + style FS_BACKUP fill:#fce4d6,stroke:#ed7d31 +``` + +## Proxy Resolution Flow + +`Get-CWAAProxy` discovers settings from the installed agent. `Set-CWAAProxy` applies changes with three modes. + +```mermaid +flowchart TD + subgraph Discovery["Get-CWAAProxy (Discovery)"] + GP1[Get-CWAAInfo — read registry] --> GP2{ServerPassword
in registry?} + GP2 -->|No| GP3[Keys empty — no proxy available] + GP2 -->|Yes| GP4[ConvertFrom-CWAASecurity
Decrypt ServerPasswordString] + GP4 --> GP5[ConvertFrom-CWAASecurity
Decrypt PasswordString
using ServerPasswordString as key] + GP5 --> GP6[Get-CWAASettings — read Settings key] + GP6 --> GP7{ProxyServerURL
matches https?://?} + GP7 -->|No| GP8[Proxy disabled — clear LTProxy] + GP7 -->|Yes| GP9[Decrypt ProxyUsername +
ProxyPassword using
PasswordString as key] + GP9 --> GP10["Store in $Script:LTProxy
(Enabled, URL, User, Pass)"] + end + + subgraph Configuration["Set-CWAAProxy (Configuration)"] + SP1[Set-CWAAProxy called] --> SP2{Which mode?} + SP2 -->|ResetProxy| SP3[Clear all proxy state
New empty WebProxy] + SP2 -->|DetectProxy| SP4[GetSystemWebProxy
+ netsh winhttp show proxy] + SP2 -->|Manual URL| SP5[Create WebProxy with URL
+ optional credentials] + SP3 --> SP6{Registry settings
changed?} + SP4 --> SP6 + SP5 --> SP6 + SP6 -->|No| SP7[Return — no update needed] + SP6 -->|Yes| SP8[Stop-CWAA services] + SP8 --> SP9[ConvertTo-CWAASecurity
Encrypt URL + Username + Password
Write to registry Settings key] + SP9 --> SP10[Start-CWAA services] + SP10 --> SP11[Write-CWAAEventLog 3020] + end + + GP10 -.->|"Module session proxy
used by all networking"| SP1 + + style GP10 fill:#d4edda,stroke:#28a745 + style SP11 fill:#d4edda,stroke:#28a745 + style GP3 fill:#f8d7da,stroke:#dc3545 + style GP8 fill:#fff3cd,stroke:#ffc107 + style SP7 fill:#e2e3f1,stroke:#6c63ff +``` + +## Uninstall/Cleanup Sequence + +`Uninstall-CWAA` end-to-end flow from validation through post-uninstall verification. + +```mermaid +flowchart TD + A[Uninstall-CWAA called] --> B[Begin Block] + B --> B1[Initialize-CWAANetworking] + B1 --> B2{Running as
Administrator?} + B2 -->|No| B3[Throw: Needs Administrator] + B2 -->|Yes| B4{Probe agent
detected?} + B4 -->|"Yes, no -Force"| B5[Error: Probe uninstall denied] + B4 -->|"Yes, -Force"| B6[Continue] + B4 -->|No| B6 + B6 --> B7{-Backup specified?} + B7 -->|Yes| B8[New-CWAABackup] + B7 -->|No| B9[Build registry key list
30+ keys across HKLM,
HKCR, HKU, Wow6432Node] + B8 --> B9 + + B9 --> C[Process Block] + C --> C1{Server provided?} + C1 -->|No| C2[Read from agent registry
or prompt user] + C1 -->|Yes| C3[Use provided server] + C2 --> C3 + C3 --> C4[Resolve-CWAAServer
Find first reachable server] + C4 -->|No server| C5[Return — cannot proceed] + C4 -->|Server found| C6[Download Agent_Uninstall.msi
+ Agent_Uninstall.exe] + C6 --> C7[Test-CWAADownloadIntegrity
MSI > 1234 KB, EXE > 80 KB] + C7 -->|Failed| C5 + C7 -->|Passed| C8[GoodServer set] + + C8 --> D[End Block] + D --> D1[Stop-CWAA — stop all services] + D1 --> D2[Kill agent processes
from install path] + D2 --> D3[regsvr32 /u wodVPN.dll] + D3 --> D4[msiexec /x Agent_Uninstall.msi /qn] + D4 --> D5[Execute Agent_Uninstall.exe] + D5 --> D6["sc.exe delete — LTService,
LTSvcMon, LabVNC"] + D6 --> D7[Remove-CWAAFolderRecursive
Install path + temp dir] + D7 --> D8[Remove MSI file
retry up to 4 times] + D8 --> D9[Remove 30+ registry keys
depth-first removal] + D9 --> D10{Remnants
detected?} + D10 -->|Yes| D11[Error: Reboot recommended
Event 1011] + D10 -->|No| D12[Success: Agent uninstalled
Event 1010] + + style B3 fill:#f8d7da,stroke:#dc3545 + style B5 fill:#f8d7da,stroke:#dc3545 + style C5 fill:#f8d7da,stroke:#dc3545 + style D11 fill:#fff3cd,stroke:#ffc107 + style D12 fill:#d4edda,stroke:#28a745 +``` diff --git a/Docs/CommonParameters.md b/Docs/CommonParameters.md new file mode 100644 index 0000000..1ffa70a --- /dev/null +++ b/Docs/CommonParameters.md @@ -0,0 +1,175 @@ +# Common Parameters Reference + +Parameters shared across multiple ConnectWiseAutomateAgent functions. This reference documents how each parameter behaves, its validation rules, and which functions accept it. + +For PowerShell's built-in common parameters (`-Verbose`, `-Debug`, `-ErrorAction`, etc.), see [about_CommonParameters](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_commonparameters). + +--- + +## -Server + +Specifies one or more ConnectWise Automate server URLs. The module tries each server in order and uses the first one that responds with a valid version string. + +| Detail | Value | +| --- | --- | +| **Type** | `[string[]]` | +| **Required** | Yes (on most functions) | +| **Pipeline** | `ValueFromPipelineByPropertyName` | +| **Validation** | Regex: `$Script:CWAAServerValidationRegex` | + +Bare hostnames (e.g., `automate.example.com`) are automatically normalized with an `https://` prefix. The server is validated by downloading the version response from `/LabTech/Agent.aspx`. Invalid formats produce a warning and are skipped. + +On `Uninstall-CWAA`, the `-Server` parameter is `[AllowNull()]` because the server URL can be read from the installed agent's registry if not explicitly provided. + +### Functions that accept -Server + +| Function | Mandatory | Notes | +| --- | --- | --- | +| `Install-CWAA` | Yes | Downloads installer from this server | +| `Uninstall-CWAA` | No | Falls back to registry; downloads uninstaller | +| `Update-CWAA` | Yes | Downloads updated installer | +| `Redo-CWAA` | Yes | Passed through to Install-CWAA | +| `Repair-CWAA` | No | Used for reinstall; falls back to agent config | +| `Test-CWAAPort` | Yes | Tests port connectivity to this server | +| `Test-CWAAServerConnectivity` | No | Auto-discovers from agent config if omitted | +| `Test-CWAAHealth` | No | Passed to Test-CWAAServerConnectivity if `-TestServerConnectivity` | +| `Register-CWAAHealthCheckTask` | Yes | Stored in scheduled task for recurring health checks | + +--- + +## -LocationID + +Specifies the Automate location (site) to assign the agent to during installation. + +| Detail | Value | +| --- | --- | +| **Type** | `[int]` | +| **Required** | No | +| **Pipeline** | `ValueFromPipelineByPropertyName` | +| **Validation** | `[ValidateRange(1, [int]::MaxValue)]` on some functions | + +**Breaking change in 1.0.0:** `Redo-CWAA` changed `-LocationID` from `[string]` to `[int]`. Scripts passing string values like `'5'` instead of `5` may need updating. See [Migration Guide](Migration.md) for details. + +### Functions that accept -LocationID + +| Function | Notes | +| --- | --- | +| `Install-CWAA` | Passed as MSI property `LOCATION=` | +| `Redo-CWAA` | Passed through to Install-CWAA | +| `Repair-CWAA` | Used for reinstall if escalation required | +| `Register-CWAAHealthCheckTask` | Stored in scheduled task arguments | + +--- + +## -InstallerToken + +The modern, preferred authentication method for agent deployment. An alphanumeric token generated in the Automate console. + +| Detail | Value | +| --- | --- | +| **Type** | `[string]` | +| **Required** | No (but recommended) | +| **Parameter Set** | `installertoken` | +| **Validation** | `[ValidatePattern('(?s:^[0-9a-z]+$)')]` — lowercase alphanumeric only | + +When provided, the installer is downloaded via `Deployment.aspx?InstallerToken=`. This is more secure than `-ServerPassword` because the token is scoped and revocable. + +### Functions that accept -InstallerToken + +| Function | Notes | +| --- | --- | +| `Install-CWAA` | Primary installation parameter | +| `Redo-CWAA` | Passed through to Install-CWAA | +| `Repair-CWAA` | Used for reinstall if escalation required | +| `Register-CWAAHealthCheckTask` | Stored in scheduled task arguments | + +--- + +## -ServerPassword + +The legacy authentication method for agent deployment. A server-level password configured in the Automate console. + +| Detail | Value | +| --- | --- | +| **Type** | `[string]` | +| **Required** | No | +| **Parameter Set** | `deployment` | +| **Alias** | `Password` | + +When provided, the password is passed as an MSI property during installation. **Use `-InstallerToken` instead whenever possible.** `InstallerToken` is scoped, revocable, and does not expose a server-wide credential. See [Security Model](Security.md#authentication-installertoken-vs-serverpassword) for details. + +### Functions that accept -ServerPassword + +| Function | Notes | +| --- | --- | +| `Install-CWAA` | Passed as MSI property `SERVERPASS=` | +| `Redo-CWAA` | Passed through to Install-CWAA | + +--- + +## -Force + +Overrides safety checks. **The specific behavior varies by function.** + +| Detail | Value | +| --- | --- | +| **Type** | `[switch]` | +| **Required** | No | +| **Default** | `$False` | + +### Behavior by function + +| Function | What -Force does | +| --- | --- | +| `Install-CWAA` | Skips the "agent already installed" check and .NET prerequisite validation | +| `Uninstall-CWAA` | Allows uninstall even when the probe agent is detected (normally blocked to prevent accidental probe removal) | +| `Redo-CWAA` | Passed through to Install-CWAA | +| `Repair-CWAA` | Passed through to Install-CWAA during reinstall escalation | +| `ConvertFrom-CWAASecurity` | Attempts alternate decryption keys if the primary key fails | +| `Register-CWAAHealthCheckTask` | Recreates the scheduled task unconditionally, even if one already exists | + +--- + +## -SkipCertificateCheck + +Disables all SSL certificate validation for the current PowerShell session. + +| Detail | Value | +| --- | --- | +| **Type** | `[switch]` | +| **Required** | No | +| **Default** | `$False` | +| **Scope** | Session-wide (affects ALL HTTPS connections) | + +Sets the `SkipAll` flag on the compiled C# `ServerCertificateValidationCallback` class, bypassing the graduated trust model entirely. This is necessary when connecting to servers with self-signed certificates where the hostname matches but the CA is not trusted. + +**Warning:** This affects all HTTPS connections in the PowerShell session, not just Automate operations. Use only when the graduated SSL trust model (which auto-bypasses IP addresses and tolerates name mismatches) is insufficient. See [Security Model](Security.md#ssl-certificate-validation) for the full trust hierarchy. + +### Functions that accept -SkipCertificateCheck + +| Function | Notes | +| --- | --- | +| `Install-CWAA` | Passed to Initialize-CWAANetworking | +| `Uninstall-CWAA` | Passed to Initialize-CWAANetworking | +| `Update-CWAA` | Passed to Initialize-CWAANetworking | +| `Set-CWAAProxy` | Passed to Initialize-CWAANetworking | + +--- + +## -Backup + +Creates a backup of the agent's registry configuration before performing a destructive operation. + +| Detail | Value | +| --- | --- | +| **Type** | `[switch]` | +| **Required** | No | +| **Default** | `$False` | + +Calls `New-CWAABackup` internally, which copies the agent's registry keys to `HKLM:\SOFTWARE\LabTechBackup\Service` and exports configuration files to `C:\Windows\LTSVC\Backup\`. + +### Functions that accept -Backup + +| Function | Notes | +| --- | --- | +| `Uninstall-CWAA` | Backs up before removing the agent | diff --git a/Docs/FAQ.md b/Docs/FAQ.md new file mode 100644 index 0000000..9698e56 --- /dev/null +++ b/Docs/FAQ.md @@ -0,0 +1,175 @@ +# Frequently Asked Questions + +## Installation & Deployment + +### What authentication method should I use? + +Use **InstallerToken** whenever possible. It is scoped, revocable, and more secure than the server-wide `ServerPassword`. Generate tokens in the Automate console. + +```powershell +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +`ServerPassword` is supported for backward compatibility with older Automate servers and existing scripts. See [Security Model](Security.md#authentication-installertoken-vs-serverpassword) for details. + +### Does the module work on Windows Server Core? + +Yes. PowerShell 3.0+ is available on all Server Core editions. The module has no GUI dependencies. + +### How do I deploy agents via GPO? + +Use the [GPOScheduledTaskDeployment.ps1](../Examples/GPOScheduledTaskDeployment.ps1) example script. It creates a Group Policy scheduled task that runs the installation on target machines. + +### Can I deploy with Intune, SCCM, or another RMM tool? + +Yes. Any tool that can execute a PowerShell script with administrator privileges can deploy the agent. The key command is: + +```powershell +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +For environments without PowerShell Gallery access, use the single-file version: + +```powershell +Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +### What PowerShell versions are supported? + +| Version | Support Level | +| --- | --- | +| PowerShell 2.0 | Limited (no module manifest support, use single-file mode) | +| PowerShell 3.0 - 5.1 | Full support | +| PowerShell 7+ | Full support | + +The module is tested on PowerShell 5.1 and 7+. + +--- + +## Configuration + +### How do I set up a proxy for the agent? + +```powershell +# Auto-detect from system settings (IE/WinHTTP) +Set-CWAAProxy -DetectProxy + +# Or set manually +Set-CWAAProxy -ProxyServerURL 'proxy.example.com:8080' + +# With authentication +Set-CWAAProxy -ProxyServerURL 'proxy.example.com:8080' -ProxyUsername 'user' -ProxyPassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) +``` + +See [ProxyConfiguration.ps1](../Examples/ProxyConfiguration.ps1) for a complete walkthrough. + +### How do I set up automated health checks? + +One command registers a Windows scheduled task that runs `Repair-CWAA` on a schedule: + +```powershell +Register-CWAAHealthCheckTask -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +This creates a task that checks agent health every 60 minutes and applies escalating remediation (restart, then reinstall) as needed. See [HealthCheck-Monitoring.ps1](../Examples/HealthCheck-Monitoring.ps1) for details. + +### Does 32-bit vs 64-bit matter? + +Yes. The Automate agent is a 32-bit application, but its registry keys are accessible from both 32-bit and 64-bit PowerShell (with different paths due to WOW64 redirection). The module handles this automatically: + +- **Module mode:** Emits a warning if imported in 32-bit PowerShell on a 64-bit OS. Re-import in 64-bit PowerShell. +- **Single-file mode:** Automatically relaunches under 64-bit PowerShell. + +Always use 64-bit PowerShell when possible. See [Troubleshooting](Troubleshooting.md#wow64--32-bit-vs-64-bit-mismatches) for details. + +--- + +## Backward Compatibility + +### Can I still use the old LT function names? + +Yes. All 32 legacy `LT` aliases are preserved and will not be removed: + +```powershell +# These are identical: +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +Install-LTService -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +List all aliases: `Get-Alias -Definition *-CWAA*` + +### Will my existing scripts break after upgrading? + +No, with one exception: `Redo-CWAA` (aliased as `Redo-LTService`) changed `-LocationID` from `[string]` to `[int]`. If you pass `''` (empty string) where `$null` is intended, it may now fail. See [Migration Guide](Migration.md#breaking-changes) for details. + +--- + +## Troubleshooting + +### Where are the agent logs? + +| Log | Location | Access | +| --- | --- | --- | +| Agent errors | `C:\Windows\LTSVC\errors.txt` | `Get-CWAAError` | +| Probe errors | `C:\Windows\LTSVC\Probes\*.txt` | `Get-CWAAProbeError` | +| Module events | Windows Application Event Log | `Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='ConnectWiseAutomateAgent' }` | + +### How do I check if the agent is healthy? + +```powershell +Test-CWAAHealth +``` + +For a more thorough check including server connectivity: + +```powershell +Test-CWAAHealth -TestServerConnectivity +``` + +### How do I force the agent to check in? + +```powershell +Invoke-CWAACommand -Command 'Send Status' +``` + +Other useful commands: `'Send Inventory'`, `'Send Drives'`, `'Send Procs'`. See `Get-Help Invoke-CWAACommand` for the full list. + +--- + +## Module Usage + +### What does the single-file version do differently? + +Functionally identical — all the same functions are available. The difference is packaging: + +- **Module (Install-Module):** Installed to the module path, imported with `Import-Module`, supports `Get-Help`, auto-updates via `Update-Module`. +- **Single-file (.ps1):** All functions concatenated into one file, loaded via `Invoke-Expression`. Used when the PowerShell Gallery is unavailable. `Initialize-CWAA` is appended at the end of the file. + +Prefer `Install-Module` whenever possible. + +### How do I update the module? + +```powershell +Update-Module ConnectWiseAutomateAgent +``` + +For prerelease builds: + +```powershell +Update-Module ConnectWiseAutomateAgent -AllowPrerelease +``` + +### Can I use this in a CI/CD pipeline? + +Yes. The module is published on the PowerShell Gallery and can be installed non-interactively: + +```powershell +Install-Module ConnectWiseAutomateAgent -Force -Scope AllUsers -AllowPrerelease +``` + +Note that most functions require administrator privileges and a Windows target. Functions that read agent state (`Get-CWAAInfo`, `Test-CWAAHealth`) are most useful in CI/CD for validation steps. + +### What is the `-Force` parameter doing? + +It varies by function. See [Common Parameters](CommonParameters.md#-force) for the full breakdown. In general, it overrides safety checks (existing installation detection, probe agent protection, existing scheduled tasks). diff --git a/Docs/Get-CWAAError.md b/Docs/Get-CWAAError.md deleted file mode 100644 index 3777978..0000000 --- a/Docs/Get-CWAAError.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAAError - -## SYNOPSIS -This will pull the %ltsvcdir%\LTErrors.txt file into an object. - -## SYNTAX - -``` -Get-CWAAError [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### EXAMPLE 1 -``` -Get-CWAAErrors | where {(Get-date $_.Time) -gt (get-date).AddHours(-24)} -``` - -Get a list of all errors in the last 24hr - -### EXAMPLE 2 -``` -Get-CWAAErrors | Out-Gridview -``` - -Open the log file in a sortable searchable window. - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.3 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/18/2018 -Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. - -Update Date: 1/26/2019 -Purpose/Change: Update for better international date parsing support. -Function rename. - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Get-CWAAInfo.md b/Docs/Get-CWAAInfo.md deleted file mode 100644 index 815b94e..0000000 --- a/Docs/Get-CWAAInfo.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAAInfo - -## SYNOPSIS -This function will pull all of the registry data into an object. - -## SYNTAX - -``` -Get-CWAAInfo [-WhatIf] [-Confirm] [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: wi - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Confirm -Prompts you for confirmation before running the cmdlet. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: cf - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.5 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 8/24/2017 -Purpose/Change: Update to use Clear-Variable. - -Update Date: 3/12/2018 -Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. - -Update Date: 8/28/2018 -Purpose/Change: Remove '~' from server addresses. - -Update Date: 1/19/2019 -Purpose/Change: Improved BasePath value assignment - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Get-CWAAInfoBackup.md b/Docs/Get-CWAAInfoBackup.md deleted file mode 100644 index 1849e56..0000000 --- a/Docs/Get-CWAAInfoBackup.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAAInfoBackup - -## SYNOPSIS -This function will pull all of the backed up registry data into an object. - -## SYNTAX - -``` -Get-CWAAInfoBackup [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.1 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 5/11/2017 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/18/2018 -Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Get-CWAALogLevel.md b/Docs/Get-CWAALogLevel.md deleted file mode 100644 index 95806ab..0000000 --- a/Docs/Get-CWAALogLevel.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAALogLevel - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -Get-CWAALogLevel [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Get-CWAAProbeError.md b/Docs/Get-CWAAProbeError.md deleted file mode 100644 index e65dead..0000000 --- a/Docs/Get-CWAAProbeError.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAAProbeError - -## SYNOPSIS -This will pull the %ltsvcdir%\LTProbeErrors.txt file into an object. - -## SYNTAX - -``` -Get-CWAAProbeError [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### EXAMPLE 1 -``` -Get-CWAAProbeErrors | where {(Get-date $_.Time) -gt (get-date).AddHours(-24)} -``` - -Get a list of all errors in the last 24hr - -### EXAMPLE 2 -``` -Get-CWAAProbeErrors | Out-Gridview -``` - -Open the log file in a sortable searchable window. - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.3 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/18/2018 -Purpose/Change: Changed Erroraction from Stop to unspecified to allow caller to set the ErrorAction. - -Update Date: 1/26/2019 -Purpose/Change: Update for better international date parsing support - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Get-CWAAProxy.md b/Docs/Get-CWAAProxy.md deleted file mode 100644 index 6b8c88b..0000000 --- a/Docs/Get-CWAAProxy.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAAProxy - -## SYNOPSIS -This function retrieves the current agent proxy settings for module functions -to use the specified proxy configuration for all communication operations as -long as the module remains loaded. - -## SYNTAX - -``` -Get-CWAAProxy [] -``` - -## DESCRIPTION -This function will get the current LabTech Proxy settings from the -installed agent (if present). -If no agent settings are found, the function -will attempt to discover the current proxy settings for the system. -The Proxy Settings determined will be stored in memory for internal use, and -returned as the function result. - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.1 -Author: Darren White -Creation Date: 1/24/2018 -Purpose/Change: Initial function development - -Update Date: 3/18/2018 -Purpose/Change: Ensure ProxyUser and ProxyPassword are set correctly when proxy -is not configured. - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Get-CWAASettings.md b/Docs/Get-CWAASettings.md deleted file mode 100644 index 9bacbb8..0000000 --- a/Docs/Get-CWAASettings.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Get-CWAASettings - -## SYNOPSIS -Pulls information about the agent install. - -## SYNTAX - -``` -Get-CWAASettings [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Help/ConnectWiseAutomateAgent.md b/Docs/Help/ConnectWiseAutomateAgent.md new file mode 100644 index 0000000..7f067c0 --- /dev/null +++ b/Docs/Help/ConnectWiseAutomateAgent.md @@ -0,0 +1,83 @@ +--- +Module Name: ConnectWiseAutomateAgent +Module Guid: 37424fc5-48d4-4d15-8b19-e1c2bf4bab67 +Download Help Link: {{ Update Download Link }} +Help Version: {{ Please enter version of help manually (X.X.X.X) format }} +Locale: en-US +--- + +# ConnectWiseAutomateAgent Module + +PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Install, configure, troubleshoot, and manage the Automate agent on Windows systems. + +> Every function below has a legacy `LT` alias (e.g., `Install-CWAA` = `Install-LTService`). Run `Get-Alias -Definition *-CWAA*` to see them all. + +## Install & Uninstall + +| Function | Description | +| --- | --- | +| [Install-CWAA](Install-CWAA.md) | Installs the ConnectWise Automate Agent on the local computer. | +| [Uninstall-CWAA](Uninstall-CWAA.md) | Completely uninstalls the ConnectWise Automate Agent from the local computer. | +| [Update-CWAA](Update-CWAA.md) | Manually updates the ConnectWise Automate Agent to a specified version. | +| [Redo-CWAA](Redo-CWAA.md) | Reinstalls the ConnectWise Automate Agent on the local computer. | + +## Service Management + +| Function | Description | +| --- | --- | +| [Start-CWAA](Start-CWAA.md) | Starts the ConnectWise Automate agent services. | +| [Stop-CWAA](Stop-CWAA.md) | Stops the ConnectWise Automate agent services. | +| [Restart-CWAA](Restart-CWAA.md) | Restarts the ConnectWise Automate agent services. | +| [Repair-CWAA](Repair-CWAA.md) | Performs escalating remediation of the ConnectWise Automate agent. | + +## Agent Settings & Backup + +| Function | Description | +| --- | --- | +| [Get-CWAAInfo](Get-CWAAInfo.md) | Retrieves ConnectWise Automate agent configuration from the registry. | +| [Get-CWAAInfoBackup](Get-CWAAInfoBackup.md) | Retrieves backed-up ConnectWise Automate agent configuration from the registry. | +| [Get-CWAASettings](Get-CWAASettings.md) | Retrieves ConnectWise Automate agent settings from the registry. | +| [New-CWAABackup](New-CWAABackup.md) | Creates a complete backup of the ConnectWise Automate agent installation. | +| [Reset-CWAA](Reset-CWAA.md) | Removes local agent identity settings to force re-registration. | + +## Logging + +| Function | Description | +| --- | --- | +| [Get-CWAAError](Get-CWAAError.md) | Reads the ConnectWise Automate Agent error log into structured objects. | +| [Get-CWAAProbeError](Get-CWAAProbeError.md) | Reads the ConnectWise Automate Agent probe error log into structured objects. | +| [Get-CWAALogLevel](Get-CWAALogLevel.md) | Retrieves the current logging level for the ConnectWise Automate Agent. | +| [Set-CWAALogLevel](Set-CWAALogLevel.md) | Sets the logging level for the ConnectWise Automate Agent. | + +## Proxy + +| Function | Description | +| --- | --- | +| [Get-CWAAProxy](Get-CWAAProxy.md) | Retrieves the current agent proxy settings for module operations. | +| [Set-CWAAProxy](Set-CWAAProxy.md) | Configures module proxy settings for all operations during the current session. | + +## Add/Remove Programs + +| Function | Description | +| --- | --- | +| [Hide-CWAAAddRemove](Hide-CWAAAddRemove.md) | Hides the Automate agent from the Add/Remove Programs list. | +| [Show-CWAAAddRemove](Show-CWAAAddRemove.md) | Shows the Automate agent in the Add/Remove Programs list. | +| [Rename-CWAAAddRemove](Rename-CWAAAddRemove.md) | Renames the Automate agent entry in the Add/Remove Programs list. | + +## Health & Monitoring + +| Function | Description | +| --- | --- | +| [Test-CWAAHealth](Test-CWAAHealth.md) | Performs a read-only health assessment of the ConnectWise Automate agent. | +| [Test-CWAAServerConnectivity](Test-CWAAServerConnectivity.md) | Tests connectivity to a ConnectWise Automate server's agent endpoint. | +| [Register-CWAAHealthCheckTask](Register-CWAAHealthCheckTask.md) | Creates or updates a scheduled task for periodic ConnectWise Automate agent health checks. | +| [Unregister-CWAAHealthCheckTask](Unregister-CWAAHealthCheckTask.md) | Removes the ConnectWise Automate agent health check scheduled task. | + +## Security & Utilities + +| Function | Description | +| --- | --- | +| [ConvertFrom-CWAASecurity](ConvertFrom-CWAASecurity.md) | Decodes a Base64-encoded string using TripleDES decryption. | +| [ConvertTo-CWAASecurity](ConvertTo-CWAASecurity.md) | Encodes a string using TripleDES encryption compatible with Automate operations. | +| [Invoke-CWAACommand](Invoke-CWAACommand.md) | Sends a service command to the ConnectWise Automate agent. | +| [Test-CWAAPort](Test-CWAAPort.md) | Tests connectivity to TCP ports required by the ConnectWise Automate agent. | \ No newline at end of file diff --git a/Docs/ConvertFrom-CWAASecurity.md b/Docs/Help/ConvertFrom-CWAASecurity.md similarity index 53% rename from Docs/ConvertFrom-CWAASecurity.md rename to Docs/Help/ConvertFrom-CWAASecurity.md index b24df77..871aa76 100644 --- a/Docs/ConvertFrom-CWAASecurity.md +++ b/Docs/Help/ConvertFrom-CWAASecurity.md @@ -1,37 +1,65 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # ConvertFrom-CWAASecurity ## SYNOPSIS -This function decodes an encoded Base64 value +Decodes a Base64-encoded string using TripleDES decryption. ## SYNTAX ``` -ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] [] +ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] + [-ProgressAction ] [] ``` ## DESCRIPTION This function decodes the provided string using the specified or default key. +It uses TripleDES with an MD5-derived key and a fixed initialization vector. +If decoding fails with the provided key and Force is enabled, alternate key +values are attempted automatically. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +ConvertFrom-CWAASecurity -InputString 'EncodedValue' +``` + +Decodes the string using the default key. + +### EXAMPLE 2 +``` +ConvertFrom-CWAASecurity -InputString 'EncodedValue' -Key 'MyCustomKey' ``` -{{ Add example description here }} +Decodes the string using a custom key. ## PARAMETERS +### -Force +Forces the function to try alternate key values if decoding fails using +the provided key. +Enabled by default. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: True +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -InputString -This is the string to be decoded. +The Base64-encoded string to be decoded. ```yaml Type: String[] @@ -46,7 +74,7 @@ Accept wildcard characters: False ``` ### -Key -This is the key used for decoding. +The key used for decoding. If not provided, default values will be tried. ```yaml @@ -61,17 +89,17 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -Force -This forces the function to try alternate key values if decoding fails using provided key. +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: +Aliases: proga Required: False Position: Named -Default value: True +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -84,12 +112,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.1 -Author: Darren White -Creation Date: 1/25/2018 -Purpose/Change: Initial function development +Author: Chris Taylor +Alias: ConvertFrom-LTSecurity ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/ConvertTo-CWAASecurity.md b/Docs/Help/ConvertTo-CWAASecurity.md similarity index 53% rename from Docs/ConvertTo-CWAASecurity.md rename to Docs/Help/ConvertTo-CWAASecurity.md index 205a5bb..918d0fa 100644 --- a/Docs/ConvertTo-CWAASecurity.md +++ b/Docs/Help/ConvertTo-CWAASecurity.md @@ -1,37 +1,47 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # ConvertTo-CWAASecurity ## SYNOPSIS -This function encodes a value compatible with LT operations. +Encodes a string using TripleDES encryption compatible with Automate operations. ## SYNTAX ``` -ConvertTo-CWAASecurity [-InputString] [[-Key] ] [] +ConvertTo-CWAASecurity [-InputString] [[-Key] ] [-ProgressAction ] + [] ``` ## DESCRIPTION This function encodes the provided string using the specified or default key. +It uses TripleDES with an MD5-derived key and a fixed initialization vector, +returning a Base64-encoded result. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +ConvertTo-CWAASecurity -InputString 'PlainTextValue' +``` + +Encodes the string using the default key. + +### EXAMPLE 2 +``` +ConvertTo-CWAASecurity -InputString 'PlainTextValue' -Key 'MyCustomKey' ``` -{{ Add example description here }} +Encodes the string using a custom key. ## PARAMETERS ### -InputString -This is the string to be encoded. +The string to be encoded. ```yaml Type: String @@ -46,7 +56,7 @@ Accept wildcard characters: False ``` ### -Key -This is the key used for encoding. +The key used for encoding. If not provided, a default value will be used. ```yaml @@ -61,6 +71,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). @@ -69,12 +94,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.1 -Author: Darren White -Creation Date: 1/25/2018 -Purpose/Change: Initial function development +Author: Chris Taylor +Alias: ConvertTo-LTSecurity ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Get-CWAAError.md b/Docs/Help/Get-CWAAError.md new file mode 100644 index 0000000..0215eea --- /dev/null +++ b/Docs/Help/Get-CWAAError.md @@ -0,0 +1,75 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAAError + +## SYNOPSIS +Reads the ConnectWise Automate Agent error log into structured objects. + +## SYNTAX + +``` +Get-CWAAError [-ProgressAction ] [] +``` + +## DESCRIPTION +Parses the LTErrors.txt file from the agent install directory into objects with +ServiceVersion, Timestamp, and Message properties. +This enables filtering, sorting, +and pipeline operations on agent error log entries. + +The log file location is determined from Get-CWAAInfo; if unavailable, falls back +to the default install path at C:\Windows\LTSVC. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAAError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} +``` + +Returns all agent errors from the last 24 hours. + +### EXAMPLE 2 +``` +Get-CWAAError | Out-GridView +``` + +Opens the error log in a sortable, searchable grid view window. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTErrors + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAAInfo.md b/Docs/Help/Get-CWAAInfo.md new file mode 100644 index 0000000..3a3c404 --- /dev/null +++ b/Docs/Help/Get-CWAAInfo.md @@ -0,0 +1,108 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAAInfo + +## SYNOPSIS +Retrieves ConnectWise Automate agent configuration from the registry. + +## SYNTAX + +``` +Get-CWAAInfo [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Reads all agent configuration values from the Automate agent service registry key and +returns them as a single object. +Resolves the BasePath from the service image path +if not present in the registry, expands environment variables in BasePath, and parses +the pipe-delimited Server Address into a clean Server array. + +This function supports ShouldProcess because many internal callers pass +-WhatIf:$False -Confirm:$False to suppress prompts during automated operations. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAAInfo +``` + +Returns an object containing all agent registry properties including ID, Server, +LocationID, BasePath, and other configuration values. + +### EXAMPLE 2 +``` +Get-CWAAInfo -WhatIf:$False -Confirm:$False +``` + +Retrieves agent info with ShouldProcess suppressed, as used by internal callers. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTServiceInfo + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAAInfoBackup.md b/Docs/Help/Get-CWAAInfoBackup.md new file mode 100644 index 0000000..29faa20 --- /dev/null +++ b/Docs/Help/Get-CWAAInfoBackup.md @@ -0,0 +1,75 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAAInfoBackup + +## SYNOPSIS +Retrieves backed-up ConnectWise Automate agent configuration from the registry. + +## SYNTAX + +``` +Get-CWAAInfoBackup [-ProgressAction ] [] +``` + +## DESCRIPTION +Reads all agent configuration values from the LabTechBackup registry key +and returns them as a single object. +This backup is created by New-CWAABackup and +stores a snapshot of the agent configuration at the time of backup. + +Expands environment variables in BasePath and parses the pipe-delimited Server +Address into a clean Server array, matching the behavior of Get-CWAAInfo. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAAInfoBackup +``` + +Returns an object containing all backed-up agent registry properties. + +### EXAMPLE 2 +``` +Get-CWAAInfoBackup | Select-Object -ExpandProperty Server +``` + +Returns only the server addresses from the backup configuration. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTServiceInfoBackup + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAALogLevel.md b/Docs/Help/Get-CWAALogLevel.md new file mode 100644 index 0000000..eac4da2 --- /dev/null +++ b/Docs/Help/Get-CWAALogLevel.md @@ -0,0 +1,76 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAALogLevel + +## SYNOPSIS +Retrieves the current logging level for the ConnectWise Automate Agent. + +## SYNTAX + +``` +Get-CWAALogLevel [-ProgressAction ] [] +``` + +## DESCRIPTION +Checks the agent's registry settings to determine the current logging verbosity level. +The ConnectWise Automate Agent supports two logging levels: Normal (value 1) for standard +operations, and Verbose (value 1000) for detailed diagnostic logging. + +The logging level is stored in the registry at HKLM:\SOFTWARE\LabTech\Service\Settings +under the "Debuging" value. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAALogLevel +``` + +Returns the current logging level (Normal or Verbose). + +### EXAMPLE 2 +``` +Get-CWAALogLevel +``` + +Set-CWAALogLevel -Level Verbose +Get-CWAALogLevel +Typical troubleshooting workflow: check level, enable verbose, verify the change. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTLogging + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAAProbeError.md b/Docs/Help/Get-CWAAProbeError.md new file mode 100644 index 0000000..a1db635 --- /dev/null +++ b/Docs/Help/Get-CWAAProbeError.md @@ -0,0 +1,75 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAAProbeError + +## SYNOPSIS +Reads the ConnectWise Automate Agent probe error log into structured objects. + +## SYNTAX + +``` +Get-CWAAProbeError [-ProgressAction ] [] +``` + +## DESCRIPTION +Parses the LTProbeErrors.txt file from the agent install directory into objects with +ServiceVersion, Timestamp, and Message properties. +This enables filtering, sorting, +and pipeline operations on agent probe error log entries. + +The log file location is determined from Get-CWAAInfo; if unavailable, falls back +to the default install path at C:\Windows\LTSVC. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAAProbeError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} +``` + +Returns all probe errors from the last 24 hours. + +### EXAMPLE 2 +``` +Get-CWAAProbeError | Out-GridView +``` + +Opens the probe error log in a sortable, searchable grid view window. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTProbeErrors + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAAProxy.md b/Docs/Help/Get-CWAAProxy.md new file mode 100644 index 0000000..e759bcb --- /dev/null +++ b/Docs/Help/Get-CWAAProxy.md @@ -0,0 +1,76 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAAProxy + +## SYNOPSIS +Retrieves the current agent proxy settings for module operations. + +## SYNTAX + +``` +Get-CWAAProxy [-ProgressAction ] [] +``` + +## DESCRIPTION +Reads the current Automate agent proxy settings from the installed agent (if present) +and stores them in the module-scoped $Script:LTProxy object. +The proxy URL, +username, and password are decrypted using the agent's password string. +The +discovered settings are used by all module communication operations for the +duration of the session, and returned as the function result. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAAProxy +``` + +Retrieves and returns the current proxy configuration. + +### EXAMPLE 2 +``` +$proxy = Get-CWAAProxy +``` + +if ($proxy.Enabled) { Write-Host "Proxy: $($proxy.ProxyServerURL)" } +Checks whether a proxy is configured and displays the URL. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Darren White +Alias: Get-LTProxy + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Get-CWAASettings.md b/Docs/Help/Get-CWAASettings.md new file mode 100644 index 0000000..f07f622 --- /dev/null +++ b/Docs/Help/Get-CWAASettings.md @@ -0,0 +1,74 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Get-CWAASettings + +## SYNOPSIS +Retrieves ConnectWise Automate agent settings from the registry. + +## SYNTAX + +``` +Get-CWAASettings [-ProgressAction ] [] +``` + +## DESCRIPTION +Reads agent settings from the Automate agent service settings registry subkey +(HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. +These settings are separate from the main agent configuration returned by +Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, +ProxyPassword), logging level, and other operational parameters written by +the agent or Set-CWAAProxy. + +## EXAMPLES + +### EXAMPLE 1 +``` +Get-CWAASettings +``` + +Returns an object containing all agent settings registry properties. + +### EXAMPLE 2 +``` +(Get-CWAASettings).ProxyServerURL +``` + +Returns just the configured proxy URL, if any. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Get-LTServiceSettings + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Hide-CWAAAddRemove.md b/Docs/Help/Hide-CWAAAddRemove.md similarity index 50% rename from Docs/Hide-CWAAAddRemove.md rename to Docs/Help/Hide-CWAAAddRemove.md index 4073536..6b878b7 100644 --- a/Docs/Hide-CWAAAddRemove.md +++ b/Docs/Help/Hide-CWAAAddRemove.md @@ -1,43 +1,51 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Hide-CWAAAddRemove ## SYNOPSIS -This function hides the LabTech install from the Add/Remove Programs list. +Hides the Automate agent from the Add/Remove Programs list. ## SYNTAX ``` -Hide-CWAAAddRemove [-WhatIf] [-Confirm] [] +Hide-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -This function will rename the DisplayName registry key to hide it from the Add/Remove Programs list. +Sets the SystemComponent registry value to 1 on Automate agent uninstall keys, +which hides the agent from the Windows Add/Remove Programs (Programs and Features) list. +Also cleans up any leftover HiddenProductName registry values from older hiding methods. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 ``` +Hide-CWAAAddRemove +``` + +Hides the Automate agent entry from Add/Remove Programs. -{{ Add example description here }} +### EXAMPLE 2 +``` +Hide-CWAAAddRemove -WhatIf +``` + +Shows what registry changes would be made without applying them. ## PARAMETERS -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: wi +Aliases: proga Required: False Position: Named @@ -61,6 +69,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). @@ -69,21 +93,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.2 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/12/2018 -Purpose/Change: Support for ShouldProcess. -Added Registry Paths to be checked. -Modified hiding method to be compatible with standard software controls. +Author: Chris Taylor +Alias: Hide-LTAddRemove ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Install-CWAA.md b/Docs/Help/Install-CWAA.md new file mode 100644 index 0000000..fd4a015 --- /dev/null +++ b/Docs/Help/Install-CWAA.md @@ -0,0 +1,308 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Install-CWAA + +## SYNOPSIS +Installs the ConnectWise Automate Agent on the local computer. + +## SYNTAX + +### deployment (Default) +``` +Install-CWAA [-Server ] [-ServerPassword ] [-LocationID ] [-TrayPort ] + [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +### installertoken +``` +Install-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] + [-TrayPort ] [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Downloads and installs the ConnectWise Automate agent from the specified server URL. +Supports authentication via InstallerToken (preferred) or ServerPassword. +The function handles .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. + +If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. +The -Force parameter allows installation even when services are already present or when only .NET 4.0+ is available without 3.5. + +## EXAMPLES + +### EXAMPLE 1 +``` +Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 +``` + +Installs the agent using an InstallerToken for authentication. + +### EXAMPLE 2 +``` +Install-CWAA -Server https://automate.domain.com -ServerPassword 'encryptedpass' -LocationID 1 +``` + +Installs the agent using a legacy server password. + +### EXAMPLE 3 +``` +Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait +``` + +Installs the agent without waiting for registration to complete. + +## PARAMETERS + +### -Force +Disables safety checks including existing service detection and .NET version requirements. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Hide +Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InstallerToken +An installer token for authenticated agent deployment. +This is the preferred authentication method over ServerPassword. + +```yaml +Type: String +Parameter Sets: installertoken +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -LocationID +The LocationID of the location the agent will be assigned to. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -NoWait +Skips the post-install health check that waits for agent registration. +The function exits immediately after the installer completes. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Rename +Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +One or more ConnectWise Automate server URLs to download the installer from. +Example: https://automate.domain.com The function tries each server in order until a successful download occurs. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ServerPassword +The server password that agents use to authenticate with the Automate server. +Used for legacy deployment method. +InstallerToken is preferred. + +```yaml +Type: String +Parameter Sets: deployment +Aliases: Password + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +```yaml +Type: String +Parameter Sets: installertoken +Aliases: Password + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SkipCertificateCheck +Bypasses SSL/TLS certificate validation for server connections. +Use in lab or test environments with self-signed certificates. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SkipDotNet +Skips .NET Framework 3.5 and 2.0 prerequisite checks. +Use when .NET 4.0+ is already installed. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TrayPort +The local port LTSvc.exe listens on for communication with LTTray processes. +Defaults to 42000. +If the port is in use, the function auto-selects the next available port. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.String[] +### System.String +### System.Int32 +## OUTPUTS + +### System.Object +## NOTES +Author: Chris Taylor Alias: Install-LTService + +## RELATED LINKS diff --git a/Docs/Help/Invoke-CWAACommand.md b/Docs/Help/Invoke-CWAACommand.md new file mode 100644 index 0000000..b78b27b --- /dev/null +++ b/Docs/Help/Invoke-CWAACommand.md @@ -0,0 +1,124 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Invoke-CWAACommand + +## SYNOPSIS +Sends a service command to the ConnectWise Automate agent. + +## SYNTAX + +``` +Invoke-CWAACommand [-Command] [-ProgressAction ] [-WhatIf] [-Confirm] + [] +``` + +## DESCRIPTION +Sends a control command to the LTService Windows service using sc.exe. +The agent supports a set of predefined commands (mapped to numeric IDs 128-145) +that trigger actions such as sending inventory, updating schedules, or killing processes. + +## EXAMPLES + +### EXAMPLE 1 +``` +Invoke-CWAACommand -Command 'Send Inventory' +``` + +Sends the 'Send Inventory' command to the agent service. + +### EXAMPLE 2 +``` +'Send Status', 'Send Apps' | Invoke-CWAACommand +``` + +Sends multiple commands to the agent service via pipeline. + +## PARAMETERS + +### -Command +One or more commands to send to the agent service. +Valid values include +'Update Schedule', 'Send Inventory', 'Send Drives', 'Send Processes', +'Send Spyware List', 'Send Apps', 'Send Events', 'Send Printers', +'Send Status', 'Send Screen', 'Send Services', 'Analyze Network', +'Write Last Contact Date', 'Kill VNC', 'Kill Trays', 'Send Patch Reboot', +'Run App Care Update', and 'Start App Care Daytime Patching'. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: True +Position: 2 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Invoke-LTServiceCommand + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/New-CWAABackup.md b/Docs/Help/New-CWAABackup.md new file mode 100644 index 0000000..c7f1cce --- /dev/null +++ b/Docs/Help/New-CWAABackup.md @@ -0,0 +1,118 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# New-CWAABackup + +## SYNOPSIS +Creates a complete backup of the ConnectWise Automate agent installation. + +## SYNTAX + +``` +New-CWAABackup [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Creates a comprehensive backup of the currently installed ConnectWise Automate agent +by copying all files from the agent installation directory and exporting all related +registry keys. +This backup can be used to restore the agent configuration if needed, +or to preserve settings before performing maintenance operations. + +The backup process performs the following operations: +1. +Locates the agent installation directory (typically C:\Windows\LTSVC) +2. +Creates a Backup subdirectory within the agent installation path +3. +Copies all files from the installation directory to the Backup folder +4. +Exports registry keys from HKLM\SOFTWARE\LabTech to a .reg file +5. +Modifies the exported registry data to use the LabTechBackup key name +6. +Imports the modified registry data to HKLM\SOFTWARE\LabTechBackup + +## EXAMPLES + +### EXAMPLE 1 +``` +New-CWAABackup +``` + +Creates a complete backup of the agent installation files and registry settings. + +### EXAMPLE 2 +``` +New-CWAABackup -WhatIf +``` + +Shows what the backup operation would do without actually creating the backup. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: New-LTServiceBackup + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Private/Clear-CWAAInstallerArtifacts.md b/Docs/Help/Private/Clear-CWAAInstallerArtifacts.md new file mode 100644 index 0000000..dfa1490 --- /dev/null +++ b/Docs/Help/Private/Clear-CWAAInstallerArtifacts.md @@ -0,0 +1,64 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Clear-CWAAInstallerArtifacts + +## SYNOPSIS +Cleans up stale ConnectWise Automate installer processes and temporary files. + +## SYNTAX + +``` +Clear-CWAAInstallerArtifacts [] +``` + +## DESCRIPTION +Clear-CWAAInstallerArtifacts is a private helper that terminates any running installer-related processes and removes temporary installer files left behind by incomplete or failed installations. This prevents conflicts when starting a new install, reinstall, or update operation. + +Process names are read from `$Script:CWAAInstallerProcessNames` (includes `Agent_Uninstall`, `Uninstall`, `LTUpdate`). File paths are read from `$Script:CWAAInstallerArtifactPaths` (includes temp MSI files, executables, and extracted archives in the installer temp directory). + +All operations are best-effort with errors suppressed. This function is intended as a defensive cleanup step, not a validated operation. If a process cannot be stopped or a file cannot be removed, the function continues silently. + +Called by `Install-CWAA` and `Redo-CWAA` before beginning a new installation to ensure a clean starting state. + +## EXAMPLES + +### Example 1 +```powershell +# Called internally before starting a new installation. +Clear-CWAAInstallerArtifacts +``` + +Kills any stale installer processes and removes leftover temporary files from prior installation attempts. + +## PARAMETERS + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function takes no input. + +## OUTPUTS + +This function produces no output. It terminates processes and removes files as a side effect. + +## NOTES + +- **Private function** — not exported by the module. +- Uses centralized constants: `$Script:CWAAInstallerProcessNames`, `$Script:CWAAInstallerArtifactPaths`. +- All errors are suppressed (`-ErrorAction SilentlyContinue`) — this function never disrupts the caller. +- Process termination uses `Stop-Process -Force` to handle unresponsive installer processes. + +Author: Chris Taylor + +## RELATED LINKS + +[Install-CWAA](../Install-CWAA.md) + +[Redo-CWAA](../Redo-CWAA.md) diff --git a/Docs/Help/Private/Initialize-CWAA.md b/Docs/Help/Private/Initialize-CWAA.md new file mode 100644 index 0000000..db17868 --- /dev/null +++ b/Docs/Help/Private/Initialize-CWAA.md @@ -0,0 +1,64 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Initialize-CWAA + +## SYNOPSIS +Bootstraps module-level constants, state objects, and the PowerShell version guard. + +## SYNTAX + +``` +Initialize-CWAA [] +``` + +## DESCRIPTION +Initialize-CWAA is the Phase 1 initialization function called once at module import time by ConnectWiseAutomateAgent.psm1. It creates all `$Script:CWAA*` constants (registry paths, file paths, service names, validation patterns), initializes empty state objects for credential storage (`$Script:LTServiceKeys`) and proxy configuration (`$Script:LTProxy`), and sets the deferred networking flags to `$False`. + +This function also handles the WOW64 relaunch guard: when running as 32-bit PowerShell on a 64-bit OS, it re-launches the script under the native 64-bit PowerShell host. This is critical because the Automate agent's registry keys and file paths differ between 32-bit and 64-bit views. The relaunch works in single-file mode (`ConnectWiseAutomateAgent.ps1`); in module mode the `.psm1` emits a warning instead. + +Phase 2 initialization (networking, SSL, proxy) is handled separately by `Initialize-CWAANetworking`, which runs on-demand at first networking call. + +**No network calls, registry reads, or side effects occur during Phase 1.** Module import remains fast and safe. + +## EXAMPLES + +### Example 1 +```powershell +# Called automatically by the module — not typically invoked directly. +# In single-file mode, Initialize-CWAA is appended at the end of ConnectWiseAutomateAgent.ps1. +Initialize-CWAA +``` + +Bootstraps all module constants and state. This runs automatically during module import. + +## PARAMETERS + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function takes no input. + +## OUTPUTS + +This function produces no output. It initializes `$Script:` scoped variables. + +## NOTES + +- **Private function** — not exported by the module. +- Creates constants: `$Script:CWAARegistryRoot`, `$Script:CWAARegistrySettings`, `$Script:CWAAInstallPath`, `$Script:CWAAInstallerTempPath`, `$Script:CWAAServiceNames`, `$Script:CWAAServerValidationRegex`, and others. +- Creates state objects: `$Script:LTServiceKeys`, `$Script:LTProxy`. +- Sets flags: `$Script:CWAANetworkInitialized`, `$Script:CWAACertCallbackRegistered` to `$False`. +- Also defines the private helper function `Get-CWAARedactedValue` for credential logging. + +Author: Chris Taylor + +## RELATED LINKS + +[Initialize-CWAANetworking](Initialize-CWAANetworking.md) diff --git a/Docs/Help/Private/Initialize-CWAANetworking.md b/Docs/Help/Private/Initialize-CWAANetworking.md new file mode 100644 index 0000000..c38a27e --- /dev/null +++ b/Docs/Help/Private/Initialize-CWAANetworking.md @@ -0,0 +1,90 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Initialize-CWAANetworking + +## SYNOPSIS +Lazily initializes networking objects on first use rather than at module load. + +## SYNTAX + +``` +Initialize-CWAANetworking [-SkipCertificateCheck] [] +``` + +## DESCRIPTION +Initialize-CWAANetworking is the Phase 2 initialization function that performs deferred setup of SSL certificate validation, TLS protocol enablement, WebProxy, WebClient, and proxy configuration. It runs on first networking call rather than at module import, keeping `Import-Module` fast and side-effect free. + +SSL certificate handling uses a compiled C# callback (`ServerCertificateValidationCallback`) with graduated trust: + +1. **IP address targets:** auto-bypass (IPs cannot have properly signed certificates) +2. **Hostname name mismatch:** tolerated (certificate is trusted but CN/SAN does not match) +3. **Chain/trust errors on hostnames:** rejected (untrusted CA, self-signed) +4. **`-SkipCertificateCheck`:** full bypass for all certificate errors + +The C# type is compiled via `Add-Type` once per AppDomain. Because compiled .NET types cannot be unloaded, the callback survives module re-import. On PowerShell 7+ (`.NET 6+`), `ServicePointManager` triggers `SYSLIB0014` obsolescence warnings; these are suppressed with `#pragma` directives. + +This function is idempotent. The `$Script:CWAANetworkInitialized` flag ensures TLS enablement, WebClient creation, and proxy discovery only run once per session. The SSL callback registration has its own guard (`$Script:CWAACertCallbackRegistered`) since it must execute even when called multiple times with different `-SkipCertificateCheck` values. + +Called automatically by networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`) in their `Begin` blocks. + +## EXAMPLES + +### Example 1 +```powershell +# Called automatically — not typically invoked directly. +# Networking functions call this in their Begin block: +Initialize-CWAANetworking +``` + +Initializes TLS protocols, creates WebClient/WebProxy objects, and discovers proxy settings from the installed agent. + +### Example 2 +```powershell +Initialize-CWAANetworking -SkipCertificateCheck +``` + +Same as above, but also sets the `SkipAll` flag on the SSL callback to bypass all certificate validation for the session. + +## PARAMETERS + +### -SkipCertificateCheck +Disables all SSL certificate validation for the current PowerShell session. Use when connecting to servers with self-signed certificates on hostname URLs. This affects ALL HTTPS connections in the session, not just Automate operations. + +```yaml +Type: SwitchParameter +Required: False +Position: Named +Default value: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function takes no pipeline input. + +## OUTPUTS + +This function produces no output. It initializes `$Script:` scoped networking objects. + +## NOTES + +- **Private function** — not exported by the module. +- Creates: `$Script:LTWebProxy` (System.Net.WebProxy), `$Script:LTServiceNetWebClient` (System.Net.WebClient). +- Sets flag: `$Script:CWAANetworkInitialized` to `$True` after first successful run. +- Sets flag: `$Script:CWAACertCallbackRegistered` to `$True` after SSL callback compilation. +- Enables TLS 1.2 and TLS 1.3 via bitwise OR on `[Net.ServicePointManager]::SecurityProtocol`. +- Calls `Get-CWAAProxy` to auto-discover proxy settings from the installed agent (non-fatal if no agent is present). +- WebClient and WebProxy are deprecated in .NET 6+ but remain the only option compatible with PowerShell 3.0-5.1. + +Author: Chris Taylor + +## RELATED LINKS + +[Initialize-CWAA](Initialize-CWAA.md) diff --git a/Docs/Help/Private/Remove-CWAAFolderRecursive.md b/Docs/Help/Private/Remove-CWAAFolderRecursive.md new file mode 100644 index 0000000..abfeef3 --- /dev/null +++ b/Docs/Help/Private/Remove-CWAAFolderRecursive.md @@ -0,0 +1,82 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Remove-CWAAFolderRecursive + +## SYNOPSIS +Performs depth-first removal of a folder and all its contents. + +## SYNTAX + +``` +Remove-CWAAFolderRecursive -Path [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Remove-CWAAFolderRecursive is a private helper that removes a folder using a three-pass depth-first strategy: + +1. **Pass 1:** Remove files inside each subfolder (leaves first) +2. **Pass 2:** Remove subfolders sorted by path depth (deepest first) +3. **Pass 3:** Remove the root folder itself + +This approach maximizes cleanup even when some files or folders are locked by running processes, which is common during agent uninstall and update operations. Standard `Remove-Item -Recurse` can fail entirely if any single file is locked; this strategy removes everything it can and leaves only the genuinely locked items. + +All removal operations use best-effort error handling (`-ErrorAction SilentlyContinue`). The caller's `$WhatIfPreference` and `$ConfirmPreference` propagate automatically through PowerShell's preference variable mechanism. + +Used by `Uninstall-CWAA` to remove the agent installation directory (`C:\Windows\LTSVC`) and installer temp directory (`C:\Windows\Temp\LabTech`). + +## EXAMPLES + +### Example 1 +```powershell +# Called internally by Uninstall-CWAA after stopping agent services. +Remove-CWAAFolderRecursive -Path "$env:windir\LTSVC" +``` + +Removes the agent installation directory using the three-pass depth-first strategy. + +## PARAMETERS + +### -Path +The full path to the folder to remove. If the path does not exist, the function returns silently. + +```yaml +Type: String +Required: True +Position: Named +Default value: None +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. The cmdlet is not run. + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function does not accept pipeline input. + +## OUTPUTS + +This function produces no output. It removes the specified folder and its contents. + +## NOTES + +- **Private function** — not exported by the module. +- Supports `ShouldProcess` (`-WhatIf` / `-Confirm`) for the top-level removal decision; individual file/folder deletions within are not individually confirmed. +- If the path does not exist, the function returns silently with a debug message. +- Locked files are skipped silently — partial cleanup is preferred over total failure. + +Author: Chris Taylor + +## RELATED LINKS + +[Uninstall-CWAA](../Uninstall-CWAA.md) diff --git a/Docs/Help/Private/Resolve-CWAAServer.md b/Docs/Help/Private/Resolve-CWAAServer.md new file mode 100644 index 0000000..399b904 --- /dev/null +++ b/Docs/Help/Private/Resolve-CWAAServer.md @@ -0,0 +1,87 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Resolve-CWAAServer + +## SYNOPSIS +Finds the first reachable ConnectWise Automate server from a list of candidates. + +## SYNTAX + +``` +Resolve-CWAAServer -Server [] +``` + +## DESCRIPTION +Resolve-CWAAServer is a private helper that iterates through server URLs, validates each against `$Script:CWAAServerValidationRegex`, normalizes the URL scheme (prepending `https://` to bare hostnames), and tests reachability by downloading the version string from `/LabTech/Agent.aspx`. + +The version response uses a pipe-delimited format: six pipe characters followed by a major.minor version number (e.g., `||||||220.105`). The function extracts this version using the regex pattern `(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}`. + +Returns the first server that responds with a parseable version as a `[PSCustomObject]`, or `$null` if no server is reachable. + +Used by `Install-CWAA`, `Uninstall-CWAA`, and `Update-CWAA` to eliminate duplicated server validation logic. Callers handle their own download logic after receiving the resolved server, since URL construction differs per operation. + +Requires `$Script:LTServiceNetWebClient` to be initialized (via `Initialize-CWAANetworking`) before calling. + +## EXAMPLES + +### Example 1 +```powershell +# Called internally by Install-CWAA, Uninstall-CWAA, Update-CWAA. +$resolved = Resolve-CWAAServer -Server 'automate.example.com', 'automate2.example.com' +if ($resolved) { + Write-Verbose "Using server $($resolved.ServerUrl) (version $($resolved.ServerVersion))" +} +``` + +Tests each server URL in order and returns the first one that responds with a valid version string. + +## PARAMETERS + +### -Server +One or more server URLs to test. Bare hostnames are normalized with `https://` prefix. Each URL is validated against `$Script:CWAAServerValidationRegex` before testing. + +```yaml +Type: String[] +Required: True +Position: Named +Default value: None +Pipeline input: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function does not accept pipeline input. + +## OUTPUTS + +### PSCustomObject +Returns a `[PSCustomObject]` with two properties: +- **ServerUrl** `[string]` — The normalized URL of the first reachable server. +- **ServerVersion** `[string]` — The version string reported by the server (e.g., `220.105`). + +Returns `$null` if no server is reachable. + +## NOTES + +- **Private function** — not exported by the module. +- Depends on `$Script:LTServiceNetWebClient` being initialized by `Initialize-CWAANetworking`. +- Bare hostnames are normalized to `https://` before testing. +- Invalid server formats produce a warning and are skipped, not an error. + +Author: Chris Taylor + +## RELATED LINKS + +[Install-CWAA](../Install-CWAA.md) + +[Uninstall-CWAA](../Uninstall-CWAA.md) + +[Update-CWAA](../Update-CWAA.md) diff --git a/Docs/Help/Private/Test-CWAADownloadIntegrity.md b/Docs/Help/Private/Test-CWAADownloadIntegrity.md new file mode 100644 index 0000000..d65e69d --- /dev/null +++ b/Docs/Help/Private/Test-CWAADownloadIntegrity.md @@ -0,0 +1,102 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Test-CWAADownloadIntegrity + +## SYNOPSIS +Validates a downloaded file meets minimum size requirements. + +## SYNTAX + +``` +Test-CWAADownloadIntegrity -FilePath [-FileName ] [-MinimumSizeKB ] [] +``` + +## DESCRIPTION +Test-CWAADownloadIntegrity is a private helper that checks whether a downloaded installer file exists and exceeds a specified minimum size threshold. If the file is missing or below the threshold, it is treated as corrupt or incomplete: a warning is emitted and any undersized file is removed. + +The default threshold of 1234 KB matches the established convention for MSI/EXE installer files. The `Agent_Uninstall.exe` uses a lower threshold of 80 KB due to its smaller expected size. + +This function is used by `Install-CWAA` and `Uninstall-CWAA` after downloading installer files to verify the download completed successfully before proceeding with execution. + +## EXAMPLES + +### Example 1 +```powershell +# Called internally after downloading the MSI installer. +$isValid = Test-CWAADownloadIntegrity -FilePath "$env:windir\Temp\LabTech\Agent_Install.msi" +if (-not $isValid) { return } +``` + +Checks that the MSI file exists and is larger than 1234 KB (default threshold). + +### Example 2 +```powershell +# Called with a lower threshold for the smaller uninstall executable. +Test-CWAADownloadIntegrity -FilePath "$env:windir\Temp\LabTech\Agent_Uninstall.exe" -MinimumSizeKB 80 +``` + +Checks that the uninstall executable exists and is larger than 80 KB. + +## PARAMETERS + +### -FilePath +The full path to the downloaded file to validate. + +```yaml +Type: String +Required: True +Position: Named +Default value: None +``` + +### -FileName +A display name for the file used in warning and debug messages. If not provided, it is automatically extracted from the `-FilePath` using `Split-Path -Leaf`. + +```yaml +Type: String +Required: False +Position: Named +Default value: (extracted from FilePath) +``` + +### -MinimumSizeKB +The minimum acceptable file size in kilobytes. Files at or below this threshold are treated as corrupt and removed. + +```yaml +Type: Int32 +Required: False +Position: Named +Default value: 1234 +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function does not accept pipeline input. + +## OUTPUTS + +### Boolean +Returns `$true` if the file exists and exceeds the minimum size threshold. Returns `$false` if the file is missing or undersized (the undersized file is removed automatically). + +## NOTES + +- **Private function** — not exported by the module. +- Undersized files are removed automatically to prevent execution of corrupt installers. +- Default threshold (1234 KB) matches established MSI installer conventions. +- The 80 KB threshold for `Agent_Uninstall.exe` is passed explicitly by callers. + +Author: Chris Taylor + +## RELATED LINKS + +[Install-CWAA](../Install-CWAA.md) + +[Uninstall-CWAA](../Uninstall-CWAA.md) diff --git a/Docs/Help/Private/Write-CWAAEventLog.md b/Docs/Help/Private/Write-CWAAEventLog.md new file mode 100644 index 0000000..d225b2b --- /dev/null +++ b/Docs/Help/Private/Write-CWAAEventLog.md @@ -0,0 +1,117 @@ +--- +external help file: +Module Name: ConnectWiseAutomateAgent +online version: +schema: 2.0.0 +--- + +# Write-CWAAEventLog + +## SYNOPSIS +Writes an entry to the Windows Event Log for ConnectWise Automate Agent operations. + +## SYNTAX + +``` +Write-CWAAEventLog -Message -EntryType -EventId [] +``` + +## DESCRIPTION +Write-CWAAEventLog is the centralized event log writer for the ConnectWiseAutomateAgent module. It writes to the Application event log under the source defined by `$Script:CWAAEventLogSource`. + +On first call, it registers the event source if it does not already exist (requires administrator privileges for registration). If the source cannot be registered or the write fails for any reason, the error is written to `Write-Debug` and the function returns silently. This ensures event logging never disrupts the calling function. + +Event IDs are organized by category: + +| Range | Category | Examples | +| --- | --- | --- | +| 1000-1039 | Installation | Install (1000), Uninstall (1010), Redo (1020), Update (1030) | +| 2000-2029 | Service Control | Start (2000), Stop (2010), Restart (2020) | +| 3000-3069 | Configuration | Reset (3000), Backup (3010), Proxy (3020), LogLevel (3030), AddRemove (3040) | +| 4000-4039 | Health/Monitoring | Repair (4000-4008), Register task (4020), Unregister task (4030) | + +Events are viewable in Windows Event Viewer or via PowerShell: + +```powershell +Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'ConnectWiseAutomateAgent' } +``` + +## EXAMPLES + +### Example 1 +```powershell +# Called internally after a successful installation. +Write-CWAAEventLog -Message 'Agent installed successfully.' -EntryType 'Information' -EventId 1000 +``` + +Writes an informational event to the Application log with Event ID 1000 (Installation category). + +### Example 2 +```powershell +# Called internally when a repair operation detects an unreachable server. +Write-CWAAEventLog -Message 'Server unreachable during repair.' -EntryType 'Error' -EventId 4008 +``` + +Writes an error event to the Application log with Event ID 4008 (Health/Monitoring category). + +## PARAMETERS + +### -Message +The event log message text. + +```yaml +Type: String +Required: True +Position: Named +Default value: None +``` + +### -EntryType +The severity level of the event log entry. Valid values: `Information`, `Warning`, `Error`. + +```yaml +Type: String +Required: True +Position: Named +Default value: None +Accepted values: Information, Warning, Error +``` + +### -EventId +The numeric event identifier. Should follow the category ranges documented above. + +```yaml +Type: Int32 +Required: True +Position: Named +Default value: None +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +This function does not accept pipeline input. + +## OUTPUTS + +This function produces no output. It writes to the Windows Event Log as a side effect. + +## NOTES + +- **Private function** — not exported by the module. +- Auto-registers the event source on first call (requires administrator privileges). +- Non-blocking design: all failures are written to `Write-Debug`, never thrown. +- Uses centralized constants: `$Script:CWAAEventLogSource`, `$Script:CWAAEventLogName`. +- Events integrate with Windows Event Log forwarding for centralized monitoring in SIEM tools. + +Author: Chris Taylor + +## RELATED LINKS + +[Repair-CWAA](../Repair-CWAA.md) + +[Register-CWAAHealthCheckTask](../Register-CWAAHealthCheckTask.md) + +[Install-CWAA](../Install-CWAA.md) diff --git a/Docs/Redo-CWAA.md b/Docs/Help/Redo-CWAA.md similarity index 50% rename from Docs/Redo-CWAA.md rename to Docs/Help/Redo-CWAA.md index e61e4e4..e0a45ae 100644 --- a/Docs/Redo-CWAA.md +++ b/Docs/Help/Redo-CWAA.md @@ -1,33 +1,48 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Redo-CWAA ## SYNOPSIS -This function will reinstall the LabTech agent from the machine. +Reinstalls the ConnectWise Automate Agent on the local computer. ## SYNTAX ### deployment ``` -Redo-CWAA [[-Server] ] [[-ServerPassword] ] [[-LocationID] ] [-Backup] [-Hide] - [[-Rename] ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] [] +Redo-CWAA [-Server ] [-ServerPassword ] [-LocationID ] [-Backup] [-Hide] + [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ### installertoken ``` -Redo-CWAA [[-Server] ] [[-ServerPassword] ] [-InstallerToken ] - [[-LocationID] ] [-Backup] [-Hide] [[-Rename] ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] - [] +Redo-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] + [-Backup] [-Hide] [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION -This script will attempt to pull all current settings from machine and issue an 'Uninstall-CWAA', 'Install-CWAA' with gathered information. -If the function is unable to find the settings it will ask for needed parameters. +Performs a complete reinstall of the ConnectWise Automate Agent by uninstalling and then +reinstalling the agent. +The function attempts to retrieve current settings (server, location, +etc.) from the existing installation or from a backup. +If settings cannot be determined +automatically, the function will prompt for the required parameters. + +The reinstall process: +1. +Reads current agent settings from registry or backup +2. +Uninstalls the existing agent via Uninstall-CWAA +3. +Waits 20 seconds for the uninstall to settle +4. +Installs a fresh agent via Install-CWAA with the gathered settings ## EXAMPLES @@ -36,111 +51,120 @@ If the function is unable to find the settings it will ask for needed parameters Redo-CWAA ``` -This will ReInstall the LabTech agent using the server address in the registry. +Reinstalls the agent using settings from the current installation registry. ### EXAMPLE 2 ``` -Redo-CWAA -Server https://automate.domain.com -Password sQWZzEDYKFFnTT0yP56vgA== -LocationID 42 +Redo-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 ``` -This will ReInstall the LabTech agent using the provided server URL to download the installation files. +Reinstalls the agent with explicitly provided settings. + +### EXAMPLE 3 +``` +Redo-CWAA -Backup -Force +``` + +Backs up settings, then forces reinstallation even if a probe agent is detected. ## PARAMETERS -### -Server -This is the URL to your LabTech server. -Example: https://automate.domain.com -This is used to download the installation and removal utilities. -If no server is provided the uninstaller will use Get-CWAAInfo to get the server address. -If it is unable to find LT currently installed it will try Get-CWAAInfoBackup +### -Backup +Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. ```yaml -Type: String[] +Type: SwitchParameter Parameter Sets: (All) Aliases: Required: False -Position: 1 -Default value: None -Accept pipeline input: True (ByPropertyName, ByValue) +Position: Named +Default value: False +Accept pipeline input: False Accept wildcard characters: False ``` -### -ServerPassword -{{ Fill ServerPassword Description }} +### -Force +Forces reinstallation even when a probe agent is detected. ```yaml -Type: SecureString -Parameter Sets: deployment -Aliases: Password +Type: SwitchParameter +Parameter Sets: (All) +Aliases: Required: False -Position: 2 -Default value: None +Position: Named +Default value: False Accept pipeline input: False Accept wildcard characters: False ``` +### -Hide +Hides the agent entry from Add/Remove Programs after reinstallation. + ```yaml -Type: SecureString -Parameter Sets: installertoken -Aliases: Password +Type: SwitchParameter +Parameter Sets: (All) +Aliases: Required: False -Position: 2 -Default value: None +Position: Named +Default value: False Accept pipeline input: False Accept wildcard characters: False ``` -### -LocationID -The LocationID of the location that you want the agent in -example: 555 +### -InstallerToken +An installer token for authenticated agent deployment. +This is the preferred +authentication method over ServerPassword. +See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken ```yaml Type: String -Parameter Sets: (All) +Parameter Sets: installertoken Aliases: Required: False -Position: 3 +Position: Named Default value: None -Accept pipeline input: True (ByPropertyName) +Accept pipeline input: False Accept wildcard characters: False ``` -### -Backup -This will run a New-CWAABackup command before uninstalling. +### -LocationID +The LocationID of the location the agent will be assigned to. +If not provided, reads from the current agent configuration or prompts interactively. ```yaml -Type: SwitchParameter +Type: Int32 Parameter Sets: (All) Aliases: Required: False Position: Named -Default value: False -Accept pipeline input: False +Default value: None +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -Hide -Will remove from add-remove programs +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: +Aliases: proga Required: False Position: Named -Default value: False +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` ### -Rename -This will call Rename-CWAAAddRemove to rename the install in Add/Remove Programs +Renames the agent entry in Add/Remove Programs after reinstallation. ```yaml Type: String @@ -148,54 +172,70 @@ Parameter Sets: (All) Aliases: Required: False -Position: 4 +Position: Named Default value: None Accept pipeline input: False Accept wildcard characters: False ``` -### -SkipDotNet -This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. +### -Server +One or more ConnectWise Automate server URLs. +Example: https://automate.domain.com +If not provided, the function reads the server URL from the current agent configuration +or backup settings. +If neither is available, prompts interactively. ```yaml -Type: SwitchParameter +Type: String[] Parameter Sets: (All) Aliases: Required: False Position: Named -Default value: False -Accept pipeline input: False +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) Accept wildcard characters: False ``` -### -Force -This will force operation on an agent detected as a probe. +### -ServerPassword +The server password for agent authentication. +InstallerToken is preferred. ```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: +Type: String +Parameter Sets: deployment +Aliases: Password Required: False Position: Named -Default value: False +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +```yaml +Type: String +Parameter Sets: installertoken +Aliases: Password + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SkipDotNet +Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: wi +Aliases: Required: False Position: Named -Default value: None +Default value: False Accept pipeline input: False Accept wildcard characters: False ``` @@ -215,15 +255,14 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -InstallerToken -An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - -https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. ```yaml -Type: String -Parameter Sets: installertoken -Aliases: +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi Required: False Position: Named @@ -240,34 +279,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.5 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 6/8/2017 -Purpose/Change: Update to support user provided settings for -Server, -Password, -LocationID. - -Update Date: 6/10/2017 -Purpose/Change: Updates for pipeline input, support for multiple servers - -Update Date: 8/24/2017 -Purpose/Change: Update to use Clear-Variable. - -Update Date: 3/12/2018 -Purpose/Change: Added detection of "Probe" enabled agent. -Added support for -Force parameter to override probe detection. -Updated support of -WhatIf parameter. - -Update Date: 2/22/2019 -Purpose/Change: Added -SkipDotNet parameter. -Allows for skipping of .NET 3.5 and 2.0 framework checks for installing on OS with .NET 4.0+ already installed +Author: Chris Taylor +Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Register-CWAAHealthCheckTask.md b/Docs/Help/Register-CWAAHealthCheckTask.md new file mode 100644 index 0000000..879d883 --- /dev/null +++ b/Docs/Help/Register-CWAAHealthCheckTask.md @@ -0,0 +1,217 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Register-CWAAHealthCheckTask + +## SYNOPSIS +Creates or updates a scheduled task for periodic ConnectWise Automate agent health checks. + +## SYNTAX + +``` +Register-CWAAHealthCheckTask [-InstallerToken] [[-Server] ] [[-LocationID] ] + [[-TaskName] ] [[-IntervalHours] ] [-Force] [-ProgressAction ] [-WhatIf] + [-Confirm] [] +``` + +## DESCRIPTION +Creates a Windows scheduled task that runs Repair-CWAA at a configurable interval +(default every 6 hours) to monitor agent health and automatically remediate issues. + +The task runs as SYSTEM with highest privileges, includes a random delay equal to the +interval to stagger execution across multiple machines, and has a 1-hour execution timeout. + +If the task already exists and the InstallerToken has changed, the task is recreated +with the new token. +Use -Force to recreate unconditionally. + +A backup of the current agent configuration is created before task registration +via New-CWAABackup. + +## EXAMPLES + +### EXAMPLE 1 +``` +Register-CWAAHealthCheckTask -InstallerToken 'abc123def456' +``` + +Creates a task that runs Repair-CWAA in Checkup mode every 6 hours. + +### EXAMPLE 2 +``` +Register-CWAAHealthCheckTask -InstallerToken 'token' -Server 'https://automate.domain.com' -LocationID 42 +``` + +Creates a task that runs Repair-CWAA in Install mode (can install fresh if agent is missing). + +### EXAMPLE 3 +``` +Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' +``` + +Creates a custom-named task running every 12 hours. + +## PARAMETERS + +### -Force +Force recreation of the task even if it already exists with the same token. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InstallerToken +The installer token for authenticated agent deployment. +Embedded in the scheduled +task action for use by Repair-CWAA. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -IntervalHours +Hours between health check runs. +Default: 6. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: 5 +Default value: 6 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -LocationID +Optional location ID. +Required when Server is provided. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: 3 +Default value: 0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +Optional server URL. +When provided, the scheduled task passes this to Repair-CWAA +in Install mode (with Server, LocationID, and InstallerToken). + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TaskName +Name of the scheduled task. +Default: 'CWAAHealthCheck'. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 4 +Default value: CWAAHealthCheck +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Register-LTHealthCheckTask + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Rename-CWAAAddRemove.md b/Docs/Help/Rename-CWAAAddRemove.md similarity index 54% rename from Docs/Rename-CWAAAddRemove.md rename to Docs/Help/Rename-CWAAAddRemove.md index 4da87a0..b2ea864 100644 --- a/Docs/Rename-CWAAAddRemove.md +++ b/Docs/Help/Rename-CWAAAddRemove.md @@ -1,37 +1,47 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Rename-CWAAAddRemove ## SYNOPSIS -This function renames the LabTech install as shown in the Add/Remove Programs list. +Renames the Automate agent entry in the Add/Remove Programs list. ## SYNTAX ``` -Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-WhatIf] [-Confirm] [] +Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-ProgressAction ] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -This function will change the value of the DisplayName registry key to effect Add/Remove Programs list. +Changes the DisplayName (and optionally Publisher) registry values for the Automate agent +uninstall keys, which controls how the agent appears in the Windows Add/Remove Programs +(Programs and Features) list. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +Rename-CWAAAddRemove -Name 'My Remote Agent' +``` + +Renames the Automate agent display name to 'My Remote Agent'. + +### EXAMPLE 2 +``` +Rename-CWAAAddRemove -Name 'My Remote Agent' -PublisherName 'My Company' ``` -{{ Add example description here }} +Renames both the display name and publisher name in Add/Remove Programs. ## PARAMETERS ### -Name -This is the Name for the LabTech Agent as displayed in the list of installed software. +The display name for the Automate agent as shown in the list of installed software. ```yaml Type: Object @@ -45,8 +55,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -PublisherName -This is the Name for the Publisher of the LabTech Agent as displayed in the list of installed software. +The publisher name for the Automate agent as shown in the list of installed software. ```yaml Type: String @@ -60,14 +85,13 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -Confirm +Prompts you for confirmation before running the cmdlet. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: wi +Aliases: cf Required: False Position: Named @@ -76,13 +100,14 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Confirm -Prompts you for confirmation before running the cmdlet. +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: cf +Aliases: wi Required: False Position: Named @@ -99,19 +124,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.2 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 5/14/2017 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/12/2018 -Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. +Author: Chris Taylor +Alias: Rename-LTAddRemove ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Repair-CWAA.md b/Docs/Help/Repair-CWAA.md new file mode 100644 index 0000000..fd66265 --- /dev/null +++ b/Docs/Help/Repair-CWAA.md @@ -0,0 +1,224 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Repair-CWAA + +## SYNOPSIS +Performs escalating remediation of the ConnectWise Automate agent. + +## SYNTAX + +### Install +``` +Repair-CWAA -Server -LocationID -InstallerToken [-HoursRestart ] + [-HoursReinstall ] [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +### Checkup +``` +Repair-CWAA -InstallerToken [-HoursRestart ] [-HoursReinstall ] + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +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 + 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 + 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 + or from backup settings. + +All remediation actions are logged to the Windows Event Log (Application log, +source ConnectWiseAutomateAgent) for visibility in unattended scheduled task runs. + +Designed to be called periodically via Register-CWAAHealthCheckTask or any +external scheduler. + +## EXAMPLES + +### EXAMPLE 1 +``` +Repair-CWAA -InstallerToken 'abc123def456' +``` + +Checks the installed agent and repairs if needed (Checkup mode). + +### EXAMPLE 2 +``` +Repair-CWAA -Server 'https://automate.domain.com' -LocationID 42 -InstallerToken 'token' +``` + +Checks agent health. +If the agent is missing or pointed at the wrong server, +installs or reinstalls with the specified settings. + +### EXAMPLE 3 +``` +Repair-CWAA -InstallerToken 'token' -HoursRestart -4 -HoursReinstall -240 +``` + +Uses custom thresholds: restart after 4 hours offline, reinstall after 10 days. + +## PARAMETERS + +### -HoursReinstall +Hours since last check-in before a full reinstall is attempted. +Expressed as a +negative number (e.g., -120 means 120 hours / 5 days ago). +Default: -120. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: -120 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -HoursRestart +Hours since last check-in before a service restart is attempted. +Expressed as a +negative number (e.g., -2 means 2 hours ago). +Default: -2. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: -2 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -InstallerToken +An installer token for authenticated agent deployment. +Required for both parameter sets. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -LocationID +The LocationID for fresh agent installs. +Required with the Install parameter set. + +```yaml +Type: Int32 +Parameter Sets: Install +Aliases: + +Required: True +Position: Named +Default value: 0 +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +The ConnectWise Automate server URL for fresh installs or server mismatch correction. +Required when using the Install parameter set. + +```yaml +Type: String +Parameter Sets: Install +Aliases: + +Required: True +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Repair-LTService + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Reset-CWAA.md b/Docs/Help/Reset-CWAA.md similarity index 56% rename from Docs/Reset-CWAA.md rename to Docs/Help/Reset-CWAA.md index 5ab1abc..908264d 100644 --- a/Docs/Reset-CWAA.md +++ b/Docs/Help/Reset-CWAA.md @@ -1,28 +1,35 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Reset-CWAA ## SYNOPSIS -This function will remove local settings on the agent. +Removes local agent identity settings to force re-registration. ## SYNTAX ``` -Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-WhatIf] [-Confirm] [] +Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION -This function can remove some of the agents local settings. -ID, MAC, LocationID -The function will stop the services, make the change, then start the services. -Resetting all of these will force the agent to check in as a new agent. -If you have MAC filtering enabled it should check back in with the same ID. -This function is useful for duplicate agents. +Removes some of the agent's local settings: ID, MAC, and/or LocationID. +The function +stops the services, removes the specified registry values, then restarts the services. +Resetting all three values forces the agent to check in as a new agent. +If MAC filtering +is enabled on the server, the agent should check back in with the same ID. + +This function is useful for resolving duplicate agent entries. +If no switches are +specified, all three values (ID, Location, MAC) are reset. + +Probe agents are protected from reset unless the -Force switch is used. ## EXAMPLES @@ -31,19 +38,26 @@ This function is useful for duplicate agents. Reset-CWAA ``` -This resets the ID, MAC and LocationID on the agent. +Resets the ID, MAC, and LocationID on the agent, then waits for re-registration. ### EXAMPLE 2 ``` Reset-CWAA -ID ``` -This resets only the ID of the agent. +Resets only the AgentID of the agent. + +### EXAMPLE 3 +``` +Reset-CWAA -Force -NoWait +``` + +Resets all values on a probe agent without waiting for re-registration. ## PARAMETERS -### -ID -This will reset the AgentID of the computer +### -Force +Forces the reset operation on an agent detected as a probe. ```yaml Type: SwitchParameter @@ -57,8 +71,8 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Location -This will reset the LocationID of the computer +### -ID +Resets the AgentID of the computer. ```yaml Type: SwitchParameter @@ -72,8 +86,8 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -MAC -This will reset the MAC of the computer +### -Location +Resets the LocationID of the computer. ```yaml Type: SwitchParameter @@ -87,8 +101,8 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Force -This will force operation on an agent detected as a probe. +### -MAC +Resets the MAC address of the computer. ```yaml Type: SwitchParameter @@ -103,8 +117,7 @@ Accept wildcard characters: False ``` ### -NoWait -This will skip the ending health check for the reset process. -The function will exit once the values specified have been reset. +Skips the post-reset health check that waits for the agent to re-register. ```yaml Type: SwitchParameter @@ -118,14 +131,13 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: wi +Aliases: proga Required: False Position: Named @@ -149,6 +161,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). @@ -157,28 +185,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.4 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/12/2018 -Purpose/Change: Added detection of "Probe" enabled agent. -Added support for -Force parameter to override probe detection. -Added support for -WhatIf. -Added support for -NoWait paramter to bypass agent health check. - -Update Date: 3/21/2018 -Purpose/Change: Removed ErrorAction Override - -Update Date: 8/5/2019 -Purpose/Change: Bugfixes for -Location parameter +Author: Chris Taylor +Alias: Reset-LTService ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Uninstall-CWAA.md b/Docs/Help/Restart-CWAA.md similarity index 57% rename from Docs/Uninstall-CWAA.md rename to Docs/Help/Restart-CWAA.md index 9cf005c..edb80e9 100644 --- a/Docs/Uninstall-CWAA.md +++ b/Docs/Help/Restart-CWAA.md @@ -1,47 +1,55 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- -# Uninstall-CWAA +# Restart-CWAA ## SYNOPSIS -{{ Fill in the Synopsis }} +Restarts the ConnectWise Automate agent services. ## SYNTAX ``` -Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-WhatIf] [-Confirm] [] +Restart-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -{{ Fill in the Description }} +Verifies that the Automate agent services (LTService, LTSvcMon) are present, then +calls Stop-CWAA followed by Start-CWAA to perform a full service restart. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +Restart-CWAA +``` + +Restarts the ConnectWise Automate agent services. + +### EXAMPLE 2 +``` +Restart-CWAA -WhatIf ``` -{{ Add example description here }} +Shows what would happen without actually restarting the services. ## PARAMETERS -### -Backup -{{ Fill Backup Description }} +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: +Aliases: proga Required: False Position: Named Default value: None -Accept pipeline input: True (ByPropertyName) +Accept pipeline input: False Accept wildcard characters: False ``` @@ -60,36 +68,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Force -{{ Fill Force Description }} - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Server -{{ Fill Server Description }} - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: - -Required: False -Position: 0 -Default value: None -Accept pipeline input: True (ByPropertyName) -Accept wildcard characters: False -``` - ### -WhatIf Shows what would happen if the cmdlet runs. The cmdlet is not run. @@ -111,11 +89,13 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## INPUTS -### System.String[] -### System.Management.Automation.SwitchParameter ## OUTPUTS -### System.Object ## NOTES +Author: Chris Taylor +Alias: Restart-LTService ## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Set-CWAALogLevel.md b/Docs/Help/Set-CWAALogLevel.md new file mode 100644 index 0000000..8bd631d --- /dev/null +++ b/Docs/Help/Set-CWAALogLevel.md @@ -0,0 +1,132 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Set-CWAALogLevel + +## SYNOPSIS +Sets the logging level for the ConnectWise Automate Agent. + +## SYNTAX + +``` +Set-CWAALogLevel [[-Level] ] [-ProgressAction ] [-WhatIf] [-Confirm] + [] +``` + +## DESCRIPTION +Configures the agent's logging verbosity by updating the registry and restarting the +agent services. +Supports Normal (standard) and Verbose (detailed diagnostic) levels. + +The function stops the agent service, writes the new logging level to the registry at +HKLM:\SOFTWARE\LabTech\Service\Settings under the "Debuging" value, then restarts the +agent service. +After applying the change, it outputs the current logging level. + +## EXAMPLES + +### EXAMPLE 1 +``` +Set-CWAALogLevel -Level Verbose +``` + +Enables verbose diagnostic logging on the agent. + +### EXAMPLE 2 +``` +Set-CWAALogLevel -Level Normal +``` + +Returns the agent to standard logging. + +### EXAMPLE 3 +``` +Set-CWAALogLevel -Level Verbose -WhatIf +``` + +Shows what changes would be made without applying them. + +## PARAMETERS + +### -Level +The desired logging level. +Valid values are 'Normal' (default) and 'Verbose'. +Normal sets registry value 1; Verbose sets registry value 1000. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: Normal +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Set-LTLogging + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Set-CWAAProxy.md b/Docs/Help/Set-CWAAProxy.md similarity index 51% rename from Docs/Set-CWAAProxy.md rename to Docs/Help/Set-CWAAProxy.md index cc9d48b..b3ad5ef 100644 --- a/Docs/Set-CWAAProxy.md +++ b/Docs/Help/Set-CWAAProxy.md @@ -1,66 +1,94 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Set-CWAAProxy ## SYNOPSIS -This function configures module functions to use the specified proxy -configuration for all operations as long as the module remains loaded. +Configures module proxy settings for all operations during the current session. ## SYNTAX ``` Set-CWAAProxy [[-ProxyServerURL] ] [[-ProxyUsername] ] [[-ProxyPassword] ] - [-EncodedProxyUsername ] [-EncodedProxyPassword ] [-DetectProxy] [-ResetProxy] [-WhatIf] - [-Confirm] [] + [-EncodedProxyUsername ] [-EncodedProxyPassword ] [-DetectProxy] [-ResetProxy] + [-SkipCertificateCheck] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -This function will set or clear Proxy settings needed for function and -agent operations. -If an agent is already installed, this function will -set the ProxyUsername, ProxyPassword, and ProxyServerURL values for the -Agent. -NOTE: Agent Services will be restarted for changes (if found) to be applied. +Sets or clears Proxy settings needed for module function and agent operations. +If an agent is already installed, this function will update the ProxyUsername, +ProxyPassword, and ProxyServerURL values in the agent registry settings. +Agent services will be restarted for changes (if found) to be applied. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +Set-CWAAProxy -DetectProxy +``` + +Automatically detects and configures the system proxy. + +### EXAMPLE 2 +``` +Set-CWAAProxy -ResetProxy ``` -{{ Add example description here }} +Clears all proxy settings. + +### EXAMPLE 3 +``` +Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' +``` + +Sets the proxy server URL without authentication. ## PARAMETERS -### -ProxyServerURL -This is the URL and Port to assign as the ProxyServerURL for Module -operations during this session and for the Installed Agent (if present). -Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com' -Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -This parameter may be used with the additional following parameters: -ProxyUsername, ProxyPassword, EncodedProxyUsername, EncodedProxyPassword +### -DetectProxy +Automatically detect system proxy settings for module operations. +Discovered settings are applied to the installed agent (if present). +Cannot be used with other parameters. ```yaml -Type: String +Type: SwitchParameter +Parameter Sets: (All) +Aliases: AutoDetect, Detect + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -EncodedProxyPassword +Encoded password for proxy authentication, encrypted with the agent password. +Will be decoded using the agent password. +Must be used with ProxyServerURL +and EncodedProxyUsername. + +```yaml +Type: SecureString Parameter Sets: (All) Aliases: Required: False -Position: 1 +Position: Named Default value: None -Accept pipeline input: True (ByPropertyName, ByValue) +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -ProxyUsername -This is the plain text Username for Proxy operations. -Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -ProxyUsername 'Test-User' -ProxyPassword 'SomeFancyPassword' +### -EncodedProxyUsername +Encoded username for proxy authentication, encrypted with the agent password. +Will be decoded using the agent password. +Must be used with ProxyServerURL +and EncodedProxyPassword. ```yaml Type: String @@ -68,14 +96,30 @@ Parameter Sets: (All) Aliases: Required: False -Position: 2 +Position: Named Default value: None Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProxyPassword -This is the plain text Password for Proxy operations. +Plain text password for proxy authentication. +Must be used with ProxyServerURL and ProxyUsername. ```yaml Type: SecureString @@ -89,14 +133,11 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -EncodedProxyUsername -This is the encoded Username for Proxy operations. -The parameter must be -encoded with the Agent Password. -This Parameter will be decoded using the -Agent Password, and the decoded string will be configured. -NOTE: Reinstallation of the Agent will generate a new agent password. -Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' -EncodedProxyUsername '1GzhlerwMy0ElG9XNgiIkg==' -EncodedProxyPassword 'Duft4r7fekTp5YnQL9F0V9TbP7sKzm0n' +### -ProxyServerURL +The URL and optional port to assign as the proxy server for module operations +and for the installed agent (if present). +Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' +May be used with ProxyUsername/ProxyPassword or EncodedProxyUsername/EncodedProxyPassword. ```yaml Type: String @@ -104,44 +145,37 @@ Parameter Sets: (All) Aliases: Required: False -Position: Named +Position: 1 Default value: None -Accept pipeline input: True (ByPropertyName) +Accept pipeline input: True (ByPropertyName, ByValue) Accept wildcard characters: False ``` -### -EncodedProxyPassword -This is the encoded Password for Proxy operations. -The parameter must be -encoded with the Agent Password. -This Parameter will be decoded using the -Agent Password, and the decoded string will be configured. -NOTE: Reinstallation of the Agent will generate a new password. +### -ProxyUsername +Plain text username for proxy authentication. +Must be used with ProxyServerURL and ProxyPassword. ```yaml -Type: SecureString +Type: String Parameter Sets: (All) Aliases: Required: False -Position: Named +Position: 2 Default value: None Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -DetectProxy -This parameter attempts to automatically detect the system Proxy settings -for Module operations during this session. -Discovered settings will be -assigned to the Installed Agent (if present). -Example: Set-CWAAProxy -DetectProxy -This parameter may not be used with other parameters. +### -ResetProxy +Clears any currently defined proxy settings for module operations. +Changes are applied to the installed agent (if present). +Cannot be used with other parameters. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: AutoDetect, Detect +Aliases: ClearProxy, Reset, Clear Required: False Position: Named @@ -150,34 +184,28 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -ResetProxy -This parameter clears any currently defined Proxy Settings for Module -operations during this session. -Discovered settings will be assigned -to the Installed Agent (if present). -Example: Set-CWAAProxy -ResetProxy -This parameter may not be used with other parameters. +### -SkipCertificateCheck +{{ Fill SkipCertificateCheck Description }} ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: ClearProxy, Reset, Clear +Aliases: Required: False Position: Named Default value: False -Accept pipeline input: True (ByPropertyName) +Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -Confirm +Prompts you for confirmation before running the cmdlet. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: wi +Aliases: cf Required: False Position: Named @@ -186,13 +214,14 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Confirm -Prompts you for confirmation before running the cmdlet. +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: cf +Aliases: wi Required: False Position: Named @@ -209,12 +238,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.1 -Author: Darren White -Creation Date: 1/24/2018 -Purpose/Change: Initial function development +Author: Darren White +Alias: Set-LTProxy ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Show-CWAAAddRemove.md b/Docs/Help/Show-CWAAAddRemove.md similarity index 50% rename from Docs/Show-CWAAAddRemove.md rename to Docs/Help/Show-CWAAAddRemove.md index ecd1fec..2df4a92 100644 --- a/Docs/Show-CWAAAddRemove.md +++ b/Docs/Help/Show-CWAAAddRemove.md @@ -1,44 +1,51 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- # Show-CWAAAddRemove ## SYNOPSIS -This function shows the LabTech install in the add/remove programs list. +Shows the Automate agent in the Add/Remove Programs list. ## SYNTAX ``` -Show-CWAAAddRemove [-WhatIf] [-Confirm] [] +Show-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -This function will rename the HiddenDisplayName registry key to show it in the add/remove programs list. -If there is not HiddenDisplayName key the function will import a new entry. +Sets the SystemComponent registry value to 0 on Automate agent uninstall keys, +which makes the agent visible in the Windows Add/Remove Programs (Programs and Features) list. +Also cleans up any leftover HiddenProductName registry values from older hiding methods. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 ``` +Show-CWAAAddRemove +``` + +Makes the Automate agent entry visible in Add/Remove Programs. -{{ Add example description here }} +### EXAMPLE 2 +``` +Show-CWAAAddRemove -WhatIf +``` + +Shows what registry changes would be made without applying them. ## PARAMETERS -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: SwitchParameter +Type: ActionPreference Parameter Sets: (All) -Aliases: wi +Aliases: proga Required: False Position: Named @@ -62,6 +69,22 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). @@ -70,21 +93,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.2 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/12/2018 -Purpose/Change: Support for ShouldProcess. -Added Registry Paths to be checked. -Modified hiding method to be compatible with standard software controls. +Author: Chris Taylor +Alias: Show-LTAddRemove ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Start-CWAA.md b/Docs/Help/Start-CWAA.md new file mode 100644 index 0000000..3913594 --- /dev/null +++ b/Docs/Help/Start-CWAA.md @@ -0,0 +1,109 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Start-CWAA + +## SYNOPSIS +Starts the ConnectWise Automate agent services. + +## SYNTAX + +``` +Start-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Verifies that the Automate agent services (LTService, LTSvcMon) are present. +Checks +for any process using the LTTray port (default 42000) and kills it. +If a +protected application holds the port, increments the TrayPort (wrapping from +42009 back to 42000). +Sets services to Automatic startup and starts them via +sc.exe. +Waits up to one minute for LTService to reach the Running state, then +issues a Send Status command for immediate check-in. + +## EXAMPLES + +### EXAMPLE 1 +``` +Start-CWAA +``` + +Starts the ConnectWise Automate agent services. + +### EXAMPLE 2 +``` +Start-CWAA -WhatIf +``` + +Shows what would happen without actually starting the services. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Start-LTService + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Invoke-CWAACommand.md b/Docs/Help/Stop-CWAA.md similarity index 53% rename from Docs/Invoke-CWAACommand.md rename to Docs/Help/Stop-CWAA.md index 9ed5cfa..adce3be 100644 --- a/Docs/Invoke-CWAACommand.md +++ b/Docs/Help/Stop-CWAA.md @@ -1,58 +1,69 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- -# Invoke-CWAACommand +# Stop-CWAA ## SYNOPSIS -This function tells the agent to execute the desired command. +Stops the ConnectWise Automate agent services. ## SYNTAX ``` -Invoke-CWAACommand [-Command] [-WhatIf] [-Confirm] [] +Stop-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION -This function will allow you to execute all known commands against an agent. +Verifies that the Automate agent services (LTService, LTSvcMon) are present, then +attempts to stop them gracefully via sc.exe. +Waits up to one minute for the +services to reach a Stopped state. +If they do not stop in time, remaining +Automate agent processes (LTTray, LTSVC, LTSvcMon) are forcefully terminated. ## EXAMPLES -### Example 1 -```powershell -PS C:\> {{ Add example code here }} +### EXAMPLE 1 +``` +Stop-CWAA +``` + +Stops the ConnectWise Automate agent services. + +### EXAMPLE 2 +``` +Stop-CWAA -WhatIf ``` -{{ Add example description here }} +Shows what would happen without actually stopping the services. ## PARAMETERS -### -Command -{{ Fill Command Description }} +### -ProgressAction +{{ Fill ProgressAction Description }} ```yaml -Type: String[] +Type: ActionPreference Parameter Sets: (All) -Aliases: +Aliases: proga -Required: True -Position: 2 +Required: False +Position: Named Default value: None -Accept pipeline input: True (ByValue) +Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -Confirm +Prompts you for confirmation before running the cmdlet. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: wi +Aliases: cf Required: False Position: Named @@ -61,13 +72,14 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Confirm -Prompts you for confirmation before running the cmdlet. +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: cf +Aliases: wi Required: False Position: Named @@ -84,20 +96,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.2 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 2/2/2018 -Purpose/Change: Initial script development -Thanks: Gavin Stone, for finding the command list - -Update Date: 2/8/2018 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/21/2018 -Purpose/Change: Removed ErrorAction Override +Author: Chris Taylor +Alias: Stop-LTService ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Test-CWAAHealth.md b/Docs/Help/Test-CWAAHealth.md new file mode 100644 index 0000000..f75b04e --- /dev/null +++ b/Docs/Help/Test-CWAAHealth.md @@ -0,0 +1,130 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Test-CWAAHealth + +## SYNOPSIS +Performs a read-only health assessment of the ConnectWise Automate agent. + +## SYNTAX + +``` +Test-CWAAHealth [[-Server] ] [-TestServerConnectivity] [-ProgressAction ] + [] +``` + +## DESCRIPTION +Checks the overall health of the installed Automate agent without taking any +remediation action. +Returns a status object with details about the agent's +installation state, service status, last check-in times, and server connectivity. + +This function never modifies the agent, services, or registry. +It is safe to call +at any time for monitoring or diagnostic purposes. + +Health assessment criteria: +- Agent is installed (LTService exists) +- Services are running (LTService and LTSvcMon) +- Agent has checked in recently (LastSuccessStatus or HeartbeatLastSent within threshold) +- Server is reachable (optional, tested when Server param is provided or auto-discovered) + +The Healthy property is True only when the agent is installed, services are running, +and LastContact is not null. + +## EXAMPLES + +### EXAMPLE 1 +``` +Test-CWAAHealth +``` + +Returns a health status object for the installed agent. + +### EXAMPLE 2 +``` +Test-CWAAHealth -Server 'https://automate.domain.com' -TestServerConnectivity +``` + +Checks agent health, validates the server address matches, and tests server connectivity. + +### EXAMPLE 3 +``` +if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } +``` + +Uses the Healthy boolean for conditional logic. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +An Automate server URL to validate against the installed agent's configured server. +If provided, the ServerMatch property indicates whether the installed agent points +to this server. +If omitted, ServerMatch is null. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -TestServerConnectivity +When specified, tests whether the agent's server is reachable via the agent.aspx +endpoint. +Adds a brief network call. +The ServerReachable property is null when +this switch is not used. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Test-LTHealth + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Test-CWAAPort.md b/Docs/Help/Test-CWAAPort.md new file mode 100644 index 0000000..7f52dc8 --- /dev/null +++ b/Docs/Help/Test-CWAAPort.md @@ -0,0 +1,121 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Test-CWAAPort + +## SYNOPSIS +Tests connectivity to TCP ports required by the ConnectWise Automate agent. + +## SYNTAX + +``` +Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [-ProgressAction ] + [] +``` + +## DESCRIPTION +Verifies that the local LTTray port is available and tests connectivity to +the required TCP ports (70, 80, 443) on the Automate server, plus port 8002 +on the Automate mediator server. +If no server is provided, the function attempts to detect it from the installed +agent configuration or backup info. + +## EXAMPLES + +### EXAMPLE 1 +``` +Test-CWAAPort -Server 'https://automate.domain.com' +``` + +Tests all required ports against the specified server. + +### EXAMPLE 2 +``` +Test-CWAAPort -Quiet +``` + +Returns $True if the TrayPort is available, $False otherwise. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Quiet +Returns a boolean connectivity result instead of verbose output. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -Server +The URL of the Automate server (e.g., https://automate.domain.com). +If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### -TrayPort +The local port LTSvc.exe listens on for LTTray communication. +Defaults to 42000 if not provided or not found in agent configuration. + +```yaml +Type: Int32 +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: 0 +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Test-LTPorts + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Test-CWAAServerConnectivity.md b/Docs/Help/Test-CWAAServerConnectivity.md new file mode 100644 index 0000000..14750ba --- /dev/null +++ b/Docs/Help/Test-CWAAServerConnectivity.md @@ -0,0 +1,117 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Test-CWAAServerConnectivity + +## SYNOPSIS +Tests connectivity to a ConnectWise Automate server's agent endpoint. + +## SYNTAX + +``` +Test-CWAAServerConnectivity [[-Server] ] [-Quiet] [-ProgressAction ] + [] +``` + +## DESCRIPTION +Verifies that an Automate server is online and responding by querying the +agent.aspx endpoint. +Validates that the response matches the expected version +format (pipe-delimited string ending with a version number). + +If no server is provided, the function attempts to discover it from the +installed agent configuration or backup settings. + +Returns a result object per server with availability status and version info, +or a simple boolean in Quiet mode. + +## EXAMPLES + +### EXAMPLE 1 +``` +Test-CWAAServerConnectivity -Server 'https://automate.domain.com' +``` + +Tests connectivity and returns a result object with Server, Available, Version, and ErrorMessage. + +### EXAMPLE 2 +``` +Test-CWAAServerConnectivity -Quiet +``` + +Returns $True if the discovered server is reachable, $False otherwise. + +### EXAMPLE 3 +``` +Get-CWAAInfo | Test-CWAAServerConnectivity +``` + +Tests connectivity to the server configured on the installed agent via pipeline. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Quiet +Returns $True if all servers are reachable, $False otherwise. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +One or more ConnectWise Automate server URLs (e.g., https://automate.domain.com). +If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName, ByValue) +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Test-LTServerConnectivity + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Uninstall-CWAA.md b/Docs/Help/Uninstall-CWAA.md new file mode 100644 index 0000000..f12091a --- /dev/null +++ b/Docs/Help/Uninstall-CWAA.md @@ -0,0 +1,226 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Uninstall-CWAA + +## SYNOPSIS +Completely uninstalls the ConnectWise Automate Agent from the local computer. + +## SYNTAX + +``` +Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-SkipCertificateCheck] + [-ProgressAction ] [-WhatIf] [-Confirm] [] +``` + +## DESCRIPTION +Performs a comprehensive removal of the ConnectWise Automate Agent from a Windows computer. +This function is more thorough than a standard MSI uninstall, as it also removes residual +files, registry keys, and services that may not be cleaned up by the normal uninstall process. + +The uninstall process performs the following operations: +1. +Downloads official uninstaller files (Agent_Uninstall.msi and Agent_Uninstall.exe) from the server +2. +Optionally creates a backup of the current agent installation (if -Backup is specified) +3. +Stops all running agent services (LTService, LTSvcMon, LabVNC) +4. +Terminates any running agent processes +5. +Unregisters the wodVPN.dll component +6. +Runs the MSI uninstaller (Agent_Uninstall.msi) +7. +Runs the agent uninstaller executable (Agent_Uninstall.exe) +8. +Removes agent Windows services +9. +Removes all agent files from the installation directory +10. +Removes all agent-related registry keys (over 30 different registry locations) +11. +Verifies the uninstall was successful + +Probe Agent Protection: By default, this function will refuse to uninstall probe agents to +prevent accidental removal of critical infrastructure. +Use -Force to override this protection. + +## EXAMPLES + +### EXAMPLE 1 +``` +Uninstall-CWAA +``` + +Uninstalls the agent using the server URL from the agent's registry settings. + +### EXAMPLE 2 +``` +Uninstall-CWAA -Backup +``` + +Creates a backup of the agent installation before uninstalling. + +### EXAMPLE 3 +``` +Uninstall-CWAA -Server "https://automate.company.com" +``` + +Uninstalls using the specified server URL to download uninstaller files. + +### EXAMPLE 4 +``` +Uninstall-CWAA -Server "https://primary.company.com","https://backup.company.com" +``` + +Provides multiple server URLs with fallback. +Tries each until uninstaller files download successfully. + +### EXAMPLE 5 +``` +Uninstall-CWAA -Force +``` + +Forces uninstallation even if a probe agent is detected. + +### EXAMPLE 6 +``` +Uninstall-CWAA -WhatIf +``` + +Simulates the uninstall process without making any actual changes. + +## PARAMETERS + +### -Backup +Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -Force +Forces uninstallation even when a probe agent is detected. +Use with extreme caution, +as probe agents are typically critical infrastructure components. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Server +One or more ConnectWise Automate server URLs to download uninstaller files from. +If not specified, reads the server URL from the agent's current registry configuration. +If that fails, prompts interactively for a server URL. +Example: https://automate.domain.com + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: True (ByPropertyName) +Accept wildcard characters: False +``` + +### -SkipCertificateCheck +{{ Fill SkipCertificateCheck Description }} + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Chris Taylor +Alias: Uninstall-LTService +Requires: Administrator privileges + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Update-CWAA.md b/Docs/Help/Unregister-CWAAHealthCheckTask.md similarity index 53% rename from Docs/Update-CWAA.md rename to Docs/Help/Unregister-CWAAHealthCheckTask.md index 850a883..7fb7eb8 100644 --- a/Docs/Update-CWAA.md +++ b/Docs/Help/Unregister-CWAAHealthCheckTask.md @@ -1,47 +1,62 @@ --- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent schema: 2.0.0 --- -# Update-CWAA +# Unregister-CWAAHealthCheckTask ## SYNOPSIS -This function will manually update the LabTech agent to the requested version. +Removes the ConnectWise Automate agent health check scheduled task. ## SYNTAX ``` -Update-CWAA [[-Version] ] [-WhatIf] [-Confirm] [] +Unregister-CWAAHealthCheckTask [[-TaskName] ] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION -This script will attempt to pull current server settings from machine, then download and run the agent updater. +Deletes the Windows scheduled task created by Register-CWAAHealthCheckTask. +If the task does not exist, writes a warning and returns gracefully. ## EXAMPLES ### EXAMPLE 1 ``` -Update-CWAA -Version 120.240 +Unregister-CWAAHealthCheckTask ``` -This will update the Automate agent to the specific version requested, using the server address in the registry. +Removes the default CWAAHealthCheck scheduled task. ### EXAMPLE 2 ``` -Update-CWAA +Unregister-CWAAHealthCheckTask -TaskName 'MyHealthCheck' ``` -This will update the Automate agent to the current version advertised, using the server address in the registry. +Removes a custom-named health check task. ## PARAMETERS -### -Version -This is the agent version to install. -Example: 120.240 -This is needed to download the update file. -If omitted, the version advertised by the server will be used. +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -TaskName +Name of the scheduled task to remove. +Default: 'CWAAHealthCheck'. ```yaml Type: String @@ -50,19 +65,18 @@ Aliases: Required: False Position: 1 -Default value: None +Default value: CWAAHealthCheck Accept pipeline input: False Accept wildcard characters: False ``` -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. +### -Confirm +Prompts you for confirmation before running the cmdlet. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: wi +Aliases: cf Required: False Position: Named @@ -71,13 +85,14 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -Confirm -Prompts you for confirmation before running the cmdlet. +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. ```yaml Type: SwitchParameter Parameter Sets: (All) -Aliases: cf +Aliases: wi Required: False Position: Named @@ -94,16 +109,10 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## OUTPUTS ## NOTES -Version: 1.1 -Author: Darren White -Creation Date: 8/28/2018 -Purpose/Change: Initial function development - -Update Date: 1/21/2019 -Purpose/Change: Minor bugfixes/adjustments. -Allow single label server name, accept less digits for Agent Minor version number +Author: Chris Taylor +Alias: Unregister-LTHealthCheckTask ## RELATED LINKS -[http://labtechconsulting.com](http://labtechconsulting.com) +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) diff --git a/Docs/Help/Update-CWAA.md b/Docs/Help/Update-CWAA.md new file mode 100644 index 0000000..55b1d6b --- /dev/null +++ b/Docs/Help/Update-CWAA.md @@ -0,0 +1,152 @@ +--- +external help file: ConnectWiseAutomateAgent-help.xml +Module Name: ConnectWiseAutomateAgent +online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent +schema: 2.0.0 +--- + +# Update-CWAA + +## SYNOPSIS +Manually updates the ConnectWise Automate Agent to a specified version. + +## SYNTAX + +``` +Update-CWAA [[-Version] ] [-SkipCertificateCheck] [-ProgressAction ] [-WhatIf] + [-Confirm] [] +``` + +## DESCRIPTION +Downloads and applies an agent update from the ConnectWise Automate server. +The function +reads the current server configuration from the agent's registry settings, downloads the +appropriate update package, extracts it, and runs the updater. + +If no version is specified, the function uses the version advertised by the server. +The function validates that the requested version is higher than the currently installed +version and not higher than the server version before proceeding. + +The update process: +1. +Reads current agent settings and server information +2. +Downloads the LabtechUpdate.exe for the target version +3. +Stops agent services +4. +Extracts and runs the update +5. +Restarts agent services + +## EXAMPLES + +### EXAMPLE 1 +``` +Update-CWAA -Version 120.240 +``` + +Updates the agent to the specific version requested. + +### EXAMPLE 2 +``` +Update-CWAA +``` + +Updates the agent to the current version advertised by the server. + +## PARAMETERS + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -SkipCertificateCheck +{{ Fill SkipCertificateCheck Description }} + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Version +The target agent version to update to. +Example: 120.240 +If omitted, the version advertised by the server will be used. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 1 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf +Shows what would happen if the cmdlet runs. +The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +## NOTES +Author: Darren White +Alias: Update-LTService + +## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Install-CWAA.md b/Docs/Install-CWAA.md deleted file mode 100644 index 34a0b36..0000000 --- a/Docs/Install-CWAA.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Install-CWAA - -## SYNOPSIS -This function will install the LabTech agent on the machine. - -## SYNTAX - -### deployment (Default) -``` -Install-CWAA [[-Server] ] [[-ServerPassword] ] [[-LocationID] ] [[-TrayPort] ] - [[-Rename] ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-WhatIf] [-Confirm] [] -``` - -### installertoken -``` -Install-CWAA [[-Server] ] [[-ServerPassword] ] [-InstallerToken ] - [[-LocationID] ] [[-TrayPort] ] [[-Rename] ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] - [-WhatIf] [-Confirm] [] -``` - -## DESCRIPTION -This function will install the LabTech agent on the machine with the specified server/password/location. - -## EXAMPLES - -### EXAMPLE 1 -``` -Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 -``` - -This will install the LabTech agent using the provided Server URL, InstallerToken, and LocationID. - -## PARAMETERS - -### -Server -This is the URL to your LabTech server. -example: https://automate.domain.com -This is used to download the installation files. -(Get-CWAAInfo|Select-Object -Expand 'Server Address' -ErrorAction SilentlyContinue) - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: - -Required: False -Position: 1 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ServerPassword -This is the server password that agents use to authenticate with the LabTech server. -SELECT SystemPassword FROM config; - -```yaml -Type: String -Parameter Sets: deployment -Aliases: Password - -Required: False -Position: 2 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -```yaml -Type: String -Parameter Sets: installertoken -Aliases: Password - -Required: False -Position: 2 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -LocationID -This is the LocationID of the location that the agent will be put into. -(Get-CWAAInfo).LocationID - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: - -Required: False -Position: 3 -Default value: 0 -Accept pipeline input: True (ByPropertyName) -Accept wildcard characters: False -``` - -### -TrayPort -This is the port LTSvc.exe listens on for communication with LTTray processes. - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: - -Required: False -Position: 4 -Default value: 0 -Accept pipeline input: True (ByPropertyName) -Accept wildcard characters: False -``` - -### -Rename -This will call Rename-CWAAAddRemove after the install. - -```yaml -Type: String -Parameter Sets: (All) -Aliases: - -Required: False -Position: 5 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Hide -This will call Hide-CWAAAddRemove after the install. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -SkipDotNet -This will disable the error checking for the .NET 3.5 and .NET 2.0 frameworks during the install process. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Force -This will disable some of the error checking on the install process. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -NoWait -This will skip the ending health check for the install process. -The function will exit once the installer has completed. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: wi - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Confirm -Prompts you for confirmation before running the cmdlet. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: cf - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -InstallerToken -An installer token is preferred over the server password. Please see the following forum post about generating installer tokens. - -https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken - - -```yaml -Type: String -Parameter Sets: installertoken -Aliases: - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 2.0 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 6/10/2017 -Purpose/Change: Updates for pipeline input, support for multiple servers - -Update Date: 6/24/2017 -Purpose/Change: Update to detect Server Version and use updated URL format for LabTech 11 Patch 13. - -Update Date: 8/24/2017 -Purpose/Change: Update to use Clear-Variable. -Additional Debugging. - -Update Date: 8/29/2017 -Purpose/Change: Additional Debugging. - -Update Date: 9/7/2017 -Purpose/Change: Support for ShouldProcess to enable -Confirm and -WhatIf. - -Update Date: 1/26/2018 -Purpose/Change: Added support for Proxy Server for Download and Installation steps. - -Update Date: 2/13/2018 -Purpose/Change: Added -TrayPort parameter. - -Update Date: 3/13/2018 -Purpose/Change: Added -NoWait parameter. -Added minimum size requirement for agent installer to detect and skip a bad file download. - -Update Date: 6/5/2018 -Purpose/Change: Added -SkipDotNet parameter. -Allows for skipping of .NET 3.5 and 2.0 framework checks for installing on OS with .NET 4.0+ already installed - -Update Date: 1/21/2019 -Purpose/Change: Minor bugfixes/adjustments. -Allow single label server name, accept Agent ID 1 as valid. - -Update Date: 2/28/2019 -Purpose/Change: Update to try both http and https methods if not specified for Server - -Update Date: 12/28/2019 -Purpose/Change: Handle .NET 3.5 in pending state, accept .NET 4.0+ or higher with -Force parameter - -Update Date: 6/10/2020 -Purpose/Change: Remove Deployment.aspx dependance - -Update Date: 6/11/2020 -Purpose/Change: Update to work with or without Deployment.aspx - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Migration.md b/Docs/Migration.md new file mode 100644 index 0000000..d4b5279 --- /dev/null +++ b/Docs/Migration.md @@ -0,0 +1,167 @@ +# Migration Guide: 0.1.4.0 to 1.0.0 + +## Quick Start + +**Your existing scripts will keep working.** All 32 legacy `LT` aliases are preserved in 1.0.0. No function was removed from the public API. Upgrade the module and your existing scripts will run without changes: + +```powershell +Update-Module ConnectWiseAutomateAgent -AllowPrerelease +``` + +Read on if you want to adopt the new naming, use the new features, or need to handle the one breaking change. + +--- + +## What Changed + +### Module Prefix: LT to CWAA + +Every public function was renamed from `Verb-LT*` to `Verb-CWAA*`. All original names remain as aliases. + +### Complete Function-to-Alias Mapping + +| CWAA Function (1.0.0) | LT Alias (legacy) | Category | +| --- | --- | --- | +| `Install-CWAA` | `Install-LTService` | Install & Lifecycle | +| `Uninstall-CWAA` | `Uninstall-LTService` | Install & Lifecycle | +| `Update-CWAA` | `Update-LTService` | Install & Lifecycle | +| `Redo-CWAA` | `Redo-LTService`, `Reinstall-CWAA`, `Reinstall-LTService` | Install & Lifecycle | +| `Start-CWAA` | `Start-LTService` | Service Control | +| `Stop-CWAA` | `Stop-LTService` | Service Control | +| `Restart-CWAA` | `Restart-LTService` | Service Control | +| `Repair-CWAA` | `Repair-LTService` | Service Control (new) | +| `Test-CWAAHealth` | `Test-LTHealth` | Health & Connectivity (new) | +| `Test-CWAAPort` | `Test-LTPorts` | Health & Connectivity | +| `Test-CWAAServerConnectivity` | `Test-LTServerConnectivity` | Health & Connectivity (new) | +| `Register-CWAAHealthCheckTask` | `Register-LTHealthCheckTask` | Scheduled Monitoring (new) | +| `Unregister-CWAAHealthCheckTask` | `Unregister-LTHealthCheckTask` | Scheduled Monitoring (new) | +| `Get-CWAAInfo` | `Get-LTServiceInfo` | Settings & Backup | +| `Get-CWAAInfoBackup` | `Get-LTServiceInfoBackup` | Settings & Backup | +| `Get-CWAASettings` | `Get-LTServiceSettings` | Settings & Backup | +| `New-CWAABackup` | `New-LTServiceBackup` | Settings & Backup | +| `Reset-CWAA` | `Reset-LTService` | Settings & Backup | +| `Get-CWAAError` | `Get-LTErrors` | Logging & Diagnostics | +| `Get-CWAAProbeError` | `Get-LTProbeErrors` | Logging & Diagnostics | +| `Get-CWAALogLevel` | `Get-LTLogging` | Logging & Diagnostics | +| `Set-CWAALogLevel` | `Set-LTLogging` | Logging & Diagnostics | +| `Get-CWAAProxy` | `Get-LTProxy` | Proxy | +| `Set-CWAAProxy` | `Set-LTProxy` | Proxy | +| `Hide-CWAAAddRemove` | `Hide-LTAddRemove` | Add/Remove Programs | +| `Show-CWAAAddRemove` | `Show-LTAddRemove` | Add/Remove Programs | +| `Rename-CWAAAddRemove` | `Rename-LTAddRemove` | Add/Remove Programs | +| `ConvertFrom-CWAASecurity` | `ConvertFrom-LTSecurity` | Security & Utilities | +| `ConvertTo-CWAASecurity` | `ConvertTo-LTSecurity` | Security & Utilities | +| `Invoke-CWAACommand` | `Invoke-LTServiceCommand` | Security & Utilities | + +To list all available aliases in a running session: + +```powershell +Get-Alias -Definition *-CWAA* | Format-Table Name, Definition +``` + +### New Functions in 1.0.0 + +Five new public functions (no legacy equivalents existed): + +| Function | Purpose | +| --- | --- | +| `Test-CWAAHealth` | Read-only health assessment of the installed agent | +| `Repair-CWAA` | Escalating remediation (restart, reinstall) based on health status | +| `Test-CWAAServerConnectivity` | Tests HTTPS reachability of the Automate server | +| `Register-CWAAHealthCheckTask` | Creates a Windows scheduled task for recurring health checks | +| `Unregister-CWAAHealthCheckTask` | Removes the scheduled health check task | + +### Removed Private Functions + +Three internal functions were removed (not part of the public API — no script impact): + +| Removed | Replacement | +| --- | --- | +| `Get-CurrentLineNumber` | Removed — no longer used | +| `Initialize-CWAAKeys` | Inlined into `Get-CWAAProxy` (only consumer) | +| `Initialize-CWAAModule` | Merged into `Initialize-CWAA` | + +--- + +## Breaking Changes + +### LocationID type on Redo-CWAA + +`Redo-CWAA` changed `-LocationID` from `[string]` to `[int]`. + +**Impact:** Scripts passing a string value will still work due to PowerShell's implicit type coercion (`'5'` becomes `5`). However, passing an empty string `''` where `$null` was intended will now fail validation. + +```powershell +# Before (0.1.4.0) — worked with string +Redo-LTService -Server 'https://automate.example.com' -LocationID '5' -ServerPassword 'pwd' + +# After (1.0.0) — use integer, and prefer InstallerToken +Redo-CWAA -Server 'https://automate.example.com' -LocationID 5 -InstallerToken 'MyToken' +``` + +--- + +## Authentication Migration + +### ServerPassword (Legacy) to InstallerToken (Modern) + +`InstallerToken` is the preferred authentication method in 1.0.0. It is scoped, revocable, and more secure than the server-wide `ServerPassword`. See [Security Model](Security.md#authentication-installertoken-vs-serverpassword) for details. + +**Before (ServerPassword):** +```powershell +Install-LTService -Server 'https://automate.example.com' -ServerPassword 'LegacyPassword' -LocationID '5' +``` + +**After (InstallerToken):** +```powershell +Install-CWAA -Server 'https://automate.example.com' -InstallerToken 'abc123def456' -LocationID 5 +``` + +Both methods are still supported. `ServerPassword` will not be removed, but `InstallerToken` should be preferred in all new scripts and documentation. + +--- + +## Script Upgrade Examples + +### Basic Installation + +```powershell +# Before +Install-LTService -Server 'https://automate.example.com' -ServerPassword 'pwd' -LocationID '1' + +# After +Install-CWAA -Server 'https://automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +### Reinstall + +```powershell +# Before +Reinstall-LTService -Server 'https://automate.example.com' -ServerPassword 'pwd' -LocationID '1' + +# After +Redo-CWAA -Server 'https://automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +### Health Check (new in 1.0.0) + +```powershell +# No equivalent in 0.1.4.0 +$health = Test-CWAAHealth +if (-not $health.Healthy) { + Repair-CWAA -Server 'https://automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +} +``` + +### Automated Monitoring (new in 1.0.0) + +```powershell +# No equivalent in 0.1.4.0 +Register-CWAAHealthCheckTask -Server 'https://automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +--- + +## Deprecation Policy + +There is **no planned deprecation** of the `LT` aliases. They are maintained indefinitely for backward compatibility. However, new documentation and examples use the `CWAA` prefix exclusively. diff --git a/Docs/New-CWAABackup.md b/Docs/New-CWAABackup.md deleted file mode 100644 index 84ee777..0000000 --- a/Docs/New-CWAABackup.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# New-CWAABackup - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -New-CWAABackup [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Private/Get-CurrentLineNumber.md b/Docs/Private/Get-CurrentLineNumber.md deleted file mode 100644 index f76d20c..0000000 --- a/Docs/Private/Get-CurrentLineNumber.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -external help file: -Module Name: -online version: -schema: 2.0.0 ---- - -# Get-CurrentLineNumber - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -Get-CurrentLineNumber [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None - -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Private/Initialize-CWAA.md b/Docs/Private/Initialize-CWAA.md deleted file mode 100644 index b0d0107..0000000 --- a/Docs/Private/Initialize-CWAA.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -external help file: -Module Name: -online version: -schema: 2.0.0 ---- - -# Initialize-CWAA - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -Initialize-CWAA [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None - -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Private/Initialize-CWAAKeys.md b/Docs/Private/Initialize-CWAAKeys.md deleted file mode 100644 index 2d109e1..0000000 --- a/Docs/Private/Initialize-CWAAKeys.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -external help file: -Module Name: -online version: -schema: 2.0.0 ---- - -# Initialize-CWAAKeys - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -Initialize-CWAAKeys [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None - -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Private/Initialize-CWAAModule.md b/Docs/Private/Initialize-CWAAModule.md deleted file mode 100644 index 0bdc747..0000000 --- a/Docs/Private/Initialize-CWAAModule.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -external help file: -Module Name: -online version: -schema: 2.0.0 ---- - -# Initialize-CWAAModule - -## SYNOPSIS -{{ Fill in the Synopsis }} - -## SYNTAX - -``` -Initialize-CWAAModule [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None - -## OUTPUTS - -### System.Object -## NOTES - -## RELATED LINKS diff --git a/Docs/Restart-CWAA.md b/Docs/Restart-CWAA.md deleted file mode 100644 index 538fc6e..0000000 --- a/Docs/Restart-CWAA.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Restart-CWAA - -## SYNOPSIS -This function will restart the LabTech Services. - -## SYNTAX - -``` -Restart-CWAA [-WhatIf] [-Confirm] [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: wi - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Confirm -Prompts you for confirmation before running the cmdlet. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: cf - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.3 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/13/2018 -Purpose/Change: Added additional debugging output, support for ShouldProcess (-Confirm, -WhatIf) - -Update Date: 3/21/2018 -Purpose/Change: Removed ErrorAction Override - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Security.md b/Docs/Security.md new file mode 100644 index 0000000..d125b66 --- /dev/null +++ b/Docs/Security.md @@ -0,0 +1,144 @@ +# Security Model + +How ConnectWiseAutomateAgent handles SSL certificates, credential encryption, and sensitive data in logs. + +This module manages a privileged Windows agent — it requires administrator access and interacts with services, registry keys, and network downloads. The security design balances practical MSP deployment needs (self-signed certs, legacy encryption, restricted networks) with defense-in-depth principles. + +--- + +## SSL Certificate Validation + +### The Problem + +Many MSP ConnectWise Automate servers use self-signed certificates, internal CA certificates, or certificates where the Common Name (CN) or Subject Alternative Name (SAN) doesn't match the hostname used to connect. A strict SSL policy would break most real-world deployments. + +### Graduated Trust Model + +Rather than bypassing all certificate validation (the legacy approach), the module registers a compiled C# callback with three tiers of graduated trust: + +| Scenario | Behavior | Rationale | +| --- | --- | --- | +| **IP address target** | Auto-bypass | IP addresses cannot have properly signed certificates (no CA will issue a cert for an IP). This is always safe to bypass. | +| **Hostname name mismatch** | Tolerated | The certificate is from a trusted CA but the CN/SAN doesn't match the hostname. Common when servers are accessed by IP, CNAME, or internal hostname that differs from the cert. | +| **Chain/trust errors on hostname** | **Rejected** | The certificate is self-signed or from an untrusted CA on a hostname URL. This is the only case that blocks by default. | + +The callback is compiled via `Add-Type` in `Initialize-CWAANetworking` (see [private function docs](Help/Private/Initialize-CWAANetworking.md)). Because compiled .NET types cannot be unloaded from an AppDomain, the callback persists for the lifetime of the PowerShell process — even across module re-imports. + +### -SkipCertificateCheck (Full Bypass) + +When the graduated model is insufficient (e.g., self-signed certificate on a hostname URL with no trusted chain), pass `-SkipCertificateCheck` to any networking function: + +```powershell +Install-CWAA -Server 'automate.example.com' -LocationID 1 -InstallerToken 'MyToken' -SkipCertificateCheck +``` + +This sets `[ServerCertificateValidationCallback]::SkipAll = $True`, which bypasses all certificate validation for the remainder of the PowerShell session. A warning is emitted on first use. + +**This affects ALL HTTPS connections in the session**, not just Automate operations. Use it only when necessary, and consider running the operation in an isolated PowerShell session. + +Functions that accept `-SkipCertificateCheck`: `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`. See [Common Parameters](CommonParameters.md#-skipcertificatecheck) for the full reference. + +### PowerShell 7+ Compatibility + +On PowerShell 7+ (`.NET 6+`), `System.Net.ServicePointManager` triggers `SYSLIB0014` obsolescence warnings. The module wraps the C# source with `#pragma warning disable SYSLIB0014` to suppress these. `WebClient` and `WebProxy` are similarly deprecated but remain the only option compatible with PowerShell 3.0-5.1 (`.NET Framework`). + +--- + +## Agent Credential Encryption (TripleDES) + +### Why TripleDES? + +The ConnectWise Automate agent stores several credentials in the Windows registry using TripleDES encryption with an MD5-derived key: + +- `ServerPasswordString` — server authentication credential +- `PasswordString` — agent-specific credential (encrypted with `ServerPasswordString` as the key) +- Proxy credentials (`ProxyUsername`, `ProxyPassword`) + +**This encryption scheme is required by the Automate agent for interoperability.** The module did not choose TripleDES — it matches the agent's format so it can read and write values the agent understands. + +### How It Works + +| Component | Value | +| --- | --- | +| **Algorithm** | TripleDES (168-bit key) | +| **Key derivation** | MD5 hash of the key string | +| **Initialization Vector** | Fixed 8-byte IV | +| **Encoding** | Base64 (input and output) | +| **Default key** | `'Thank you for using LabTech.'` | + +The functions `ConvertFrom-CWAASecurity` (decrypt) and `ConvertTo-CWAASecurity` (encrypt) implement this scheme. Crypto objects are disposed in `Finally` blocks with a `Dispose()`/`Clear()` fallback for older .NET runtimes that don't support `Dispose()`. + +### Usage + +```powershell +# Decrypt a value from the agent's registry +$decrypted = ConvertFrom-CWAASecurity -InputString $encryptedRegistryValue + +# Decrypt with a custom key (e.g., using the agent's ServerPassword as key) +$agentPassword = ConvertFrom-CWAASecurity -InputString $passwordString -Key $serverPassword + +# Encrypt a value for writing back to the registry +$encrypted = ConvertTo-CWAASecurity -InputString 'plain text value' +``` + +If decryption fails with the provided key and `-Force` is enabled (default), `ConvertFrom-CWAASecurity` automatically tries alternate key values. + +--- + +## Credential Redaction in Logs + +The module never logs credential values in plain text. When debug or verbose output needs to reference a credential (for troubleshooting proxy changes, comparing server passwords, etc.), it uses a private helper function that produces a SHA256 hash prefix: + +| Input | Output | +| --- | --- | +| Non-empty string | `[SHA256:a1b2c3d4]` (first 8 hex characters of the SHA256 hash) | +| Null or empty string | `[EMPTY]` | + +This format logs that a credential is present and whether it changed between operations (same hash = same value), without exposing the actual content. + +Example debug output: +``` +Set-CWAAProxy: ProxyPassword changed from [SHA256:7f83b162] to [SHA256:ef2d127d] +``` + +--- + +## Authentication: InstallerToken vs ServerPassword + +### InstallerToken (Recommended) + +The modern authentication method for agent deployment. Tokens are generated in the Automate console and are: + +- **Scoped** — can be limited to specific locations or clients +- **Revocable** — can be invalidated without changing server-wide settings +- **URL-based** — the installer is downloaded via `Deployment.aspx?InstallerToken=` + +```powershell +Install-CWAA -Server 'automate.example.com' -LocationID 1 -InstallerToken 'abc123def456' +``` + +### ServerPassword (Legacy) + +The legacy authentication method. A single password configured at the server level: + +- **Server-wide** — one password for all deployments +- **Not revocable** — changing it affects all future deployments +- **MSI property** — passed as `SERVERPASS=` during installation + +```powershell +Install-CWAA -Server 'automate.example.com' -LocationID 1 -ServerPassword 'LegacyPassword' +``` + +**Always prefer InstallerToken.** ServerPassword is supported for backward compatibility with older Automate server versions and existing deployment scripts. + +--- + +## Vulnerability Awareness + +`Install-CWAA` checks the Automate server version during installation. If the server reports a version below **v200.197** (the June 2020 security patch), a warning is emitted: + +``` +WARNING: Automate server version X.Y is below v200.197. This version may have known security vulnerabilities. Consider updating the Automate server. +``` + +This is informational only — the installation proceeds. The version check uses the same `/LabTech/Agent.aspx` response used for server reachability validation. diff --git a/Docs/Set-CWAALogLevel.md b/Docs/Set-CWAALogLevel.md deleted file mode 100644 index 0090000..0000000 --- a/Docs/Set-CWAALogLevel.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Set-CWAALogLevel - -## SYNOPSIS -This function will restart the LabTech Services. - -## SYNTAX - -``` -Set-CWAALogLevel [[-Level] ] [] -``` - -## DESCRIPTION -{{ Fill in the Description }} - -## EXAMPLES - -### Example 1 -``` -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -Level -{{ Fill Level Description }} - -```yaml -Type: Object -Parameter Sets: (All) -Aliases: -Accepted values: Normal, Verbose - -Required: False -Position: 0 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -### None -## OUTPUTS - -### System.Object -## NOTES -Version: 1.3 Author: Chris Taylor Website: labtechconsulting.com Creation Date: 3/14/2016 Purpose/Change: Initial script development - -Update Date: 6/1/2017 Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/13/2018 Purpose/Change: Added additional debugging output, support for ShouldProcess (-Confirm, -WhatIf) - -Update Date: 3/21/2018 Purpose/Change: Removed ErrorAction Override - -## RELATED LINKS diff --git a/Docs/Start-CWAA.md b/Docs/Start-CWAA.md deleted file mode 100644 index e512415..0000000 --- a/Docs/Start-CWAA.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Start-CWAA - -## SYNOPSIS -This function will start the LabTech Services. - -## SYNTAX - -``` -Start-CWAA [-WhatIf] [-Confirm] [] -``` - -## DESCRIPTION -This function will verify that the LabTech services are present. -It will then check for any process that is using the LTTray port (Default 42000) and kill it. -Next it will start the services. - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: wi - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Confirm -Prompts you for confirmation before running the cmdlet. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: cf - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.5 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 5/11/2017 -Purpose/Change: added check for non standard port number and set services to auto start - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 12/14/2017 -Purpose/Change: Will increment the tray port if a conflict is detected. - -Update Date: 2/1/2018 -Purpose/Change: Added support for -WhatIf. -Added Service Control Command to request agent check-in immediately after startup. - -Update Date: 3/21/2018 -Purpose/Change: Removed ErrorAction Override - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Stop-CWAA.md b/Docs/Stop-CWAA.md deleted file mode 100644 index d573be4..0000000 --- a/Docs/Stop-CWAA.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Stop-CWAA - -## SYNOPSIS -This function will stop the LabTech Services. - -## SYNTAX - -``` -Stop-CWAA [-WhatIf] [-Confirm] [] -``` - -## DESCRIPTION -This function will verify that the LabTech services are present then attempt to stop them. -It will then check for any remaining LabTech processes and kill them. - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -WhatIf -Shows what would happen if the cmdlet runs. -The cmdlet is not run. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: wi - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -Confirm -Prompts you for confirmation before running the cmdlet. - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: cf - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.3 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 3/12/2018 -Purpose/Change: Updated Support for ShouldProcess to enable -Confirm and -WhatIf parameters. - -Update Date: 3/21/2018 -Purpose/Change: Removed ErrorAction Override - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Test-CWAAPort.md b/Docs/Test-CWAAPort.md deleted file mode 100644 index 554c8e9..0000000 --- a/Docs/Test-CWAAPort.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -external help file: ConnectWiseAutomateAgent-help.xml -Module Name: ConnectWiseAutomateAgent -online version: http://labtechconsulting.com -schema: 2.0.0 ---- - -# Test-CWAAPort - -## SYNOPSIS -This function will attempt to connect to all required TCP ports. - -## SYNTAX - -``` -Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [] -``` - -## DESCRIPTION -The function will confirm the LTTray port is available locally. -It will then test required TCP ports to the Server. - -## EXAMPLES - -### Example 1 -```powershell -PS C:\> {{ Add example code here }} -``` - -{{ Add example description here }} - -## PARAMETERS - -### -Server -This is the URL to your LabTech server. -Example: https://automate.domain.com -If no server is provided the function will use Get-CWAAInfo to -get the server address. -If it is unable to find LT currently installed -it will try calling Get-CWAAInfoBackup. - -```yaml -Type: String[] -Parameter Sets: (All) -Aliases: - -Required: False -Position: 1 -Default value: None -Accept pipeline input: True (ByPropertyName, ByValue) -Accept wildcard characters: False -``` - -### -TrayPort -This is the port LTSvc.exe listens on for communication with LTTray. -It will be checked to verify it is available. -If not provided the -default port will be used (42000). - -```yaml -Type: Int32 -Parameter Sets: (All) -Aliases: - -Required: False -Position: 2 -Default value: 0 -Accept pipeline input: True (ByPropertyName) -Accept wildcard characters: False -``` - -### -Quiet -This will return a boolean for connectivity status to the Server - -```yaml -Type: SwitchParameter -Parameter Sets: (All) -Aliases: - -Required: False -Position: Named -Default value: False -Accept pipeline input: True (ByPropertyName) -Accept wildcard characters: False -``` - -### CommonParameters -This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). - -## INPUTS - -## OUTPUTS - -## NOTES -Version: 1.6 -Author: Chris Taylor -Website: labtechconsulting.com -Creation Date: 3/14/2016 -Purpose/Change: Initial script development - -Update Date: 5/11/2017 -Purpose/Change: Quiet feature - -Update Date: 6/1/2017 -Purpose/Change: Updates for better overall compatibility, including better support for PowerShell V2 - -Update Date: 6/10/2017 -Purpose/Change: Updates for pipeline input, support for multiple servers - -Update Date: 8/24/2017 -Purpose/Change: Update to use Clear-Variable. - -Update Date: 8/29/2017 -Purpose/Change: Added Server Address Format Check - -Update Date: 2/13/2018 -Purpose/Change: Added -TrayPort parameter. - -## RELATED LINKS - -[http://labtechconsulting.com](http://labtechconsulting.com) - diff --git a/Docs/Troubleshooting.md b/Docs/Troubleshooting.md new file mode 100644 index 0000000..a10c305 --- /dev/null +++ b/Docs/Troubleshooting.md @@ -0,0 +1,409 @@ +# Troubleshooting Guide + +Symptom-based reference for diagnosing and resolving ConnectWise Automate agent issues. Each section starts with the symptom, lists diagnostic commands, and provides resolution steps. + +For a quick all-in-one diagnostic, run the [Troubleshooting-QuickDiagnostic.ps1](../Examples/Troubleshooting-QuickDiagnostic.ps1) example script. + +--- + +## Quick Diagnostic + +Run this one-liner for a snapshot of agent health: + +```powershell +Get-CWAAInfo | Format-List ID, Server, LocationID, LastSuccessStatus, LastContact, LastHeartbeat +``` + +For a more thorough assessment: + +```powershell +Test-CWAAHealth -TestServerConnectivity | Format-List +``` + +--- + +## Agent Not Appearing in Automate Console + +The agent is installed but does not show up or appears offline in the Automate server. + +### Diagnose + +```powershell +# Is the agent installed? +Get-CWAAInfo | Format-List ID, Server, LocationID + +# Are services running? +Get-Service LTService, LTSvcMon -ErrorAction SilentlyContinue | Format-Table Name, Status + +# Can the agent reach the server? +Test-CWAAServerConnectivity | Format-List + +# Are the required ports open? +Test-CWAAPort -Server 'automate.example.com' | Format-Table +``` + +### Common Causes + +- **Services not running:** `Restart-CWAA` +- **Wrong server URL:** Check `(Get-CWAAInfo).Server` against your expected URL +- **Firewall blocking ports:** TCP 70, 80, 443, 8002 must be open to the server. Run `Test-CWAAPort` to verify. +- **Agent not registered:** If `ID` is empty, the agent never completed registration. Run `Redo-CWAA` to reinstall. + +### Resolution + +```powershell +# Restart services +Restart-CWAA + +# If services won't stay running, reinstall +Redo-CWAA -Server 'automate.example.com' -LocationID 1 -InstallerToken 'MyToken' +``` + +--- + +## Agent Services Keep Stopping + +`LTService` or `LTSvcMon` repeatedly stop after being started. + +### Diagnose + +```powershell +# Check current service state +Get-Service LTService, LTSvcMon | Format-Table Name, Status, StartType + +# Check recent agent errors +Get-CWAAError -Days 1 | Select-Object -First 10 + +# Check probe errors +Get-CWAAProbeError -Days 1 | Select-Object -First 10 + +# Check Windows Event Log for module events +Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'ConnectWiseAutomateAgent' } -MaxEvents 20 -ErrorAction SilentlyContinue | Format-Table TimeCreated, Id, Message -Wrap +``` + +### Common Causes + +- **Corrupt installation:** Files missing or damaged in `C:\Windows\LTSVC` +- **Registry corruption:** Agent configuration values missing or invalid +- **Conflicting software:** Security software blocking agent processes + +### Resolution + +```powershell +# Try restart first +Restart-CWAA + +# If it keeps failing, repair (escalating remediation) +Repair-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +--- + +## Agent Offline / Not Checking In + +The agent appears in the Automate console but shows as offline. + +### Diagnose + +```powershell +# When did the agent last check in? +$info = Get-CWAAInfo +Write-Host "Last Contact: $($info.LastContact)" +Write-Host "Last Heartbeat: $($info.LastHeartbeat)" +Write-Host "Last Success: $($info.LastSuccessStatus)" + +# Full health assessment +Test-CWAAHealth -TestServerConnectivity +``` + +### Interpreting Health Check Results + +| Property | Healthy Value | Meaning | +| --- | --- | --- | +| `AgentInstalled` | `True` | LTService service exists | +| `ServicesRunning` | `True` | LTService and LTSvcMon are running | +| `LastContactRecent` | `True` | LastContact within the threshold | +| `ServerReachable` | `True` | HTTPS connection to server succeeded | + +### Resolution + +```powershell +# If services are running but not checking in — force a status update +Invoke-CWAACommand -Command 'Send Status' + +# If server unreachable — check network/firewall +Test-CWAAPort -Server 'automate.example.com' + +# If nothing works — full reinstall +Redo-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +--- + +## Installation Fails + +`Install-CWAA` or `Redo-CWAA` fails during agent deployment. + +### Diagnose + +```powershell +# Run installation with verbose output for full diagnostics +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 -Verbose -Debug + +# Check if a previous installation exists +Get-CWAAInfo -ErrorAction SilentlyContinue + +# Check installer temp directory +Get-ChildItem "$env:windir\Temp\LabTech" -ErrorAction SilentlyContinue +``` + +### Common Causes + +| Symptom | Cause | Fix | +| --- | --- | --- | +| "Needs to be ran as Administrator" | Not elevated | Run PowerShell as Administrator | +| "Agent already installed" | Existing agent present | Use `-Force` or `Uninstall-CWAA` first | +| Download integrity check failed | Corrupt/incomplete download | Check network, proxy, and firewall. Retry. | +| "Unable to download" | Server unreachable | Verify server URL, check `Test-CWAAServerConnectivity` | +| MSI execution failed | .NET 3.5 missing or MSI conflict | Check `Add-WindowsFeature NET-Framework-Core` or use `-SkipDotNet` | +| Server version warning | Server below v200.197 | Update the Automate server (security concern) | + +### Resolution + +```powershell +# Clean up failed installation artifacts, then retry +Uninstall-CWAA -Server 'automate.example.com' -Force +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +--- + +## TrayPort Conflicts + +The agent cannot bind to its local communication port (default: 42000). + +### Diagnose + +```powershell +# Check which TrayPort the agent is configured for +Get-CWAAInfo | Select-Object -ExpandProperty TrayPort + +# Test if ports 42000-42009 are available +Test-CWAAPort | Format-Table +``` + +### About TrayPort + +The Automate agent uses a local TCP port (42000-42009) for communication between the `LTService` service and the system tray icon. During installation, `Install-CWAA` automatically scans ports 42000-42009 and picks the first available one. + +### Resolution + +If another application is occupying the TrayPort: + +```powershell +# Find what is using the port +netstat -ano | Select-String ':42000' + +# Reinstall with a specific port +Redo-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 -TrayPort 42001 +``` + +--- + +## Proxy Issues + +The agent cannot communicate through a network proxy. + +### Diagnose + +```powershell +# View current proxy configuration +Get-CWAAProxy | Format-List + +# Test server connectivity (will use configured proxy) +Test-CWAAServerConnectivity +``` + +See [ProxyConfiguration.ps1](../Examples/ProxyConfiguration.ps1) for a step-by-step proxy setup walkthrough. + +### Common Causes + +- **No proxy configured:** Agent uses direct connection but network requires proxy +- **Wrong proxy URL:** Typo or outdated proxy address +- **Credential issue:** Proxy requires authentication but credentials are missing or expired + +### Resolution + +```powershell +# Auto-detect system proxy (reads from IE/WinHTTP settings) +Set-CWAAProxy -DetectProxy + +# Or set manually +Set-CWAAProxy -ProxyServerURL 'proxy.example.com:8080' -ProxyUsername 'user' -ProxyPassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + +# Clear proxy if misconfigured +Set-CWAAProxy -ResetProxy +``` + +--- + +## WOW64 / 32-bit vs 64-bit Mismatches + +Agent registry keys or files are missing or inconsistent between 32-bit and 64-bit views. + +### Background + +On 64-bit Windows, 32-bit PowerShell sees a different registry view (`HKLM:\SOFTWARE\Wow6432Node\LabTech\Service`) than 64-bit PowerShell (`HKLM:\SOFTWARE\LabTech\Service`). The Automate agent is a 32-bit application that writes to the WOW64 node. + +### Diagnose + +```powershell +# Check which PowerShell architecture you are running +[IntPtr]::Size # 4 = 32-bit, 8 = 64-bit + +# Check if WOW64 is active +$env:PROCESSOR_ARCHITEW6432 # Non-empty = running 32-bit on 64-bit OS +``` + +### Module Behavior + +- **Module mode:** If imported in 32-bit PowerShell on a 64-bit OS, the module emits a warning. Re-import in 64-bit PowerShell. +- **Single-file mode:** The script automatically relaunches under the native 64-bit PowerShell host via `Initialize-CWAA`. + +### Resolution + +Always run ConnectWiseAutomateAgent from a 64-bit PowerShell session: + +```powershell +# Launch 64-bit PowerShell explicitly +%SystemRoot%\SysNative\WindowsPowerShell\v1.0\powershell.exe +``` + +--- + +## Certificate Errors During Install/Update + +SSL/TLS errors when downloading the agent installer or communicating with the server. + +### Diagnose + +```powershell +# Test basic HTTPS connectivity +Test-CWAAServerConnectivity -Server 'automate.example.com' + +# Try with verbose output to see SSL callback details +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 -Verbose -Debug +``` + +### About the SSL Trust Model + +The module uses a graduated trust model (see [Security Model](Security.md#ssl-certificate-validation)): + +1. IP addresses — auto-bypass (always works) +2. Hostname name mismatches — tolerated +3. Chain/trust errors — **blocked by default** + +### Resolution + +If you get certificate errors with a hostname URL and a self-signed certificate: + +```powershell +# Use -SkipCertificateCheck to bypass all validation for this session +Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 -SkipCertificateCheck +``` + +Or connect by IP address (auto-bypassed): + +```powershell +Install-CWAA -Server '10.0.0.50' -InstallerToken 'MyToken' -LocationID 1 +``` + +--- + +## Advanced Diagnostics + +### Increase Log Verbosity + +```powershell +# Set agent log level to Verbose (1 = Normal, 2 = Verbose) +Set-CWAALogLevel -Level 2 + +# After reproducing the issue, read the detailed logs +Get-CWAAError -Days 1 + +# Reset to normal verbosity +Set-CWAALogLevel -Level 1 +``` + +### Read Structured Errors + +```powershell +# Agent errors (from C:\Windows\LTSVC\errors.txt) +Get-CWAAError -Days 7 | Format-Table Time, Source, Message -Wrap + +# Probe errors (from C:\Windows\LTSVC\Probes\*.txt) +Get-CWAAProbeError -Days 7 +``` + +### Compare Current vs Backup Settings + +```powershell +# Current agent config +$current = Get-CWAAInfo + +# Last backed-up config (from before uninstall/reset) +$backup = Get-CWAAInfoBackup + +# Compare key fields +'Server', 'LocationID', 'ID', 'Password' | ForEach-Object { + [PSCustomObject]@{ + Property = $_ + Current = $current.$_ + Backup = $backup.$_ + Match = $current.$_ -eq $backup.$_ + } +} | Format-Table +``` + +### Audit Event Log + +```powershell +# All module events +Get-WinEvent -FilterHashtable @{ + LogName = 'Application' + ProviderName = 'ConnectWiseAutomateAgent' +} -MaxEvents 50 -ErrorAction SilentlyContinue | Format-Table TimeCreated, Id, LevelDisplayName, Message -Wrap + +# Filter by category (see event ID ranges) +# 1000-1039: Installation +# 2000-2029: Service Control +# 3000-3069: Configuration +# 4000-4039: Health/Monitoring +``` + +--- + +## Automated Remediation + +### One-Time Repair + +```powershell +Repair-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 +``` + +`Repair-CWAA` assesses health and escalates: restart services, then reinstall if the agent has been offline beyond the threshold. + +### Scheduled Health Checks + +```powershell +# Register a recurring health check (runs every 60 minutes by default) +Register-CWAAHealthCheckTask -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 + +# Check task status +Get-ScheduledTask -TaskName 'CWAA Health Check' -ErrorAction SilentlyContinue + +# Remove when no longer needed +Unregister-CWAAHealthCheckTask +``` + +See [HealthCheck-Monitoring.ps1](../Examples/HealthCheck-Monitoring.ps1) for a complete walkthrough. diff --git a/Examples/AgentInstall.ps1 b/Examples/AgentInstall.ps1 index 8dc2b4b..b1e5386 100644 --- a/Examples/AgentInstall.ps1 +++ b/Examples/AgentInstall.ps1 @@ -1,18 +1,28 @@ $InstallParameters = @{ - Server = 'automate.christaylor.codes' + Server = 'automate.example.com' LocationID = 1 InstallerToken = 'MyGeneratedInstallerToken' } # ^^ This info is sensitive take precautions to secure it ^^ +# ============================================================================== +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# ============================================================================== + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { $Module = 'ConnectWiseAutomateAgent' try { Update-Module $Module -ErrorAction Stop } - catch { - Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/Initialize-PSGallery/main/PSGalleryHelper.ps1' | Invoke-Expression - Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck - } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } Get-Module $Module -ListAvailable | Sort-Object Version -Descending | @@ -20,6 +30,8 @@ try { Import-Module *>$null } catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/AgentInstallWithHealthCheck.ps1 b/Examples/AgentInstallWithHealthCheck.ps1 new file mode 100644 index 0000000..6cc419e --- /dev/null +++ b/Examples/AgentInstallWithHealthCheck.ps1 @@ -0,0 +1,110 @@ +# ============================================================================== +# AgentInstallWithHealthCheck.ps1 +# +# Installs the ConnectWise Automate agent and registers a recurring scheduled +# task that automatically monitors agent health and repairs it when needed. +# +# Usage: +# 1. Fill in $InstallParameters below with your Automate server details. +# 2. Run this script elevated (Administrator) on target machines. +# 3. The agent installs, then a scheduled task is created that runs every +# 6 hours as SYSTEM to check agent health and self-heal if necessary. +# +# What the health check does (Repair-CWAA): +# - If the agent hasn't checked in for 2+ hours -> restarts services +# - If offline for 120+ hours after restart -> reinstalls the agent +# - If no agent is found and Server/LocationID -> performs a fresh install +# were provided +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - Administrator privileges (for agent install and scheduled task creation) +# - Internet access to the Automate server and PowerShell Gallery (or use +# the single-file fallback) +# ============================================================================== + +# --- Configuration ----------------------------------------------------------- + +$InstallParameters = @{ + Server = 'automate.example.com' + LocationID = 1 + InstallerToken = 'YourGeneratedInstallerToken' +} +# ^^ This info is sensitive -- take precautions to secure it ^^ + +$HealthCheckIntervalHours = 6 # How often the health check runs (default: 6) +$TaskName = 'CWAAHealthCheck' # Scheduled task name (default: CWAAHealthCheck) + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null +} +catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# --- Step 1: Install the Agent ----------------------------------------------- + +Write-Host 'Installing the ConnectWise Automate agent...' -ForegroundColor Cyan + +# Redo-CWAA removes any existing agent before installing fresh. +# Use Install-CWAA instead if you know no agent is present. +Redo-CWAA @InstallParameters + +# Verify installation +$agentInfo = Get-CWAAInfo -ErrorAction SilentlyContinue +if ($agentInfo -and ($agentInfo | Select-Object -ExpandProperty 'ID' -ErrorAction SilentlyContinue) -ge 1) { + Write-Host "Agent installed. ID: $($agentInfo.ID), Location: $($agentInfo.LocationID)" -ForegroundColor Green +} +else { + Write-Warning 'Agent may not have registered yet. The health check task will monitor and repair if needed.' +} + +# --- Step 2: Register the Health Check Scheduled Task ------------------------- + +Write-Host "`nRegistering health check scheduled task..." -ForegroundColor Cyan + +$taskParameters = @{ + InstallerToken = $InstallParameters.InstallerToken + Server = $InstallParameters.Server + LocationID = $InstallParameters.LocationID + TaskName = $TaskName + IntervalHours = $HealthCheckIntervalHours + Force = $true +} +Register-CWAAHealthCheckTask @taskParameters + +Write-Host "`nSetup complete." -ForegroundColor Green +Write-Host " Agent: Installed and running" +Write-Host " Health check: '$TaskName' runs every $HealthCheckIntervalHours hours as SYSTEM" +Write-Host " Event log: Application log, source 'ConnectWiseAutomateAgent'" + +# --- Optional: Verify Health Now ---------------------------------------------- + +# Uncomment the lines below to run an immediate health check after install: +# +# Write-Host "`nRunning initial health check..." -ForegroundColor Cyan +# Test-CWAAHealth | Format-List diff --git a/Examples/GPOScheduledTaskDeployment.ps1 b/Examples/GPOScheduledTaskDeployment.ps1 new file mode 100644 index 0000000..710296b --- /dev/null +++ b/Examples/GPOScheduledTaskDeployment.ps1 @@ -0,0 +1,222 @@ +# ============================================================================== +# GPOScheduledTaskDeployment.ps1 +# +# Deploys the ConnectWise Automate agent via a GPO-delivered scheduled task. +# This is the recommended approach for mass deployment across a domain. +# +# How it works: +# 1. A GPO creates a scheduled task on domain machines that calls this script +# with -Server, -LocationID, and -InstallerToken parameters. +# 2. This script installs the agent if missing (or repairs if misconfigured). +# 3. It then registers a recurring health check task (Register-CWAAHealthCheckTask) +# that runs Repair-CWAA every 6 hours to keep the agent connected. +# 4. On subsequent GPO runs, the script updates the health check task if the +# InstallerToken has changed (supports monthly token rotation). +# +# GPO Setup: +# Computer Configuration > Preferences > Control Panel Settings > +# Scheduled Tasks > New > Scheduled Task (At least Windows 7) +# +# General tab: +# - Action: Create or Update +# - When running the task, use the following user account: NT AUTHORITY\SYSTEM +# - Run whether user is logged on or not +# - Run with highest privileges +# +# Triggers tab: +# - At startup, with a 5-minute delay to allow network readiness +# +# Actions tab: +# - Start a program +# - Program/script: C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe +# - Add arguments: +# -ExecutionPolicy Bypass -NoProfile -File "\\domain.local\NETLOGON\Deploy-CWAAAgent.ps1" -Server "automate.example.com" -LocationID 1 -InstallerToken "YourToken" +# +# Conditions tab: +# - Start only if the following network connection is available: Any connection +# +# Token rotation: +# When you generate a new InstallerToken each month, update only the +# -InstallerToken value in the GPO scheduled task arguments. On the next GPO +# refresh (or reboot), this script detects the token change and updates the +# recurring health check task automatically. +# +# What Repair-CWAA does (called by the health check task every 6 hours): +# - Agent healthy and checking in -> no action +# - Agent hasn't checked in for 2+ hours -> restarts services, waits up to +# 2 minutes for recovery +# - Still offline after 120+ hours -> full reinstall via Redo-CWAA +# - Agent config unreadable -> uninstall and reinstall +# - Agent pointing at wrong server -> reinstall with correct server +# - Agent not installed at all -> fresh install from parameters +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - SYSTEM context (GPO scheduled tasks run as SYSTEM) +# - Network access to the Automate server and PowerShell Gallery (or the +# single-file fallback URL) +# +# Security considerations: +# - Store this script on a secured NETLOGON or SYSVOL share with restricted +# write permissions. Anyone who can modify this script can run code as +# SYSTEM on every domain machine. +# - InstallerToken is visible in the scheduled task arguments. Restrict GPO +# read access and NETLOGON permissions to Domain Computers and Domain +# Admins only. +# - Consider using GPO Item-Level Targeting to limit which OUs or groups +# receive the task. +# ============================================================================== + +[CmdletBinding()] +Param( + [Parameter(Mandatory = $true)] + [string]$Server, + + [Parameter(Mandatory = $true)] + [int]$LocationID, + + [Parameter(Mandatory = $true)] + [string]$InstallerToken, + + [int]$HealthCheckIntervalHours = 6, + + [string]$TaskName = 'CWAAHealthCheck' +) + +$ErrorActionPreference = 'Stop' + +# --- Logging ----------------------------------------------------------------- + +# Log to file since GPO scheduled tasks have no interactive console. +$LogFile = "$env:windir\Temp\CWAA-GPODeploy.log" + +function Write-Log { + param ([string]$Message) + $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" + Add-Content -Path $LogFile -Value $entry -ErrorAction SilentlyContinue +} + +Write-Log '--- CWAA GPO deployment script started ---' + +# --- Kill Duplicate Processes ------------------------------------------------ + +# Prevent overlapping runs if GPO fires while a previous run is still going. +Get-CimInstance Win32_Process | Where-Object { + $_.Name -eq 'powershell.exe' -and + $_.CommandLine -match 'GPOScheduledTaskDeployment|Deploy-CWAAAgent' -and + $_.ProcessId -ne $PID +} | ForEach-Object { + Write-Log "Killing duplicate process $($_.ProcessId)." + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue +} + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null + + Write-Log "Module '$Module' loaded." +} +catch { + Write-Log 'PowerShell Gallery unavailable. Falling back to single-file download.' + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# --- Install or Repair the Agent --------------------------------------------- + +$installParameters = @{ + Server = $Server + LocationID = $LocationID + InstallerToken = $InstallerToken +} + +$agentInfo = Get-CWAAInfo -ErrorAction SilentlyContinue +$agentId = $agentInfo | Select-Object -ExpandProperty 'ID' -ErrorAction SilentlyContinue + +if ($agentInfo -and $agentId -ge 1) { + # Agent is installed. Check if it's pointed at the correct server. + $currentServers = ($agentInfo.'Server Address' -split '\|') | Where-Object { $_ } + if ($currentServers -notcontains $Server) { + Write-Log "Agent installed but pointed at wrong server ($($currentServers -join ', ')). Reinstalling." + Redo-CWAA @installParameters + Write-Log 'Reinstall completed.' + } + else { + Write-Log "Agent already installed. ID: $agentId, Server: $($currentServers -join ', '). Skipping install." + } +} +else { + # Agent not installed or not registered. Install fresh. + Write-Log 'Agent not detected. Installing...' + try { + # Redo-CWAA removes leftover files from partial/corrupted installs before + # installing fresh -- safer than Install-CWAA for mass deployment. + Redo-CWAA @installParameters + Write-Log 'Installation completed.' + } + catch { + Write-Log "ERROR: Installation failed. Error: $($_.Exception.Message)" + exit 1 + } + + # Verify + $agentInfo = Get-CWAAInfo -ErrorAction SilentlyContinue + $agentId = $agentInfo | Select-Object -ExpandProperty 'ID' -ErrorAction SilentlyContinue + if ($agentId -ge 1) { + Write-Log "Agent registered. ID: $agentId, Location: $($agentInfo.LocationID)" + } + else { + Write-Log 'WARNING: Agent installed but has not registered yet. Health check task will monitor.' + } +} + +# --- Register / Update Health Check Task ------------------------------------- + +# Register-CWAAHealthCheckTask creates a recurring scheduled task that runs +# Repair-CWAA every $HealthCheckIntervalHours. If the task already exists and +# the InstallerToken matches, it's left alone. If the token has changed (monthly +# rotation), the task is automatically recreated with the new token. + +Write-Log 'Ensuring health check task is registered...' +try { + New-CWAABackup -ErrorAction SilentlyContinue + + $taskParameters = @{ + InstallerToken = $InstallerToken + Server = $Server + LocationID = $LocationID + TaskName = $TaskName + IntervalHours = $HealthCheckIntervalHours + } + Register-CWAAHealthCheckTask @taskParameters + + Write-Log "Health check task '$TaskName' is active (runs every $HealthCheckIntervalHours hours)." +} +catch { + Write-Log "WARNING: Failed to register health check task. Error: $($_.Exception.Message)" +} + +Write-Log '--- CWAA GPO deployment script finished ---' +exit 0 diff --git a/Examples/HealthCheck-Monitoring.ps1 b/Examples/HealthCheck-Monitoring.ps1 new file mode 100644 index 0000000..20cd4c1 --- /dev/null +++ b/Examples/HealthCheck-Monitoring.ps1 @@ -0,0 +1,245 @@ +# ============================================================================== +# HealthCheck-Monitoring.ps1 +# +# Demonstrates the full lifecycle of the ConnectWise Automate agent health check +# system: registering a scheduled task, running an immediate health assessment, +# inspecting the scheduled task, and unregistering it when no longer needed. +# +# Usage: +# 1. Fill in the configuration variables below. +# 2. Run this script elevated (Administrator) on the target machine. +# 3. The script registers a health check task, runs an immediate health test, +# and shows the task status. +# +# What the health check task does (Repair-CWAA): +# - Agent healthy and checking in -> no action +# - Agent hasn't checked in for 2+ hours -> restarts services, waits up to +# 2 minutes for recovery +# - Still offline after 120+ hours -> full reinstall via Redo-CWAA +# - Agent config unreadable -> uninstall and reinstall +# - Agent pointing at wrong server -> reinstall with correct server +# - Agent not installed at all -> fresh install from parameters +# +# Two registration modes: +# - Checkup mode: Only InstallerToken is required. The task monitors and +# repairs the existing agent but cannot perform a fresh install if the agent +# is completely missing. +# - Install mode: Server, LocationID, and InstallerToken are provided. The +# task can install the agent from scratch if it is removed. +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - Administrator privileges (for scheduled task creation and service access) +# ============================================================================== + +# --- Configuration ----------------------------------------------------------- + +$InstallerToken = 'YourGeneratedInstallerToken' +$Server = 'automate.example.com' # Only needed for Install mode +$LocationID = 1 # Only needed for Install mode +$TaskName = 'CWAAHealthCheck' # Scheduled task name +$HealthCheckInterval = 6 # Hours between health checks + +# ^^ Fill in the InstallerToken at minimum. Server and LocationID are needed +# only if you want the health check to be able to install a missing agent. ^^ + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null +} +catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# --- Step 1: Register the Health Check Task ----------------------------------- + +# Install mode: provides Server and LocationID so the task can install a missing +# agent from scratch. This is the recommended mode for deployment scripts. + +Write-Host '--- Registering Health Check Task (Install Mode) ---' -ForegroundColor Cyan + +$taskParameters = @{ + InstallerToken = $InstallerToken + Server = $Server + LocationID = $LocationID + TaskName = $TaskName + IntervalHours = $HealthCheckInterval + Force = $true +} + +try { + $registerResult = Register-CWAAHealthCheckTask @taskParameters -ErrorAction Stop + if ($registerResult.Created) { + Write-Host " Task '$TaskName' created successfully." -ForegroundColor Green + } + elseif ($registerResult.Updated) { + Write-Host " Task '$TaskName' updated (token or settings changed)." -ForegroundColor Yellow + } + else { + Write-Host " Task '$TaskName' already exists with the same configuration." -ForegroundColor DarkGray + } +} +catch { + Write-Host " ERROR: Failed to register health check task." -ForegroundColor Red + Write-Host " Detail: $($_.Exception.Message)" -ForegroundColor Red +} + +Write-Host '' + +# --- Alternative: Checkup Mode (Example) ------------------------------------- + +# Checkup mode: only InstallerToken is required. The task monitors the existing +# agent and can restart or reinstall it, but cannot perform a fresh install if +# the agent is completely removed. Uncomment to use instead of Install mode. + +# Write-Host '--- Registering Health Check Task (Checkup Mode) ---' -ForegroundColor Cyan +# +# $checkupParameters = @{ +# InstallerToken = $InstallerToken +# TaskName = $TaskName +# IntervalHours = $HealthCheckInterval +# } +# +# try { +# Register-CWAAHealthCheckTask @checkupParameters -ErrorAction Stop +# Write-Host " Task '$TaskName' registered in Checkup mode." -ForegroundColor Green +# } +# catch { +# Write-Host " ERROR: Failed to register task. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +# --- Step 2: Run an Immediate Health Test ------------------------------------- + +Write-Host '--- Running Immediate Health Check ---' -ForegroundColor Cyan + +try { + $healthResult = Test-CWAAHealth -TestServerConnectivity -ErrorAction Stop + + Write-Host " Agent Installed: $($healthResult.AgentInstalled)" + Write-Host " Services Running: $($healthResult.ServicesRunning)" + Write-Host " Last Contact: $($healthResult.LastContact)" + Write-Host " Last Heartbeat: $($healthResult.LastHeartbeat)" + Write-Host " Server Address: $($healthResult.ServerAddress)" + Write-Host " Server Reachable: $($healthResult.ServerReachable)" + + if ($healthResult.Healthy) { + Write-Host " Overall Healthy: True" -ForegroundColor Green + } + else { + Write-Host " Overall Healthy: False" -ForegroundColor Red + Write-Host '' + Write-Host ' The health check task will automatically remediate issues on its next run.' -ForegroundColor Yellow + } +} +catch { + Write-Host " ERROR: Health check failed. Detail: $($_.Exception.Message)" -ForegroundColor Red +} + +Write-Host '' + +# --- Step 3: Verify the Scheduled Task Exists --------------------------------- + +Write-Host '--- Verifying Scheduled Task ---' -ForegroundColor Cyan + +$taskExists = $false +try { + $null = schtasks /QUERY /TN $TaskName 2>$null + if ($LASTEXITCODE -eq 0) { + $taskExists = $true + } +} +catch { } + +if ($taskExists) { + Write-Host " Scheduled task '$TaskName' is registered." -ForegroundColor Green + + # Display task details using schtasks verbose output + try { + $taskInfo = schtasks /QUERY /TN $TaskName /V /FO LIST 2>$null + $nextRunLine = $taskInfo | Select-String -Pattern 'Next Run Time' | Select-Object -First 1 + $statusLine = $taskInfo | Select-String -Pattern '^Status' | Select-Object -First 1 + $lastRunLine = $taskInfo | Select-String -Pattern 'Last Run Time' | Select-Object -First 1 + + if ($statusLine) { Write-Host " $($statusLine.Line.Trim())" } + if ($lastRunLine) { Write-Host " $($lastRunLine.Line.Trim())" } + if ($nextRunLine) { Write-Host " $($nextRunLine.Line.Trim())" } + } + catch { + Write-Host ' (Unable to retrieve task details.)' -ForegroundColor DarkGray + } +} +else { + Write-Host " Scheduled task '$TaskName' was NOT found." -ForegroundColor Red + Write-Host ' Registration may have failed. Check the error output above.' -ForegroundColor Yellow +} + +Write-Host '' + +# --- Step 4: Unregister the Task (Example - Uncomment to Use) ---------------- + +# Uncomment the block below to remove the health check scheduled task. This +# stops automatic monitoring and remediation. The agent itself is not affected. + +# Write-Host '--- Unregistering Health Check Task ---' -ForegroundColor Cyan +# +# try { +# $unregisterResult = Unregister-CWAAHealthCheckTask -TaskName $TaskName -ErrorAction Stop +# if ($unregisterResult.Removed) { +# Write-Host " Task '$TaskName' has been removed." -ForegroundColor Green +# } +# else { +# Write-Host " Task '$TaskName' was not found or could not be removed." -ForegroundColor Yellow +# } +# } +# catch { +# Write-Host " ERROR: Failed to unregister task. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } +# +# # Verify removal +# $taskStillExists = $false +# try { +# $null = schtasks /QUERY /TN $TaskName 2>$null +# if ($LASTEXITCODE -eq 0) { $taskStillExists = $true } +# } +# catch { } +# +# if ($taskStillExists) { +# Write-Host " WARNING: Task '$TaskName' still exists after unregister attempt." -ForegroundColor Red +# } +# else { +# Write-Host " Confirmed: Task '$TaskName' no longer exists." -ForegroundColor Green +# } + +# --- Summary ------------------------------------------------------------------ + +Write-Host '--- Summary ---' -ForegroundColor Cyan +Write-Host " Health check task: '$TaskName'" +Write-Host " Interval: Every $HealthCheckInterval hours" +Write-Host " Mode: Install (can reinstall from Server/LocationID/InstallerToken)" +Write-Host '' +Write-Host 'The health check task runs as SYSTEM and logs to the Windows Event Log' -ForegroundColor DarkGray +Write-Host '(Application log, source ConnectWiseAutomateAgent).' -ForegroundColor DarkGray +Write-Host 'To remove the task, uncomment the unregister section at the end of this script.' -ForegroundColor DarkGray diff --git a/Examples/PipelineUsage.ps1 b/Examples/PipelineUsage.ps1 new file mode 100644 index 0000000..2b4acc5 --- /dev/null +++ b/Examples/PipelineUsage.ps1 @@ -0,0 +1,183 @@ +# ============================================================================== +# PipelineUsage.ps1 +# +# Demonstrates PowerShell pipeline patterns with ConnectWiseAutomateAgent +# functions. Shows how to chain commands, use pipeline input, filter results, +# and combine with Invoke-Command for multi-machine operations. +# +# Usage: +# 1. Uncomment and adapt the examples below for your environment. +# 2. Multi-machine examples require PowerShell Remoting (WinRM) configured +# on the target machines. +# 3. All examples assume the module is already imported. +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - Administrator privileges (for most operations) +# - PowerShell Remoting (for Invoke-Command examples) +# ============================================================================== + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null +} +catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# ============================================================================== +# EXAMPLES +# ============================================================================== + +# --- Example 1: Basic Pipeline — Select Specific Properties ------------------ +# +# Get-CWAAInfo returns a PSCustomObject with many properties. Use +# Select-Object to extract just what you need. + +# Get-CWAAInfo | Select-Object ID, Server, LocationID, LastSuccessStatus, LastContact + +# --- Example 2: Health Check to Conditional Action --------------------------- +# +# Test-CWAAHealth returns a structured object. Use its properties to +# decide what action to take. + +# $health = Test-CWAAHealth +# if ($health.AgentInstalled -and -not $health.ServicesRunning) { +# Write-Host "Agent installed but services stopped. Restarting..." +# Restart-CWAA +# } +# elseif (-not $health.AgentInstalled) { +# Write-Host "Agent not installed." +# } +# else { +# Write-Host "Agent healthy." +# } + +# --- Example 3: Filter Agent Errors by Pattern ------------------------------ +# +# Get-CWAAError returns structured error entries. Use Where-Object to +# filter for specific error types. + +# Get-CWAAError -Days 7 | Where-Object { $_.Message -match 'heartbeat|timeout|connection' } + +# --- Example 4: Multi-Machine Agent Inventory -------------------------------- +# +# Use Invoke-Command to gather agent information from multiple machines. +# Requires PowerShell Remoting (WinRM) on all targets. + +# $computers = 'PC-001', 'PC-002', 'PC-003' +# +# $inventory = Invoke-Command -ComputerName $computers -ScriptBlock { +# Import-Module ConnectWiseAutomateAgent -ErrorAction SilentlyContinue +# Get-CWAAInfo | Select-Object @{N='Computer'; E={$env:COMPUTERNAME}}, ID, Server, LocationID, LastSuccessStatus +# } -ErrorAction SilentlyContinue +# +# $inventory | Sort-Object Computer | Format-Table -AutoSize + +# --- Example 5: Export Agent Inventory to CSV -------------------------------- +# +# Combine Invoke-Command with Export-Csv for reporting. + +# $computers = (Get-ADComputer -Filter * -SearchBase 'OU=Workstations,DC=example,DC=com').Name +# +# Invoke-Command -ComputerName $computers -ScriptBlock { +# Import-Module ConnectWiseAutomateAgent -ErrorAction SilentlyContinue +# Get-CWAAInfo +# } -ErrorAction SilentlyContinue | +# Select-Object PSComputerName, ID, Server, LocationID, LastContact, LastSuccessStatus | +# Export-Csv 'AgentInventory.csv' -NoTypeInformation +# +# Write-Host "Exported inventory for $($computers.Count) machines to AgentInventory.csv" + +# --- Example 6: Fleet Health Assessment -------------------------------------- +# +# Check health across multiple machines and filter for unhealthy agents. + +# $computers = 'PC-001', 'PC-002', 'PC-003' +# +# $results = Invoke-Command -ComputerName $computers -ScriptBlock { +# Import-Module ConnectWiseAutomateAgent -ErrorAction SilentlyContinue +# Test-CWAAHealth +# } -ErrorAction SilentlyContinue +# +# Write-Host "`n--- Unhealthy Agents ---" +# $results | Where-Object { -not $_.Healthy } | +# Format-Table PSComputerName, AgentInstalled, ServicesRunning, LastContactRecent -AutoSize +# +# Write-Host "--- Summary ---" +# Write-Host "Total: $($results.Count) Healthy: $(($results | Where-Object Healthy).Count) Unhealthy: $(($results | Where-Object { -not $_.Healthy }).Count)" + +# --- Example 7: Backup Then Uninstall Pipeline ------------------------------- +# +# Chain operations sequentially for a safe removal workflow. + +# $serverUrl = 'https://automate.example.com' +# +# Write-Host 'Creating backup...' +# New-CWAABackup +# +# Write-Host 'Verifying backup...' +# $backup = Get-CWAAInfoBackup +# if ($backup.Server) { +# Write-Host "Backup verified (Server: $($backup.Server), ID: $($backup.ID))" +# Write-Host 'Proceeding with uninstall...' +# Uninstall-CWAA -Server $serverUrl +# } +# else { +# Write-Host 'Backup failed — aborting uninstall.' -ForegroundColor Red +# } + +# --- Example 8: ForEach-Object with Splatting -------------------------------- +# +# Deploy agents to multiple locations using splatting for clean parameter passing. + +# $deployments = @( +# @{ Server = 'https://automate.example.com'; LocationID = 1; InstallerToken = 'TokenForSiteA' } +# @{ Server = 'https://automate.example.com'; LocationID = 2; InstallerToken = 'TokenForSiteB' } +# @{ Server = 'https://automate.example.com'; LocationID = 3; InstallerToken = 'TokenForSiteC' } +# ) +# +# $deployments | ForEach-Object { +# $params = $_ +# Write-Host "Deploying to LocationID $($params.LocationID)..." +# Install-CWAA @params +# } + +# --- Example 9: Compare Current Settings to Backup -------------------------- +# +# Use calculated properties to build a comparison report. + +# $current = Get-CWAAInfo +# $backup = Get-CWAAInfoBackup +# +# 'Server', 'LocationID', 'ID', 'Version' | ForEach-Object { +# [PSCustomObject]@{ +# Property = $_ +# Current = $current.$_ +# Backup = $backup.$_ +# Match = $current.$_ -eq $backup.$_ +# } +# } | Format-Table -AutoSize diff --git a/Examples/ProxyConfiguration.ps1 b/Examples/ProxyConfiguration.ps1 new file mode 100644 index 0000000..e8da236 --- /dev/null +++ b/Examples/ProxyConfiguration.ps1 @@ -0,0 +1,191 @@ +# ============================================================================== +# ProxyConfiguration.ps1 +# +# Demonstrates how to view, configure, and clear proxy settings for the +# ConnectWise Automate agent using the CWAA module. +# +# Usage: +# 1. Run this script elevated (Administrator) on the target machine. +# 2. The script displays the current proxy settings, then shows example +# commands for setting and clearing proxy configuration. +# 3. To actually apply proxy changes, uncomment the relevant section below +# and fill in your proxy details. +# +# What this script covers: +# - Viewing current proxy settings with Get-CWAAProxy +# - Setting a proxy with URL, username, and password via Set-CWAAProxy +# - Auto-detecting system proxy settings with Set-CWAAProxy -DetectProxy +# - Clearing proxy settings with Set-CWAAProxy -ResetProxy +# - Verifying proxy changes after applying them +# +# How proxy settings work in the Automate agent: +# - Proxy URL, username, and password are stored encrypted in the agent's +# registry settings under HKLM:\SOFTWARE\LabTech\Service\Settings. +# - When Set-CWAAProxy detects a change, it stops the agent services, writes +# the new values, and restarts the services automatically. +# - Get-CWAAProxy reads and decrypts the stored values for display. +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - Administrator privileges (for registry and service access) +# ============================================================================== + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null +} +catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# --- Step 1: View Current Proxy Settings -------------------------------------- + +Write-Host '--- Current Proxy Settings ---' -ForegroundColor Cyan + +$currentProxy = $null +try { + $currentProxy = Get-CWAAProxy -ErrorAction Stop +} +catch { + Write-Host "Unable to read proxy settings. The agent may not be installed." -ForegroundColor Red + Write-Host "Detail: $($_.Exception.Message)" -ForegroundColor Red +} + +if ($currentProxy) { + Write-Host " Enabled: $($currentProxy.Enabled)" + Write-Host " Proxy URL: $($currentProxy.ProxyServerURL)" + Write-Host " Username: $($currentProxy.ProxyUsername)" + + if ($currentProxy.Enabled) { + Write-Host "`nA proxy is currently configured." -ForegroundColor Yellow + } + else { + Write-Host "`nNo proxy is currently configured." -ForegroundColor Green + } +} +else { + Write-Host ' No proxy information available.' -ForegroundColor DarkGray +} + +Write-Host '' + +# --- Step 2: Set Proxy (Example - Uncomment to Use) -------------------------- + +# To configure a proxy with authentication, uncomment and fill in the block +# below with your proxy server details. +# +# IMPORTANT: ProxyPassword must be passed as a SecureString. The example below +# converts a plain text password for simplicity. In production, consider using +# Read-Host -AsSecureString or a credential store for the password. + +# Write-Host '--- Setting Proxy ---' -ForegroundColor Cyan +# +# $ProxyUrl = 'proxy.example.com:8080' +# $ProxyUser = 'DOMAIN\proxyuser' +# $ProxyPass = ConvertTo-SecureString 'YourProxyPassword' -AsPlainText -Force +# +# try { +# Set-CWAAProxy -ProxyServerURL $ProxyUrl -ProxyUsername $ProxyUser -ProxyPassword $ProxyPass -ErrorAction Stop +# Write-Host "Proxy set to '$ProxyUrl' with user '$ProxyUser'." -ForegroundColor Green +# } +# catch { +# Write-Host "Failed to set proxy. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +# --- Step 3: Set Proxy Without Authentication (Example) ----------------------- + +# To configure a proxy that does not require credentials: + +# Write-Host '--- Setting Proxy (No Auth) ---' -ForegroundColor Cyan +# +# try { +# Set-CWAAProxy -ProxyServerURL 'proxy.example.com:8080' -ErrorAction Stop +# Write-Host 'Proxy set successfully (no authentication).' -ForegroundColor Green +# } +# catch { +# Write-Host "Failed to set proxy. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +# --- Step 4: Auto-Detect System Proxy (Example) ------------------------------ + +# DetectProxy reads the system proxy settings (IE/WinHTTP) and applies them to +# the Automate agent. This is useful in environments where proxy settings are +# pushed via GPO. + +# Write-Host '--- Auto-Detecting System Proxy ---' -ForegroundColor Cyan +# +# try { +# Set-CWAAProxy -DetectProxy -ErrorAction Stop +# Write-Host 'System proxy detection complete.' -ForegroundColor Green +# } +# catch { +# Write-Host "Failed to detect proxy. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +# --- Step 5: Clear / Reset Proxy (Example) ------------------------------------ + +# ResetProxy removes all proxy settings from the agent. The agent will connect +# directly to the Automate server without a proxy. + +# Write-Host '--- Clearing Proxy Settings ---' -ForegroundColor Cyan +# +# try { +# Set-CWAAProxy -ResetProxy -ErrorAction Stop +# Write-Host 'Proxy settings cleared.' -ForegroundColor Green +# } +# catch { +# Write-Host "Failed to clear proxy. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +# --- Step 6: Verify Proxy After Changes --------------------------------------- + +# After making proxy changes (uncomment one of the sections above), uncomment +# this verification step to confirm the new settings took effect. + +# Write-Host '' +# Write-Host '--- Verifying Proxy Settings ---' -ForegroundColor Cyan +# +# try { +# $updatedProxy = Get-CWAAProxy -ErrorAction Stop +# Write-Host " Enabled: $($updatedProxy.Enabled)" +# Write-Host " Proxy URL: $($updatedProxy.ProxyServerURL)" +# Write-Host " Username: $($updatedProxy.ProxyUsername)" +# +# if ($updatedProxy.Enabled) { +# Write-Host "`nProxy is now active." -ForegroundColor Green +# } +# else { +# Write-Host "`nProxy is not active (direct connection)." -ForegroundColor Green +# } +# } +# catch { +# Write-Host "Unable to verify proxy settings. Detail: $($_.Exception.Message)" -ForegroundColor Red +# } + +Write-Host '--- Proxy Configuration Script Complete ---' -ForegroundColor Cyan +Write-Host '' +Write-Host 'To apply proxy changes, edit this script and uncomment the relevant section.' -ForegroundColor DarkGray +Write-Host 'After changes, use Get-CWAAProxy to verify the new settings.' -ForegroundColor DarkGray diff --git a/Examples/Troubleshooting-QuickDiagnostic.ps1 b/Examples/Troubleshooting-QuickDiagnostic.ps1 new file mode 100644 index 0000000..4a415e1 --- /dev/null +++ b/Examples/Troubleshooting-QuickDiagnostic.ps1 @@ -0,0 +1,276 @@ +# ============================================================================== +# Troubleshooting-QuickDiagnostic.ps1 +# +# All-in-one diagnostic script for the ConnectWise Automate agent. Gathers +# agent configuration, health status, server connectivity, port tests, recent +# errors, and proxy settings into a single summary report. +# +# Usage: +# 1. Run this script elevated (Administrator) on the target machine. +# 2. Review the on-screen report to identify issues. +# 3. Optionally redirect output to a file for remote review: +# powershell -File Troubleshooting-QuickDiagnostic.ps1 > C:\Temp\CWAA-Diag.txt +# +# What this script checks: +# - Agent installation and registration (ID, server, location, last check-in) +# - Service health (LTService, LTSvcMon running status) +# - Server reachability (agent.aspx endpoint and server version) +# - Required TCP port connectivity (70, 80, 443, 8002, TrayPort) +# - Recent agent errors (last 5 entries from LTErrors.txt) +# - Proxy configuration (enabled, URL, username) +# +# Requirements: +# - Windows PowerShell 3.0 or later +# - Administrator privileges (for full registry and service access) +# ============================================================================== + +# --- Module Loading ---------------------------------------------------------- + +# SECURITY WARNING: The fallback method below uses Invoke-Expression to load +# code downloaded from the internet at runtime. This is convenient but carries +# inherent risk -- a compromised source or man-in-the-middle attack could +# execute arbitrary code on this machine. +# +# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# +# The Invoke-Expression fallback is provided ONLY for systems where the +# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). + +[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +try { + $Module = 'ConnectWiseAutomateAgent' + try { Update-Module $Module -ErrorAction Stop } + catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } + + Get-Module $Module -ListAvailable | + Sort-Object Version -Descending | + Select-Object -First 1 | + Import-Module *>$null +} +catch { + # WARNING: Invoke-Expression executes downloaded code. See security note above. + # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. + $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression +} + +# --- Diagnostic Banner ------------------------------------------------------- + +$diagnosticTimestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' +Write-Host '' +Write-Host '=====================================================================' -ForegroundColor Cyan +Write-Host ' ConnectWise Automate Agent - Quick Diagnostic Report' -ForegroundColor Cyan +Write-Host " Generated: $diagnosticTimestamp" -ForegroundColor Cyan +Write-Host " Computer: $env:COMPUTERNAME" -ForegroundColor Cyan +Write-Host '=====================================================================' -ForegroundColor Cyan +Write-Host '' + +# Track summary findings for the final report +$summaryFindings = @() + +# --- Step 1: Agent Information ------------------------------------------------ + +Write-Host '--- Agent Information ---' -ForegroundColor Yellow +$agentInfo = $null +try { + $agentInfo = Get-CWAAInfo -ErrorAction Stop +} +catch { + Write-Host " ERROR: Unable to read agent configuration. The agent may not be installed." -ForegroundColor Red + Write-Host " Detail: $($_.Exception.Message)" -ForegroundColor Red + $summaryFindings += 'FAIL: Agent configuration not readable' +} + +if ($agentInfo) { + $agentId = $agentInfo | Select-Object -ExpandProperty 'ID' -ErrorAction SilentlyContinue + $agentServer = ($agentInfo | Select-Object -ExpandProperty 'Server' -ErrorAction SilentlyContinue) -join ', ' + $agentLocationId = $agentInfo | Select-Object -ExpandProperty 'LocationID' -ErrorAction SilentlyContinue + $lastSuccessStatus = $agentInfo | Select-Object -ExpandProperty 'LastSuccessStatus' -ErrorAction SilentlyContinue + $heartbeatLastSent = $agentInfo | Select-Object -ExpandProperty 'HeartbeatLastSent' -ErrorAction SilentlyContinue + + Write-Host " Agent ID: $agentId" + Write-Host " Server: $agentServer" + Write-Host " Location ID: $agentLocationId" + Write-Host " Last Check-in: $lastSuccessStatus" + Write-Host " Last Heartbeat: $heartbeatLastSent" + + if ($agentId -ge 1) { + $summaryFindings += 'OK: Agent is installed and registered' + } + else { + $summaryFindings += 'WARN: Agent is installed but has not registered (ID < 1)' + } +} +else { + Write-Host ' No agent information available.' -ForegroundColor DarkGray +} + +Write-Host '' + +# --- Step 2: Health Check ----------------------------------------------------- + +Write-Host '--- Health Status ---' -ForegroundColor Yellow +try { + $healthResult = Test-CWAAHealth -ErrorAction Stop + Write-Host " Agent Installed: $($healthResult.AgentInstalled)" + Write-Host " Services Running: $($healthResult.ServicesRunning)" + Write-Host " Last Contact: $($healthResult.LastContact)" + Write-Host " Last Heartbeat: $($healthResult.LastHeartbeat)" + Write-Host " Server Address: $($healthResult.ServerAddress)" + + if ($healthResult.Healthy) { + Write-Host " Overall Healthy: $($healthResult.Healthy)" -ForegroundColor Green + $summaryFindings += 'OK: Agent is healthy' + } + else { + Write-Host " Overall Healthy: $($healthResult.Healthy)" -ForegroundColor Red + $summaryFindings += 'FAIL: Agent is NOT healthy' + } +} +catch { + Write-Host " ERROR: Health check failed. Detail: $($_.Exception.Message)" -ForegroundColor Red + $summaryFindings += 'FAIL: Health check could not complete' +} + +Write-Host '' + +# --- Step 3: Server Connectivity ---------------------------------------------- + +# Discover the server from agent config or backup for connectivity and port tests +$discoveredServer = $null +if ($agentInfo) { + $discoveredServer = ($agentInfo | Select-Object -ExpandProperty 'Server' -ErrorAction SilentlyContinue) | Select-Object -First 1 +} +if (-not $discoveredServer) { + try { + $discoveredServer = (Get-CWAAInfoBackup -ErrorAction SilentlyContinue | Select-Object -ExpandProperty 'Server' -ErrorAction SilentlyContinue) | Select-Object -First 1 + } + catch { } +} + +Write-Host '--- Server Connectivity ---' -ForegroundColor Yellow +if ($discoveredServer) { + try { + $connectivityResult = Test-CWAAServerConnectivity -Server $discoveredServer -ErrorAction Stop + Write-Host " Server: $($connectivityResult.Server)" + if ($connectivityResult.Available) { + Write-Host " Available: $($connectivityResult.Available)" -ForegroundColor Green + Write-Host " Version: $($connectivityResult.Version)" + $summaryFindings += "OK: Server '$discoveredServer' is reachable" + } + else { + Write-Host " Available: $($connectivityResult.Available)" -ForegroundColor Red + Write-Host " Error: $($connectivityResult.ErrorMessage)" -ForegroundColor Red + $summaryFindings += "FAIL: Server '$discoveredServer' is NOT reachable" + } + } + catch { + Write-Host " ERROR: Connectivity test failed. Detail: $($_.Exception.Message)" -ForegroundColor Red + $summaryFindings += 'FAIL: Server connectivity test could not complete' + } +} +else { + Write-Host ' No server could be discovered from agent config or backup.' -ForegroundColor DarkGray + Write-Host ' Skipping connectivity test.' -ForegroundColor DarkGray + $summaryFindings += 'SKIP: No server discovered for connectivity test' +} + +Write-Host '' + +# --- Step 4: Port Test -------------------------------------------------------- + +Write-Host '--- Port Connectivity ---' -ForegroundColor Yellow +if ($discoveredServer) { + try { + Test-CWAAPort -Server $discoveredServer -ErrorAction SilentlyContinue + $summaryFindings += 'INFO: Port test completed (review details above)' + } + catch { + Write-Host " ERROR: Port test failed. Detail: $($_.Exception.Message)" -ForegroundColor Red + $summaryFindings += 'FAIL: Port test could not complete' + } +} +else { + Write-Host ' No server discovered. Skipping port test.' -ForegroundColor DarkGray + $summaryFindings += 'SKIP: No server discovered for port test' +} + +Write-Host '' + +# --- Step 5: Recent Errors ---------------------------------------------------- + +Write-Host '--- Recent Agent Errors (last 5) ---' -ForegroundColor Yellow +try { + $recentErrors = Get-CWAAError -ErrorAction Stop | + Sort-Object Timestamp -Descending | + Select-Object -First 5 + + if ($recentErrors) { + foreach ($errorEntry in $recentErrors) { + $timestampDisplay = if ($errorEntry.Timestamp) { $errorEntry.Timestamp.ToString('yyyy-MM-dd HH:mm:ss') } else { '(unknown)' } + Write-Host " [$timestampDisplay] $($errorEntry.Message)" -ForegroundColor DarkGray + } + $summaryFindings += "INFO: $(@($recentErrors).Count) recent error(s) found in agent log" + } + else { + Write-Host ' No errors found in agent log.' -ForegroundColor Green + $summaryFindings += 'OK: No errors in agent log' + } +} +catch { + Write-Host " Unable to read error log. Detail: $($_.Exception.Message)" -ForegroundColor DarkGray + $summaryFindings += 'SKIP: Could not read agent error log' +} + +Write-Host '' + +# --- Step 6: Proxy Configuration ---------------------------------------------- + +Write-Host '--- Proxy Configuration ---' -ForegroundColor Yellow +try { + $proxyConfig = Get-CWAAProxy -ErrorAction SilentlyContinue + if ($proxyConfig) { + Write-Host " Enabled: $($proxyConfig.Enabled)" + Write-Host " Proxy URL: $($proxyConfig.ProxyServerURL)" + Write-Host " Proxy Username: $($proxyConfig.ProxyUsername)" + if ($proxyConfig.Enabled) { + $summaryFindings += "INFO: Proxy is enabled ($($proxyConfig.ProxyServerURL))" + } + else { + $summaryFindings += 'OK: No proxy configured' + } + } + else { + Write-Host ' No proxy information available.' -ForegroundColor DarkGray + $summaryFindings += 'OK: No proxy configured' + } +} +catch { + Write-Host " Unable to read proxy settings. Detail: $($_.Exception.Message)" -ForegroundColor DarkGray + $summaryFindings += 'SKIP: Could not read proxy settings' +} + +Write-Host '' + +# --- Summary Report ----------------------------------------------------------- + +Write-Host '=====================================================================' -ForegroundColor Cyan +Write-Host ' Summary' -ForegroundColor Cyan +Write-Host '=====================================================================' -ForegroundColor Cyan +foreach ($finding in $summaryFindings) { + if ($finding -match '^OK:') { + Write-Host " $finding" -ForegroundColor Green + } + elseif ($finding -match '^FAIL:') { + Write-Host " $finding" -ForegroundColor Red + } + elseif ($finding -match '^WARN:') { + Write-Host " $finding" -ForegroundColor Yellow + } + else { + Write-Host " $finding" -ForegroundColor DarkGray + } +} +Write-Host '' +Write-Host "Diagnostic complete. Generated at $diagnosticTimestamp on $env:COMPUTERNAME." -ForegroundColor Cyan diff --git a/LICENSE b/LICENSE index 1686303..8c0302b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Chris Taylor +Copyright (c) 2021-2025 Chris Taylor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MAP.md b/MAP.md new file mode 100644 index 0000000..92f15d0 --- /dev/null +++ b/MAP.md @@ -0,0 +1,170 @@ +# ConnectWiseAutomateAgent - Strategic Roadmap + +**Last Updated**: 2026-01-31 +**Current Module Version**: 1.0.0 +**Purpose**: Track project evolution from initial release through production hardening + +--- + +## Overview + +ConnectWiseAutomateAgent PowerShell module for managing the ConnectWise Automate agent (formerly LabTech) on Windows systems. Used by MSPs for agent installation, configuration, troubleshooting, and automated health monitoring. + +--- + +## Phase 1: Documentation & Testing ✅ Complete + +**Status**: Complete +**Completed**: Development branch (current) + +### Delivered + +- **Comment-based help** — all 30 public functions have full `.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK` +- **Pester test suite** — 377+ tests across 4 tiers (structure, mocked, cross-version, live), 100% function coverage +- **Mocked unit tests** — all 30 functions isolated with Pester mocks (no system dependencies) +- **Cross-version tests** — PowerShell 5.1 + 7+ validated +- **Live integration tests** — 112 tests with full install/exercise/uninstall lifecycle +- **CONTRIBUTING.md** — development setup, coding conventions, PR workflow +- **Blog posts** — introduction, troubleshooting guide, mass deployment, use cases + +### Remaining + +*All remaining Phase 1 items were completed in Phase 4 (2026-01-31):* + +- [x] PSScriptAnalyzer settings file (`.PSScriptAnalyzerSettings.psd1`) — completed with 6 documented suppressions +- [x] Expand mocked test coverage to remaining 13 functions — all 30 functions now have mocked tests +- [x] Fix stub doc for `Docs/Private/Initialize-CWAA.md` — replaced placeholder with real documentation + +--- + +## Phase 2: CI/CD & Build Infrastructure ✅ Complete + +**Status**: Complete +**Completed**: Development branch (current) + +### What Was Delivered + +- **GitHub Actions CI/CD** — smoke test → build → publish pipeline + - Prerelease publish from `develop` branch + - Stable publish from `main` branch + - PSGallery environment gating +- **Build scripts** — `SingleFileBuild.ps1`, `Build-Documentation.ps1`, `Publish-CWAAModule.ps1` +- **Module version** — bumped from `0.1.4.0` to `1.0.0` + +### Still Needed + +- [ ] Changelog generation (CHANGELOG.md) +- [ ] Version bump automation +- [ ] EditorConfig for consistent formatting +- [ ] Pre-commit hooks (PSScriptAnalyzer + tests) + +--- + +## Phase 3: v1.0 Feature Completion ✅ Complete + +**Status**: Complete +**Completed**: Development branch (current) + +### Features Shipped + +- **Health check system** — `Test-CWAAHealth`, `Repair-CWAA`, `Register-CWAAHealthCheckTask`, `Unregister-CWAAHealthCheckTask` +- **Server connectivity testing** — `Test-CWAAServerConnectivity` with auto-discovery +- **Windows Event Log integration** — `Write-CWAAEventLog` with categorized event IDs (1000-4039) +- **Lazy networking initialization** — `Initialize-CWAANetworking` with graduated SSL trust +- **Installer cleanup** — `Clear-CWAAInstallerArtifacts` +- **Credential redaction** — `Get-CWAARedactedValue` (SHA256 hash prefix) +- **Error handling standardization** — consistent Try-Catch-Finally throughout +- **Variable naming cleanup** — all cryptic names replaced with descriptive names +- **WhatIf/Confirm** — all destructive operations support `ShouldProcess` +- **PowerShell Core compatibility** — PS 5.1 and 7+ validated +- **Debug/logging overhaul** — Write-Debug throughout + Windows Event Log + +--- + +## Phase 4: Production Hardening ✅ Complete + +**Status**: Complete +**Completed**: 2026-01-31 + +### Delivered + +- **PSScriptAnalyzer compliance** — `.PSScriptAnalyzerSettings.psd1` with 6 documented suppressions, all issues fixed (empty catch blocks, global variable removal) +- **Expanded mocked tests** — 13 functions added, all 30 functions now covered (377+ tests total, up from 272) +- **Security documentation** — SSL graduation strategy, TripleDES usage, credential redaction, SecureString handling, InstallerToken vs ServerPassword documented in CLAUDE.md +- **Input validation hardening** — `ValidateScript` on mandatory Server params (`Install-CWAA`, `Repair-CWAA`), `ValidateRange` on LocationID, `ValidatePattern` on TaskName, LocationID type fix (`[string]`→`[int]` on `Redo-CWAA`) +- **Expanded examples** — 5 new scripts: health check monitoring, proxy configuration, troubleshooting diagnostic, GPO deployment, install with health check +- **Inline comments** — TrayPort selection logic (42000-42009), server version detection thresholds (110.374/200.197/240.331), regex breakdown in Initialize-CWAA +- **Documentation fixes** — Initialize-CWAA.md replaced PlatyPS placeholder with real docs, all 31 markdown docs + MAML regenerated +- **Single-file build** — verified at 4,465 lines / 236.5 KB + +### Remaining + +- [ ] **Merge develop → main** — ship v1.0.0 stable release to PSGallery + +--- + +## Phase 5: Code Quality & Documentation ✅ Complete + +**Status**: Complete +**Completed**: 2026-01-31 + +### Delivered + +- **Architecture diagrams** — [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams (module init, install workflow, health check escalation, registry/file interaction map) +- **Duplicate code refactoring** — 3 new private helpers extracted: + - `Resolve-CWAAServer` — server validation loop (~300 duplicated lines eliminated) + - `Test-CWAADownloadIntegrity` — download file size validation (~18 lines) + - `Remove-CWAAFolderRecursive` — depth-first folder deletion (~6 lines) +- **Caller updates** — `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA` refactored to use helpers +- **EditorConfig** — `.editorconfig` with PS 4-space, YAML/JSON 2-space, CRLF, UTF-8 +- **CHANGELOG.md** — Keep a Changelog format with v1.0.0-alpha001 and v0.1.4.0 entries +- **Versioning documentation** — CONTRIBUTING.md updated with semver bump criteria and prerelease tag progression +- **Version validation** — `SingleFileBuild.ps1` checks manifest version vs CHANGELOG latest entry +- **18 new mocked tests** — private helper functions fully tested (392 total, up from 377) +- **Documentation restructure** — separated generated vs hand-written docs into distinct folders + - `Docs/` for hand-written guides (Architecture.md) + - `Docs/Help/` for auto-generated function reference (PlatyPS output) + - `.blog/` for gitignored blog drafts + - `Build-Documentation.ps1` updated to output to `Docs/Help/` + - README.md restructured with clear "Guides" and "Function Reference (Auto-Generated)" sections + - 11 new documentation structure tests (403 total, up from 392) + +--- + +## Phase 6: Community & Polish 🔮 Planned + +**Status**: Planned +**Target**: Q2-Q3 2026 + +### Targets + +1. **Pre-commit hooks** — PSScriptAnalyzer + Pester before commit +2. **Pipeline support review** — audit `ValueFromPipeline` attributes, test and document +3. **FAQ section** — common installation errors, proxy issues, version compatibility +4. **Progress indicators** — `Write-Progress` for long-running operations + +--- + +## Scorecard + +| Area | Status | Detail | +| --- | --- | --- | +| Public functions | 30 | 25 original + 5 new | +| Private functions | 6 | Initialize, Networking, Cleanup, Resolve, Integrity, FolderRemove | +| Legacy aliases | 32 | Full backward compatibility | +| Test cases | 403+ | Structure + mocked + cross-version + live + doc structure | +| Test files | 4 | ~5,200 lines total | +| Build scripts | 3 | Single-file, docs, publish | +| CI/CD | GitHub Actions | Smoke, build, prerelease/stable publish | +| PS compatibility | 5.1 + 7+ | Cross-version tested | +| Comment-based help | 100% | All public functions documented | +| PSScriptAnalyzer | Clean | `.PSScriptAnalyzerSettings.psd1` with 6 suppressions | +| Event log integration | Yes | Categorized IDs (1000-4039) | +| Health monitoring | Yes | Test, Repair, Scheduled Task | +| Example scripts | 6 | Install, health check, proxy, troubleshooting, GPO, install+health | +| Architecture docs | Yes | 4 Mermaid diagrams in Docs/Architecture.md | +| Doc structure | Separated | Hand-written (Docs/) vs auto-generated (Docs/Help/) | +| Changelog | Yes | CHANGELOG.md (Keep a Changelog format) | +| EditorConfig | Yes | .editorconfig for consistent formatting | + +--- diff --git a/README.md b/README.md index 0003846..290a373 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@


- logo + logo
ConnectWiseAutomateAgent

-

PowerShell module for administering the ConnectWise Automate Windows agent..

+

Stop managing Automate agents by hand. Automate it with PowerShell.

-[![Build status](https://ci.appveyor.com/api/projects/status/gkmh0h0234s1x7rt?svg=true)](https://ci.appveyor.com/project/christaylorcodes/connectwiseAutomateAgent) +[![CI / Publish](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci-publish.yml/badge.svg)](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci-publish.yml) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/8aa3633cda3d41d5baa5e9f595b8124f)](https://www.codacy.com/gh/christaylorcodes/ConnectWiseAutomateAgent/dashboard?utm_source=github.com&utm_medium=referral&utm_content=christaylorcodes/ConnectWiseAutomateAgent&utm_campaign=Badge_Grade) [![Gallery](https://img.shields.io/powershellgallery/v/ConnectWiseAutomateAgent?label=PS%20Gallery&logo=powershell&logoColor=white)](https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent) [![Donate](https://img.shields.io/badge/$-donate-ff69b4.svg?maxAge=2592000&style=flat)](https://paypal.me/ChrisTaylorCodes) @@ -18,45 +18,163 @@

- List of Functions • - Examples • - Install • - Contribute • - Submit a Bug • - Request a Feature + Function Reference • + Examples • + Install • + Contribute • + Submit a Bug • + Request a Feature

- +--- -This module makes it easy to leverage PowerShell to automate tasks related to the Automate agent. +[ConnectWise Automate](https://www.connectwise.com/software/automate) is a remote monitoring and management (RMM) platform used by Managed Service Providers (MSPs) to monitor and maintain their clients' Windows endpoints. The **Automate agent** is the Windows service that runs on each managed machine, reporting status back to the Automate server and executing remote commands. This module manages that agent. - +## Why ConnectWiseAutomateAgent? -## [Install](https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent) +Deploying agents to hundreds of endpoints, chasing down offline machines at 2 AM, manually restarting services through RDP -- it's slow, error-prone, and doesn't scale. This module puts the entire Automate agent lifecycle into PowerShell so you can script, automate, and move on. -The module can be easily installed from the [PowerShellGallery](https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent) +**One command to install.** **One command to diagnose.** **One command to fix.** + +```powershell +Import-Module ConnectWiseAutomateAgent + +# Deploy an agent +Install-CWAA -Server 'automate.example.com' -LocationID 1 -InstallerToken 'MyToken' + +# What's wrong with this agent? +Get-CWAAInfo | Format-List + +# Fix it +Redo-CWAA -Server 'automate.example.com' -LocationID 1 -InstallerToken 'MyToken' +``` + +## What You Get + +| Category | Functions | What it covers | +| --- | --- | --- | +| **Install & Lifecycle** | `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Redo-CWAA` | Deploy, remove, upgrade, and reinstall agents | +| **Service Control** | `Start-CWAA`, `Stop-CWAA`, `Restart-CWAA`, `Repair-CWAA` | Manage agent services with escalating remediation | +| **Health & Connectivity** | `Test-CWAAHealth`, `Test-CWAAPort`, `Test-CWAAServerConnectivity` | Diagnose problems before they become tickets | +| **Settings & Backup** | `Get-CWAAInfo`, `Get-CWAAInfoBackup`, `Get-CWAASettings`, `New-CWAABackup`, `Reset-CWAA` | Read, back up, and reset agent configuration | +| **Logging & Diagnostics** | `Get-CWAAError`, `Get-CWAAProbeError`, `Get-CWAALogLevel`, `Set-CWAALogLevel` | Structured error logs and verbosity control | +| **Proxy** | `Get-CWAAProxy`, `Set-CWAAProxy` | Full proxy support for restricted networks | +| **Scheduled Monitoring** | `Register-CWAAHealthCheckTask`, `Unregister-CWAAHealthCheckTask` | Automated health checks via Windows Task Scheduler | +| **Add/Remove Programs** | `Hide-CWAAAddRemove`, `Show-CWAAAddRemove`, `Rename-CWAAAddRemove` | Control agent visibility in Control Panel | +| **Security & Utilities** | `ConvertFrom-CWAASecurity`, `ConvertTo-CWAASecurity`, `Invoke-CWAACommand` | Agent credential encryption and remote command execution | + +30 functions total. Full details in the **[Function Reference](Docs/Help/ConnectWiseAutomateAgent.md)**. + +## Install + +Install from the [PowerShell Gallery](https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent): ```powershell Install-Module 'ConnectWiseAutomateAgent' ``` ->If you are having issues accessing the PowerShell Gallery check out my [repair script](https://github.com/christaylorcodes/Initialize-PSGallery) +Or for prerelease builds: + +```powershell +Install-Module 'ConnectWiseAutomateAgent' -AllowPrerelease +``` + +> Having issues with the Gallery? Try this [repair script](https://github.com/christaylorcodes/Initialize-PSGallery). + +### Single-File Usage + +For older machines or environments without PowerShell Gallery access, a standalone `.ps1` file is available. This is a fallback -- prefer `Install-Module` above whenever possible. -You can also invoke the script from github for older machines that cant use the gallery. ```powershell Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' | Invoke-Expression ``` -## [Contributing](https://github.com/christaylorcodes/GitHub-Template/blob/main/CONTRIBUTING.md) +> **Note:** This downloads and executes code at runtime. Use `Install-Module` when the Gallery is available. + +## Getting Started + +After installing, import the module and check the agent status on the local machine: + +```powershell +Import-Module ConnectWiseAutomateAgent +Get-CWAAInfo +``` + +From there, jump straight to the [Examples](Examples/) for ready-to-use scripts, or browse the [Function Reference](Docs/Help/ConnectWiseAutomateAgent.md) for full details on every command. + +## Requirements + +- **Windows** (all supported versions) +- **PowerShell 3.0+** (2.0 with limitations) +- **Administrator privileges** for most operations + +## Backward Compatibility + +Every function has a legacy `LT` alias (`Install-CWAA` = `Install-LTService`, etc.) so existing LabTech-era scripts keep working. Run `Get-Alias -Definition *-CWAA*` to see them all. + +## Documentation + +### Guides + +_Hand-written documentation covering architecture and concepts._ + +| Guide | Description | +| --- | --- | +| [Architecture](Docs/Architecture.md) | Module initialization, installation, health check, proxy, uninstall, and system interaction diagrams | +| [Migration Guide](Docs/Migration.md) | Upgrading from 0.1.4.0 to 1.0.0, function-to-alias mapping, breaking changes | +| [Security Model](Docs/Security.md) | SSL certificate validation, TripleDES encryption, credential redaction, authentication methods | +| [Common Parameters](Docs/CommonParameters.md) | Shared parameters (-Server, -LocationID, -InstallerToken, etc.) with cross-reference tables | +| [Troubleshooting](Docs/Troubleshooting.md) | Symptom-based diagnostic guide with resolution steps | +| [FAQ](Docs/FAQ.md) | Common questions about deployment, configuration, compatibility, and usage | + +### Function Reference (Auto-Generated) + +_Generated from source code comment-based help via [PlatyPS](https://github.com/PowerShell/platyPS). Regenerate with `Build\Build-Documentation.ps1`._ + +| Resource | Description | +| --- | --- | +| [Function Reference](Docs/Help/ConnectWiseAutomateAgent.md) | All 30 functions organized by category with descriptions | +| [Individual Function Docs](Docs/Help/) | One file per function -- parameters, examples, and syntax | +| `Get-Help ` | In-session help compiled from the same source (MAML XML) | + +### Examples + +Ready-to-use scripts in the [Examples/](Examples/) directory: + +| Script | Purpose | +| --- | --- | +| [AgentInstall.ps1](Examples/AgentInstall.ps1) | Basic agent deployment | +| [AgentInstallWithHealthCheck.ps1](Examples/AgentInstallWithHealthCheck.ps1) | Deploy with automated health monitoring | +| [GPOScheduledTaskDeployment.ps1](Examples/GPOScheduledTaskDeployment.ps1) | Deploy via Group Policy scheduled task | +| [ProxyConfiguration.ps1](Examples/ProxyConfiguration.ps1) | Configure proxy settings for restricted networks | +| [HealthCheck-Monitoring.ps1](Examples/HealthCheck-Monitoring.ps1) | Set up ongoing health check monitoring | +| [Troubleshooting-QuickDiagnostic.ps1](Examples/Troubleshooting-QuickDiagnostic.ps1) | Quick diagnostic script for common issues | +| [PipelineUsage.ps1](Examples/PipelineUsage.ps1) | Pipeline patterns, multi-machine operations, function chaining | + +## Project + +| | | +| --- | --- | +| [Roadmap](MAP.md) | Where the project has been and where it's going | +| [Changelog](CHANGELOG.md) | Version history and release notes | +| [Contributing](CONTRIBUTING.md) | How to report bugs, suggest features, and submit PRs | + +If you use this project, please star and follow the repo -- it helps prioritize development time. Contributions of all kinds are welcome, even if you don't write code. See the [contributing guide](CONTRIBUTING.md) for details. -If you use this project please give it a star and follow so you can get updated when new features are released. This also lets me know what projects are getting used and what ones I should dedicate more time to. If you want to get more involved please see the [contributing page](https://github.com/christaylorcodes/GitHub-Template/blob/main/CONTRIBUTING.md). Projects need all kinds of help even if you don't know how to code. +## For AI Agents -Want to share something you created using the module? Submit it to be featured as a Community Package. +If you're an AI assistant helping a user with this module, start here: -## [Donating](https://github.com/christaylorcodes/GitHub-Template/blob/main/DONATE.md) +| Resource | What it contains | +| --- | --- | +| [CLAUDE.md](CLAUDE.md) | Architecture, module loading flow, coding conventions, key system paths, security considerations, and testing requirements | +| [Docs/Help/ConnectWiseAutomateAgent.md](Docs/Help/ConnectWiseAutomateAgent.md) | Complete function reference with links to per-function documentation (parameters, examples, syntax) | +| [Docs/Help/](Docs/Help/) | Individual function docs -- one file per function (e.g., `Docs/Help/Install-CWAA.md`) | +| [Examples/](Examples/) | Ready-to-use scripts covering common deployment and troubleshooting scenarios | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Development setup, coding standards, and PR workflow for code changes | -If you cant take time to contribute maybe you would like to help another way. +Key things to know: every `CWAA` function has a legacy `LT` alias (e.g., `Install-CWAA` = `Install-LTService`). The module requires Windows + PowerShell 3.0+ + admin privileges. `InstallerToken` is the preferred auth method over `ServerPassword`. -It takes time to maintain this project. Does the time spent on this module help you do cool things? Is that time worth a beer or two? +## [Donate](https://github.com/christaylorcodes/GitHub-Template/blob/main/DONATE.md) -Donations allow me to spend more time on this project and implement your feature requests. +This module is free and open source. If it saves you time, consider [buying me a beer](https://paypal.me/ChrisTaylorCodes). diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..a5746f8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,334 @@ +# ConnectWiseAutomateAgent - TODO List + +This document tracks improvements and tasks for the ConnectWiseAutomateAgent project. Tasks are organized by priority and category to help contributors and AI assistants work more effectively with the codebase. + +## Priority 1: Polish & Remaining Gaps + +These items close out the last gaps from the major development push. + +### Documentation + +- [x] **Fix incomplete function documentation** (Completed 2025-11-04) + - [x] Complete synopsis for `Get-CWAALogLevel` + - [x] Complete synopsis for `New-CWAABackup` + - [x] Complete synopsis for `Uninstall-CWAA` + - [x] 25 of 26 public function docs fully complete with MAML format + - [x] Fix stub doc for [Docs/Private/Initialize-CWAA.md](Docs/Private/Initialize-CWAA.md) (replaced PlatyPS placeholder with real documentation) + +- [x] **Add inline code comments for complex logic** (Mostly complete) + - [x] Encryption/decryption algorithms in `ConvertFrom-CWAASecurity.ps1` and `ConvertTo-CWAASecurity.ps1` + - [x] WOW64 redirection handling + - [x] Install-CWAA business logic (vulnerability checks, parameter building) + - [x] Document the TrayPort selection logic (42000-42009 range) + - [x] Explain the server version detection mechanism in more detail + +- [x] **Create architecture diagram** (Completed 2026-01-31) + - [x] Module initialization flow (two-phase: import vs. on-demand networking) + - [x] Agent installation workflow + - [x] Health check escalation flow (Test-CWAAHealth → Repair-CWAA) + - [x] Registry/file system interaction map + - Created [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams + +### Code Quality + +- [x] **Fix manifest encoding issues** (Resolved) + - Manifest is clean ASCII/CRLF, no BOM issues + +- [x] **Add PSScriptAnalyzer configuration** (Completed 2026-01-31) + - [x] Create `.PSScriptAnalyzerSettings.psd1` with project-specific rules + - [x] Run analyzer and fix critical/error issues + - [x] Document any intentional rule suppressions with justifications (6 suppressions documented) + - **Why**: Static analysis catches common errors and enforces best practices + +- [x] **Standardize error handling patterns** (Complete) + - All functions use consistent Try-Catch-Finally with context-aware error messages + - Standard `$_` usage in Catch blocks throughout + - Consistent `-ErrorAction` parameter usage + +### Testing + +- [x] **Create comprehensive Pester tests** (Complete — expanded in Phase 4) + - 377+ tests across 4 files + - `ConnectWiseAutomateAgent.Tests.ps1` — 58 tests: module structure, exports, security round-trip + - `ConnectWiseAutomateAgent.Mocked.Tests.ps1` — 193+ tests: 30 functions with Pester mocks + - `ConnectWiseAutomateAgent.CrossVersion.Tests.ps1` — 14 tests: PS 5.1 + 7+ compatibility + - `ConnectWiseAutomateAgent.Live.Tests.ps1` — 112 tests: full lifecycle with real Automate server + - All 30 public functions and 32 aliases covered across test tiers + +- [x] **Expand mocked test coverage** (Completed 2026-01-31) + - All 30 functions now have mocked tests (was 17 of 30) + - [x] Add mocked tests for `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA` + - [x] Add mocked tests for `Test-CWAAPort`, `Test-CWAAServerConnectivity` + - [x] Add mocked tests for `Set-CWAAProxy`, `New-CWAABackup` + - [x] Add mocked tests for `Repair-CWAA`, `Test-CWAAHealth` + - [x] Add mocked tests for `Register-CWAAHealthCheckTask`, `Unregister-CWAAHealthCheckTask` + - [x] Add edge-case tests for `ConvertTo-CWAASecurity`, `ConvertFrom-CWAASecurity` + - **Why**: Mocked tests run without system dependencies, making CI faster and more reliable + +## Priority 2: Code Maintainability + +These tasks improve long-term maintainability and reduce technical debt. + +### Code Organization + +- [x] **Refactor long functions** (Completed 2026-01-31) + - [x] Extracted `Resolve-CWAAServer` — eliminated ~300 duplicated lines across Install/Uninstall/Update + - [x] Extracted `Test-CWAADownloadIntegrity` — centralized file size validation (was inline in 3 files) + - [x] Extracted `Remove-CWAAFolderRecursive` — centralized depth-first folder deletion (was inline in 2 files) + - [x] Updated Install-CWAA, Uninstall-CWAA, Update-CWAA to use new helpers + - **Note**: Install-CWAA still has Install-specific logic inline (auth branching, vulnerability check, MSI execution) — these are not duplicated elsewhere + +- [x] **Consolidate duplicate code** (Completed 2026-01-31) + - [x] Server validation loop extracted to `Resolve-CWAAServer` + - [x] Download integrity check extracted to `Test-CWAADownloadIntegrity` + - [x] Folder cleanup extracted to `Remove-CWAAFolderRecursive` + - Registry operations remain inline (context-dependent, not worth abstracting) + +- [x] **Improve variable naming** (Complete) + - All cryptic names (`$Svr`, `$SVer`, `$tmpLTSI`) replaced with descriptive names + - Examples: `$automateServerUrl`, `$serverVersionResponse`, `$restartThreshold` + +### Module Structure + +- [x] **Add build automation** (Complete) + - `Build/SingleFileBuild.ps1` — single-file distribution builder + - `Build/Build-Documentation.ps1` — PlatyPS doc generation + - `Build/Publish-CWAAModule.ps1` — PSGallery publishing with dry-run support + - GitHub Actions CI/CD with smoke test → build → prerelease/stable publish + +- [x] **Implement proper semantic versioning** (Completed 2026-01-31) + - [x] Version bumped from `0.1.4.0` to `1.0.0` + - [x] Document version bump criteria (added to [CONTRIBUTING.md](CONTRIBUTING.md)) + - [x] Add version validation in build process (`SingleFileBuild.ps1` checks manifest vs CHANGELOG) + - [x] Add changelog generation ([CHANGELOG.md](CHANGELOG.md) with Keep a Changelog format) + +- [x] **Add EditorConfig** (Completed 2026-01-31) + - [x] Create `.editorconfig` for consistent formatting + - [x] 4-space indentation for PowerShell, 2-space for YAML/JSON/XML + - [x] CRLF line endings, UTF-8 encoding, trim trailing whitespace + +## Priority 3: Security & Best Practices + +These tasks address security concerns and improve code quality. + +### Security + +- [x] **Document security considerations** (Completed 2026-01-31) + - [x] Document graduated SSL certificate validation strategy (implemented in `Initialize-CWAANetworking`) + - [x] Explain TripleDES usage and migration path + - [x] Add security checklist for contributors (added to CLAUDE.md) + - **Why**: Users need to understand security tradeoffs + +- [x] **Implement secure credential handling** (Partially complete) + - [x] `Get-CWAARedactedValue` added — SHA256 hash prefix for credential logging + - [x] Password redaction in installer arguments output + - [ ] Review `$Script:LTServiceKeys` for further hardening + - [ ] Consider PSCredential objects for password parameters + - **Why**: Reduces risk of credential exposure + +- [x] **Add input validation** (Completed 2026-01-31) + - [x] `InstallerToken` — `ValidatePattern('(?s:^[0-9a-z]+$)')` + - [x] `IntervalHours` — `ValidateRange(1, 168)` + - [x] Validate URL parameters against injection (`ValidateScript` on mandatory `Server` params in `Install-CWAA`, `Repair-CWAA`) + - [x] Validate LocationID ranges (`ValidateRange(1, [int]::MaxValue)` on `Repair-CWAA`, type fix `[string]`→`[int]` on `Redo-CWAA`) + - [x] Validate TaskName (`ValidatePattern` on `Register-CWAAHealthCheckTask`) + - **Why**: Prevents injection attacks and improves error messages + - **Note**: `ValidateScript` intentionally omitted from optional `Server` params — PowerShell fires validation on internal variable assignment, breaking auto-discovery + +### Modern PowerShell Practices + +- [x] **Add PowerShell Core compatibility** (Complete) + - Cross-version tests validate PS 5.1 + 7+ + - Module requires PS 3.0+, works on 5.1 and 7+ + - .NET 6+ obsolescence warnings handled with pragma directives + +- [x] **Implement proper logging** (Complete) + - Write-Debug in Begin/Process/End blocks throughout all functions + - Windows Event Log integration via `Write-CWAAEventLog` + - Organized event ID ranges: 1000s install, 2000s service, 3000s config, 4000s health + +- [ ] **Add pipeline support** + - [ ] Review all ValueFromPipeline attributes + - [ ] Test pipeline scenarios + - [ ] Document pipeline usage in examples + - **Why**: PowerShell users expect good pipeline support + +## Priority 4: Developer Experience + +These tasks improve the experience for contributors and users. + +### Development Environment + +- [x] **Create development setup guide** (Complete) + - [CONTRIBUTING.md](CONTRIBUTING.md) covers setup, coding conventions, PR workflow, and new function checklist + +- [ ] **Add pre-commit hooks** + - [ ] Run PSScriptAnalyzer before commit + - [ ] Run tests before commit + - [ ] Validate formatting + - **Why**: Catches issues before they reach the repository + +- [x] **Improve debugging experience** (Complete) + - Write-Debug throughout all functions with Begin/End markers + - Windows Event Log for production debugging + - Event IDs organized by category for easy filtering + +### Examples & Documentation + +- [x] **Expand examples** (Completed 2026-01-31) + - [x] [Examples/AgentInstall.ps1](Examples/AgentInstall.ps1) — basic installation workflow + - [x] [Examples/HealthCheck-Monitoring.ps1](Examples/HealthCheck-Monitoring.ps1) — health check task lifecycle + - [x] [Examples/ProxyConfiguration.ps1](Examples/ProxyConfiguration.ps1) — proxy configuration walkthrough + - [x] [Examples/Troubleshooting-QuickDiagnostic.ps1](Examples/Troubleshooting-QuickDiagnostic.ps1) — all-in-one diagnostic script + - [x] [Examples/AgentInstallWithHealthCheck.ps1](Examples/AgentInstallWithHealthCheck.ps1) — installation with health monitoring + - [x] [Examples/GPOScheduledTaskDeployment.ps1](Examples/GPOScheduledTaskDeployment.ps1) — GPO-based deployment + - **Why**: Examples are the fastest way for users to learn + +- [ ] **Add FAQ section** + - [ ] Common installation errors + - [ ] Proxy configuration issues + - [ ] Version compatibility questions + - **Why**: Reduces support burden + +## Priority 5: Features & Enhancements + +These tasks add new capabilities to the module. + +### New Features + +- [x] **Add WhatIf/Confirm to all destructive operations** (Complete) + - All destructive functions declare `SupportsShouldProcess=$True` + - `$PSCmdlet.ShouldProcess()` checks before all destructive actions + +- [ ] **Implement progress indicators** + - [ ] Add Write-Progress to long-running operations (Install, Uninstall, Update) + - [ ] Show download progress for installers + - [ ] Display installation steps + - **Why**: Better user experience during long operations + +- [ ] **Add parallel server testing** + - [ ] Test multiple servers simultaneously + - [ ] Return fastest responding server + - **Why**: Improves installation speed + +### Integration + +- [x] **Add CI/CD pipeline** (Complete) + - GitHub Actions workflow: smoke test → build → publish + - Prerelease publish from `develop` branch + - Stable publish from `main` branch + - PSGallery environment gating + +- [x] **Create integration tests** (Complete) + - 112-test live suite with full install → exercise → uninstall lifecycle + - Three-phase testing: fresh install, restore/reinstall, idempotency check + - All 30 functions and 32 aliases exercised against real Automate server + +## Completed Tasks + +Track completed items here for historical reference. + +### 2025-11-03 + +- [x] Create CLAUDE.md with comprehensive codebase context +- [x] Create TODO.md with prioritized improvement tasks +- [x] Create blog posts highlighting the module and use cases + - [BLOG-Introduction.md](BLOG-Introduction.md) - Module introduction and overview + - [BLOG-TroubleshootingGuide.md](BLOG-TroubleshootingGuide.md) - Troubleshooting guide + - [BLOG-MassDeployment.md](BLOG-MassDeployment.md) - Mass deployment strategies + - [BLOG-UseCases.md](BLOG-UseCases.md) - 10 real-world use cases + +### 2025-11-04 + +- [x] Complete function documentation for `Get-CWAALogLevel`, `New-CWAABackup`, `Uninstall-CWAA` + +### Development Branch (Current) + +- [x] **Health check and auto-remediation system** + - `Test-CWAAHealth` — read-only health assessment + - `Repair-CWAA` — escalating remediation (restart → reinstall → fresh install) + - `Register-CWAAHealthCheckTask` / `Unregister-CWAAHealthCheckTask` — scheduled task management +- [x] **Server connectivity testing** — `Test-CWAAServerConnectivity` with auto-discovery and `-Quiet` flag +- [x] **Windows Event Log integration** — `Write-CWAAEventLog` with categorized event IDs (1000-4039) +- [x] **Lazy networking initialization** — `Initialize-CWAANetworking` with graduated SSL trust +- [x] **Installer cleanup utility** — `Clear-CWAAInstallerArtifacts` +- [x] **Credential redaction** — `Get-CWAARedactedValue` (SHA256 hash prefix) +- [x] **CONTRIBUTING.md** — comprehensive contributor guide +- [x] **GitHub Actions CI/CD** — smoke test, build artifact, prerelease/stable publish +- [x] **Comprehensive test suite** — 377+ tests across 4 files (structure, mocked, cross-version, live) +- [x] **Variable naming cleanup** — all cryptic names replaced with descriptive names +- [x] **Error handling standardization** — consistent Try-Catch-Finally with context +- [x] **Module version bump** — `0.1.4.0` → `1.0.0` +- [x] **WhatIf/Confirm on all destructive operations** +- [x] **PowerShell Core compatibility** — validated on PS 5.1 and 7+ +- [x] **Debug/logging overhaul** — Write-Debug throughout + Windows Event Log + +### Phase 4 Production Hardening (2026-01-31) + +- [x] **PSScriptAnalyzer configuration** — `.PSScriptAnalyzerSettings.psd1` with 6 documented suppressions, all issues fixed +- [x] **Input validation hardening** — `ValidateScript` on mandatory Server params, `ValidateRange` on LocationID, `ValidatePattern` on TaskName, LocationID type fix +- [x] **Inline comments** — TrayPort selection logic, server version detection thresholds, regex breakdown in Initialize-CWAA +- [x] **Initialize-CWAA.md doc** — replaced PlatyPS placeholder with real documentation +- [x] **Expanded examples** — 5 new example scripts (health check, proxy, troubleshooting, GPO deployment, install with health check) +- [x] **Expanded mocked tests** — 13 functions added, all 30 functions now have mocked tests (377+ total tests) +- [x] **Security documentation** — SSL graduation strategy, TripleDES usage, credential redaction, SecureString handling documented in CLAUDE.md +- [x] **Full verification** — 377 tests pass, PSScriptAnalyzer clean, single-file build (4465 lines, 236.5 KB), docs regenerated + +### Phase 5 Code Quality & Documentation (2026-01-31) + +- [x] **Architecture diagrams** — [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams (module init, install workflow, health check escalation, registry/file interaction map) +- [x] **Refactored duplicate code** — 3 new private helpers: `Resolve-CWAAServer` (~300 lines deduplicated), `Test-CWAADownloadIntegrity` (~18 lines), `Remove-CWAAFolderRecursive` (~6 lines) +- [x] **Updated callers** — Install-CWAA, Uninstall-CWAA, Update-CWAA refactored to use helpers +- [x] **EditorConfig** — `.editorconfig` with PS 4-space, YAML/JSON 2-space, CRLF, UTF-8 +- [x] **CHANGELOG.md** — Keep a Changelog format, v1.0.0-alpha001 and v0.1.4.0 entries +- [x] **Versioning docs** — CONTRIBUTING.md updated with semver bump criteria and prerelease tag progression +- [x] **Version validation** — `SingleFileBuild.ps1` checks manifest version vs CHANGELOG latest entry +- [x] **New mocked tests** — 18 tests for private helpers (392 total tests, up from 377) +- [x] **Full verification** — 392 tests pass, PSScriptAnalyzer clean, single-file build (4552 lines, 235 KB), docs regenerated + +### Documentation Restructure (2026-01-31) + +- [x] **Separated generated vs hand-written docs** — clear folder structure distinguishing auto-generated reference from hand-written guides + - `Docs/` — hand-written documentation (Architecture.md) + - `Docs/Help/` — auto-generated function reference (26 function docs + module overview, generated by PlatyPS) + - `Docs/Help/Private/` — private function reference (Initialize-CWAA.md) + - `.blog/` — gitignored blog drafts (Introduction, UseCases, MassDeployment, TroubleshootingGuide) +- [x] **Updated Build-Documentation.ps1** — default output path now `Docs\Help` +- [x] **Updated cross-references** — README.md, CLAUDE.md, CONTRIBUTING.md paths updated to `Docs/Help/` +- [x] **Restructured README.md** — new "Documentation" section with distinct "Guides" (hand-written) and "Function Reference (Auto-Generated)" subsections +- [x] **Added documentation structure tests** — 11 new Pester tests validating folder layout, function doc coverage, MAML help, and build script config (403 total tests, up from 392) +- [x] **Full verification** — 403 tests pass, PSScriptAnalyzer clean + +--- + +## How to Use This TODO List + +### For AI Assistants + +When working on this codebase: + +1. **Check Priority 1 tasks first** — these close remaining gaps +2. **Run tests after changes** — `Invoke-Pester Tests\ -ExcludeTag 'Live'` +3. **Run analyzer after changes** — `Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning` +4. **Rebuild after changes** — `Build\SingleFileBuild.ps1` and `Build\Build-Documentation.ps1` + +### For Contributors + +1. Pick tasks that match your skill level +2. Reference [CLAUDE.md](CLAUDE.md) for codebase context and conventions +3. Reference [CONTRIBUTING.md](CONTRIBUTING.md) for workflow and standards +4. Create an issue before starting major work +5. Submit small, focused PRs rather than large changes +6. Update this TODO.md as you complete tasks + +### For Maintainers + +1. Review and update priorities quarterly +2. Move completed tasks to "Completed Tasks" section +3. Add new tasks as issues are discovered +4. Link to GitHub issues where applicable + +--- + +**Last Updated**: 2026-01-31 (Phase 5) +**Module Version**: 1.0.0-alpha001 diff --git a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 new file mode 100644 index 0000000..0b5ec19 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 @@ -0,0 +1,219 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Cross-version compatibility tests for the ConnectWiseAutomateAgent module. + +.DESCRIPTION + Verifies that the module loads and core functions work correctly under every + PowerShell version available on the test machine. Each version is tested by + spawning a child process (powershell.exe for 5.1, pwsh.exe for 7+) that + imports the module and returns structured results as JSON. + + The module targets PowerShell 3.0+ but these tests exercise whichever + versions are installed. Versions not present are skipped automatically. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 -Output Detailed + + This test file is designed to run from PowerShell 7 (pwsh) as the test host. +#> + +# BeforeDiscovery runs at discovery time so -ForEach data is available for test generation. +BeforeDiscovery { + $script:PSVersions = @() + + # Windows PowerShell 5.1 + $ps51 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (Test-Path $ps51) { + $script:PSVersions += @{ Name = 'Windows PowerShell 5.1'; Exe = $ps51 } + } + + # PowerShell 7 (stable) + $ps7 = Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty Source -First 1 + if ($ps7 -and (Test-Path $ps7)) { + $script:PSVersions += @{ Name = 'PowerShell 7 stable'; Exe = $ps7 } + } + + # PowerShell 7 (preview) — only if it's a different binary from stable + $ps7preview = Join-Path $env:ProgramFiles 'PowerShell\7-preview\pwsh.exe' + if ((Test-Path $ps7preview) -and $ps7preview -ne $ps7) { + $script:PSVersions += @{ Name = 'PowerShell 7 preview'; Exe = $ps7preview } + } +} + +BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $script:ModulePsd1 = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1' +} + +# ============================================================================= +# Cross-Version Module Loading +# ============================================================================= +Describe 'Cross-Version Compatibility' { + + Context '' -ForEach $script:PSVersions { + + BeforeAll { + $currentExe = $Exe + $modulePath = $script:ModulePsd1 + + # Build the verification script as a here-string with the module path baked in. + # This script runs inside the child process, imports the module, and returns JSON. + $verifyScript = @" +`$ErrorActionPreference = 'Stop' +`$results = [ordered]@{ + Success = `$false + PSVersion = `$PSVersionTable.PSVersion.ToString() + PSEdition = `$PSVersionTable.PSEdition + ModuleLoaded = `$false + ModuleVersion = '' + FunctionCount = 0 + AliasCount = 0 + Functions = @() + Aliases = @() + EncryptDecrypt = `$false + ImportError = '' + TestErrors = @() +} +if (-not `$results.PSEdition) { `$results.PSEdition = 'Desktop' } + +Try { + Import-Module '$($modulePath -replace "'","''")' -Force -ErrorAction Stop + `$results.ModuleLoaded = `$true + + `$mod = Get-Module 'ConnectWiseAutomateAgent' + `$results.ModuleVersion = `$mod.Version.ToString() + `$results.Functions = @(`$mod.ExportedFunctions.Keys | Sort-Object) + `$results.FunctionCount = `$results.Functions.Count + `$results.Aliases = @(`$mod.ExportedAliases.Keys | Sort-Object) + `$results.AliasCount = `$results.Aliases.Count + + Try { + `$encoded = ConvertTo-CWAASecurity -InputString 'CrossVersionTest' + `$decoded = ConvertFrom-CWAASecurity -InputString `$encoded + `$results.EncryptDecrypt = (`$decoded -eq 'CrossVersionTest') + if (-not `$results.EncryptDecrypt) { + `$results.TestErrors += "Round-trip mismatch: got '`$decoded'" + } + } + Catch { + `$results.TestErrors += "Crypto error: `$(`$_.Exception.Message)" + } + + `$results.Success = `$true +} +Catch { + `$results.ImportError = `$_.Exception.Message +} + +`$results | ConvertTo-Json -Depth 3 -Compress +"@ + + # Spawn the child process and capture output + $rawOutput = & $currentExe -NoProfile -NonInteractive -Command $verifyScript 2>&1 + + # The output may contain warnings/progress before the JSON line. + # Extract the JSON object (the compressed JSON will be a single line starting with {). + $allOutput = ($rawOutput | Out-String).Trim() + $jsonLine = ($allOutput -split "`n" | Where-Object { $_.Trim() -match '^\{' } | Select-Object -Last 1) + + if ($jsonLine) { + $script:Result = $jsonLine.Trim() | ConvertFrom-Json + } + else { + $script:Result = [PSCustomObject]@{ + Success = $false + ModuleLoaded = $false + ImportError = "No JSON in child output: $allOutput" + PSVersion = 'Unknown' + PSEdition = 'Unknown' + FunctionCount = 0 + AliasCount = 0 + Functions = @() + Aliases = @() + EncryptDecrypt = $false + TestErrors = @() + } + } + } + + It 'reports its PowerShell version' { + $script:Result.PSVersion | Should -Not -BeNullOrEmpty + } + + It 'imports the module without errors' { + $script:Result.ModuleLoaded | Should -BeTrue -Because $script:Result.ImportError + } + + It 'reports the expected module version' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $expectedVersion = (Import-PowerShellDataFile $script:ModulePsd1).ModuleVersion + $script:Result.ModuleVersion | Should -Be $expectedVersion + } + + It 'exports all 30 functions' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.FunctionCount | Should -Be 30 + } + + It 'exports all 32 aliases' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.AliasCount | Should -Be 32 + } + + It 'exports the ConvertTo-CWAASecurity function' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Functions | Should -Contain 'ConvertTo-CWAASecurity' + } + + It 'exports the ConvertFrom-CWAASecurity function' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Functions | Should -Contain 'ConvertFrom-CWAASecurity' + } + + It 'exports the Install-CWAA function' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Functions | Should -Contain 'Install-CWAA' + } + + It 'exports the Uninstall-CWAA function' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Functions | Should -Contain 'Uninstall-CWAA' + } + + It 'exports the Install-LTService alias' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Aliases | Should -Contain 'Install-LTService' + } + + It 'exports the ConvertTo-LTSecurity alias' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $script:Result.Aliases | Should -Contain 'ConvertTo-LTSecurity' + } + + It 'encrypt/decrypt round-trip succeeds' { + if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } + $errorDetail = if ($script:Result.TestErrors) { $script:Result.TestErrors -join '; ' } else { 'no errors' } + $script:Result.EncryptDecrypt | Should -BeTrue -Because $errorDetail + } + } +} + +# ============================================================================= +# Version Coverage Summary +# ============================================================================= +Describe 'Version Coverage' { + + It 'powershell.exe (5.1) is available on this machine' { + $ps51 = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + Test-Path $ps51 | Should -BeTrue -Because 'Windows PowerShell 5.1 should be present' + } + + It 'pwsh.exe (7+) is available on this machine' { + $ps7 = Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue + $ps7 | Should -Not -BeNullOrEmpty -Because 'PowerShell 7 should be installed for cross-version testing' + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 new file mode 100644 index 0000000..8d27d0a --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 @@ -0,0 +1,1194 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Live integration tests for the ConnectWiseAutomateAgent module. + +.DESCRIPTION + Comprehensive lifecycle tests that exercise all 25 public functions across a + full agent lifecycle: + + Phase 1: Fresh install -> exercise every function -> backup -> uninstall (clean verify) + Phase 2: Restore from backup via Redo-CWAA -> verify -> uninstall (clean verify) + Phase 3: Fresh reinstall -> idempotency checks -> final uninstall (thorough verify) + + These tests WILL modify system state (services, registry, files) and require + administrator privileges. DO NOT run on a machine with a production Automate agent. + +.NOTES + Required environment variables: + $env:CWAATestServer - Automate server URL (e.g. https://automate.example.com) + $env:CWAATestInstallerToken - Installer token for agent deployment + + Optional environment variables: + $env:CWAATestLocationID - Location ID for agent assignment (default: 1) + + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Live.Tests.ps1 -Output Detailed + + These tests are tagged 'Live' so they can be excluded from standard runs: + Invoke-Pester Tests\ -ExcludeTag 'Live' + + Expected runtime: 30-60+ minutes (3 install/uninstall cycles with registration waits) +#> + +BeforeAll { + $ModuleName = 'ConnectWiseAutomateAgent' + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" + + Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ModulePath -Force -ErrorAction Stop + + # ---- State variables ---- + $script:AgentInstalled = $false + $script:AgentServer = $env:CWAATestServer + $script:AgentInstallerToken = $env:CWAATestInstallerToken + $script:AgentLocationID = if ($env:CWAATestLocationID) { [int]$env:CWAATestLocationID } else { 1 } + $script:OriginalAgentID = $null + $script:BackupCreated = $false + $script:PreUninstallInfo = $null + + # ---- Helper: Wait-ServiceState ---- + # Polls Get-Service until all named services reach the target state. + # Returns $true if reached, $false on timeout. + # Treats "service not found" (null) as equivalent to 'Stopped'. + function script:Wait-ServiceState { + param( + [Parameter(Mandatory)] + [string[]]$ServiceName, + + [Parameter(Mandatory)] + [ValidateSet('Running', 'Stopped')] + [string]$State, + + [int]$TimeoutSeconds = 60, + + [int]$PollIntervalMs = 2000 + ) + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + Start-Sleep -Milliseconds $PollIntervalMs + $allMatch = $true + foreach ($name in $ServiceName) { + $svc = Get-Service $name -ErrorAction SilentlyContinue + if ($State -eq 'Running') { + if (-not $svc -or $svc.Status -ne 'Running') { $allMatch = $false; break } + } + else { + # 'Stopped' — service not found counts as stopped + if ($svc -and $svc.Status -ne 'Stopped') { $allMatch = $false; break } + } + } + } until ($allMatch -or $stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) + $stopwatch.Stop() + return $allMatch + } + + # ---- Helper: Wait-AgentRegistration ---- + # Polls Get-CWAAInfo until the agent has a numeric ID. + # Returns $true if registered, $false on timeout. + function script:Wait-AgentRegistration { + param( + [int]$TimeoutSeconds = 120, + [int]$PollIntervalMs = 5000 + ) + $stopwatch = [Diagnostics.Stopwatch]::StartNew() + do { + Start-Sleep -Milliseconds $PollIntervalMs + $info = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false -Debug:$false + $id = $info | Select-Object -ExpandProperty ID -EA SilentlyContinue + } until (($id -match '^\d+$') -or $stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) + $stopwatch.Stop() + return ($id -match '^\d+$') + } + + # ---- Helper: Assert-CleanUninstall ---- + # Verifies all categories of agent remnants are gone. + # -AllowBackupRegistry skips the LabTechBackup registry check (it survives normal uninstall). + function script:Assert-CleanUninstall { + param( + [switch]$AllowBackupRegistry + ) + # Services + Get-Service 'LTService' -EA SilentlyContinue | Should -BeNullOrEmpty -Because 'LTService should be removed' + Get-Service 'LTSvcMon' -EA SilentlyContinue | Should -BeNullOrEmpty -Because 'LTSvcMon should be removed' + Get-Service 'LabVNC' -EA SilentlyContinue | Should -BeNullOrEmpty -Because 'LabVNC should be removed' + + # Registry + Test-Path 'HKLM:\SOFTWARE\LabTech\Service' | Should -BeFalse -Because 'agent registry key should be removed' + Test-Path 'HKLM:\SOFTWARE\WOW6432Node\LabTech\Service' | Should -BeFalse -Because 'WOW6432Node registry key should be removed' + + if (-not $AllowBackupRegistry) { + Test-Path 'HKLM:\SOFTWARE\LabTechBackup' | Should -BeFalse -Because 'backup registry should be removed' + } + + # Files + Test-Path "$env:windir\LTSVC" | Should -BeFalse -Because 'agent installation directory should be removed' + + # Module function + $info = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false + $info | Should -BeNullOrEmpty -Because 'Get-CWAAInfo should return null after uninstall' + } +} + +AfterAll { + # Safety net: if the agent is still present after tests, attempt cleanup + $agentInfo = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false + if ($agentInfo) { + Write-Warning 'Agent still installed after test run - attempting cleanup uninstall.' + try { Uninstall-CWAA -Server $script:AgentServer -Force -Confirm:$false } + catch { Write-Warning "Cleanup uninstall failed: $_" } + } + + # Clean backup registry + foreach ($regPath in @('HKLM:\SOFTWARE\LabTechBackup', 'HKLM:\SOFTWARE\WOW6432Node\LabTechBackup')) { + if (Test-Path $regPath) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Pre-Flight Checks +# ============================================================================= +Describe 'Pre-Flight Checks' -Tag 'Live' { + + It 'is running as Administrator' { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) | Should -BeTrue + } + + It 'has CWAATestServer environment variable set' { + $env:CWAATestServer | Should -Not -BeNullOrEmpty -Because 'set $env:CWAATestServer to your Automate server URL' + } + + It 'has CWAATestInstallerToken environment variable set' { + $env:CWAATestInstallerToken | Should -Not -BeNullOrEmpty -Because 'set $env:CWAATestInstallerToken to a valid installer token' + } + + It 'server URL is in a valid format' { + $env:CWAATestServer | Should -Match '^https?://' -Because 'server URL should start with http:// or https://' + } + + It 'no existing Automate agent is installed' { + $existingAgent = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false + $existingAgent | Should -BeNullOrEmpty -Because 'a live agent would be disrupted by these tests' + } + + It 'LTService service does not exist' { + $service = Get-Service 'LTService' -EA SilentlyContinue + $service | Should -BeNullOrEmpty -Because 'remnant services indicate a partial install' + } +} + +# ============================================================================= +# Environment Cleanup (remove remnants from prior runs) +# ============================================================================= +Describe 'Environment Cleanup' -Tag 'Live' { + + It 'removes leftover installer temp files' { + # Clean the LabTech installer staging directory + $installerTempPath = "$env:windir\Temp\LabTech" + if (Test-Path $installerTempPath) { + Remove-Item $installerTempPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $installerTempPath" + } + + # Clean remnant uninstaller files from system and user temp directories + $searchDirs = @("$env:windir\Temp", $env:TEMP) | Select-Object -Unique + $filesToClean = @('Agent_Uninstall.exe', 'Uninstall.exe', 'Uninstall.exe.config') + + foreach ($dir in $searchDirs) { + foreach ($fileName in $filesToClean) { + $filePath = Join-Path $dir $fileName + if (Test-Path $filePath) { + Remove-Item $filePath -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $filePath" + } + } + } + + # Verify all locations are clean + $allRemnants = foreach ($dir in $searchDirs) { + foreach ($fileName in $filesToClean) { + $filePath = Join-Path $dir $fileName + if (Test-Path $filePath) { $filePath } + } + } + $allRemnants | Should -BeNullOrEmpty -Because 'leftover temp files cause interactive prompts during install/uninstall' + } + + It 'removes leftover agent installation directory' { + $installPath = "$env:windir\LTSVC" + if (Test-Path $installPath) { + Remove-Item $installPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $installPath" + } + Test-Path $installPath | Should -BeFalse -Because 'remnant install directory interferes with fresh install' + } + + It 'removes leftover registry keys' { + $registryPaths = @( + 'HKLM:\SOFTWARE\LabTech\Service' + 'HKLM:\SOFTWARE\WOW6432Node\LabTech\Service' + 'HKLM:\SOFTWARE\LabTechBackup' + 'HKLM:\SOFTWARE\WOW6432Node\LabTechBackup' + ) + foreach ($regPath in $registryPaths) { + if (Test-Path $regPath) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $regPath" + } + } + } + + It 'stops and removes leftover services' { + foreach ($serviceName in @('LTService', 'LTSvcMon')) { + $service = Get-Service $serviceName -ErrorAction SilentlyContinue + if ($service) { + if ($service.Status -eq 'Running') { + Stop-Service $serviceName -Force -ErrorAction SilentlyContinue + Write-Host " Stopped: $serviceName" + } + & "$env:windir\system32\sc.exe" delete $serviceName 2>$null + Write-Host " Removed: $serviceName" + } + } + } +} + +# ============================================================================= +# Phase 1: Fresh Install +# ============================================================================= +Describe 'Phase 1: Fresh Install' -Tag 'Live' { + + It 'Install-CWAA completes without throwing' { + $installParams = @{ + Server = $script:AgentServer + InstallerToken = $script:AgentInstallerToken + LocationID = $script:AgentLocationID + Force = $true + Confirm = $false + } + + { Install-CWAA @installParams } | Should -Not -Throw + $script:AgentInstalled = $true + } + + It 'LTService service exists and is running' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $ready = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 90 + $ready | Should -BeTrue -Because 'LTService should be running after install' + } + + It 'LTSvcMon service exists and is running' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $ready = Wait-ServiceState -ServiceName 'LTSvcMon' -State 'Running' -TimeoutSeconds 60 + $ready | Should -BeTrue -Because 'LTSvcMon should be running after install' + } + + It 'Get-CWAAInfo returns agent data with a numeric ID' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $registered = Wait-AgentRegistration -TimeoutSeconds 120 + $registered | Should -BeTrue -Because 'agent should register with a numeric ID' + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info | Should -Not -BeNullOrEmpty + $info.ID | Should -Match '^\d+$' + $script:OriginalAgentID = $info.ID + } + + It 'agent server matches the provided server' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $cleanExpected = ($script:AgentServer -replace 'https?://','').TrimEnd('/') + ($info.Server -replace 'https?://','') | Should -Contain $cleanExpected + } + + It 'agent installation directory exists' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info.BasePath | Should -Not -BeNullOrEmpty + Test-Path $info.BasePath | Should -BeTrue + } + + It 'agent registry key exists' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + Test-Path 'HKLM:\SOFTWARE\LabTech\Service' | Should -BeTrue + } + + It 'Install-LTService alias resolves correctly' { + $alias = Get-Alias 'Install-LTService' -EA SilentlyContinue + $alias | Should -Not -BeNullOrEmpty + $alias.ResolvedCommand.Name | Should -Be 'Install-CWAA' + } +} + +# ============================================================================= +# Phase 1: Exercise All Functions +# ============================================================================= + +# ---- Agent Information and Settings ---- +Describe 'Phase 1: Agent Information and Settings' -Tag 'Live' { + + It 'Get-CWAAInfo returns Server, BasePath, and Version properties' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info.Server | Should -Not -BeNullOrEmpty + $info.BasePath | Should -Not -BeNullOrEmpty + $info | Get-Member -Name 'Version' -EA SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It 'Get-CWAASettings returns settings data with ServerAddress' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $settings = Get-CWAASettings -EA SilentlyContinue + $settings | Should -Not -BeNullOrEmpty + $settings | Get-Member -Name 'ServerAddress' -EA SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It 'Get-LTServiceInfo alias returns matching data' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $info = Get-LTServiceInfo -WhatIf:$false -Confirm:$false + $info | Should -Not -BeNullOrEmpty + $info.ID | Should -Be $script:OriginalAgentID + } + + It 'Get-LTServiceSettings alias returns data' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $settings = Get-LTServiceSettings -EA SilentlyContinue + $settings | Should -Not -BeNullOrEmpty + } +} + +# ---- Service Operations ---- +Describe 'Phase 1: Service Operations' -Tag 'Live' { + + It 'Stop-CWAA stops the agent services' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Stop-CWAA -Confirm:$false } | Should -Not -Throw + + $stopped = Wait-ServiceState -ServiceName 'LTService' -State 'Stopped' -TimeoutSeconds 60 + $stopped | Should -BeTrue -Because 'LTService should stop' + } + + It 'LTSvcMon is also stopped' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $stopped = Wait-ServiceState -ServiceName 'LTSvcMon' -State 'Stopped' -TimeoutSeconds 30 + $stopped | Should -BeTrue -Because 'LTSvcMon should stop when LTService stops' + } + + It 'Start-CWAA starts the agent services' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Start-CWAA -Confirm:$false } | Should -Not -Throw + + $started = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 60 + $started | Should -BeTrue -Because 'LTService should start' + } + + It 'LTSvcMon is also running after start' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $started = Wait-ServiceState -ServiceName 'LTSvcMon' -State 'Running' -TimeoutSeconds 30 + $started | Should -BeTrue -Because 'LTSvcMon should start when LTService starts' + } + + It 'Restart-CWAA cycles the agent services' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Restart-CWAA -Confirm:$false } | Should -Not -Throw + + $running = Wait-ServiceState -ServiceName 'LTService','LTSvcMon' -State 'Running' -TimeoutSeconds 90 + $running | Should -BeTrue -Because 'both services should be running after restart' + } + + It 'Stop-LTService alias resolves to Stop-CWAA' { + $alias = Get-Alias 'Stop-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Stop-CWAA' + } + + It 'Start-LTService alias resolves to Start-CWAA' { + $alias = Get-Alias 'Start-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Start-CWAA' + } + + It 'Restart-LTService alias resolves to Restart-CWAA' { + $alias = Get-Alias 'Restart-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Restart-CWAA' + } +} + +# ---- Invoke-CWAACommand ---- +Describe 'Phase 1: Invoke-CWAACommand' -Tag 'Live' { + + It 'sends Send Status command without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + # Ensure service is running first + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 30 + if (-not $running) { Set-ItResult -Skipped -Because 'LTService is not running' } + + { Invoke-CWAACommand -Command 'Send Status' -Confirm:$false } | Should -Not -Throw + } + + It 'sends Send Inventory command without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Invoke-CWAACommand -Command 'Send Inventory' -Confirm:$false } | Should -Not -Throw + } + + It 'sends multiple commands in a single call without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Invoke-CWAACommand -Command 'Send Apps','Send Services' -Confirm:$false } | Should -Not -Throw + } + + It 'Invoke-LTServiceCommand alias works' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Invoke-LTServiceCommand -Command 'Send Status' -Confirm:$false } | Should -Not -Throw + } +} + +# ---- Test-CWAAPort ---- +Describe 'Phase 1: Test-CWAAPort' -Tag 'Live' { + + It 'returns output for the agent server' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $result = Test-CWAAPort -Server $script:AgentServer -EA SilentlyContinue + $result | Should -Not -BeNullOrEmpty -Because 'port test should produce output' + } + + It '-Quiet switch returns a boolean' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $result = Test-CWAAPort -Server $script:AgentServer -Quiet -EA SilentlyContinue + $result | Should -BeOfType [bool] + } + + It 'auto-discovers server from installed agent when -Server is omitted' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + try { + $result = Test-CWAAPort -EA SilentlyContinue + $result | Should -Not -BeNullOrEmpty + } + catch { + Set-ItResult -Skipped -Because "network error prevented port test: $_" + } + } + + It 'Test-LTPorts alias returns output' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $result = Test-LTPorts -Server $script:AgentServer -Quiet -EA SilentlyContinue + $result | Should -Not -BeNullOrEmpty + } +} + +# ---- Logging Operations ---- +Describe 'Phase 1: Logging Operations' -Tag 'Live' { + + It 'Get-CWAALogLevel does not throw (Settings key may not exist yet on fresh install)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Get-CWAALogLevel -EA SilentlyContinue } | Should -Not -Throw + } + + It 'Set-CWAALogLevel changes to Verbose (Debuging=1000)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Set-CWAALogLevel -Level Verbose -Confirm:$false } | Should -Not -Throw + + # Wait for services to restart after log level change + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 60 + $running | Should -BeTrue -Because 'services should restart after log level change' + + $settings = Get-CWAASettings -EA SilentlyContinue + $settings | Select-Object -ExpandProperty 'Debuging' -EA SilentlyContinue | Should -Be 1000 + } + + It 'Set-CWAALogLevel restores to Normal (Debuging=1)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Set-CWAALogLevel -Level Normal -Confirm:$false } | Should -Not -Throw + + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 60 + $running | Should -BeTrue + + $settings = Get-CWAASettings -EA SilentlyContinue + $settings | Select-Object -ExpandProperty 'Debuging' -EA SilentlyContinue | Should -Be 1 + } + + It 'Get-CWAAError does not throw (log may be empty)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Get-CWAAError -EA SilentlyContinue } | Should -Not -Throw + } + + It 'Get-LTLogging alias returns data' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $level = Get-LTLogging -EA SilentlyContinue + $level | Should -Not -BeNullOrEmpty + } + + It 'Set-LTLogging alias resolves to Set-CWAALogLevel' { + $alias = Get-Alias 'Set-LTLogging' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Set-CWAALogLevel' + } + + It 'Get-LTErrors alias resolves to Get-CWAAError' { + $alias = Get-Alias 'Get-LTErrors' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Get-CWAAError' + } +} + +# ---- Get-CWAAProbeError ---- +Describe 'Phase 1: Get-CWAAProbeError' -Tag 'Live' { + + It 'does not throw when called (probe log may not exist)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Get-CWAAProbeError -EA SilentlyContinue } | Should -Not -Throw + } + + It 'returns objects with expected properties if log exists' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $errors = Get-CWAAProbeError -EA SilentlyContinue + if ($null -eq $errors) { + Set-ItResult -Skipped -Because 'no probe error log found on this agent' + } + else { + $first = $errors | Select-Object -First 1 + $first | Get-Member -Name 'ServiceVersion' -EA SilentlyContinue | Should -Not -BeNullOrEmpty + $first | Get-Member -Name 'Timestamp' -EA SilentlyContinue | Should -Not -BeNullOrEmpty + $first | Get-Member -Name 'Message' -EA SilentlyContinue | Should -Not -BeNullOrEmpty + } + } + + It 'Get-LTProbeErrors alias resolves to Get-CWAAProbeError' { + $alias = Get-Alias 'Get-LTProbeErrors' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Get-CWAAProbeError' + } +} + +# ---- Add/Remove Programs Operations ---- +Describe 'Phase 1: Add/Remove Programs Operations' -Tag 'Live' { + + It 'Hide-CWAAAddRemove hides the agent entry (SystemComponent=1)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Hide-CWAAAddRemove -Confirm:$false } | Should -Not -Throw + + $uninstallPaths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}' + ) + $found = $false + foreach ($path in $uninstallPaths) { + if (Test-Path $path) { + $value = Get-ItemProperty $path -Name 'SystemComponent' -EA SilentlyContinue | + Select-Object -ExpandProperty 'SystemComponent' -EA SilentlyContinue + $value | Should -Be 1 + $found = $true + break + } + } + if (-not $found) { + Set-ItResult -Skipped -Because 'uninstall registry key not found for this agent version' + } + } + + It 'Show-CWAAAddRemove reveals the agent entry' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Show-CWAAAddRemove -Confirm:$false } | Should -Not -Throw + } + + It 'Rename-CWAAAddRemove renames the entry' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $testName = 'CWAA Pester Test Agent' + { Rename-CWAAAddRemove -Name $testName -Confirm:$false } | Should -Not -Throw + + $uninstallPaths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}' + ) + $verified = $false + foreach ($path in $uninstallPaths) { + if (Test-Path $path) { + $displayName = Get-ItemProperty $path -Name 'DisplayName' -EA SilentlyContinue | + Select-Object -ExpandProperty 'DisplayName' -EA SilentlyContinue + $displayName | Should -Be $testName + $verified = $true + break + } + } + if (-not $verified) { + Set-ItResult -Skipped -Because 'uninstall registry key not found for this agent version' + } + } + + It 'Hide-LTAddRemove alias resolves to Hide-CWAAAddRemove' { + $alias = Get-Alias 'Hide-LTAddRemove' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Hide-CWAAAddRemove' + } + + It 'Show-LTAddRemove alias resolves to Show-CWAAAddRemove' { + $alias = Get-Alias 'Show-LTAddRemove' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Show-CWAAAddRemove' + } + + It 'Rename-LTAddRemove alias resolves to Rename-CWAAAddRemove' { + $alias = Get-Alias 'Rename-LTAddRemove' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Rename-CWAAAddRemove' + } +} + +# ---- Proxy Operations ---- +Describe 'Phase 1: Proxy Operations' -Tag 'Live' { + + It 'Get-CWAAProxy returns proxy configuration with expected properties' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $proxy = Get-CWAAProxy -EA SilentlyContinue + $proxy | Should -Not -BeNullOrEmpty + $proxy | Get-Member -Name 'Enabled' | Should -Not -BeNullOrEmpty + $proxy | Get-Member -Name 'ProxyServerURL' | Should -Not -BeNullOrEmpty + } + + It 'proxy is disabled by default on fresh install' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $proxy = Get-CWAAProxy -EA SilentlyContinue + $proxy.Enabled | Should -BeFalse + } + + It 'Set-CWAAProxy -DetectProxy does not throw' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Set-CWAAProxy -DetectProxy -Confirm:$false } | Should -Not -Throw + } + + It 'Set-CWAAProxy -ResetProxy clears proxy settings' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Set-CWAAProxy -ResetProxy -Confirm:$false } | Should -Not -Throw + + $proxy = Get-CWAAProxy -EA SilentlyContinue + $proxy.Enabled | Should -BeFalse + } + + It 'Get-LTProxy alias returns proxy data' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $proxy = Get-LTProxy -EA SilentlyContinue + $proxy | Should -Not -BeNullOrEmpty + } + + It 'Set-LTProxy alias resolves to Set-CWAAProxy' { + $alias = Get-Alias 'Set-LTProxy' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Set-CWAAProxy' + } +} + +# ---- Security Conversion with Agent Keys ---- +Describe 'Phase 1: Security Conversion with Agent Keys' -Tag 'Live' { + + It 'ConvertTo/ConvertFrom round-trips using the agent server password' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $serverPwd = $info | Select-Object -ExpandProperty 'ServerPassword' -EA SilentlyContinue + + if (-not $serverPwd) { + Set-ItResult -Skipped -Because 'agent has no ServerPassword in registry' + } + + $testValue = 'LiveKeyTest_12345' + $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key $serverPwd + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $serverPwd + $decoded | Should -Be $testValue + } + + It 'ConvertTo-LTSecurity / ConvertFrom-LTSecurity alias round-trip works' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $testValue = 'AliasRoundTripTest' + $encoded = ConvertTo-LTSecurity -InputString $testValue + $decoded = ConvertFrom-LTSecurity -InputString $encoded + $decoded | Should -Be $testValue + } +} + +# ---- Update-CWAA (before Reset — needs stable agent identity) ---- +Describe 'Phase 1: Update-CWAA' -Tag 'Live' { + + It 'Update-CWAA does not throw a terminating error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + # Update may warn "installed version is current" — that is non-terminating and acceptable. + # It may also fail to download if the version endpoint returns unexpected data. + # We test that it does not produce a terminating exception. + $threwTerminating = $false + try { + Update-CWAA -Confirm:$false -EA SilentlyContinue + } + catch { + $threwTerminating = $true + } + $threwTerminating | Should -BeFalse -Because 'Update-CWAA should not throw a terminating error' + } + + It 'agent services are running after update attempt' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + # If update stopped services, restart them + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 30 + if (-not $running) { + Start-CWAA -Confirm:$false -EA SilentlyContinue + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 60 + } + $running | Should -BeTrue -Because 'LTService must be running for subsequent tests' + } + + It 'Update-LTService alias resolves to Update-CWAA' { + $alias = Get-Alias 'Update-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Update-CWAA' + } +} + +# ---- Reset-CWAA (last in exercise phase — destructive to identity) ---- +Describe 'Phase 1: Reset-CWAA' -Tag 'Live' { + + It 'captures pre-reset agent identity' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info.ID | Should -Not -BeNullOrEmpty -Because 'agent must have an ID before reset' + } + + It 'Reset-CWAA -ID -NoWait removes the agent ID' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Reset-CWAA -ID -NoWait -Force -Confirm:$false } | Should -Not -Throw + + # Services should restart + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 60 + $running | Should -BeTrue -Because 'LTService should restart after reset' + } + + It 'agent re-registers with a numeric ID after reset' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $registered = Wait-AgentRegistration -TimeoutSeconds 120 + $registered | Should -BeTrue -Because 'agent should re-register after ID reset' + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info.ID | Should -Match '^\d+$' + } + + It 'Reset-LTService alias resolves to Reset-CWAA' { + $alias = Get-Alias 'Reset-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Reset-CWAA' + } +} + +# ============================================================================= +# Phase 1: Backup and Uninstall +# ============================================================================= + +# ---- Backup Creation ---- +Describe 'Phase 1: Backup Creation' -Tag 'Live' { + + It 'New-CWAABackup creates a backup without errors' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { New-CWAABackup -Confirm:$false } | Should -Not -Throw + $script:BackupCreated = $true + } + + It 'backup registry key HKLM:\SOFTWARE\LabTechBackup\Service exists' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + Test-Path 'HKLM:\SOFTWARE\LabTechBackup\Service' | Should -BeTrue + } + + It 'Get-CWAAInfoBackup returns backup data' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + $backup = Get-CWAAInfoBackup -EA SilentlyContinue + $backup | Should -Not -BeNullOrEmpty + } + + It 'backup Server matches the original agent Server' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $backup = Get-CWAAInfoBackup -EA SilentlyContinue + + $originalServer = ($info.Server | Select-Object -First 1) -replace 'https?://','' + $backupServer = ($backup.Server | Select-Object -First 1) -replace 'https?://','' + $backupServer | Should -Be $originalServer + } + + It 'backup file directory exists at BasePath\Backup' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $backupPath = Join-Path $info.BasePath 'Backup' + Test-Path $backupPath | Should -BeTrue + } + + It 'New-LTServiceBackup alias resolves to New-CWAABackup' { + $alias = Get-Alias 'New-LTServiceBackup' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'New-CWAABackup' + } + + It 'Get-LTServiceInfoBackup alias returns backup data' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + $backup = Get-LTServiceInfoBackup -EA SilentlyContinue + $backup | Should -Not -BeNullOrEmpty + } +} + +# ---- First Uninstall ---- +Describe 'Phase 1: First Uninstall' -Tag 'Live' { + + It 'records the agent info before uninstall' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + $script:PreUninstallInfo = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $script:PreUninstallInfo | Should -Not -BeNullOrEmpty + } + + It 'Uninstall-CWAA removes the agent without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Uninstall-CWAA -Server $script:AgentServer -Force -Confirm:$false } | Should -Not -Throw + $script:AgentInstalled = $false + } + + It 'passes clean uninstall verification (backup registry allowed)' { + Assert-CleanUninstall -AllowBackupRegistry + } + + It 'backup registry survives uninstall' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + Test-Path 'HKLM:\SOFTWARE\LabTechBackup\Service' | Should -BeTrue -Because 'LabTechBackup is not in Uninstall-CWAA cleanup list' + } + + It 'Get-CWAAInfoBackup still returns data after uninstall' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'backup was not created' } + + $backup = Get-CWAAInfoBackup -EA SilentlyContinue + $backup | Should -Not -BeNullOrEmpty + } + + It 'Uninstall-LTService alias resolves to Uninstall-CWAA' { + $alias = Get-Alias 'Uninstall-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Uninstall-CWAA' + } +} + +# ============================================================================= +# Phase 2: Restore from Backup via Redo-CWAA +# ============================================================================= + +# ---- Restore ---- +Describe 'Phase 2: Redo-CWAA Restore from Backup' -Tag 'Live' { + + It 'Redo-CWAA restores the agent from backup settings' { + if (-not $script:BackupCreated) { Set-ItResult -Skipped -Because 'no backup available for restore' } + + # Do NOT pass -Server or -LocationID — force Redo-CWAA to read them from backup. + # Only InstallerToken is explicit (not stored in backup registry). + $redoParams = @{ + InstallerToken = $script:AgentInstallerToken + Force = $true + Confirm = $false + } + + { Redo-CWAA @redoParams } | Should -Not -Throw + $script:AgentInstalled = $true + } + + It 'LTService is running after Redo-CWAA' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'Redo-CWAA failed' } + + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 90 + $running | Should -BeTrue + } + + It 'LTSvcMon is running after Redo-CWAA' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'Redo-CWAA failed' } + + $running = Wait-ServiceState -ServiceName 'LTSvcMon' -State 'Running' -TimeoutSeconds 60 + $running | Should -BeTrue + } + + It 'Get-CWAAInfo returns valid agent data after restore' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'Redo-CWAA failed' } + + $registered = Wait-AgentRegistration -TimeoutSeconds 120 + $registered | Should -BeTrue + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info | Should -Not -BeNullOrEmpty + $info.ID | Should -Match '^\d+$' + } + + It 'agent server matches after restore (verifying backup-read worked)' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'Redo-CWAA failed' } + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $cleanExpected = ($script:AgentServer -replace 'https?://','').TrimEnd('/') + ($info.Server -replace 'https?://','') | Should -Contain $cleanExpected + } + + It 'Redo-LTService alias resolves to Redo-CWAA' { + $alias = Get-Alias 'Redo-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Redo-CWAA' + } + + It 'Reinstall-CWAA alias resolves to Redo-CWAA' { + $alias = Get-Alias 'Reinstall-CWAA' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Redo-CWAA' + } + + It 'Reinstall-LTService alias resolves to Redo-CWAA' { + $alias = Get-Alias 'Reinstall-LTService' -EA SilentlyContinue + $alias.ResolvedCommand.Name | Should -Be 'Redo-CWAA' + } +} + +# ---- Second Uninstall ---- +Describe 'Phase 2: Second Uninstall' -Tag 'Live' { + + It 'Uninstall-CWAA removes the restored agent without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Uninstall-CWAA -Server $script:AgentServer -Force -Confirm:$false } | Should -Not -Throw + $script:AgentInstalled = $false + } + + It 'passes full clean uninstall verification' { + # No -AllowBackupRegistry — expect everything clean + # But backup registry may still exist from Phase 1; clean it first + foreach ($regPath in @('HKLM:\SOFTWARE\LabTechBackup', 'HKLM:\SOFTWARE\WOW6432Node\LabTechBackup')) { + if (Test-Path $regPath) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + Assert-CleanUninstall + } +} + +# ============================================================================= +# Phase 3: Fresh Reinstall and Idempotency +# ============================================================================= + +# ---- Idempotency Checks ---- +Describe 'Phase 3: Idempotency Checks' -Tag 'Live' { + + It 'double-uninstall on clean system does not throw' { + # Uninstall-CWAA uses Read-Host if -Server is omitted, so pass it explicitly. + # It also uses -ErrorAction Stop internally, so use try/catch. + $threw = $false + try { + Uninstall-CWAA -Server $script:AgentServer -Force -Confirm:$false -EA SilentlyContinue + } + catch { + $threw = $true + } + $threw | Should -BeFalse -Because 'uninstalling on a clean system should be a no-op' + } + + It 'Get-CWAAInfo returns null on uninstalled system' { + $info = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false + $info | Should -BeNullOrEmpty + } + + It 'Get-CWAAInfoBackup returns no data when backup registry is absent' { + # Backup registry was cleaned in Phase 2 second uninstall + $backup = Get-CWAAInfoBackup -EA SilentlyContinue + $backup | Should -BeNullOrEmpty + } +} + +# ---- Fresh Reinstall ---- +Describe 'Phase 3: Fresh Reinstall' -Tag 'Live' { + + It 'Install-CWAA succeeds on a fully clean system' { + $installParams = @{ + Server = $script:AgentServer + InstallerToken = $script:AgentInstallerToken + LocationID = $script:AgentLocationID + Force = $true + Confirm = $false + } + + { Install-CWAA @installParams } | Should -Not -Throw + $script:AgentInstalled = $true + } + + It 'LTService is running after fresh reinstall' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $running = Wait-ServiceState -ServiceName 'LTService' -State 'Running' -TimeoutSeconds 90 + $running | Should -BeTrue + } + + It 'agent has a valid numeric ID' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent installation failed' } + + $registered = Wait-AgentRegistration -TimeoutSeconds 120 + $registered | Should -BeTrue + + $info = Get-CWAAInfo -WhatIf:$false -Confirm:$false + $info.ID | Should -Match '^\d+$' + } +} + +# ---- Final Uninstall with Thorough Verification ---- +Describe 'Phase 3: Final Uninstall with Thorough Verification' -Tag 'Live' { + + It 'Uninstall-CWAA completes without error' { + if (-not $script:AgentInstalled) { Set-ItResult -Skipped -Because 'agent not installed' } + + { Uninstall-CWAA -Server $script:AgentServer -Force -Confirm:$false } | Should -Not -Throw + $script:AgentInstalled = $false + } + + It 'LTService service is gone' { + Get-Service 'LTService' -EA SilentlyContinue | Should -BeNullOrEmpty + } + + It 'LTSvcMon service is gone' { + Get-Service 'LTSvcMon' -EA SilentlyContinue | Should -BeNullOrEmpty + } + + It 'LabVNC service is gone' { + Get-Service 'LabVNC' -EA SilentlyContinue | Should -BeNullOrEmpty + } + + It 'primary registry key (HKLM:\SOFTWARE\LabTech\Service) is gone' { + Test-Path 'HKLM:\SOFTWARE\LabTech\Service' | Should -BeFalse + } + + It 'WOW6432Node registry key is gone' { + Test-Path 'HKLM:\SOFTWARE\WOW6432Node\LabTech\Service' | Should -BeFalse + } + + It 'agent installation directory is gone' { + Test-Path "$env:windir\LTSVC" | Should -BeFalse + } + + It 'Get-CWAAInfo returns null' { + $info = Get-CWAAInfo -EA SilentlyContinue -WhatIf:$false -Confirm:$false + $info | Should -BeNullOrEmpty + } +} + +# ============================================================================= +# Post-Test Cleanup (remove all artifacts left by test run) +# ============================================================================= +Describe 'Post-Test Cleanup' -Tag 'Live' { + + It 'removes backup registry keys' { + $backupRegPaths = @( + 'HKLM:\SOFTWARE\LabTechBackup' + 'HKLM:\SOFTWARE\WOW6432Node\LabTechBackup' + ) + foreach ($regPath in $backupRegPaths) { + if (Test-Path $regPath) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $regPath" + } + } + } + + It 'removes backup files and agent installation directory' { + $pathsToClean = @("$env:windir\LTSVC") + if ($script:PreUninstallInfo -and $script:PreUninstallInfo.BasePath) { + $pathsToClean += $script:PreUninstallInfo.BasePath + } + $pathsToClean = $pathsToClean | Select-Object -Unique + + foreach ($dirPath in $pathsToClean) { + if (Test-Path $dirPath) { + Remove-Item $dirPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $dirPath" + } + } + } + + It 'removes installer temp files' { + $searchDirs = @("$env:windir\Temp", $env:TEMP) | Select-Object -Unique + $filesToClean = @('Agent_Uninstall.exe', 'Uninstall.exe', 'Uninstall.exe.config') + + foreach ($dir in $searchDirs) { + foreach ($fileName in $filesToClean) { + $filePath = Join-Path $dir $fileName + if (Test-Path $filePath) { + Remove-Item $filePath -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $filePath" + } + } + } + } + + It 'removes LabTech installer staging directory' { + $installerTempPath = "$env:windir\Temp\LabTech" + if (Test-Path $installerTempPath) { + Remove-Item $installerTempPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $installerTempPath" + } + } + + It 'removes _LTUpdate temp directory' { + $updateTempPath = "$env:windir\Temp\_LTUpdate" + if (Test-Path $updateTempPath) { + Remove-Item $updateTempPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $updateTempPath" + } + } + + It 'removes any remaining agent registry keys' { + $registryPaths = @( + 'HKLM:\SOFTWARE\LabTech\Service' + 'HKLM:\SOFTWARE\WOW6432Node\LabTech\Service' + 'HKLM:\SOFTWARE\LabTech' + 'HKLM:\SOFTWARE\WOW6432Node\LabTech' + ) + foreach ($regPath in $registryPaths) { + if (Test-Path $regPath) { + Remove-Item $regPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Cleaned: $regPath" + } + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 new file mode 100644 index 0000000..cdd48f9 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 @@ -0,0 +1,2642 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for the ConnectWiseAutomateAgent module. + +.DESCRIPTION + Tests the logic paths of public functions using Pester mocks to isolate from + system dependencies (registry, services, files, network). Designed to run fast + on any Windows machine without admin privileges or a real Automate agent. + + Functions that are primarily system-call wrappers (Install-CWAA, Uninstall-CWAA, + Update-CWAA, Set-CWAAProxy, New-CWAABackup, Test-CWAAPort) are tested by the + Live integration test suite instead. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $ModuleName = 'ConnectWiseAutomateAgent' + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" + + Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ModulePath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Tier 1: Data Reader Functions +# ============================================================================= + +Describe 'Get-CWAAInfo' { + + Context 'when registry key does not exist' { + BeforeAll { + $script:result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable err -WhatIf:$false -Confirm:$false + $err + } + } + + It 'returns null' { + $result2 = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Get-CWAAInfo -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false + } + $result2 | Should -BeNullOrEmpty + } + + It 'writes an error about missing agent' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find information' + } + } + } + + Context 'when registry key exists with full data' { + It 'returns an object with expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '12345' + 'Server Address' = 'automate.example.com|backup.example.com|' + LocationID = '1' + BasePath = 'C:\Windows\LTSVC' + Version = '230.105' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result | Should -Not -BeNullOrEmpty + $result.ID | Should -Be '12345' + $result.LocationID | Should -Be '1' + } + + It 'parses pipe-delimited Server Address into array' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + 'Server Address' = 'srv1.example.com|srv2.example.com|' + BasePath = 'C:\Windows\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.Server | Should -HaveCount 2 + $result.Server | Should -Contain 'srv1.example.com' + $result.Server | Should -Contain 'srv2.example.com' + } + + It 'strips tildes from Server Address entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + 'Server Address' = '~automate.example.com~|' + BasePath = 'C:\Windows\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.Server | Should -Contain 'automate.example.com' + $result.Server | Should -Not -Contain '~automate.example.com~' + } + + It 'expands environment variables in BasePath' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + BasePath = '%windir%\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.BasePath | Should -Not -Match '%windir%' + $result.BasePath | Should -Match 'LTSVC' + } + + It 'excludes PS provider properties from output' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'should-be-excluded' + PSParentPath = 'should-be-excluded' + PSChildName = 'should-be-excluded' + PSDrive = 'should-be-excluded' + PSProvider = 'should-be-excluded' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $memberNames = ($result | Get-Member -MemberType NoteProperty).Name + $memberNames | Should -Not -Contain 'PSPath' + $memberNames | Should -Not -Contain 'PSParentPath' + $memberNames | Should -Not -Contain 'PSChildName' + } + } + + Context 'when BasePath is not in registry' { + It 'falls back to default install path when service key is missing' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' } + Mock Get-ItemProperty { + [PSCustomObject]@{ ID = '1' } + } -ParameterFilter { $Path -and $Path -match 'LabTech' } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.BasePath | Should -Match 'LTSVC' + } + } + + Context 'when Get-ItemProperty throws' { + It 'writes an error and does not crash' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { throw 'Registry access denied' } + $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'problem reading' + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAASettings' { + + It 'writes error when settings key does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find LTSvc settings' + } + } + + It 'returns settings object when key exists' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + Mock Get-ItemProperty { + [PSCustomObject]@{ + Debuging = 1 + ServerAddress = 'automate.example.com' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAASettings + } + $result | Should -Not -BeNullOrEmpty + $result.Debuging | Should -Be 1 + } + + It 'writes error when Get-ItemProperty throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + Mock Get-ItemProperty { throw 'Access denied' } + $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'problem reading' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAInfoBackup' { + + It 'writes error when backup registry does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + $null = Get-CWAAInfoBackup -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'New-CWAABackup' + } + } + + It 'returns backup object with expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '99999' + 'Server Address' = 'backup.example.com|' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result | Should -Not -BeNullOrEmpty + $result.ID | Should -Be '99999' + } + + It 'parses pipe-delimited Server Address' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + 'Server Address' = 'srv1.example.com|srv2.example.com|' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result.Server | Should -HaveCount 2 + $result.Server[0] | Should -Be 'srv1.example.com' + $result.Server[1] | Should -Be 'srv2.example.com' + } + + It 'expands environment variables in BasePath' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + BasePath = '%windir%\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result.BasePath | Should -Not -Match '%windir%' + } + + It 'handles missing Server Address without crashing' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'Server' -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAALogLevel' { + + It 'returns Normal when Debuging is 1' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1 } } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Normal' + } + + It 'returns Verbose when Debuging is 1000' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1000 } } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Verbose' + } + + It 'returns Normal when Debuging is null (fresh install)' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{} } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Normal' + } + + It 'writes error for unexpected Debuging value' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 500 } } + $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unknown logging level' + } + } + + It 'writes error when Get-CWAASettings throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { throw 'Registry unavailable' } + $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAError' { + + It 'returns structured objects from log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0.3.42`tJan 15 2025 10:30 - `tSample error message::: 11.0.3.42`tJan 15 2025 11:00 - `tAnother error") } + Get-CWAAError + } + $result | Should -Not -BeNullOrEmpty + $first = $result | Select-Object -First 1 + $first.ServiceVersion | Should -Not -BeNullOrEmpty + $first.Message | Should -Not -BeNullOrEmpty + } + + It 'writes error when log file does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find agent error log' + } + } + + It 'falls back to default path when agent not installed' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Test-Path { return $false } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + "$testErr" | Should -Match 'LTSVC' + } + } + + It 'parses multiple ::: delimited entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tError one::: 11.0`tJan 01 2025 09:00 - `tError two::: 11.0`tJan 01 2025 10:00 - `tError three") } + Get-CWAAError + } + ($result | Measure-Object).Count | Should -BeGreaterOrEqual 3 + } + + It 'sets Timestamp to null for unparseable dates' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tNOT-A-DATE - `tSome error") } + Get-CWAAError + } + $result | Should -Not -BeNullOrEmpty + ($result | Select-Object -First 1).Timestamp | Should -BeNullOrEmpty + } + + It 'returns nothing for empty log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @('') } + Get-CWAAError -ErrorAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + + It 'writes error when Get-Content throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { throw 'File locked' } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAProbeError' { + + It 'returns structured objects from probe log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 15 2025 10:30 - `tProbe error message") } + Get-CWAAProbeError + } + $result | Should -Not -BeNullOrEmpty + ($result | Select-Object -First 1).Message | Should -Not -BeNullOrEmpty + } + + It 'writes error when probe log does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'probe error log' + } + } + + It 'falls back to default path when agent not installed' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Test-Path { return $false } + $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr + "$testErr" | Should -Match 'LTSVC' + } + } + + It 'parses multiple ::: delimited entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tProbe err1::: 11.0`tJan 01 2025 09:00 - `tProbe err2") } + Get-CWAAProbeError + } + ($result | Measure-Object).Count | Should -BeGreaterOrEqual 2 + } + + It 'returns nothing for empty log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @('') } + Get-CWAAProbeError -ErrorAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } +} + +# ============================================================================= +# Tier 2: Functions with Testable Logic +# ============================================================================= + +Describe 'Invoke-CWAACommand' { + + It 'warns when LTService is not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not found' + } + } + + It 'warns when LTService is not running' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not running' + } + } + + It 'sends command when service is running' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false + } + $result | Should -Match "Sent Command 'Send Status'" + } + + It 'sends multiple commands' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command 'Send Status', 'Send Inventory' -Confirm:$false + } + ($result | Measure-Object).Count | Should -Be 2 + } + + It 'accepts all 18 valid commands' -ForEach @( + @{ Cmd = 'Update Schedule' } + @{ Cmd = 'Send Inventory' } + @{ Cmd = 'Send Drives' } + @{ Cmd = 'Send Processes' } + @{ Cmd = 'Send Spyware List' } + @{ Cmd = 'Send Apps' } + @{ Cmd = 'Send Events' } + @{ Cmd = 'Send Printers' } + @{ Cmd = 'Send Status' } + @{ Cmd = 'Send Screen' } + @{ Cmd = 'Send Services' } + @{ Cmd = 'Analyze Network' } + @{ Cmd = 'Write Last Contact Date' } + @{ Cmd = 'Kill VNC' } + @{ Cmd = 'Kill Trays' } + @{ Cmd = 'Send Patch Reboot' } + @{ Cmd = 'Run App Care Update' } + @{ Cmd = 'Start App Care Daytime Patching' } + ) { + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $Cmd { + param($CommandName) + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command $CommandName -Confirm:$false + } + $result | Should -Match "Sent Command '$Cmd'" + } + + It 'accepts pipeline input' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + 'Send Status' | Invoke-CWAACommand -Confirm:$false + } + $result | Should -Match 'Send Status' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Hide-CWAAAddRemove' { + + It 'warns when no registry keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be hidden' + } + } + + It 'sets SystemComponent to 1 when uninstall key exists' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Hide-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 1 } + } + } + + It 'skips write when SystemComponent is already 1' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Hide-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 0 -Scope It + } + } + + It 'renames HiddenProductName to ProductName when ProductName is missing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'ProductName' } + Mock Rename-ItemProperty {} + + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null + + Should -Invoke Rename-ItemProperty -Times 1 -Scope It + } + } + + It 'removes unused HiddenProductName when ProductName already exists' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { [PSCustomObject]@{ ProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'ProductName' } + Mock Remove-ItemProperty {} + + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null + + Should -Invoke Remove-ItemProperty -Times 1 -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Show-CWAAAddRemove' { + + It 'warns when no registry keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } + Show-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be visible' + } + } + + It 'sets SystemComponent to 0 when hidden' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 0 } + } + } + + It 'skips write when SystemComponent is already 0' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 0 -Scope It + } + } + + It 'outputs success message when entries changed' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + } + $result | Should -Match 'visible' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Rename-CWAAAddRemove' { + + It 'sets DisplayName when key is found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'My Agent' } + } + } + + It 'sets both DisplayName and Publisher when PublisherName provided' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { [PSCustomObject]@{ Publisher = 'LabTech' } } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -PublisherName 'My Company' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' } + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Publisher' -and $Value -eq 'My Company' } + } + } + + It 'warns when no matching keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { return $null } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false 3>&1 | Should -Match 'not found.*Name was not changed' + } + } + + It 'updates HiddenProductName when DisplayName is absent' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'HiddenProductName' -and $Value -eq 'My Agent' } + } + } + + It 'outputs success message with new name' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'Custom Agent' -Confirm:$false + } + $result | Should -Match 'Custom Agent' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Set-CWAALogLevel' { + + It 'sets Debuging to 1 for Normal level' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1 } + } + } + + It 'sets Debuging to 1000 for Verbose level' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Verbose' } + + Set-CWAALogLevel -Level Verbose -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } + } + } + + It 'defaults to Normal when Level is not specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Value -eq 1 } + } + } + + It 'calls Stop-CWAA before and Start-CWAA after the registry write' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + + Should -Invoke Stop-CWAA -Times 1 -Scope It + Should -Invoke Start-CWAA -Times 1 -Scope It + } + } + + It 'calls Get-CWAALogLevel at the end to report' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + } + $result | Should -Be 'Current logging level: Normal' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAProxy' { + + BeforeEach { + # Reset module proxy state before each test to prevent cross-test pollution + $module = Get-Module 'ConnectWiseAutomateAgent' + & $module { + $Script:LTProxy.Enabled = $False + $Script:LTProxy.ProxyServerURL = '' + $Script:LTProxy.ProxyUsername = '' + $Script:LTProxy.ProxyPassword = '' + $Script:LTServiceKeys.ServerPasswordString = '' + $Script:LTServiceKeys.PasswordString = '' + } + } + + It 'returns proxy with Enabled=$false when no agent installed' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAASettings { return $null } + Get-CWAAProxy + } + $result.Enabled | Should -BeFalse + $result.ProxyServerURL | Should -Be '' + } + + It 'returns proxy with Enabled=$false when no proxy configured' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } + Mock Get-CWAASettings { + [PSCustomObject]@{ ServerAddress = 'automate.example.com' } + } + Get-CWAAProxy + } + $result.Enabled | Should -BeFalse + } + + It 'enables proxy when ProxyServerURL matches http pattern' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } + Mock Get-CWAASettings { + [PSCustomObject]@{ ProxyServerURL = 'http://proxy.example.com:8080' } + } + Get-CWAAProxy + } + $result.Enabled | Should -BeTrue + $result.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' + } + + It 'decodes proxy username and password via ConvertFrom-CWAASecurity' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded'; Password = 'fakeAgentPwd' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedValue' } + Mock Get-CWAASettings { + [PSCustomObject]@{ + ProxyServerURL = 'http://proxy.example.com:8080' + ProxyUsername = 'encryptedUser' + ProxyPassword = 'encryptedPass' + } + } + Get-CWAAProxy + + # ConvertFrom-CWAASecurity should be called for ServerPassword, Password, ProxyUsername, and ProxyPassword + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 4 + + # Verify the decryption chain: ServerPassword decoded first, then Password uses it as Key + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { + $InputString -eq 'fakeEncoded' + } + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { + $InputString -eq 'fakeAgentPwd' + } + } + } + + It 'populates ServerPasswordString in LTServiceKeys' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'theServerPwd' } + Mock Get-CWAASettings { [PSCustomObject]@{} } + + Get-CWAAProxy + + $Script:LTServiceKeys.ServerPasswordString | Should -Be 'theServerPwd' + } + } + + It 'returns the $Script:LTProxy object' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAASettings { return $null } + Get-CWAAProxy + } + $result | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'Enabled' | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'ProxyServerURL' | Should -Not -BeNullOrEmpty + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Restart-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'calls Stop-CWAA then Start-CWAA on success' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + + Restart-CWAA -Confirm:$false + } + $result | Should -Match 'restarted successfully' + } + + It 'writes error and stops when Stop-CWAA throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA { throw 'Stop failed' } + Mock Start-CWAA {} + + $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'error stopping' + Should -Invoke Start-CWAA -Times 0 -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Stop-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + $null = Stop-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs success message when services stop' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + } + $result | Should -Match 'stopped successfully' + } + + It 'sends Kill VNC and Kill Trays commands' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + + Should -Invoke Invoke-CWAACommand -Times 1 -Scope It + } + } + + It 'attempts to terminate LabTech processes' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + + Should -Invoke Get-Process -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Start-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-Service { return $null } + $null = Start-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs success when services reach running state' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Set-Service {} + Mock Invoke-CWAACommand {} + Mock Start-Sleep {} + Mock Stop-Process {} + + Start-CWAA -Confirm:$false + } + $result | Should -Match 'started successfully' + } + + It 'sends Send Status command after successful start' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Set-Service {} + Mock Invoke-CWAACommand {} + Mock Start-Sleep {} + Mock Stop-Process {} + + Start-CWAA -Confirm:$false + + Should -Invoke Invoke-CWAACommand -Times 1 -Scope It -ParameterFilter { $Command -eq 'Send Status' } + } + } +} + +# ============================================================================= +# Tier 3: Orchestration Logic +# ============================================================================= + +Describe 'Reset-CWAA' { + + It 'resets all three values when no switches specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } + } + } + + It 'resets only ID when -ID specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -ID -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 + } + } + + It 'resets only LocationID when -Location specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -Location -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 + } + } + + It 'resets only MAC when -MAC specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -MAC -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 + } + } + + It 'throws terminating error when probe detected without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + + Reset-CWAA -NoWait -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'proceeds when probe detected with -Force' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + { Reset-CWAA -Force -NoWait -Confirm:$false } | Should -Not -Throw + } + } + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { return $null } + + $null = Reset-CWAA -NoWait -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs OLD ID line with current values' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '42'; LocationID = '7'; MAC = 'DE:AD:BE:EF' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -NoWait -Confirm:$false + } + $result | Should -Contain 'OLD ID: 42 LocationID: 7 MAC: DE:AD:BE:EF' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Redo-CWAA' { + + It 'reads server from current agent settings' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke Uninstall-CWAA -Times 1 -Scope It + Should -Invoke Install-CWAA -Times 1 -Scope It + } + } + + It 'falls back to backup settings when current is null' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com'); LocationID = '2' } } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke Install-CWAA -Times 1 -Scope It + } + } + + It 'throws terminating error when probe detected without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'proceeds when probe detected with -Force' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw + } + } + + It 'calls New-CWAABackup when -Backup specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock New-CWAABackup {} + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Backup -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke New-CWAABackup -Times 1 -Scope It + } + } +} + +# ============================================================================= +# Tier 4: Installation Functions (Batch A) +# ============================================================================= + +Describe 'Install-CWAA' { + + Context 'parameter validation' { + It 'rejects an invalid server address format' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Install-CWAA -Server 'not a valid server!@#' -LocationID 1 -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + + It 'rejects InstallerToken with invalid characters' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Install-CWAA -Server 'automate.example.com' -InstallerToken 'INVALID-TOKEN!' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + } + + Context 'when services are already installed' { + It 'writes a terminating error without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*already installed*' + } + } + + # Note: Tests requiring admin bypass (e.g., download, msiexec) are not feasible + # in mocked tests because Install-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() + # which is a .NET static method that cannot be Pester-mocked. Those paths are covered + # by the Live integration test suite instead. + Context 'when not running as administrator' { + It 'throws Needs to be ran as Administrator when services are not detected' { + # When no services are found and not running elevated, the admin check fires + $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | + Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') + if ($isAdmin) { + Set-ItResult -Skipped -Because 'Test requires non-admin context' + } + else { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-Service { return $null } + Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -SkipDotNet -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Administrator*' + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Uninstall-CWAA' { + + # Note: Uninstall-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() + # which is a .NET static method that cannot be Pester-mocked. Tests that need to get past + # the admin check are conditioned on actually running elevated, or skipped. + + Context 'when not running as administrator' { + It 'throws Needs to be ran as Administrator' { + $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | + Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') + if ($isAdmin) { + Set-ItResult -Skipped -Because 'Test requires non-admin context' + } + else { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { return $null } + Uninstall-CWAA -Server 'automate.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Administrator*' + } + } + } + + Context 'parameter validation' { + It 'rejects an invalid server address format' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Uninstall-CWAA -Server 'not a valid server!@#' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + } + + Context 'probe detection logic via Redo-CWAA integration' { + # Since Uninstall-CWAA requires admin, we test probe detection through Redo-CWAA + # which calls Uninstall-CWAA internally and does its own probe check first. + It 'Redo-CWAA refuses probe uninstall without -Force (tests same probe logic)' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'Redo-CWAA proceeds with -Force past probe detection' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Update-CWAA' { + + Context 'when no existing installation is found' { + It 'writes a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { return $null } + + Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*No existing installation*' + } + } + + Context 'when installed version is higher than requested' { + It 'writes a warning and returns' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '220.100'; Server = @('automate.example.com') } } + Mock Test-Path { return $false } + + # Capture warnings via -WarningVariable. The Process block emits download warnings, + # then the End block emits the version comparison warning. + $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn + "$testWarn" | Should -Match 'higher than or equal' + } + } + } + + Context 'when installed version equals requested version' { + It 'writes a warning about equal version' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '200.100'; Server = @('automate.example.com') } } + Mock Test-Path { return $false } + + $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn + "$testWarn" | Should -Match 'higher than or equal' + } + } + } +} + +# ============================================================================= +# Tier 5: Health & Connectivity Functions (Batch B) +# ============================================================================= + +Describe 'Repair-CWAA' { + + Context 'when agent is healthy' { + It 'returns ActionTaken=None with success' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + HeartbeatLastReceived = (Get-Date).AddMinutes(-15).ToString() + } + } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'None' + $result.Success | Should -BeTrue + $result.Message | Should -Match 'healthy' + } + } + + Context 'when agent is offline beyond restart threshold' { + It 'restarts services and reports recovery' { + $script:callCount = 0 + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + # First call returns old LastSuccessStatus, subsequent calls return recent + Mock Get-CWAAInfo { + $script:callCount++ + if ($script:callCount -le 1) { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddHours(-3).ToString() + HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() + HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() + } + } + else { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-1).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-1).ToString() + HeartbeatLastReceived = (Get-Date).AddMinutes(-1).ToString() + } + } + } + Mock Test-CWAAServerConnectivity { return $true } + Mock Restart-CWAA {} + Mock Start-Sleep {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Restart' + $result.Message | Should -Match 'recovered' + } + } + + Context 'when agent is offline beyond reinstall threshold' { + It 'triggers reinstall after failed restart' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + # Return old date consistently. The wait loop calls Get-CWAAInfo + # for up to 2 minutes. To prevent spinning for 120s, mock Get-Date + # to jump past the 2-minute window after initial threshold calculations. + $Script:RepairDateCallCount = 0 + Mock Get-Date { + $Script:RepairDateCallCount++ + if ($Script:RepairDateCallCount -le 4) { + return [datetime]::Now + } + else { + return [datetime]::Now.AddMinutes(5) + } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = [datetime]::Now.AddDays(-10).ToString() + HeartbeatLastSent = [datetime]::Now.AddDays(-10).ToString() + HeartbeatLastReceived = [datetime]::Now.AddDays(-10).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $true } + Mock Restart-CWAA {} + Mock Start-Sleep {} + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Reinstall' + } + } + + Context 'when agent is not installed' { + It 'attempts a fresh install with provided parameters' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { return $null } + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -Server 'automate.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Install' + $result.Message | Should -Match 'Fresh agent install' + } + + It 'reports error when no install settings are available' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { return $null } + Mock Get-CWAAInfo { throw 'Not installed' } + Mock Get-CWAAInfoBackup { return $null } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + } + $result.Success | Should -BeFalse + $result.Message | Should -Match 'Unable to find install settings' + } + } + + Context 'when server is not reachable' { + It 'returns error about unreachable server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddHours(-3).ToString() + HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() + HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $false } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + } + $result.Success | Should -BeFalse + $result.Message | Should -Match 'not reachable' + } + } + + Context 'when agent points to wrong server' { + It 'reinstalls with the correct server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('wrong.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -Server 'correct.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Reinstall' + $result.Message | Should -Match 'correct server' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Test-CWAAHealth' { + + Context 'when agent is fully healthy' { + It 'returns a health object with Healthy=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.ServicesRunning | Should -BeTrue + $result.Healthy | Should -BeTrue + $result.LastContact | Should -Not -BeNullOrEmpty + } + + It 'returns correct object structure with all expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + Test-CWAAHealth + } + $memberNames = ($result | Get-Member -MemberType NoteProperty).Name + $memberNames | Should -Contain 'AgentInstalled' + $memberNames | Should -Contain 'ServicesRunning' + $memberNames | Should -Contain 'LastContact' + $memberNames | Should -Contain 'LastHeartbeat' + $memberNames | Should -Contain 'ServerAddress' + $memberNames | Should -Contain 'ServerMatch' + $memberNames | Should -Contain 'ServerReachable' + $memberNames | Should -Contain 'Healthy' + } + } + + Context 'when services are stopped' { + It 'returns Healthy=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Stopped' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + } + } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.ServicesRunning | Should -BeFalse + $result.Healthy | Should -BeFalse + } + } + + Context 'when agent is not installed' { + It 'returns AgentInstalled=$false and Healthy=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeFalse + $result.ServicesRunning | Should -BeFalse + $result.Healthy | Should -BeFalse + $result.LastContact | Should -BeNullOrEmpty + } + } + + Context 'when -Server parameter is provided' { + It 'sets ServerMatch=$true when server matches' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + + Test-CWAAHealth -Server 'automate.example.com' + } + $result.ServerMatch | Should -BeTrue + } + + It 'sets ServerMatch=$false when server does not match' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('other.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + + Test-CWAAHealth -Server 'automate.example.com' + } + $result.ServerMatch | Should -BeFalse + } + } + + Context 'when -TestServerConnectivity is used' { + It 'sets ServerReachable from Test-CWAAServerConnectivity' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $true } + + Test-CWAAHealth -TestServerConnectivity + } + $result.ServerReachable | Should -BeTrue + } + } + + Context 'when agent info cannot be read' { + It 'returns Healthy=$false with null timestamps' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { throw 'Registry read error' } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.LastContact | Should -BeNullOrEmpty + $result.Healthy | Should -BeFalse + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Register-CWAAHealthCheckTask' { + + Context 'when task does not exist' { + It 'creates a new scheduled task and returns Created=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { throw 'Task not found' } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result.Created | Should -BeTrue + $result.Updated | Should -BeFalse + $result.TaskName | Should -Be 'CWAAHealthCheck' + } + } + + Context 'when task exists with matching token' { + It 'skips recreation and returns Created=$false, Updated=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { + # Return XML that contains the token in the Arguments element + return '-Command "Repair-CWAA -InstallerToken abc123"' + } + } + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result.Created | Should -BeFalse + $result.Updated | Should -BeFalse + } + } + + Context 'when -Force is used with existing task' { + It 'recreates the task' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { + return '-Command "Repair-CWAA -InstallerToken abc123"' + } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Force -Confirm:$false + } + ($result.Created -or $result.Updated) | Should -BeTrue + } + } + + Context 'when custom parameters are provided' { + It 'accepts custom TaskName and IntervalHours' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { throw 'Task not found' } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -TaskName 'MyHealthCheck' -IntervalHours 12 -Confirm:$false + } + $result.TaskName | Should -Be 'MyHealthCheck' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Unregister-CWAAHealthCheckTask' { + + Context 'when task exists' { + It 'removes the task and returns Removed=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } + elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock Write-CWAAEventLog {} + + Unregister-CWAAHealthCheckTask -Confirm:$false + } + $result.Removed | Should -BeTrue + $result.TaskName | Should -Be 'CWAAHealthCheck' + } + } + + Context 'when task does not exist' { + It 'writes a warning and returns Removed=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 1; return $null } + } + + Unregister-CWAAHealthCheckTask -Confirm:$false 3>&1 | Out-Null + Unregister-CWAAHealthCheckTask -Confirm:$false + } + $result.Removed | Should -BeFalse + } + } + + Context 'when custom TaskName is provided' { + It 'targets the correct task name' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } + elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock Write-CWAAEventLog {} + + Unregister-CWAAHealthCheckTask -TaskName 'CustomTask' -Confirm:$false + } + $result.TaskName | Should -Be 'CustomTask' + $result.Removed | Should -BeTrue + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Test-CWAAServerConnectivity' { + + Context 'when server responds with valid agent pattern' { + It 'returns Available=$true with version' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # The agent.aspx response pattern requires 6+ consecutive pipes before the version + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeTrue + $result.Version | Should -Be '220.105' + $result.ErrorMessage | Should -BeNullOrEmpty + } + } + + Context 'when server responds with unexpected format' { + It 'returns Available=$false with error message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return 'not a valid response' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeFalse + $result.ErrorMessage | Should -Match 'unexpected format' + } + } + + Context 'when server is unreachable' { + It 'returns Available=$false with error message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { throw 'Connection refused' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeFalse + $result.ErrorMessage | Should -Match 'Connection refused' + } + } + + Context 'when -Quiet mode is used' { + It 'returns $true when server is reachable' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet + } + $result | Should -BeTrue + } + + It 'returns $false when server is unreachable' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { throw 'Connection refused' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet + } + $result | Should -BeFalse + } + } + + Context 'when no server is provided' { + It 'discovers server from agent config' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('discovered.example.com') } } + Mock Get-CWAAInfoBackup { return $null } + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity + } + $result.Server | Should -Be 'discovered.example.com' + $result.Available | Should -BeTrue + } + + It 'falls back to backup when agent config has no server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # Return an object without a Server property so Select-Object -Expand 'Server' returns nothing + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com') } } + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity + } + $result.Server | Should -Be 'backup.example.com' + } + + It 'writes error when no server can be determined' { + InModuleScope 'ConnectWiseAutomateAgent' { + # Return objects without a Server property so the function sees no server + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ ID = '1' } } + + $null = Test-CWAAServerConnectivity -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'No server could be determined' + } + } + } +} + +# ============================================================================= +# Tier 6: Remaining Functions (Batch C) +# ============================================================================= + +Describe 'Test-CWAAPort' { + + Context 'when TrayPort is available in Quiet mode' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + # netstat returns no matching output for the port + $env_windir = $env:windir + Mock Invoke-Expression { return $null } + # Mock netstat by ensuring no process is found on the port + function netstat { return @() } + Test-CWAAPort -TrayPort 42000 -Quiet + } + $result | Should -BeTrue + } + } + + Context 'when TrayPort is in use in non-Quiet mode' { + It 'outputs a message about the port being in use' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000'; Server = @('automate.example.com') } } + Mock Get-CWAAInfoBackup { return $null } + Mock Get-Process { [PSCustomObject]@{ ProcessName = 'LTSvc'; Id = 1234 } } + Mock Test-Connection { return $true } + # Mock netstat to return a line matching the port with a PID + $Script:MockNetstatOutput = " TCP 0.0.0.0:42000 0.0.0.0:0 LISTENING 1234" + + # We need to test the output message + Test-CWAAPort -TrayPort 42000 -Server 'automate.example.com' 2>&1 + } + # The function produces port-related output + $result | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Set-CWAAProxy' { + + BeforeEach { + # Reset module proxy state and ensure LTServiceNetWebClient exists before each test. + # Set-CWAAProxy assigns to $Script:LTServiceNetWebClient.Proxy which requires a real + # object with a Proxy property (WebClient). When Initialize-CWAANetworking is mocked, + # this object may not be initialized. + $module = Get-Module 'ConnectWiseAutomateAgent' + & $module { + $Script:LTProxy.Enabled = $False + $Script:LTProxy.ProxyServerURL = '' + $Script:LTProxy.ProxyUsername = '' + $Script:LTProxy.ProxyPassword = '' + if (-not $Script:LTServiceNetWebClient) { + $Script:LTServiceNetWebClient = New-Object System.Net.WebClient + } + if (-not $Script:LTWebProxy) { + $Script:LTWebProxy = New-Object System.Net.WebProxy + } + } + } + + Context 'when -ResetProxy is used' { + It 'clears proxy settings' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + Mock Get-Service { return $null } + + # Set proxy first + $Script:LTProxy.Enabled = $True + $Script:LTProxy.ProxyServerURL = 'http://proxy.example.com:8080' + + Set-CWAAProxy -ResetProxy -Confirm:$false + + $Script:LTProxy.Enabled | Should -BeFalse + $Script:LTProxy.ProxyServerURL | Should -Be '' + } + } + } + + Context 'when -ProxyServerURL is provided' { + It 'sets the proxy URL and enables proxy' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + Mock Get-Service { return $null } + + Set-CWAAProxy -ProxyServerURL 'http://proxy.example.com:8080' -Confirm:$false + + $Script:LTProxy.Enabled | Should -BeTrue + $Script:LTProxy.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' + } + } + } + + Context 'when invalid parameter combinations are used' { + It 'throws error for ResetProxy with ProxyServerURL' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -ResetProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Invalid parameter combination*' + } + + It 'throws error for DetectProxy with ProxyServerURL' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -DetectProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Invalid parameter combination*' + } + } + + Context 'when proxy changes require service restart' { + It 'restarts services when settings change and services are running' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { + [PSCustomObject]@{ + ProxyServerURL = 'http://old-proxy.example.com:8080' + ProxyUsername = '' + ProxyPassword = '' + } + } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock ConvertTo-CWAASecurity { return 'encoded' } + Mock ConvertFrom-CWAASecurity { return '' } + Mock Write-CWAAEventLog {} + + Set-CWAAProxy -ProxyServerURL 'http://new-proxy.example.com:8080' -Confirm:$false + + Should -Invoke Stop-CWAA -Scope It + Should -Invoke Start-CWAA -Scope It + } + } + } + + Context 'when no parameters are provided' { + It 'writes error about missing parameters' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*parameters missing*' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'New-CWAABackup' { + + Context 'when agent is not installed' { + It 'writes terminating error when BasePath is not found' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + + New-CWAABackup -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Unable to find LTSvc folder path*' + } + } + + Context 'when registry key is missing' { + It 'writes terminating error about missing registry' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + + New-CWAABackup -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Unable to find registry*' + } + } + + Context 'when agent is properly installed' { + It 'creates backup directory and copies files' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + # First call for HKLM registry check, second for agent path + Mock Test-Path { return $true } + Mock New-Item {} + Mock Get-ChildItem { @() } + Mock Copy-Item {} + # Mock reg.exe operations + $env_windir = $env:windir + Mock Get-Content { @('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech]', '"Key"="Value"') } + Mock Out-File {} + Mock Write-CWAAEventLog {} + + $result = New-CWAABackup -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke New-Item -Scope It + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'ConvertTo-CWAASecurity additional edge cases' { + + It 'returns empty string for empty input' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString '' + } + # Empty input still produces an encoded output (encrypted empty string) + $result | Should -Not -BeNullOrEmpty + } + + It 'returns empty string for null key (uses default)' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key $null + } + $result | Should -Not -BeNullOrEmpty + } + + It 'produces different output for different keys' { + $result1 = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' + } + $result2 = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' + } + $result1 | Should -Not -Be $result2 + } + + It 'round-trips successfully with ConvertFrom-CWAASecurity using same key' { + $originalValue = 'RoundTripTestValue' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key 'TestKey123' + ConvertFrom-CWAASecurity -InputString $encoded -Key 'TestKey123' -Force:$false + } + $result | Should -Be $originalValue + } + + It 'round-trips with default key' { + $originalValue = 'DefaultKeyRoundTrip' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue + ConvertFrom-CWAASecurity -InputString $encoded -Force:$false + } + $result | Should -Be $originalValue + } + + It 'handles special characters in input' { + $originalValue = 'P@ssw0rd!#$%^&*()' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue + ConvertFrom-CWAASecurity -InputString $encoded -Force:$false + } + $result | Should -Be $originalValue + } +} + +# ----------------------------------------------------------------------------- + +Describe 'ConvertFrom-CWAASecurity additional edge cases' { + + It 'returns null for invalid base64 input without Force' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertFrom-CWAASecurity -InputString 'not-valid-base64!!!' -Key 'TestKey' -Force:$false + } + $result | Should -BeNullOrEmpty + } + + It 'returns null when wrong key is used without Force' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'CorrectKey' + ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$false + } + $result | Should -BeNullOrEmpty + } + + It 'falls back to alternate keys when Force is enabled and primary key fails' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # Encode with default key + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' + # Try to decode with wrong key but Force enabled (should fall back to default) + ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$true + } + $result | Should -Be 'TestValue' + } + + It 'handles empty key by using default' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key '' + ConvertFrom-CWAASecurity -InputString $encoded -Key '' -Force:$false + } + $result | Should -Be 'TestValue' + } + + It 'handles array of input strings' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded1 = ConvertTo-CWAASecurity -InputString 'Value1' + $encoded2 = ConvertTo-CWAASecurity -InputString 'Value2' + ConvertFrom-CWAASecurity -InputString @($encoded1, $encoded2) -Force:$false + } + $result | Should -HaveCount 2 + $result[0] | Should -Be 'Value1' + $result[1] | Should -Be 'Value2' + } + + It 'rejects empty string due to mandatory parameter validation' { + # ConvertFrom-CWAASecurity has [parameter(Mandatory = $true)] [string[]]$InputString + # which prevents binding an empty string. This confirms the validation fires. + { + InModuleScope 'ConnectWiseAutomateAgent' { + ConvertFrom-CWAASecurity -InputString '' -Force:$false -ErrorAction Stop + } + } | Should -Throw + } +} + +# ============================================================================= +# Private Helper Functions +# ============================================================================= + +Describe 'Test-CWAADownloadIntegrity' { + + Context 'when file exists and exceeds minimum size' { + It 'returns true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestIntegrity.msi' + # Create a file larger than 1234 KB (write ~1300 KB) + $bytes = New-Object byte[] (1300 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestIntegrity.msi' + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + } + + Context 'when file exists but is below minimum size' { + It 'returns false and removes the file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestSmall.msi' + # Create a file smaller than 1234 KB (write 10 KB) + $bytes = New-Object byte[] (10 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestSmall.msi' -WarningAction SilentlyContinue + $fileStillExists = Test-Path $testFile + [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } + } + $result.Result | Should -Be $false + $result.FileExists | Should -Be $false + } + } + + Context 'when file does not exist' { + It 'returns false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Test-CWAADownloadIntegrity -FilePath 'C:\NonExistent\FakeFile.msi' -FileName 'FakeFile.msi' + } + $result | Should -Be $false + } + } + + Context 'with custom MinimumSizeKB threshold' { + It 'uses the custom threshold for validation' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestCustom.exe' + # Create a 100 KB file, check with 80 KB threshold + $bytes = New-Object byte[] (100 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustom.exe' -MinimumSizeKB 80 + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + + It 'fails when file is below custom threshold' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestCustomFail.exe' + # Create a 50 KB file, check with 80 KB threshold + $bytes = New-Object byte[] (50 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustomFail.exe' -MinimumSizeKB 80 -WarningAction SilentlyContinue + $fileStillExists = Test-Path $testFile + [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } + } + $result.Result | Should -Be $false + $result.FileExists | Should -Be $false + } + } + + Context 'when FileName is not provided' { + It 'derives the filename from the path' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestDerived.msi' + $bytes = New-Object byte[] (1300 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Remove-CWAAFolderRecursive' { + + Context 'when folder exists with nested content' { + It 'removes the folder and all contents' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testRoot = Join-Path $env:TEMP 'CWAATestRemoveFolder' + $subDir = Join-Path $testRoot 'SubFolder' + New-Item -Path $subDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $testRoot 'file1.txt') -Value 'test' + Set-Content -Path (Join-Path $subDir 'file2.txt') -Value 'test' + Remove-CWAAFolderRecursive -Path $testRoot -Confirm:$false + Test-Path $testRoot + } + $result | Should -Be $false + } + } + + Context 'when folder does not exist' { + It 'completes without error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Remove-CWAAFolderRecursive -Path 'C:\NonExistent\CWAATestFolder' -Confirm:$false + } + } | Should -Not -Throw + } + } + + Context 'when called with -WhatIf' { + It 'does not actually remove the folder' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testRoot = Join-Path $env:TEMP 'CWAATestWhatIf' + New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $testRoot 'file.txt') -Value 'test' + Remove-CWAAFolderRecursive -Path $testRoot -WhatIf -Confirm:$false + $exists = Test-Path $testRoot + # Clean up for real + Remove-Item $testRoot -Recurse -Force -ErrorAction SilentlyContinue + $exists + } + $result | Should -Be $true + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Resolve-CWAAServer' { + + Context 'when server responds with valid version' { + It 'returns the server URL and version' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||220.105' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') + } + $result | Should -Not -BeNullOrEmpty + $result.ServerUrl | Should -Match 'automate\.example\.com' + $result.ServerVersion | Should -Be '220.105' + } + } + + Context 'when server URL has no scheme' { + It 'normalizes the URL and still resolves' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||230.001' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('automate.example.com') + } + $result | Should -Not -BeNullOrEmpty + $result.ServerVersion | Should -Be '230.001' + } + } + + Context 'when server returns no parseable version' { + It 'returns null' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return 'no version data here' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } + + Context 'when server is unreachable' { + It 'returns null' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + throw 'Connection refused' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } + + Context 'when first server fails but second succeeds' { + It 'returns the second server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $callCount = 0 + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + # Use the URL to determine behavior since $callCount scope is tricky + if ($url -match 'bad\.example\.com') { + throw 'Connection refused' + } + return '||||||210.050' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://bad.example.com', 'https://good.example.com') -WarningAction SilentlyContinue + } + $result | Should -Not -BeNullOrEmpty + $result.ServerUrl | Should -Match 'good\.example\.com' + $result.ServerVersion | Should -Be '210.050' + } + } + + Context 'when server URL is invalid format' { + It 'returns null and writes a warning' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||220.105' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com/some/path') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Tests.ps1 index e69de29..d57e48c 100644 --- a/Tests/ConnectWiseAutomateAgent.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Tests.ps1 @@ -0,0 +1,688 @@ +#Requires -Module Pester + +BeforeAll { + $ModuleName = 'ConnectWiseAutomateAgent' + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" + + # Remove module if already loaded, then import fresh + Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + Import-Module $ModulePath -Force -ErrorAction Stop +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Module Quality Tests +# ============================================================================= +Describe 'Module: ConnectWiseAutomateAgent' { + + Context 'Module Manifest' { + BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $ManifestPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1' + $Manifest = Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop + } + + It 'has a valid module manifest' { + $Manifest | Should -Not -BeNullOrEmpty + } + + It 'has a valid root module' { + $Manifest.RootModule | Should -Be 'ConnectWiseAutomateAgent.psm1' + } + + It 'has the expected module version' { + $expectedVersion = (Import-PowerShellDataFile $ManifestPath).ModuleVersion + $Manifest.Version.ToString() | Should -Be $expectedVersion + } + + It 'has a valid GUID' { + $Manifest.GUID.ToString() | Should -Be '37424fc5-48d4-4d15-8b19-e1c2bf4bab67' + } + + It 'requires PowerShell 3.0 or higher' { + $Manifest.PowerShellVersion | Should -Be '3.0' + } + + It 'has a project URI' { + $Manifest.PrivateData.PSData.ProjectUri | Should -Not -BeNullOrEmpty + } + + It 'exports no variables' { + $Manifest.ExportedVariables.Keys | Should -HaveCount 0 + } + + It 'exports no cmdlets' { + $Manifest.ExportedCmdlets.Keys | Should -HaveCount 0 + } + } + + Context 'Module Import' { + It 'imports without errors' { + { Get-Module 'ConnectWiseAutomateAgent' } | Should -Not -Throw + } + + It 'is loaded in the session' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $module | Should -Not -BeNullOrEmpty + $module.Name | Should -Be 'ConnectWiseAutomateAgent' + } + } + + Context 'Lazy Initialization' { + It 'does not initialize networking on module import' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $initialized = & $module { $Script:CWAANetworkInitialized } + $initialized | Should -Be $False + } + + It 'has CWAA constants defined after import' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $registryRoot = & $module { $Script:CWAARegistryRoot } + $registryRoot | Should -Be 'HKLM:\SOFTWARE\LabTech\Service' + } + + It 'has CWAARegistrySettings constant defined' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $registrySettings = & $module { $Script:CWAARegistrySettings } + $registrySettings | Should -Be 'HKLM:\SOFTWARE\LabTech\Service\Settings' + } + + It 'has CWAAInstallPath constant defined' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $installPath = & $module { $Script:CWAAInstallPath } + $installPath | Should -Match 'LTSVC$' + } + + It 'has CWAAServiceNames constant defined' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $serviceNames = & $module { $Script:CWAAServiceNames } + $serviceNames | Should -Contain 'LTService' + $serviceNames | Should -Contain 'LTSvcMon' + } + + It 'has empty LTServiceKeys after import' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $keys = & $module { $Script:LTServiceKeys } + $keys | Should -Not -BeNullOrEmpty + $keys.ServerPasswordString | Should -Be '' + $keys.PasswordString | Should -Be '' + } + + It 'has empty LTProxy after import' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $proxy = & $module { $Script:LTProxy } + $proxy | Should -Not -BeNullOrEmpty + $proxy.Enabled | Should -Be $False + $proxy.ProxyServerURL | Should -Be '' + } + + It 'has LTWebProxy undefined before networking init' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $webProxy = & $module { $Script:LTWebProxy } + $webProxy | Should -BeNullOrEmpty + } + + It 'has LTServiceNetWebClient undefined before networking init' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $webClient = & $module { $Script:LTServiceNetWebClient } + $webClient | Should -BeNullOrEmpty + } + + It 'has Initialize-CWAANetworking as a recognized command' { + $module = Get-Module 'ConnectWiseAutomateAgent' + $cmd = & $module { Get-Command 'Initialize-CWAANetworking' -ErrorAction SilentlyContinue } + $cmd | Should -Not -BeNullOrEmpty + } + + It 'does not export Initialize-CWAANetworking as a public function' { + $exported = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + $exported | Should -Not -Contain 'Initialize-CWAANetworking' + } + } + + Context 'Exported Functions' { + BeforeAll { + $ExpectedFunctions = @( + 'Hide-CWAAAddRemove' + 'Rename-CWAAAddRemove' + 'Show-CWAAAddRemove' + 'Install-CWAA' + 'Redo-CWAA' + 'Uninstall-CWAA' + 'Update-CWAA' + 'Get-CWAAError' + 'Get-CWAALogLevel' + 'Get-CWAAProbeError' + 'Set-CWAALogLevel' + 'Get-CWAAProxy' + 'Set-CWAAProxy' + 'Restart-CWAA' + 'Start-CWAA' + 'Stop-CWAA' + 'Get-CWAAInfo' + 'Get-CWAAInfoBackup' + 'Get-CWAASettings' + 'New-CWAABackup' + 'Reset-CWAA' + 'ConvertFrom-CWAASecurity' + 'ConvertTo-CWAASecurity' + 'Invoke-CWAACommand' + 'Test-CWAAPort' + 'Test-CWAAServerConnectivity' + 'Test-CWAAHealth' + 'Repair-CWAA' + 'Register-CWAAHealthCheckTask' + 'Unregister-CWAAHealthCheckTask' + ) + $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + } + + It 'exports exactly 30 functions' { + $ExportedFunctions | Should -HaveCount 30 + } + + It 'exports <_>' -ForEach $ExpectedFunctions { + $_ | Should -BeIn $ExportedFunctions + } + } + + Context 'Exported Aliases' { + BeforeAll { + $ExpectedAliases = @( + 'Hide-LTAddRemove' + 'Rename-LTAddRemove' + 'Show-LTAddRemove' + 'Install-LTService' + 'Redo-LTService' + 'Reinstall-CWAA' + 'Reinstall-LTService' + 'Uninstall-LTService' + 'Update-LTService' + 'Get-LTErrors' + 'Get-LTLogging' + 'Get-LTProbeErrors' + 'Set-LTLogging' + 'Get-LTProxy' + 'Set-LTProxy' + 'Restart-LTService' + 'Start-LTService' + 'Stop-LTService' + 'Get-LTServiceInfo' + 'Get-LTServiceInfoBackup' + 'Get-LTServiceSettings' + 'New-LTServiceBackup' + 'Reset-LTService' + 'ConvertFrom-LTSecurity' + 'ConvertTo-LTSecurity' + 'Invoke-LTServiceCommand' + 'Test-LTPorts' + 'Test-LTServerConnectivity' + 'Test-LTHealth' + 'Repair-LTService' + 'Register-LTHealthCheckTask' + 'Unregister-LTHealthCheckTask' + ) + $ExportedAliases = (Get-Module 'ConnectWiseAutomateAgent').ExportedAliases.Keys + } + + It 'exports exactly 32 aliases' { + $ExportedAliases | Should -HaveCount 32 + } + + It 'exports alias <_>' -ForEach $ExpectedAliases { + $_ | Should -BeIn $ExportedAliases + } + } + + Context 'Function-to-File Mapping' { + BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $PublicPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\Public' + $PublicFiles = Get-ChildItem -Path $PublicPath -Filter '*.ps1' -Recurse | + Select-Object -ExpandProperty BaseName + } + + It 'has a .ps1 file for each exported function' { + $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + foreach ($function in $ExportedFunctions) { + $function | Should -BeIn $PublicFiles -Because "$function should have a matching .ps1 file" + } + } + + It 'every public .ps1 file corresponds to an exported function' { + $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + foreach ($file in $PublicFiles) { + $file | Should -BeIn $ExportedFunctions -Because "$file.ps1 should be exported" + } + } + } +} + +# ============================================================================= +# Function Structure Tests +# ============================================================================= +Describe 'Function Structure' { + + Context '<_> has proper structure' -ForEach @( + 'Hide-CWAAAddRemove' + 'Rename-CWAAAddRemove' + 'Show-CWAAAddRemove' + 'Install-CWAA' + 'Redo-CWAA' + 'Uninstall-CWAA' + 'Update-CWAA' + 'Get-CWAAError' + 'Get-CWAALogLevel' + 'Get-CWAAProbeError' + 'Set-CWAALogLevel' + 'Get-CWAAProxy' + 'Set-CWAAProxy' + 'Restart-CWAA' + 'Start-CWAA' + 'Stop-CWAA' + 'Get-CWAAInfo' + 'Get-CWAAInfoBackup' + 'Get-CWAASettings' + 'New-CWAABackup' + 'Reset-CWAA' + 'ConvertFrom-CWAASecurity' + 'ConvertTo-CWAASecurity' + 'Invoke-CWAACommand' + 'Test-CWAAPort' + ) { + It 'is a recognized command' { + Get-Command $_ -Module 'ConnectWiseAutomateAgent' | Should -Not -BeNullOrEmpty + } + + It 'has CmdletBinding attribute' { + $cmd = Get-Command $_ -Module 'ConnectWiseAutomateAgent' + $cmd.CmdletBinding | Should -BeTrue + } + + It 'has comment-based help with a synopsis' { + $help = Get-Help $_ -ErrorAction SilentlyContinue + $help.Synopsis | Should -Not -BeNullOrEmpty + # Synopsis should not just be the function name (indicates missing help) + $help.Synopsis.Trim() | Should -Not -Be $_ + } + } + + Context 'Legacy alias mapping' { + BeforeAll { + # Map of CWAA function -> expected LT alias(es) + $AliasMap = @{ + 'Hide-CWAAAddRemove' = @('Hide-LTAddRemove') + 'Rename-CWAAAddRemove' = @('Rename-LTAddRemove') + 'Show-CWAAAddRemove' = @('Show-LTAddRemove') + 'Install-CWAA' = @('Install-LTService') + 'Redo-CWAA' = @('Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService') + 'Uninstall-CWAA' = @('Uninstall-LTService') + 'Update-CWAA' = @('Update-LTService') + 'Get-CWAAError' = @('Get-LTErrors') + 'Get-CWAALogLevel' = @('Get-LTLogging') + 'Get-CWAAProbeError' = @('Get-LTProbeErrors') + 'Set-CWAALogLevel' = @('Set-LTLogging') + 'Get-CWAAProxy' = @('Get-LTProxy') + 'Set-CWAAProxy' = @('Set-LTProxy') + 'Restart-CWAA' = @('Restart-LTService') + 'Start-CWAA' = @('Start-LTService') + 'Stop-CWAA' = @('Stop-LTService') + 'Get-CWAAInfo' = @('Get-LTServiceInfo') + 'Get-CWAAInfoBackup' = @('Get-LTServiceInfoBackup') + 'Get-CWAASettings' = @('Get-LTServiceSettings') + 'New-CWAABackup' = @('New-LTServiceBackup') + 'Reset-CWAA' = @('Reset-LTService') + 'ConvertFrom-CWAASecurity' = @('ConvertFrom-LTSecurity') + 'ConvertTo-CWAASecurity' = @('ConvertTo-LTSecurity') + 'Invoke-CWAACommand' = @('Invoke-LTServiceCommand') + 'Test-CWAAPort' = @('Test-LTPorts') + } + } + + It ' resolves from alias ' -ForEach ( + @( + @{ Key = 'Hide-CWAAAddRemove'; Value = 'Hide-LTAddRemove' } + @{ Key = 'Rename-CWAAAddRemove'; Value = 'Rename-LTAddRemove' } + @{ Key = 'Show-CWAAAddRemove'; Value = 'Show-LTAddRemove' } + @{ Key = 'Install-CWAA'; Value = 'Install-LTService' } + @{ Key = 'Redo-CWAA'; Value = 'Redo-LTService' } + @{ Key = 'Redo-CWAA'; Value = 'Reinstall-CWAA' } + @{ Key = 'Redo-CWAA'; Value = 'Reinstall-LTService' } + @{ Key = 'Uninstall-CWAA'; Value = 'Uninstall-LTService' } + @{ Key = 'Update-CWAA'; Value = 'Update-LTService' } + @{ Key = 'Get-CWAAError'; Value = 'Get-LTErrors' } + @{ Key = 'Get-CWAALogLevel'; Value = 'Get-LTLogging' } + @{ Key = 'Get-CWAAProbeError'; Value = 'Get-LTProbeErrors' } + @{ Key = 'Set-CWAALogLevel'; Value = 'Set-LTLogging' } + @{ Key = 'Get-CWAAProxy'; Value = 'Get-LTProxy' } + @{ Key = 'Set-CWAAProxy'; Value = 'Set-LTProxy' } + @{ Key = 'Restart-CWAA'; Value = 'Restart-LTService' } + @{ Key = 'Start-CWAA'; Value = 'Start-LTService' } + @{ Key = 'Stop-CWAA'; Value = 'Stop-LTService' } + @{ Key = 'Get-CWAAInfo'; Value = 'Get-LTServiceInfo' } + @{ Key = 'Get-CWAAInfoBackup'; Value = 'Get-LTServiceInfoBackup' } + @{ Key = 'Get-CWAASettings'; Value = 'Get-LTServiceSettings' } + @{ Key = 'New-CWAABackup'; Value = 'New-LTServiceBackup' } + @{ Key = 'Reset-CWAA'; Value = 'Reset-LTService' } + @{ Key = 'ConvertFrom-CWAASecurity'; Value = 'ConvertFrom-LTSecurity' } + @{ Key = 'ConvertTo-CWAASecurity'; Value = 'ConvertTo-LTSecurity' } + @{ Key = 'Invoke-CWAACommand'; Value = 'Invoke-LTServiceCommand' } + @{ Key = 'Test-CWAAPort'; Value = 'Test-LTPorts' } + ) + ) { + $alias = Get-Alias $Value -ErrorAction SilentlyContinue + $alias | Should -Not -BeNullOrEmpty -Because "alias '$Value' should exist" + $alias.ResolvedCommand.Name | Should -Be $Key + } + } + + Context 'ShouldProcess on destructive functions' { + # Functions that perform destructive or state-changing operations should declare SupportsShouldProcess + It '<_> supports ShouldProcess' -ForEach @( + 'Install-CWAA' + 'Uninstall-CWAA' + 'Redo-CWAA' + 'Update-CWAA' + 'Reset-CWAA' + 'Restart-CWAA' + 'Start-CWAA' + 'Stop-CWAA' + ) { + $cmd = Get-Command $_ -Module 'ConnectWiseAutomateAgent' + $cmd.Parameters.Keys | Should -Contain 'WhatIf' -Because "$_ is destructive and should support -WhatIf" + } + } +} + +# ============================================================================= +# ConvertTo-CWAASecurity Unit Tests +# ============================================================================= +Describe 'ConvertTo-CWAASecurity' { + + It 'returns a non-empty string for valid input' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' + $result | Should -Not -BeNullOrEmpty + } + + It 'returns a valid Base64-encoded string' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' + # Base64 strings contain only [A-Za-z0-9+/=] + $result | Should -Match '^[A-Za-z0-9+/=]+$' + } + + It 'produces consistent output for the same input' { + $result1 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' + $result2 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' + $result1 | Should -Be $result2 + } + + It 'produces different output for different inputs' { + $result1 = ConvertTo-CWAASecurity -InputString 'Value1' + $result2 = ConvertTo-CWAASecurity -InputString 'Value2' + $result1 | Should -Not -Be $result2 + } + + It 'produces different output with different keys' { + $result1 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' + $result2 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' + $result1 | Should -Not -Be $result2 + } + + It 'handles an empty string input' { + $result = ConvertTo-CWAASecurity -InputString '' + $result | Should -Not -BeNullOrEmpty + } + + It 'handles long string input' { + $longString = 'A' * 1000 + $result = ConvertTo-CWAASecurity -InputString $longString + $result | Should -Not -BeNullOrEmpty + } + + It 'handles special characters' { + $result = ConvertTo-CWAASecurity -InputString '!@#$%^&*()_+-={}[]|;:<>?,./~`' + $result | Should -Not -BeNullOrEmpty + } + + It 'works with a custom key' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'MyCustomKey' + $result | Should -Not -BeNullOrEmpty + } + + It 'works via the legacy alias ConvertTo-LTSecurity' { + $result = ConvertTo-LTSecurity -InputString 'AliasTest' + $result | Should -Not -BeNullOrEmpty + } +} + +# ============================================================================= +# ConvertFrom-CWAASecurity Unit Tests +# ============================================================================= +Describe 'ConvertFrom-CWAASecurity' { + + It 'decodes a previously encoded string' { + $encoded = ConvertTo-CWAASecurity -InputString 'HelloWorld' + $decoded = ConvertFrom-CWAASecurity -InputString $encoded + $decoded | Should -Be 'HelloWorld' + } + + It 'returns null for invalid Base64 input' { + $result = ConvertFrom-CWAASecurity -InputString 'NotValidBase64!!!' -Force:$False + $result | Should -BeNullOrEmpty + } + + It 'decodes with a custom key' { + $customKey = 'MySecretKey123' + $encoded = ConvertTo-CWAASecurity -InputString 'CustomKeyTest' -Key $customKey + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $customKey + $decoded | Should -Be 'CustomKeyTest' + } + + It 'fails to decode with the wrong key (Force disabled)' { + $encoded = ConvertTo-CWAASecurity -InputString 'WrongKeyTest' -Key 'CorrectKey' + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$False + $decoded | Should -BeNullOrEmpty + } + + It 'works via the legacy alias ConvertFrom-LTSecurity' { + $encoded = ConvertTo-CWAASecurity -InputString 'AliasTest' + $decoded = ConvertFrom-LTSecurity -InputString $encoded + $decoded | Should -Be 'AliasTest' + } + + It 'accepts pipeline input' { + $encoded = ConvertTo-CWAASecurity -InputString 'PipelineTest' + $decoded = $encoded | ConvertFrom-CWAASecurity + $decoded | Should -Be 'PipelineTest' + } +} + +# ============================================================================= +# Security Round-Trip Tests +# ============================================================================= +Describe 'Security Encode/Decode Round-Trip' { + + It 'round-trips "" with default key' -ForEach @( + @{ TestString = 'SimpleText' } + @{ TestString = 'Hello World with spaces' } + @{ TestString = 'Special!@#$%^&*()chars' } + @{ TestString = '12345' } + @{ TestString = '' } + @{ TestString = 'https://automate.example.com' } + @{ TestString = 'P@$$w0rd!#Complex' } + ) { + $encoded = ConvertTo-CWAASecurity -InputString $TestString + $decoded = ConvertFrom-CWAASecurity -InputString $encoded + $decoded | Should -Be $TestString + } + + It 'round-trips with custom key ""' -ForEach @( + @{ Key = 'ShortKey' } + @{ Key = 'A much longer encryption key for testing purposes' } + @{ Key = '!@#$%' } + @{ Key = '12345678901234567890' } + ) { + $testValue = 'RoundTripValue' + $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key $Key + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $Key + $decoded | Should -Be $testValue + } + + It 'encoded value differs between default key and custom key' { + $input = 'CompareKeys' + $defaultEncoded = ConvertTo-CWAASecurity -InputString $input + $customEncoded = ConvertTo-CWAASecurity -InputString $input -Key 'CustomKey' + $defaultEncoded | Should -Not -Be $customEncoded + } +} + +# ============================================================================= +# Parameter Validation Tests +# ============================================================================= +Describe 'Parameter Validation' { + + Context 'Install-CWAA parameters' { + BeforeAll { + $cmd = Get-Command Install-CWAA -Module 'ConnectWiseAutomateAgent' + } + + It 'has a Server parameter' { + $cmd.Parameters.Keys | Should -Contain 'Server' + } + + It 'has an InstallerToken parameter' { + $cmd.Parameters.Keys | Should -Contain 'InstallerToken' + } + } + + Context 'Test-CWAAPort parameters' { + BeforeAll { + $cmd = Get-Command Test-CWAAPort -Module 'ConnectWiseAutomateAgent' + } + + It 'has a Server parameter' { + $cmd.Parameters.Keys | Should -Contain 'Server' + } + + It 'has a TrayPort parameter' { + $cmd.Parameters.Keys | Should -Contain 'TrayPort' + } + + It 'has a Quiet switch parameter' { + $cmd.Parameters.Keys | Should -Contain 'Quiet' + $cmd.Parameters['Quiet'].SwitchParameter | Should -BeTrue + } + } + + Context 'Get-CWAALogLevel parameters' { + BeforeAll { + $cmd = Get-Command Get-CWAALogLevel -Module 'ConnectWiseAutomateAgent' + } + + It 'is a recognized command' { + $cmd | Should -Not -BeNullOrEmpty + } + } + + Context 'Set-CWAALogLevel parameters' { + BeforeAll { + $cmd = Get-Command Set-CWAALogLevel -Module 'ConnectWiseAutomateAgent' + } + + It 'has a Level parameter' { + $cmd.Parameters.Keys | Should -Contain 'Level' + } + } +} + +# ============================================================================= +# Documentation Structure Tests +# ============================================================================= +Describe 'Documentation Structure' { + + BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $DocsRoot = Join-Path $ModuleRoot 'Docs' + $DocsHelp = Join-Path $DocsRoot 'Help' + $BuildScript = Join-Path $ModuleRoot 'Build\Build-Documentation.ps1' + $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + } + + Context 'Folder layout' { + It 'has a Docs directory' { + $DocsRoot | Should -Exist + } + + It 'has a Docs/Help directory for auto-generated reference docs' { + $DocsHelp | Should -Exist + } + + It 'has no auto-generated function docs in Docs root' { + $handWrittenGuides = @( + 'Architecture.md', + 'CommonParameters.md', + 'FAQ.md', + 'Migration.md', + 'Security.md', + 'Troubleshooting.md' + ) + $rootMdFiles = Get-ChildItem $DocsRoot -Filter '*.md' -File | + Where-Object { $_.Name -notin $handWrittenGuides } + $rootMdFiles | Should -HaveCount 0 -Because 'function docs belong in Docs/Help/, only hand-written guides in Docs/' + } + + It 'has Architecture.md in Docs root (hand-written)' { + Join-Path $DocsRoot 'Architecture.md' | Should -Exist + } + } + + Context 'Auto-generated function reference' { + It 'has a module overview page' { + Join-Path $DocsHelp 'ConnectWiseAutomateAgent.md' | Should -Exist + } + + It 'has a markdown doc for each exported function' { + foreach ($function in $ExportedFunctions) { + $docPath = Join-Path $DocsHelp "$function.md" + $docPath | Should -Exist -Because "$function should have a corresponding doc in Docs/Help/" + } + } + + It 'each function doc has PlatyPS YAML frontmatter' { + foreach ($function in $ExportedFunctions) { + $docPath = Join-Path $DocsHelp "$function.md" + if (Test-Path $docPath) { + $firstLine = (Get-Content $docPath -TotalCount 1) + $firstLine | Should -Be '---' -Because "$function.md should start with YAML frontmatter" + } + } + } + } + + Context 'MAML help' { + It 'has a compiled MAML XML help file' { + $mamlPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\ConnectWiseAutomateAgent-help.xml' + $mamlPath | Should -Exist + } + + It 'has an about help topic' { + $aboutPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\about_ConnectWiseAutomateAgent.help.txt' + $aboutPath | Should -Exist + } + } + + Context 'Build script' { + It 'Build-Documentation.ps1 exists' { + $BuildScript | Should -Exist + } + + It 'Build-Documentation.ps1 defaults output to Docs/Help' { + $scriptContent = Get-Content $BuildScript -Raw + $scriptContent | Should -Match "Join-Path.*'Help'" -Because 'default output path should target Docs/Help' + } + } +} From a28f7dc0a494c647ce85983fa2c2bfce0f95a52c Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Sun, 1 Feb 2026 02:22:59 -0700 Subject: [PATCH 2/5] Refactor to private helpers, add pipeline support and progress indicators Extract 5 private helper functions to eliminate duplicated code: - Assert-CWAANotProbeAgent (probe protection in Uninstall/Redo/Reset) - Invoke-CWAAMsiInstaller (MSI install with retry logic) - Test-CWAADotNetPrerequisite (.NET 3.5 prerequisite check) - Test-CWAAServiceExists (service existence check) - Wait-CWAACondition (generic polling replacing inline stopwatch loops) Centralize magic numbers as $Script:CWAA* constants in Initialize-CWAA (timeouts, version thresholds, port ranges, process/service names). Add -ShowProgress parameter to Install/Uninstall/Update-CWAA with Write-Progress bars. Add -Credential (PSCredential) to Install-CWAA and -ProxyCredential to Set-CWAAProxy as secure alternatives. Improve pipeline support: add ValueFromPipeline/ByPropertyName to 7+ functions, fix ServerPassword escaping placement in Install-CWAA, change Server to string[] where Get-CWAAInfo pipes arrays. Add pre-commit hook (.githooks/) running PSScriptAnalyzer and Pester. 455 tests pass, PSScriptAnalyzer clean, single-file build regenerated. Co-Authored-By: Claude Opus 4.5 --- .githooks/pre-commit | 36 + CLAUDE.md | 13 + CONTRIBUTING.md | 3 + ConnectWiseAutomateAgent.ps1 | 1008 +++++++++++------ .../Private/Assert-CWAANotProbeAgent.ps1 | 67 ++ .../Private/Initialize/Initialize-CWAA.ps1 | 26 + .../Private/Invoke-CWAAMsiInstaller.ps1 | 77 ++ .../Private/Remove-CWAAFolderRecursive.ps1 | 10 +- .../Private/Test-CWAADotNetPrerequisite.ps1 | 114 ++ .../Private/Test-CWAAServiceExists.ps1 | 53 + .../Private/Wait-CWAACondition.ps1 | 72 ++ .../Rename-CWAAAddRemove.ps1 | 2 +- .../Public/ConvertTo-CWAASecurity.ps1 | 7 +- .../Public/InstallUninstall/Install-CWAA.ps1 | 378 +++---- .../Public/InstallUninstall/Redo-CWAA.ps1 | 19 +- .../InstallUninstall/Uninstall-CWAA.ps1 | 41 +- .../Public/InstallUninstall/Update-CWAA.ps1 | 27 +- .../Public/Logging/Get-CWAALogLevel.ps1 | 5 +- .../Public/Logging/Set-CWAALogLevel.ps1 | 9 +- .../Public/Proxy/Set-CWAAProxy.ps1 | 19 +- .../Service/Register-CWAAHealthCheckTask.ps1 | 12 +- .../Public/Service/Repair-CWAA.ps1 | 4 +- .../Public/Service/Restart-CWAA.ps1 | 10 +- .../Public/Service/Start-CWAA.ps1 | 26 +- .../Public/Service/Stop-CWAA.ps1 | 29 +- .../Public/Service/Test-CWAAHealth.ps1 | 12 +- .../Public/Settings/Reset-CWAA.ps1 | 45 +- .../Public/Test-CWAAPort.ps1 | 3 + .../en-US/ConnectWiseAutomateAgent-help.xml | 976 +++------------- Docs/Help/ConnectWiseAutomateAgent.md | 2 +- Docs/Help/ConvertFrom-CWAASecurity.md | 20 +- Docs/Help/ConvertTo-CWAASecurity.md | 22 +- Docs/Help/Get-CWAAError.md | 19 +- Docs/Help/Get-CWAAInfo.md | 19 +- Docs/Help/Get-CWAAInfoBackup.md | 19 +- Docs/Help/Get-CWAALogLevel.md | 19 +- Docs/Help/Get-CWAAProbeError.md | 19 +- Docs/Help/Get-CWAAProxy.md | 19 +- Docs/Help/Get-CWAASettings.md | 21 +- Docs/Help/Hide-CWAAAddRemove.md | 19 +- Docs/Help/Install-CWAA.md | 95 +- Docs/Help/Invoke-CWAACommand.md | 20 +- Docs/Help/New-CWAABackup.md | 19 +- Docs/Help/Redo-CWAA.md | 25 +- Docs/Help/Register-CWAAHealthCheckTask.md | 26 +- Docs/Help/Rename-CWAAAddRemove.md | 22 +- Docs/Help/Repair-CWAA.md | 39 +- Docs/Help/Reset-CWAA.md | 20 +- Docs/Help/Restart-CWAA.md | 19 +- Docs/Help/Set-CWAALogLevel.md | 20 +- Docs/Help/Set-CWAAProxy.md | 22 +- Docs/Help/Show-CWAAAddRemove.md | 19 +- Docs/Help/Start-CWAA.md | 19 +- Docs/Help/Stop-CWAA.md | 19 +- Docs/Help/Test-CWAAHealth.md | 20 +- Docs/Help/Test-CWAAPort.md | 20 +- Docs/Help/Test-CWAAServerConnectivity.md | 20 +- Docs/Help/Uninstall-CWAA.md | 41 +- Docs/Help/Unregister-CWAAHealthCheckTask.md | 20 +- Docs/Help/Update-CWAA.md | 21 +- Examples/PipelineUsage.ps1 | 53 + TODO.md | 26 +- .../ConnectWiseAutomateAgent.Mocked.Tests.ps1 | 677 +++++++++++ 63 files changed, 2522 insertions(+), 2061 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 create mode 100644 ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..f023175 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/usr/bin/env pwsh +# Pre-commit hook for ConnectWiseAutomateAgent +# Runs PSScriptAnalyzer and Pester tests before allowing commits. +# +# Setup: git config core.hooksPath .githooks +# Or: git config --local core.hooksPath .githooks + +$ErrorActionPreference = 'Stop' + +# Verify required modules are installed +foreach ($moduleName in @('PSScriptAnalyzer', 'Pester')) { + if (-not (Get-Module -ListAvailable -Name $moduleName)) { + Write-Host "Pre-commit: $moduleName module not found. Install it with: Install-Module $moduleName" -ForegroundColor Red + exit 1 + } +} + +Write-Host '--- Pre-commit: Running PSScriptAnalyzer ---' -ForegroundColor Cyan +$analyzerResults = Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning -Settings .PSScriptAnalyzerSettings.psd1 +if ($analyzerResults) { + Write-Host 'PSScriptAnalyzer found issues:' -ForegroundColor Red + $analyzerResults | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize + exit 1 +} +Write-Host 'PSScriptAnalyzer: passed' -ForegroundColor Green + +Write-Host '--- Pre-commit: Running Pester tests ---' -ForegroundColor Cyan +$testResult = Invoke-Pester Tests\ -ExcludeTag 'Live' -PassThru -Output Minimal +if ($testResult.FailedCount -gt 0) { + Write-Host "Pester: $($testResult.FailedCount) test(s) failed" -ForegroundColor Red + exit 1 +} +Write-Host "Pester: $($testResult.PassedCount) tests passed" -ForegroundColor Green + +Write-Host '--- Pre-commit: All checks passed ---' -ForegroundColor Green +exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index ccb99a1..a041830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,19 @@ Local tests are the real CI gate. GitHub Actions is intentionally lightweight (s - Consider 32-bit/64-bit WOW64 behavior for registry/file operations - Verify compatibility with PowerShell 2.0 and 5.1+ +### Documentation Workflow + +**Source of truth:** Comment-based help in `.ps1` source files (`.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, `.EXAMPLE`, etc.). + +**Generated artifacts (do not edit manually):** + +- `Docs/Help/*.md` — Markdown docs generated by PlatyPS via `Build\Build-Documentation.ps1` +- `ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml` — MAML XML generated from the markdown + +**Flow:** `.ps1` comment-based help → `Get-Help` → PlatyPS → Markdown → MAML XML. To update documentation, edit only the comment-based help in the source `.ps1` files, then run `Build\Build-Documentation.ps1 -UpdateExisting` (or without `-UpdateExisting` for a full regeneration). + +**Known PowerShell help parser pitfall:** In comment-based help blocks, any line that starts with a dot followed by a word (e.g., `.NET`, `.config`) is interpreted as a help keyword by PowerShell's parser, breaking the entire help block. Always ensure terms like `.NET Framework` appear mid-line, never at the start of a line (after indentation). Example fix: reword `".NET Framework 3.5 checks"` to `"prerequisite checks for .NET Framework 3.5"`. + ### Security Considerations **Graduated SSL certificate validation.** `Initialize-CWAANetworking` registers a `ServerCertificateValidationCallback` with graduated trust rather than blanket bypass. IP address targets auto-bypass (IPs cannot have properly signed certificates). Hostname name mismatches are tolerated (trusted cert but CN/SAN differs). Chain/trust errors on hostnames are rejected unless `-SkipCertificateCheck` is passed, which sets a `SkipAll` flag for full bypass. This graduated approach is necessary because many MSP Automate servers use self-signed or internal CA certificates. The callback is registered once per session and survives module re-import (compiled .NET types cannot be unloaded). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0732d09..3108c24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,9 @@ Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force # Run the test suite Invoke-Pester Tests\ConnectWiseAutomateAgent.Tests.ps1 -Output Detailed + +# Enable pre-commit hooks (runs PSScriptAnalyzer + tests before each commit) +git config core.hooksPath .githooks ``` ## Coding Conventions diff --git a/ConnectWiseAutomateAgent.ps1 b/ConnectWiseAutomateAgent.ps1 index 332b3b6..b945503 100644 --- a/ConnectWiseAutomateAgent.ps1 +++ b/ConnectWiseAutomateAgent.ps1 @@ -1,7 +1,67 @@ # ConnectWiseAutomateAgent 1.0.0-alpha001 -# Single-file distribution - built 2026-01-31 +# Single-file distribution - built 2026-02-01 # https://github.com/christaylorcodes/ConnectWiseAutomateAgent +function Assert-CWAANotProbeAgent { + <# + .SYNOPSIS + Blocks operations on probe agents unless -Force is specified. + .DESCRIPTION + Checks the agent info object to determine if the current machine is a probe agent. + If it is and -Force is not set, writes a terminating error to prevent accidental + removal of critical infrastructure. If -Force is set, writes a warning message and + allows continuation. + The ActionName parameter produces contextual messages like + "Probe Agent Detected. UnInstall Denied." or "Probe Agent Detected. Reset Forced." + This consolidates the duplicated probe agent protection check found in + Uninstall-CWAA, Redo-CWAA, and Reset-CWAA. + .PARAMETER ServiceInfo + The agent info object from Get-CWAAInfo. If null or missing the Probe property, + the check is skipped silently. + .PARAMETER ActionName + The name of the operation for error/output messages. Used directly in the message + string, e.g., 'UnInstall', 'Re-Install', 'Reset'. + .PARAMETER Force + When set, allows the operation to proceed on a probe agent with an output message + instead of a terminating error. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [Parameter()] + [AllowNull()] + $ServiceInfo, + [Parameter(Mandatory = $True)] + [string]$ActionName, + [switch]$Force + ) + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { + if ($ServiceInfo -and ($ServiceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { + if ($Force) { + Write-Output "Probe Agent Detected. $ActionName Forced." + } + else { + if ($WhatIfPreference -ne $True) { + Write-Error -Exception ([System.OperationCanceledException]"Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop + } + else { + Write-Error -Exception ([System.OperationCanceledException]"What If: Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop + } + } + } + } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} function Clear-CWAAInstallerArtifacts { <# .SYNOPSIS @@ -39,6 +99,73 @@ function Clear-CWAAInstallerArtifacts { Write-Debug "Exiting $($MyInvocation.InvocationName)" } } +function Invoke-CWAAMsiInstaller { + <# + .SYNOPSIS + Executes the Automate agent MSI installer with retry logic. + .DESCRIPTION + Launches msiexec.exe with the provided arguments and retries up to a configurable + number of attempts if the LTService service is not detected after installation. + Between retries, polls for the service using Wait-CWAACondition. Redacts server + passwords from verbose output for security. + .PARAMETER InstallerArguments + The full argument string to pass to msiexec.exe (e.g., '/i "path\Agent_Install.msi" SERVERADDRESS=... /qn'). + .PARAMETER MaxAttempts + Maximum number of install attempts before giving up. Defaults to $Script:CWAAInstallMaxAttempts. + .PARAMETER RetryDelaySeconds + Seconds to wait (polling for service) between retry attempts. Defaults to $Script:CWAAInstallRetryDelaySeconds. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + Param( + [Parameter(Mandatory = $True)] + [string]$InstallerArguments, + [Parameter()] + [int]$MaxAttempts = $Script:CWAAInstallMaxAttempts, + [Parameter()] + [int]$RetryDelaySeconds = $Script:CWAAInstallRetryDelaySeconds + ) + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { + if (-not $PSCmdlet.ShouldProcess("msiexec.exe $InstallerArguments", 'Execute Install')) { + return $true + } + $installAttempt = 0 + Do { + if ($installAttempt -gt 0) { + Write-Warning "Service Failed to Install. Retrying in $RetryDelaySeconds seconds." -WarningAction 'Continue' + $Null = Wait-CWAACondition -Condition { + $serviceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + $serviceCount -eq 1 + } -TimeoutSeconds $RetryDelaySeconds -IntervalSeconds 5 -Activity 'Waiting for service availability before retry' + } + $installAttempt++ + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + if ($runningServiceCount -eq 0) { + $redactedArguments = $InstallerArguments -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' + Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" + Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $InstallerArguments -WorkingDirectory $env:TEMP + Start-Sleep 5 + } + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + } Until ($installAttempt -ge $MaxAttempts -or $runningServiceCount -eq 1) + if ($runningServiceCount -eq 0) { + Write-Error "LTService was not installed. Installation failed after $MaxAttempts attempts." + return $false + } + return $true + } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} function Remove-CWAAFolderRecursive { <# .SYNOPSIS @@ -61,7 +188,8 @@ function Remove-CWAAFolderRecursive { [CmdletBinding(SupportsShouldProcess = $True)] Param( [Parameter(Mandatory = $True)] - [string]$Path + [string]$Path, + [switch]$ShowProgress ) Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" @@ -73,8 +201,11 @@ function Remove-CWAAFolderRecursive { } if ($PSCmdlet.ShouldProcess($Path, 'Remove Folder')) { Write-Debug "Removing Folder: $Path" + $folderProgressId = 10 + $folderProgressActivity = "Removing folder: $Path" Try { # Pass 1: Remove files inside each subfolder (leaves first) + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing files (pass 1 of 3)' -PercentComplete 33 } Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.psiscontainer } | ForEach-Object { @@ -83,14 +214,18 @@ function Remove-CWAAFolderRecursive { Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False } # Pass 2: Remove subfolders sorted by path depth (deepest first) + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing subfolders (pass 2 of 3)' -PercentComplete 66 } Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.psiscontainer } | Sort-Object { $_.FullName.Length } -Descending | Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False # Pass 3: Remove the root folder itself + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing root folder (pass 3 of 3)' -PercentComplete 100 } Remove-Item -Recurse -Force -Path $Path -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } } Catch { + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } Write-Debug "Error removing folder '$Path': $($_.Exception.Message)" } } @@ -174,6 +309,110 @@ function Resolve-CWAAServer { Write-Debug "Exiting $($MyInvocation.InvocationName)" } } +function Test-CWAADotNetPrerequisite { + <# + .SYNOPSIS + Checks for and optionally installs the .NET Framework 3.5 prerequisite. + .DESCRIPTION + Verifies that .NET Framework 3.5 is installed, which is required by the ConnectWise + Automate agent. If 3.5 is missing, attempts automatic installation via + Enable-WindowsOptionalFeature (Windows 8+) or Dism.exe (Windows 7/Server 2008 R2). + With -Force, allows the agent install to proceed if .NET 2.0 or higher is present + even when 3.5 cannot be installed. Without -Force, a missing 3.5 is a terminating error. + .PARAMETER SkipDotNet + Skips the .NET Framework check entirely. Returns $true immediately. + .PARAMETER Force + Allows fallback to .NET 2.0+ if 3.5 cannot be installed. + Without -Force, missing 3.5 is a terminating error. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + Param( + [switch]$SkipDotNet, + [switch]$Force + ) + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { + if ($SkipDotNet) { + Write-Debug 'SkipDotNet specified, skipping .NET prerequisite check.' + return $true + } + $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 + if ($DotNet -like '3.5.*') { + Write-Debug '.NET Framework 3.5 is already installed.' + return $true + } + Write-Warning '.NET Framework 3.5 installation needed.' + $OSVersion = [System.Environment]::OSVersion.Version + if ([version]$OSVersion -gt [version]'6.2') { + # Windows 8 / Server 2012 and later -- use Enable-WindowsOptionalFeature + Try { + if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { + $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' + if ($Install.State -ne 'EnablePending') { + $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart + } + if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { + Write-Warning '.NET Framework 3.5 installed but a reboot is needed.' + } + } + } + Catch { + Write-Error ".NET 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Install -ErrorAction Stop } + } + } + Elseif ([version]$OSVersion -gt [version]'6.1') { + # Windows 7 / Server 2008 R2 -- use Dism.exe + if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { + Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } + Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } + Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } + Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } + if ($Result -contains 'State : Enabled') { + Write-Warning ".Net Framework 3.5 has been installed and enabled." + } + Elseif ($Result -contains 'State : Enable Pending') { + Write-Warning ".Net Framework 3.5 installed but a reboot is needed." + } + else { + Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Result -ErrorAction Stop } + } + } + } + # Re-check after install attempt + $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse | Get-ItemProperty -Name Version -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version + if ($DotNet -like '3.5.*') { + return $true + } + # .NET 3.5 still not available after install attempt + if ($Force) { + if ($DotNet -match '(?m)^[2-4].\d') { + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue + return $true + } + else { + Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop + return $false + } + } + else { + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop + return $false + } + } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} function Test-CWAADownloadIntegrity { <# .SYNOPSIS @@ -224,6 +463,116 @@ function Test-CWAADownloadIntegrity { Write-Debug "Exiting $($MyInvocation.InvocationName)" } } +function Test-CWAAServiceExists { + <# + .SYNOPSIS + Tests whether the Automate agent services are installed on the local computer. + .DESCRIPTION + Checks for the existence of the LTService and LTSvcMon services using the + centralized $Script:CWAAServiceNames constant. Returns $true if at least one + service is found, $false otherwise. + When -WriteErrorOnMissing is specified, writes a WhatIf-aware error message + if the services are not found. This consolidates the duplicated service existence + check pattern found in Start-CWAA, Stop-CWAA, Restart-CWAA, and Reset-CWAA. + .PARAMETER WriteErrorOnMissing + When specified, writes a Write-Error message if the services are not found. + The error message is WhatIf-aware (includes 'What If:' prefix when + $WhatIfPreference is $true in the caller's scope). + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [switch]$WriteErrorOnMissing + ) + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { + $services = Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue + if ($services) { + return $true + } + if ($WriteErrorOnMissing) { + if ($WhatIfPreference -ne $True) { + Write-Error "Services NOT Found." + } + else { + Write-Error "What If: Services NOT Found." + } + } + return $false + } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} +function Wait-CWAACondition { + <# + .SYNOPSIS + Polls a condition script block until it returns $true or a timeout is reached. + .DESCRIPTION + Generic polling helper that evaluates a condition at regular intervals. Returns $true + if the condition was satisfied before the timeout, or $false if the timeout expired. + Used to replace duplicated stopwatch-based Do-Until polling loops throughout the module. + .PARAMETER Condition + A script block that is evaluated each interval. The loop exits when this returns $true. + .PARAMETER TimeoutSeconds + Maximum number of seconds to wait before giving up. Must be at least 1. + .PARAMETER IntervalSeconds + Number of seconds to sleep between condition evaluations. Defaults to 5. + .PARAMETER Activity + Optional description logged via Write-Verbose at start and finish for diagnostics. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $True)] + [ScriptBlock]$Condition, + [Parameter(Mandatory = $True)] + [ValidateRange(1, [int]::MaxValue)] + [int]$TimeoutSeconds, + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int]$IntervalSeconds = 5, + [Parameter()] + [string]$Activity + ) + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { + if ($Activity) { Write-Verbose "Waiting for: $Activity" } + $timeout = New-TimeSpan -Seconds $TimeoutSeconds + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + Do { + Start-Sleep -Seconds $IntervalSeconds + $conditionMet = & $Condition + } Until ($stopwatch.Elapsed -gt $timeout -or $conditionMet) + $stopwatch.Stop() + $elapsedSeconds = [int]$stopwatch.Elapsed.TotalSeconds + if ($conditionMet) { + if ($Activity) { Write-Verbose "$Activity completed after $elapsedSeconds seconds." } + return $true + } + else { + if ($Activity) { Write-Verbose "$Activity timed out after $elapsedSeconds seconds." } + return $false + } + } + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} function Write-CWAAEventLog { <# .SYNOPSIS @@ -395,6 +744,28 @@ function Initialize-CWAA { # Windows Event Log settings (used by Write-CWAAEventLog) $Script:CWAAEventLogSource = 'ConnectWiseAutomateAgent' $Script:CWAAEventLogName = 'Application' + # Timeout and retry configuration — used by Wait-CWAACondition and Install-CWAA callers. + # Centralized here so they are tunable and self-documenting in one place. + $Script:CWAAInstallMaxAttempts = 3 + $Script:CWAAInstallRetryDelaySeconds = 30 + $Script:CWAAServiceStartTimeoutSec = 120 # 2 minutes — proxy startup wait + $Script:CWAARegistrationTimeoutSec = 900 # 15 minutes — agent registration wait + $Script:CWAATrayPortMin = 42000 + $Script:CWAATrayPortMax = 42009 + $Script:CWAATrayPortDefault = 42000 + $Script:CWAAUninstallWaitSeconds = 10 + $Script:CWAAServiceWaitTimeoutSec = 60 # 1 minute — Start/Stop/Restart/Reset service waits + $Script:CWAARedoSettleDelaySeconds = 20 # Redo-CWAA settling delay between uninstall and reinstall + # Server version thresholds — document breaking changes in the server's deployment API. + # Each threshold gates a different URL construction or installer format in Install-CWAA. + $Script:CWAAVersionZipInstaller = '240.331' # InstallerToken deployments return ZIP (MSI+MST) + $Script:CWAAVersionAnonymousChange = '110.374' # Anonymous MSI download URL changed (LT11 Patch 13) + $Script:CWAAVersionVulnerabilityFix = '200.197' # CVE fix: unauthenticated Deployment.aspx access + $Script:CWAAVersionUpdateMinimum = '105.001' # Minimum version with update support + # Agent process names — for forceful termination in Stop-CWAA after service stop timeout. + $Script:CWAAAgentProcessNames = @('LTTray', 'LTSVC', 'LTSvcMon') + # All service names including LabVNC — for full service cleanup in Uninstall-CWAA. + $Script:CWAAAllServiceNames = @('LTService', 'LTSvcMon', 'LabVNC') # Service credential storage â€" populated on-demand by Get-CWAAProxy $Script:LTServiceKeys = [PSCustomObject]@{ ServerPasswordString = '' @@ -679,7 +1050,7 @@ function ConvertTo-CWAASecurity { [CmdletBinding()] [Alias('ConvertTo-LTSecurity')] Param( - [parameter(Mandatory = $true, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false)] [AllowNull()] [AllowEmptyString()] [AllowEmptyCollection()] @@ -690,8 +1061,10 @@ function ConvertTo-CWAASecurity { [AllowEmptyCollection()] $Key ) - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { $_initializationVector = [byte[]](240, 3, 45, 29, 0, 76, 173, 59) $DefaultKey = 'Thank you for using LabTech.' if ($Null -eq $Key) { @@ -866,6 +1239,9 @@ function Test-CWAAPort { .EXAMPLE Test-CWAAPort -Quiet Returns $True if the TrayPort is available, $False otherwise. + .EXAMPLE + Get-CWAAInfo | Test-CWAAPort + Pipes the installed agent's Server and TrayPort into Test-CWAAPort via pipeline. .NOTES Author: Chris Taylor Alias: Test-LTPorts @@ -1238,7 +1614,7 @@ function Rename-CWAAAddRemove { [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Rename-LTAddRemove')] Param( - [Parameter(Mandatory = $True)] + [Parameter(Mandatory = $True, ValueFromPipeline = $true)] $Name, [Parameter(Mandatory = $False)] [AllowNull()] @@ -1418,8 +1794,8 @@ function Install-CWAA { .DESCRIPTION Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles - .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy - configuration, TrayPort conflict resolution, and post-install agent registration verification. + prerequisite checks for .NET Framework 3.5, MSI download with file integrity validation, + proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. The -Force parameter allows installation even when services are already present or when only .NET 4.0+ is available without 3.5. @@ -1450,9 +1826,16 @@ function Install-CWAA { .PARAMETER NoWait Skips the post-install health check that waits for agent registration. The function exits immediately after the installer completes. + .PARAMETER Credential + A PSCredential object containing the server password for deployment authentication. + The password is extracted and used as the ServerPassword. This is the preferred + secure alternative to passing -ServerPassword as plain text. .PARAMETER SkipCertificateCheck Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing installation progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 Installs the agent using an InstallerToken for authentication. @@ -1462,6 +1845,9 @@ function Install-CWAA { .EXAMPLE Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait Installs the agent without waiting for registration to complete. + .EXAMPLE + Get-CWAAInfoBackup | Install-CWAA -InstallerToken 'GeneratedToken' + Reinstalls the agent using Server and LocationID from a previous backup via pipeline. .NOTES Author: Chris Taylor Alias: Install-LTService @@ -1484,6 +1870,10 @@ function Install-CWAA { [AllowNull()] [Alias('Password')] [string]$ServerPassword, + [Parameter(ParameterSetName = 'deployment')] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential, [Parameter(ParameterSetName = 'installertoken')] [ValidatePattern('(?s:^[0-9a-z]+$)')] [string]$InstallerToken, @@ -1500,15 +1890,27 @@ function Install-CWAA { [switch]$SkipDotNet, [switch]$Force, [switch]$NoWait, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { Write-Debug "Starting $($myInvocation.InvocationName)" + # Snapshot error count so we can detect new errors from this function only, + # rather than checking the global $Error collection which accumulates all session errors. + $errorCountAtStart = $Error.Count + # If a PSCredential was provided, extract the password for the deployment workflow. + # This is the preferred secure alternative to passing -ServerPassword as plain text. + if ($Credential) { + $ServerPassword = $Credential.GetNetworkCredential().Password + } # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. # Only runs once per session, skips immediately on subsequent calls. $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck + $progressId = 1 + $progressActivity = 'Installing ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Checking prerequisites' -PercentComplete 11 } if (-not $Force) { - if (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue) { + if (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue) { if ($WhatIfPreference -ne $True) { Write-Error "Services are already installed." -ErrorAction Stop } @@ -1520,66 +1922,10 @@ function Install-CWAA { if (-not ([bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544'))) { Throw 'Needs to be ran as Administrator' } - if (-not $SkipDotNet) { - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 - if (-not ($DotNet -like '3.5.*')) { - Write-Output '.NET Framework 3.5 installation needed.' - $OSVersion = [System.Environment]::OSVersion.Version - if ([version]$OSVersion -gt [version]'6.2') { - Try { - if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { - $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' - if ($Install.State -ne 'EnablePending') { - $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart - } - if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { - Write-Output '.NET Framework 3.5 installed but a reboot is needed.' - } - } - } - Catch { - Write-Error ".NET 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Install -ErrorAction Stop } - } - } - Elseif ([version]$OSVersion -gt [version]'6.1') { - if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } - Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } - Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } - if ($Result -contains 'State : Enabled') { - Write-Warning ".Net Framework 3.5 has been installed and enabled." - } - Elseif ($Result -contains 'State : Enable Pending') { - Write-Warning ".Net Framework 3.5 installed but a reboot is needed." - } - else { - Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Result -ErrorAction Stop } - } - } - } - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse | Get-ItemProperty -Name Version -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version - } - if (-not ($DotNet -like '3.5.*')) { - if ($Force) { - if ($DotNet -match '(?m)^[2-4].\d') { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue - } - else { - Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop - } - } - else { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop - } - } - } + $Null = Test-CWAADotNetPrerequisite -SkipDotNet:$SkipDotNet -Force:$Force $InstallBase = $Script:CWAAInstallerTempPath $logfile = 'LTAgentInstall' $curlog = "$InstallBase\$logfile.log" - if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } if (-not (Test-Path -PathType Container -Path "$InstallBase\Installer")) { New-Item "$InstallBase\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null } @@ -1593,13 +1939,17 @@ function Install-CWAA { } } Process { + # Escape double quotes in ServerPassword for MSI argument safety. + # Placed in Process (not Begin) because ServerPassword may arrive via pipeline binding. + if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } if (-not ($LocationID -or $PSCmdlet.ParameterSetName -eq 'installertoken')) { $LocationID = '1' } if (-not ($TrayPort) -or -not ($TrayPort -ge 1 -and $TrayPort -le 65535)) { - $TrayPort = '42000' + $TrayPort = $Script:CWAATrayPortDefault } # Resolve the first reachable server and its advertised version + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 22 } $serverResult = Resolve-CWAAServer -Server $Server if ($serverResult) { $serverUrl = $serverResult.ServerUrl @@ -1616,7 +1966,7 @@ function Install-CWAA { # - Pre-110.374: Legacy deployment URL with per-location MSI targeting if ($PSCmdlet.ParameterSetName -eq 'installertoken') { $installer = "$serverUrl/LabTech/Deployment.aspx?InstallerToken=$InstallerToken" - if ([System.Version]$serverVersion -ge [System.Version]'240.331') { + if ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { Write-Debug "New MSI Installer Format Needed" $InstallMSI = 'Agent_Install.zip' } @@ -1624,7 +1974,7 @@ function Install-CWAA { Elseif ($ServerPassword) { $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" } - Elseif ([System.Version]$serverVersion -ge [System.Version]'110.374') { + Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionAnonymousChange) { $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=1" } else { @@ -1633,7 +1983,7 @@ function Install-CWAA { } # Vulnerability test June 10, 2020: ConnectWise Automate API Vulnerability # Servers below v200.197 may allow unauthenticated access to Deployment.aspx - if ([System.Version]$serverVersion -lt [System.Version]'200.197') { + if ([System.Version]$serverVersion -lt [System.Version]$Script:CWAAVersionVulnerabilityFix) { Try { $HTTP_Request = [System.Net.WebRequest]::Create("$serverUrl/LabTech/Deployment.aspx") if ($HTTP_Request.GetResponse().StatusCode -eq 'OK') { @@ -1649,6 +1999,7 @@ function Install-CWAA { } } if ($PSCmdlet.ShouldProcess($installer, 'DownloadFile')) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading agent installer' -PercentComplete 33 } Write-Debug "Downloading $InstallMSI from $installer" $Script:LTServiceNetWebClient.DownloadFile($installer, "$InstallBase\Installer\$InstallMSI") if (-not (Test-CWAADownloadIntegrity -FilePath "$InstallBase\Installer\$InstallMSI" -FileName $InstallMSI)) { @@ -1662,7 +2013,7 @@ function Install-CWAA { Elseif (Test-Path "$InstallBase\Installer\$InstallMSI") { $GoodServer = $serverUrl Write-Verbose "$InstallMSI downloaded successfully from server $serverUrl." - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { Expand-Archive "$InstallBase\Installer\$InstallMSI" -DestinationPath "$InstallBase\Installer" -Force Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False $InstallMSI = 'Agent_Install.msi' @@ -1675,176 +2026,141 @@ function Install-CWAA { } } End { - if ($GoodServer) { - if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { - Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" - } - else { - if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { - Write-Warning "Previous installation detected. Calling Uninstall-CWAA" - Uninstall-CWAA -Server $GoodServer -Force - Start-Sleep 10 + try { + if ($GoodServer) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Preparing installation environment' -PercentComplete 44 } + if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { + Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" } - } - if ($WhatIfPreference -ne $True) { - # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) - # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. - # If the requested port is occupied by another process, we scan sequentially through - # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. - $GoodTrayPort = $Null - $TestTrayPort = $TrayPort - For ($i = 0; $i -le 10; $i++) { - if (-not $GoodTrayPort) { - if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { - $TestTrayPort++ - if ($TestTrayPort -gt 42009) { $TestTrayPort = 42000 } - } - else { - $GoodTrayPort = $TestTrayPort - } + else { + if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { + Write-Warning "Previous installation detected. Calling Uninstall-CWAA" + Uninstall-CWAA -Server $GoodServer -Force + Start-Sleep $Script:CWAAUninstallWaitSeconds } } - if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { - Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" - $TrayPort = $GoodTrayPort - } - Write-Output 'Starting Install.' - } - # Build parameter string - $installerArguments = ($( - "/i `"$InstallBase\Installer\$InstallMSI`"" - "SERVERADDRESS=$GoodServer" - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { "TRANSFORMS=`"Agent_Install.mst`"" } - if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } - if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } - if ($TrayPort -and $TrayPort -ne 42000) { "SERVICEPORT=$TrayPort" } - "/qn" - "/l `"$InstallBase\$logfile.log`"" - ) | Where-Object { $_ }) -join ' ' - Try { - if ($PSCmdlet.ShouldProcess("msiexec.exe $installerArguments", 'Execute Install')) { - $InstallAttempt = 0 - Do { - if ($InstallAttempt -gt 0) { - Write-Warning "Service Failed to Install. Retrying in 30 seconds." -WarningAction 'Continue' - $timeout = New-TimeSpan -Seconds 30 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for service to become available...' - Do { - Start-Sleep 5 - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) - $stopwatch.Stop() - Write-Verbose 'Service wait completed.' - } - $InstallAttempt++ - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -eq 0) { - $redactedArguments = ($installerArguments -join '') -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' - Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" - Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $installerArguments -WorkingDirectory $env:TEMP - Start-Sleep 5 + if ($WhatIfPreference -ne $True) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving TrayPort' -PercentComplete 55 } + # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) + # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. + # If the requested port is occupied by another process, we scan sequentially through + # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. + $GoodTrayPort = $Null + $TestTrayPort = $TrayPort + For ($i = 0; $i -le 10; $i++) { + if (-not $GoodTrayPort) { + if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { + $TestTrayPort++ + if ($TestTrayPort -gt $Script:CWAATrayPortMax) { $TestTrayPort = $Script:CWAATrayPortMin } + } + else { + $GoodTrayPort = $TestTrayPort + } } - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($InstallAttempt -ge 3 -or $runningServiceCount -eq 1) - if ($runningServiceCount -eq 0) { - Write-Error "LTService was not installed. Installation failed." - Return } - } - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' - if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -ne 0) { - $timeout = New-TimeSpan -Minutes 2 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for service to start...' - Do { - Start-Sleep 2 - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) - $stopwatch.Stop() - if ($runningServiceCount -eq 1) { + if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { + Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" + $TrayPort = $GoodTrayPort + } + Write-Output 'Starting Install.' + } + # Build parameter string + $installerArguments = ($( + "/i `"$InstallBase\Installer\$InstallMSI`"" + "SERVERADDRESS=$GoodServer" + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { "TRANSFORMS=`"Agent_Install.mst`"" } + if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } + if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } + if ($TrayPort -and $TrayPort -ne $Script:CWAATrayPortDefault) { "SERVICEPORT=$TrayPort" } + "/qn" + "/l `"$InstallBase\$logfile.log`"" + ) | Where-Object { $_ }) -join ' ' + Try { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI installer' -PercentComplete 66 } + $installSuccess = Invoke-CWAAMsiInstaller -InstallerArguments $installerArguments + if (-not $installSuccess) { Return } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for services to start' -PercentComplete 77 } + if (($Script:LTProxy.Enabled) -eq $True) { + Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' + if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { + $serviceRunning = Wait-CWAACondition -Condition { + $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count + $count -eq 1 + } -TimeoutSeconds $Script:CWAAServiceStartTimeoutSec -IntervalSeconds 2 -Activity 'LTService initial startup' + if ($serviceRunning) { Write-Debug "LTService Initial Startup Successful." } else { Write-Debug "LTService Initial Startup failed to complete within expected period." } - Write-Verbose 'Service wait completed.' + Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False } - Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False - } - } - else { - Write-Verbose 'No Proxy Configuration has been specified - Continuing.' - } - if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { - $timeout = New-TimeSpan -Minutes 15 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for agent to register...' - Do { - Start-Sleep 5 - $tempServiceInfo = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0) - } Until ($stopwatch.elapsed -gt $timeout -or $tempServiceInfo -ge 1) - $stopwatch.Stop() - Write-Verbose "Agent registration wait completed after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." - $Null = Get-CWAAProxy -ErrorAction Continue - } - if ($Hide) { Hide-CWAAAddRemove } - } - Catch { - Write-Error "There was an error during the install process. $_" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" - Return - } - if ($WhatIfPreference -ne $True) { - # Cleanup install files - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False - @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { - if (Test-Path -PathType Leaf -LiteralPath $_) { - $logcontents = Get-Content -Path $_ - $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' - if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } - } - } - $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($tempServiceInfo) { - if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { - Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - } - Elseif (-not $NoWait) { - Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue - Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." } else { - Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue + Write-Verbose 'No Proxy Configuration has been specified - Continuing.' } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for agent registration' -PercentComplete 88 } + if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { + $Null = Wait-CWAACondition -Condition { + $agentId = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0 + $agentId -ge 1 + } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 5 -Activity 'Agent registration' + $Null = Get-CWAAProxy -ErrorAction Continue + } + if ($Hide) { Hide-CWAAAddRemove } } - else { - if ($Error) { - Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" - Return + Catch { + Write-Error "There was an error during the install process. $_" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" + Return + } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Completing installation' -PercentComplete 100 } + if ($WhatIfPreference -ne $True) { + # Cleanup install files + Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False + Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False + @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { + if (Test-Path -PathType Leaf -LiteralPath $_) { + $logcontents = Get-Content -Path $_ + $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' + if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } + } } - Elseif (-not $NoWait) { - Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" - Return + $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($tempServiceInfo) { + if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { + Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" + Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" + } + Elseif (-not $NoWait) { + Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue + Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." + } + else { + Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue + } } else { - Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue + if ($Error.Count -gt $errorCountAtStart -or (-not $NoWait)) { + Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" + Return + } + else { + Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue + } } } + if ($Rename) { Rename-CWAAAddRemove -Name $Rename } + } + Elseif ($WhatIfPreference -ne $True) { + Write-Error "No valid server was reached to use for the install." } - if ($Rename -and $Rename -notmatch 'False') { Rename-CWAAAddRemove -Name $Rename } } - Elseif ($WhatIfPreference -ne $True) { - Write-Error "No valid server was reached to use for the install." + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" } - Write-Debug "Exiting $($myInvocation.InvocationName)" } } function Redo-CWAA { @@ -1894,6 +2210,9 @@ function Redo-CWAA { .EXAMPLE Redo-CWAA -Backup -Force Backs up settings, then forces reinstallation even if a probe agent is detected. + .EXAMPLE + Get-CWAAInfo | Redo-CWAA -InstallerToken 'token' + Reinstalls the agent using Server and LocationID from the current installation via pipeline. .NOTES Author: Chris Taylor Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService @@ -1934,21 +2253,7 @@ function Redo-CWAA { Catch { Write-Debug "Failed to retrieve current Agent Settings: $_" } - # Probe protection — outside Try/Catch so the terminating error propagates to caller. - # Matches the pattern in Reset-CWAA and Uninstall-CWAA. - if ($Null -ne $Settings -and ($Settings | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { - Write-Output 'Probe Agent Detected. Re-Install Forced.' - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - else { - Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - } - } + Assert-CWAANotProbeAgent -ServiceInfo $Settings -ActionName 'Re-Install' -Force:$Force if ($Null -eq $Settings) { Write-Debug "Unable to retrieve current Agent Settings. Testing for Backup Settings." Try { @@ -2060,6 +2365,12 @@ function Uninstall-CWAA { .PARAMETER Force Forces uninstallation even when a probe agent is detected. Use with extreme caution, as probe agents are typically critical infrastructure components. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing uninstall progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Uninstall-CWAA Uninstalls the agent using the server URL from the agent's registry settings. @@ -2078,6 +2389,9 @@ function Uninstall-CWAA { .EXAMPLE Uninstall-CWAA -WhatIf Simulates the uninstall process without making any actual changes. + .EXAMPLE + Get-CWAAInfo | Uninstall-CWAA + Pipes the installed agent's Server property into Uninstall-CWAA via pipeline. .NOTES Author: Chris Taylor Alias: Uninstall-LTService @@ -2091,10 +2405,10 @@ function Uninstall-CWAA { [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowNull()] [string[]]$Server, - [Parameter(ValueFromPipelineByPropertyName = $true)] [switch]$Backup, [switch]$Force, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { Write-Debug "Starting $($myInvocation.InvocationName)" @@ -2105,14 +2419,7 @@ function Uninstall-CWAA { Throw "Needs to be ran as Administrator" } $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { - Write-Output 'Probe Agent Detected. UnInstall Forced.' - } - else { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. UnInstall Denied." -ErrorAction Stop - } - } + Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'UnInstall' -Force:$Force if ($Backup) { if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { New-CWAABackup @@ -2168,9 +2475,13 @@ function Uninstall-CWAA { $Server = Read-Host -Prompt 'Provide the URL to your Automate server (https://automate.domain.com):' } # Resolve the first reachable server and its advertised version + $progressId = 2 + $progressActivity = 'Uninstalling ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 12 } $serverResult = Resolve-CWAAServer -Server $Server if (-not $serverResult) { return } $serverUrl = $serverResult.ServerUrl + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading uninstaller files' -PercentComplete 25 } Try { # Download the uninstall MSI (same URL for all server versions) $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" @@ -2234,9 +2545,11 @@ function Uninstall-CWAA { } } End { + try { if ($GoodServer -match 'https?://.+' -or $AlternateServer -match 'https?://.+') { Try { Write-Output 'Starting Uninstall.' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services and processes' -PercentComplete 37 } Try { Stop-CWAA -ErrorAction SilentlyContinue } Catch { Write-Debug "Stop-CWAA encountered an error: $_" } # Kill all running processes from %ltsvcdir% if (Test-Path $BasePath) { @@ -2255,6 +2568,7 @@ function Uninstall-CWAA { Catch { Write-Output 'Error calling regsvr32.exe.' } } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI uninstaller' -PercentComplete 50 } if ($PSCmdlet.ShouldProcess("msiexec.exe $uninstallArguments", 'Execute MSI Uninstall')) { if (Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") { Write-Verbose 'Launching MSI Uninstall.' @@ -2266,6 +2580,7 @@ function Uninstall-CWAA { Write-Verbose "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi was not found." } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running agent uninstaller' -PercentComplete 62 } if ($PSCmdlet.ShouldProcess("${env:windir}\temp\Agent_Uninstall.exe", 'Execute Agent Uninstall')) { if (Test-Path "${env:windir}\temp\Agent_Uninstall.exe") { # Remove previously extracted SFX files to prevent UnRAR overwrite prompts @@ -2279,8 +2594,9 @@ function Uninstall-CWAA { Write-Verbose "${env:windir}\temp\Agent_Uninstall.exe was not found." } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Removing services' -PercentComplete 75 } Write-Verbose 'Removing Services if found.' - @('LTService', 'LTSvcMon', 'LabVNC') | ForEach-Object { + $Script:CWAAAllServiceNames | ForEach-Object { if (Get-Service $_ -EA 0) { if ($PSCmdlet.ShouldProcess($_, 'Remove Service')) { Write-Debug "Removing Service: $_" @@ -2294,6 +2610,7 @@ function Uninstall-CWAA { } } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning up files and registry' -PercentComplete 87 } Write-Verbose 'Cleaning Files remaining if found.' # Depth-first removal to get as much removed as possible if complete removal fails @($BasePath, "${env:windir}\temp\_ltupdate") | ForEach-Object { @@ -2337,6 +2654,7 @@ function Uninstall-CWAA { Write-CWAAEventLog -EventId 1012 -EntryType Error -Message "Agent uninstall failed. Error: $($_.Exception.Message)" Write-Error "There was an error during the uninstall process. $($_.Exception.Message)" -ErrorAction Stop } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Verifying uninstall' -PercentComplete 100 } if ($WhatIfPreference -ne $True) { # Post Uninstall Check If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { @@ -2355,7 +2673,11 @@ function Uninstall-CWAA { Elseif ($WhatIfPreference -ne $True) { Write-Error "No valid server was reached to use for the uninstall." -ErrorAction Stop } - Write-Debug "Exiting $($myInvocation.InvocationName)" + } + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" + } } } function Update-CWAA { @@ -2379,6 +2701,12 @@ function Update-CWAA { The target agent version to update to. Example: 120.240 If omitted, the version advertised by the server will be used. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing update progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Update-CWAA -Version 120.240 Updates the agent to the specific version requested. @@ -2397,7 +2725,8 @@ function Update-CWAA { [parameter(Position = 0)] [AllowNull()] [string]$Version, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { Write-Debug "Starting $($myInvocation.InvocationName)" @@ -2416,6 +2745,9 @@ function Update-CWAA { } } # Resolve the first reachable server and its advertised version + $progressId = 3 + $progressActivity = 'Updating ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 14 } if (-not $Server) { return } $serverResult = Resolve-CWAAServer -Server $Server if ($serverResult) { @@ -2427,7 +2759,7 @@ function Update-CWAA { if ($Version -match '[1-9][0-9]{2}\.[0-9]{1,3}') { $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" } - Elseif ([System.Version]$serverVersion -ge [System.Version]'105.001') { + Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionUpdateMinimum) { $Version = $serverVersion Write-Verbose "Using detected version ($Version) from server: $GoodServer." $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" @@ -2444,6 +2776,7 @@ function Update-CWAA { } } # Remove stale updater directory using depth-first removal + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning previous update files' -PercentComplete 28 } Remove-CWAAFolderRecursive -Path $updaterPath Try { if (-not (Test-Path -PathType Container -Path $updaterPath)) { @@ -2464,6 +2797,7 @@ function Update-CWAA { } else { if ($PSCmdlet.ShouldProcess($updater, 'DownloadFile')) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading update package' -PercentComplete 42 } Write-Debug "Downloading LabtechUpdate.exe from $updater" $Script:LTServiceNetWebClient.DownloadFile($updater, "$updaterPath\LabtechUpdate.exe") if (-not (Test-CWAADownloadIntegrity -FilePath "$updaterPath\LabtechUpdate.exe" -FileName 'LabtechUpdate.exe')) { @@ -2488,6 +2822,7 @@ function Update-CWAA { } } End { + try { $detectedVersion = $Settings | Select-Object -Expand 'Version' -EA 0 if ($Null -eq $detectedVersion) { Write-Error "No existing installation was found." -ErrorAction Stop @@ -2505,6 +2840,7 @@ function Update-CWAA { Write-Warning "Server version detected ($serverVersion) is higher than the requested version ($Version)." Return } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services' -PercentComplete 57 } Try { Stop-CWAA } @@ -2515,6 +2851,7 @@ function Update-CWAA { } Write-Output "Updating Agent with the following information: Server $GoodServer, Version $Version" Try { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Extracting update' -PercentComplete 71 } if ($PSCmdlet.ShouldProcess("LabtechUpdate.exe $extractArguments", 'Extracting update files')) { if (Test-Path "$updaterPath\LabtechUpdate.exe") { Write-Verbose 'Launching LabtechUpdate Self-Extractor.' @@ -2531,6 +2868,7 @@ function Update-CWAA { Write-Verbose "$updaterPath\LabtechUpdate.exe was not found." } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Applying update' -PercentComplete 85 } if ($PSCmdlet.ShouldProcess("Update.exe $updaterArguments", 'Launching Updater')) { if (Test-Path "$updaterPath\Update.exe") { Write-Verbose 'Launching Labtech Updater' @@ -2548,6 +2886,7 @@ function Update-CWAA { Write-Error "There was an error during the update process. $_" -ErrorAction Continue Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update process failed. Error: $($_.Exception.Message)" } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Restarting services' -PercentComplete 100 } Try { Start-CWAA } @@ -2557,7 +2896,11 @@ function Update-CWAA { Return } Write-CWAAEventLog -EventId 1030 -EntryType Information -Message "Agent updated successfully to version $Version." - Write-Debug "Exiting $($myInvocation.InvocationName)" + } + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" + } } } function Get-CWAAError { @@ -2645,8 +2988,10 @@ function Get-CWAALogLevel { [CmdletBinding()] [Alias('Get-LTLogging')] Param () - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { Try { # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. $logLevel = Get-CWAASettings | Select-Object -Expand Debuging -EA 0 @@ -2749,6 +3094,9 @@ function Set-CWAALogLevel { .EXAMPLE Set-CWAALogLevel -Level Verbose -WhatIf Shows what changes would be made without applying them. + .EXAMPLE + 'Verbose' | Set-CWAALogLevel + Sets the log level to Verbose via pipeline input. .NOTES Author: Chris Taylor Alias: Set-LTLogging @@ -2758,11 +3106,14 @@ function Set-CWAALogLevel { [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTLogging')] Param ( + [Parameter(ValueFromPipeline = $True)] [ValidateSet('Normal', 'Verbose')] $Level = 'Normal' ) - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + Process { Try { # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. $registryPath = "$Script:CWAARegistrySettings" @@ -2920,10 +3271,17 @@ function Set-CWAAProxy { Automatically detect system proxy settings for module operations. Discovered settings are applied to the installed agent (if present). Cannot be used with other parameters. + .PARAMETER ProxyCredential + A PSCredential object containing the proxy username and password. + This is the preferred secure alternative to passing -ProxyUsername + and -ProxyPassword separately. Must be used with -ProxyServerURL. .PARAMETER ResetProxy Clears any currently defined proxy settings for module operations. Changes are applied to the installed agent (if present). Cannot be used with other parameters. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. .EXAMPLE Set-CWAAProxy -DetectProxy Automatically detects and configures the system proxy. @@ -2953,6 +3311,10 @@ function Set-CWAAProxy { [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] [SecureString]$EncodedProxyPassword, [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $ProxyCredential, + [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] [alias('Detect')] [alias('AutoDetect')] [switch]$DetectProxy, @@ -2974,6 +3336,12 @@ function Set-CWAAProxy { catch { Write-Debug "Failed to retrieve service settings. $_" } } Process { + # If a PSCredential was provided, extract username and password. + # This is the preferred secure alternative to passing plain text proxy credentials. + if ($ProxyCredential) { + $ProxyUsername = $ProxyCredential.UserName + $ProxyPassword = $ProxyCredential.GetNetworkCredential().Password + } if ( (($ResetProxy -eq $True) -and (($DetectProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) -or (($DetectProxy -eq $True) -and (($ResetProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) -or @@ -3107,7 +3475,7 @@ function Set-CWAAProxy { } if ($settingsChanged -eq $True) { $serviceRestartNeeded = $False - if ((Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { + if ((Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { $serviceRestartNeeded = $True try { Stop-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to stop services before proxy update. $_" } } @@ -3173,6 +3541,9 @@ function Register-CWAAHealthCheckTask { .EXAMPLE Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' Creates a custom-named task running every 12 hours. + .EXAMPLE + Get-CWAAInfo | Register-CWAAHealthCheckTask -InstallerToken 'token' + Uses Server and LocationID from the installed agent via pipeline to register a health check task. .NOTES Author: Chris Taylor Alias: Register-LTHealthCheckTask @@ -3185,8 +3556,10 @@ function Register-CWAAHealthCheckTask { [Parameter(Mandatory = $True)] [ValidatePattern('(?s:^[0-9a-z]+$)')] [string]$InstallerToken, + [Parameter(ValueFromPipelineByPropertyName = $True)] [ValidatePattern('^[a-zA-Z0-9\.\-\:\/]+$')] - [string]$Server, + [string[]]$Server, + [Parameter(ValueFromPipelineByPropertyName = $True)] [int]$LocationID, [ValidatePattern('^[\w\-\. ]+$')] [string]$TaskName = 'CWAAHealthCheck', @@ -3226,7 +3599,10 @@ function Register-CWAAHealthCheckTask { # Build the PowerShell command for the scheduled task action # Use Install mode if Server and LocationID are provided, otherwise Checkup mode if ($Server -and $LocationID) { - $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server '$Server' -LocationID $LocationID -InstallerToken '$InstallerToken'" + # Build a proper PowerShell array literal for the Server argument. + # Handles both single-server and multi-server arrays from Get-CWAAInfo pipeline. + $serverArgument = ($Server | ForEach-Object { "'$_'" }) -join ',' + $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server $serverArgument -LocationID $LocationID -InstallerToken '$InstallerToken'" } else { $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -InstallerToken '$InstallerToken'" @@ -3370,13 +3746,13 @@ function Repair-CWAA { [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Repair-LTService')] Param( - [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] [ValidateScript({ if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } })] [string]$Server, - [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] [ValidateRange(1, [int]::MaxValue)] [int]$LocationID, [Parameter(ParameterSetName = 'Install', Mandatory = $True)] @@ -3702,15 +4078,7 @@ function Restart-CWAA { Write-Debug "Starting $($MyInvocation.InvocationName)" } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart Service')) { Try { Stop-CWAA @@ -3771,15 +4139,7 @@ function Start-CWAA { $startedSvcCount = 0 } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } Try { if ((('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object | Select-Object -Expand Count) -gt 0) { Try { $netstat = & "$env:windir\system32\netstat.exe" -a -o -n 2>'' | Select-String -Pattern " .*[0-9\.]+:$($Port).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 } @@ -3798,7 +4158,7 @@ function Start-CWAA { # TrayPort wraps within the 42000-42009 range. If a protected process holds # the current port, increment and wrap back to 42000 after 42009. $newPort = [int]$Port + 1 - if ($newPort -gt 42009) { $newPort = 42000 } + if ($newPort -gt $Script:CWAATrayPortMax) { $newPort = $Script:CWAATrayPortMin } Write-Warning "Setting tray port to $newPort." New-ItemProperty -Path $Script:CWAARegistryRoot -Name TrayPort -PropertyType String -Value $newPort -Force -WhatIf:$False -Confirm:$False | Out-Null } @@ -3820,15 +4180,11 @@ function Start-CWAA { # Wait for services if we issued start commands $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count if ($stoppedServiceCount -gt 0 -and $startedSvcCount -eq 2) { - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - Write-Verbose 'Waiting for services to start...' - Do { - Start-Sleep 2 - $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.Elapsed -gt $timeout -or $stoppedServiceCount -eq 0) - $stopwatch.Stop() - Write-Verbose 'Service start wait completed.' + $Null = Wait-CWAACondition -Condition { + $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count + $count -eq 0 + } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services starting' + $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count } # Report final state if ($stoppedServiceCount -eq 0) { @@ -3882,15 +4238,7 @@ function Stop-CWAA { Write-Debug "Starting $($MyInvocation.InvocationName)" } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Stop-Service')) { $Null = Invoke-CWAACommand ('Kill VNC', 'Kill Trays') -EA 0 -WhatIf:$False -Confirm:$False Write-Verbose 'Stopping Automate agent services.' @@ -3904,19 +4252,14 @@ function Stop-CWAA { } Catch { Write-Debug "Failed to call sc.exe stop for service $_." } } - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - Write-Verbose 'Waiting for services to stop...' - Do { - Start-Sleep 2 - $runningServiceCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.Elapsed -gt $timeout -or $runningServiceCount -eq 0) - $stopwatch.Stop() - Write-Verbose 'Service stop wait completed.' - if ($runningServiceCount -gt 0) { - Write-Verbose "Services did not stop. Terminating processes after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." + $servicesStopped = Wait-CWAACondition -Condition { + $count = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count + $count -eq 0 + } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services stopping' + if (-not $servicesStopped) { + Write-Verbose 'Services did not stop in time. Terminating processes.' } - Get-Process | Where-Object { @('LTTray', 'LTSVC', 'LTSvcMon') -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False + Get-Process | Where-Object { $Script:CWAAAgentProcessNames -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False # Verify final state and report $remainingCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count if ($remainingCount -eq 0) { @@ -3972,6 +4315,9 @@ function Test-CWAAHealth { .EXAMPLE if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } Uses the Healthy boolean for conditional logic. + .EXAMPLE + Get-CWAAInfo | Test-CWAAHealth + Pipes the installed agent's Server property into Test-CWAAHealth via pipeline. .NOTES Author: Chris Taylor Alias: Test-LTHealth @@ -3982,7 +4328,7 @@ function Test-CWAAHealth { [Alias('Test-LTHealth')] Param( [Parameter(ValueFromPipelineByPropertyName = $True)] - [string]$Server, + [string[]]$Server, [switch]$TestServerConnectivity ) Begin { @@ -4033,14 +4379,15 @@ function Test-CWAAHealth { Catch { Write-Verbose 'HeartbeatLastSent not available or not a valid datetime.' } - # If a Server was provided, check if it matches the installed configuration + # If a Server was provided, check if any matches the installed configuration. + # Server is string[] to handle Get-CWAAInfo pipeline output (which returns Server as an array). if ($Server) { $installedServers = @($agentInfo | Select-Object -Expand 'Server' -EA 0) - $cleanServer = $Server -replace 'https?://', '' -replace '/$', '' + $cleanProvided = @($Server | ForEach-Object { $_ -replace 'https?://', '' -replace '/$', '' }) $serverMatch = $False foreach ($installedServer in $installedServers) { $cleanInstalled = $installedServer -replace 'https?://', '' -replace '/$', '' - if ($cleanInstalled -eq $cleanServer) { + if ($cleanProvided -contains $cleanInstalled) { $serverMatch = $True break } @@ -4477,32 +4824,11 @@ function Reset-CWAA { $MAC = $True } $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force) { - Write-Output 'Probe Agent Detected. Reset Forced.' - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Reset Denied." -ErrorAction Stop - } - else { - Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Reset Denied." -ErrorAction Stop - } - } - } + Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'Reset' -Force:$Force Write-Output "OLD ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" } Process { - if (-not (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Automate agent services NOT Found." - return - } - else { - Write-Error "What If: Stopping: Automate agent services NOT Found." - return - } - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } Try { if ($ID -or $Location -or $MAC) { Stop-CWAA @@ -4528,20 +4854,12 @@ function Reset-CWAA { } End { if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Discover new settings after Service Start')) { - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Write-Verbose 'Waiting for agent to register...' - while ( - (-not ($serviceInfo | Select-Object -Expand ID -EA 0) -or - -not ($serviceInfo | Select-Object -Expand LocationID -EA 0) -or - -not ($serviceInfo | Select-Object -Expand MAC -EA 0)) -and - $stopwatch.Elapsed -lt $timeout - ) { - Start-Sleep 2 - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - } - Write-Verbose 'Agent registration wait complete.' + $Null = Wait-CWAACondition -Condition { + $svcInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + ($svcInfo | Select-Object -Expand ID -EA 0) -and + ($svcInfo | Select-Object -Expand LocationID -EA 0) -and + ($svcInfo | Select-Object -Expand MAC -EA 0) + } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 2 -Activity 'Agent re-registration' $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False Write-Output "NEW ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" Write-CWAAEventLog -EventId 3000 -EntryType Information -Message "Agent reset successfully. New ID: $($serviceInfo | Select-Object -Expand ID -EA 0), LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0)" diff --git a/ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 b/ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 new file mode 100644 index 0000000..23638c2 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 @@ -0,0 +1,67 @@ +function Assert-CWAANotProbeAgent { + <# + .SYNOPSIS + Blocks operations on probe agents unless -Force is specified. + .DESCRIPTION + Checks the agent info object to determine if the current machine is a probe agent. + If it is and -Force is not set, writes a terminating error to prevent accidental + removal of critical infrastructure. If -Force is set, writes a warning message and + allows continuation. + + The ActionName parameter produces contextual messages like + "Probe Agent Detected. UnInstall Denied." or "Probe Agent Detected. Reset Forced." + + This consolidates the duplicated probe agent protection check found in + Uninstall-CWAA, Redo-CWAA, and Reset-CWAA. + .PARAMETER ServiceInfo + The agent info object from Get-CWAAInfo. If null or missing the Probe property, + the check is skipped silently. + .PARAMETER ActionName + The name of the operation for error/output messages. Used directly in the message + string, e.g., 'UnInstall', 'Re-Install', 'Reset'. + .PARAMETER Force + When set, allows the operation to proceed on a probe agent with an output message + instead of a terminating error. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [Parameter()] + [AllowNull()] + $ServiceInfo, + + [Parameter(Mandatory = $True)] + [string]$ActionName, + + [switch]$Force + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + if ($ServiceInfo -and ($ServiceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { + if ($Force) { + Write-Output "Probe Agent Detected. $ActionName Forced." + } + else { + if ($WhatIfPreference -ne $True) { + Write-Error -Exception ([System.OperationCanceledException]"Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop + } + else { + Write-Error -Exception ([System.OperationCanceledException]"What If: Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop + } + } + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 index b454c55..69e878b 100644 --- a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 @@ -129,6 +129,32 @@ function Initialize-CWAA { $Script:CWAAEventLogSource = 'ConnectWiseAutomateAgent' $Script:CWAAEventLogName = 'Application' + # Timeout and retry configuration — used by Wait-CWAACondition and Install-CWAA callers. + # Centralized here so they are tunable and self-documenting in one place. + $Script:CWAAInstallMaxAttempts = 3 + $Script:CWAAInstallRetryDelaySeconds = 30 + $Script:CWAAServiceStartTimeoutSec = 120 # 2 minutes — proxy startup wait + $Script:CWAARegistrationTimeoutSec = 900 # 15 minutes — agent registration wait + $Script:CWAATrayPortMin = 42000 + $Script:CWAATrayPortMax = 42009 + $Script:CWAATrayPortDefault = 42000 + $Script:CWAAUninstallWaitSeconds = 10 + $Script:CWAAServiceWaitTimeoutSec = 60 # 1 minute — Start/Stop/Restart/Reset service waits + $Script:CWAARedoSettleDelaySeconds = 20 # Redo-CWAA settling delay between uninstall and reinstall + + # Server version thresholds — document breaking changes in the server's deployment API. + # Each threshold gates a different URL construction or installer format in Install-CWAA. + $Script:CWAAVersionZipInstaller = '240.331' # InstallerToken deployments return ZIP (MSI+MST) + $Script:CWAAVersionAnonymousChange = '110.374' # Anonymous MSI download URL changed (LT11 Patch 13) + $Script:CWAAVersionVulnerabilityFix = '200.197' # CVE fix: unauthenticated Deployment.aspx access + $Script:CWAAVersionUpdateMinimum = '105.001' # Minimum version with update support + + # Agent process names — for forceful termination in Stop-CWAA after service stop timeout. + $Script:CWAAAgentProcessNames = @('LTTray', 'LTSVC', 'LTSvcMon') + + # All service names including LabVNC — for full service cleanup in Uninstall-CWAA. + $Script:CWAAAllServiceNames = @('LTService', 'LTSvcMon', 'LabVNC') + # Service credential storage â€" populated on-demand by Get-CWAAProxy $Script:LTServiceKeys = [PSCustomObject]@{ ServerPasswordString = '' diff --git a/ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 b/ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 new file mode 100644 index 0000000..88a4451 --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 @@ -0,0 +1,77 @@ +function Invoke-CWAAMsiInstaller { + <# + .SYNOPSIS + Executes the Automate agent MSI installer with retry logic. + .DESCRIPTION + Launches msiexec.exe with the provided arguments and retries up to a configurable + number of attempts if the LTService service is not detected after installation. + Between retries, polls for the service using Wait-CWAACondition. Redacts server + passwords from verbose output for security. + .PARAMETER InstallerArguments + The full argument string to pass to msiexec.exe (e.g., '/i "path\Agent_Install.msi" SERVERADDRESS=... /qn'). + .PARAMETER MaxAttempts + Maximum number of install attempts before giving up. Defaults to $Script:CWAAInstallMaxAttempts. + .PARAMETER RetryDelaySeconds + Seconds to wait (polling for service) between retry attempts. Defaults to $Script:CWAAInstallRetryDelaySeconds. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + Param( + [Parameter(Mandatory = $True)] + [string]$InstallerArguments, + + [Parameter()] + [int]$MaxAttempts = $Script:CWAAInstallMaxAttempts, + + [Parameter()] + [int]$RetryDelaySeconds = $Script:CWAAInstallRetryDelaySeconds + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + if (-not $PSCmdlet.ShouldProcess("msiexec.exe $InstallerArguments", 'Execute Install')) { + return $true + } + + $installAttempt = 0 + Do { + if ($installAttempt -gt 0) { + Write-Warning "Service Failed to Install. Retrying in $RetryDelaySeconds seconds." -WarningAction 'Continue' + $Null = Wait-CWAACondition -Condition { + $serviceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + $serviceCount -eq 1 + } -TimeoutSeconds $RetryDelaySeconds -IntervalSeconds 5 -Activity 'Waiting for service availability before retry' + } + $installAttempt++ + + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + if ($runningServiceCount -eq 0) { + $redactedArguments = $InstallerArguments -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' + Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" + Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $InstallerArguments -WorkingDirectory $env:TEMP + Start-Sleep 5 + } + + $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count + } Until ($installAttempt -ge $MaxAttempts -or $runningServiceCount -eq 1) + + if ($runningServiceCount -eq 0) { + Write-Error "LTService was not installed. Installation failed after $MaxAttempts attempts." + return $false + } + + return $true + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 b/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 index eb48873..7a09767 100644 --- a/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 +++ b/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 @@ -22,7 +22,8 @@ function Remove-CWAAFolderRecursive { [CmdletBinding(SupportsShouldProcess = $True)] Param( [Parameter(Mandatory = $True)] - [string]$Path + [string]$Path, + [switch]$ShowProgress ) Begin { @@ -37,8 +38,11 @@ function Remove-CWAAFolderRecursive { if ($PSCmdlet.ShouldProcess($Path, 'Remove Folder')) { Write-Debug "Removing Folder: $Path" + $folderProgressId = 10 + $folderProgressActivity = "Removing folder: $Path" Try { # Pass 1: Remove files inside each subfolder (leaves first) + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing files (pass 1 of 3)' -PercentComplete 33 } Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.psiscontainer } | ForEach-Object { @@ -48,15 +52,19 @@ function Remove-CWAAFolderRecursive { } # Pass 2: Remove subfolders sorted by path depth (deepest first) + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing subfolders (pass 2 of 3)' -PercentComplete 66 } Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | Where-Object { $_.psiscontainer } | Sort-Object { $_.FullName.Length } -Descending | Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False # Pass 3: Remove the root folder itself + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing root folder (pass 3 of 3)' -PercentComplete 100 } Remove-Item -Recurse -Force -Path $Path -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } } Catch { + if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } Write-Debug "Error removing folder '$Path': $($_.Exception.Message)" } } diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 b/ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 new file mode 100644 index 0000000..818610b --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 @@ -0,0 +1,114 @@ +function Test-CWAADotNetPrerequisite { + <# + .SYNOPSIS + Checks for and optionally installs the .NET Framework 3.5 prerequisite. + .DESCRIPTION + Verifies that .NET Framework 3.5 is installed, which is required by the ConnectWise + Automate agent. If 3.5 is missing, attempts automatic installation via + Enable-WindowsOptionalFeature (Windows 8+) or Dism.exe (Windows 7/Server 2008 R2). + + With -Force, allows the agent install to proceed if .NET 2.0 or higher is present + even when 3.5 cannot be installed. Without -Force, a missing 3.5 is a terminating error. + .PARAMETER SkipDotNet + Skips the .NET Framework check entirely. Returns $true immediately. + .PARAMETER Force + Allows fallback to .NET 2.0+ if 3.5 cannot be installed. + Without -Force, missing 3.5 is a terminating error. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding(SupportsShouldProcess = $True)] + Param( + [switch]$SkipDotNet, + [switch]$Force + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + if ($SkipDotNet) { + Write-Debug 'SkipDotNet specified, skipping .NET prerequisite check.' + return $true + } + + $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 + if ($DotNet -like '3.5.*') { + Write-Debug '.NET Framework 3.5 is already installed.' + return $true + } + + Write-Warning '.NET Framework 3.5 installation needed.' + $OSVersion = [System.Environment]::OSVersion.Version + + if ([version]$OSVersion -gt [version]'6.2') { + # Windows 8 / Server 2012 and later -- use Enable-WindowsOptionalFeature + Try { + if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { + $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' + if ($Install.State -ne 'EnablePending') { + $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart + } + if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { + Write-Warning '.NET Framework 3.5 installed but a reboot is needed.' + } + } + } + Catch { + Write-Error ".NET 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Install -ErrorAction Stop } + } + } + Elseif ([version]$OSVersion -gt [version]'6.1') { + # Windows 7 / Server 2008 R2 -- use Dism.exe + if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { + Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } + Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } + Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } + Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } + if ($Result -contains 'State : Enabled') { + Write-Warning ".Net Framework 3.5 has been installed and enabled." + } + Elseif ($Result -contains 'State : Enable Pending') { + Write-Warning ".Net Framework 3.5 installed but a reboot is needed." + } + else { + Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue + if (-not $Force) { Write-Error $Result -ErrorAction Stop } + } + } + } + + # Re-check after install attempt + $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse | Get-ItemProperty -Name Version -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version + + if ($DotNet -like '3.5.*') { + return $true + } + + # .NET 3.5 still not available after install attempt + if ($Force) { + if ($DotNet -match '(?m)^[2-4].\d') { + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue + return $true + } + else { + Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop + return $false + } + } + else { + Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop + return $false + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 b/ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 new file mode 100644 index 0000000..16913fc --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 @@ -0,0 +1,53 @@ +function Test-CWAAServiceExists { + <# + .SYNOPSIS + Tests whether the Automate agent services are installed on the local computer. + .DESCRIPTION + Checks for the existence of the LTService and LTSvcMon services using the + centralized $Script:CWAAServiceNames constant. Returns $true if at least one + service is found, $false otherwise. + + When -WriteErrorOnMissing is specified, writes a WhatIf-aware error message + if the services are not found. This consolidates the duplicated service existence + check pattern found in Start-CWAA, Stop-CWAA, Restart-CWAA, and Reset-CWAA. + .PARAMETER WriteErrorOnMissing + When specified, writes a Write-Error message if the services are not found. + The error message is WhatIf-aware (includes 'What If:' prefix when + $WhatIfPreference is $true in the caller's scope). + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [switch]$WriteErrorOnMissing + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + $services = Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue + if ($services) { + return $true + } + + if ($WriteErrorOnMissing) { + if ($WhatIfPreference -ne $True) { + Write-Error "Services NOT Found." + } + else { + Write-Error "What If: Services NOT Found." + } + } + return $false + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 b/ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 new file mode 100644 index 0000000..5b07b6c --- /dev/null +++ b/ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 @@ -0,0 +1,72 @@ +function Wait-CWAACondition { + <# + .SYNOPSIS + Polls a condition script block until it returns $true or a timeout is reached. + .DESCRIPTION + Generic polling helper that evaluates a condition at regular intervals. Returns $true + if the condition was satisfied before the timeout, or $false if the timeout expired. + Used to replace duplicated stopwatch-based Do-Until polling loops throughout the module. + .PARAMETER Condition + A script block that is evaluated each interval. The loop exits when this returns $true. + .PARAMETER TimeoutSeconds + Maximum number of seconds to wait before giving up. Must be at least 1. + .PARAMETER IntervalSeconds + Number of seconds to sleep between condition evaluations. Defaults to 5. + .PARAMETER Activity + Optional description logged via Write-Verbose at start and finish for diagnostics. + .NOTES + Version: 1.0.0 + Author: Chris Taylor + Private function - not exported. + .LINK + https://github.com/christaylorcodes/ConnectWiseAutomateAgent + #> + [CmdletBinding()] + Param( + [Parameter(Mandatory = $True)] + [ScriptBlock]$Condition, + + [Parameter(Mandatory = $True)] + [ValidateRange(1, [int]::MaxValue)] + [int]$TimeoutSeconds, + + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int]$IntervalSeconds = 5, + + [Parameter()] + [string]$Activity + ) + + Begin { + Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { + if ($Activity) { Write-Verbose "Waiting for: $Activity" } + + $timeout = New-TimeSpan -Seconds $TimeoutSeconds + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + Do { + Start-Sleep -Seconds $IntervalSeconds + $conditionMet = & $Condition + } Until ($stopwatch.Elapsed -gt $timeout -or $conditionMet) + + $stopwatch.Stop() + $elapsedSeconds = [int]$stopwatch.Elapsed.TotalSeconds + + if ($conditionMet) { + if ($Activity) { Write-Verbose "$Activity completed after $elapsedSeconds seconds." } + return $true + } + else { + if ($Activity) { Write-Verbose "$Activity timed out after $elapsedSeconds seconds." } + return $false + } + } + + End { + Write-Debug "Exiting $($MyInvocation.InvocationName)" + } +} diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 index 3f6ded3..1ed1cb8 100644 --- a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 +++ b/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 @@ -25,7 +25,7 @@ function Rename-CWAAAddRemove { [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Rename-LTAddRemove')] Param( - [Parameter(Mandatory = $True)] + [Parameter(Mandatory = $True, ValueFromPipeline = $true)] $Name, [Parameter(Mandatory = $False)] diff --git a/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 b/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 index a63a26e..3d52e36 100644 --- a/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 +++ b/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 @@ -25,7 +25,7 @@ function ConvertTo-CWAASecurity { [CmdletBinding()] [Alias('ConvertTo-LTSecurity')] Param( - [parameter(Mandatory = $true, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] + [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false)] [AllowNull()] [AllowEmptyString()] [AllowEmptyCollection()] @@ -38,8 +38,11 @@ function ConvertTo-CWAASecurity { $Key ) - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { $_initializationVector = [byte[]](240, 3, 45, 29, 0, 76, 173, 59) $DefaultKey = 'Thank you for using LabTech.' diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 index 303342a..1d03c04 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 @@ -5,8 +5,8 @@ function Install-CWAA { .DESCRIPTION Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles - .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy - configuration, TrayPort conflict resolution, and post-install agent registration verification. + prerequisite checks for .NET Framework 3.5, MSI download with file integrity validation, + proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. The -Force parameter allows installation even when services are already present @@ -38,9 +38,16 @@ function Install-CWAA { .PARAMETER NoWait Skips the post-install health check that waits for agent registration. The function exits immediately after the installer completes. + .PARAMETER Credential + A PSCredential object containing the server password for deployment authentication. + The password is extracted and used as the ServerPassword. This is the preferred + secure alternative to passing -ServerPassword as plain text. .PARAMETER SkipCertificateCheck Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing installation progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 Installs the agent using an InstallerToken for authentication. @@ -50,6 +57,9 @@ function Install-CWAA { .EXAMPLE Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait Installs the agent without waiting for registration to complete. + .EXAMPLE + Get-CWAAInfoBackup | Install-CWAA -InstallerToken 'GeneratedToken' + Reinstalls the agent using Server and LocationID from a previous backup via pipeline. .NOTES Author: Chris Taylor Alias: Install-LTService @@ -72,6 +82,10 @@ function Install-CWAA { [AllowNull()] [Alias('Password')] [string]$ServerPassword, + [Parameter(ParameterSetName = 'deployment')] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $Credential, [Parameter(ParameterSetName = 'installertoken')] [ValidatePattern('(?s:^[0-9a-z]+$)')] [string]$InstallerToken, @@ -88,18 +102,33 @@ function Install-CWAA { [switch]$SkipDotNet, [switch]$Force, [switch]$NoWait, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { Write-Debug "Starting $($myInvocation.InvocationName)" + # Snapshot error count so we can detect new errors from this function only, + # rather than checking the global $Error collection which accumulates all session errors. + $errorCountAtStart = $Error.Count + + # If a PSCredential was provided, extract the password for the deployment workflow. + # This is the preferred secure alternative to passing -ServerPassword as plain text. + if ($Credential) { + $ServerPassword = $Credential.GetNetworkCredential().Password + } + # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. # Only runs once per session, skips immediately on subsequent calls. $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck + $progressId = 1 + $progressActivity = 'Installing ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Checking prerequisites' -PercentComplete 11 } + if (-not $Force) { - if (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue) { + if (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue) { if ($WhatIfPreference -ne $True) { Write-Error "Services are already installed." -ErrorAction Stop } @@ -113,70 +142,11 @@ function Install-CWAA { Throw 'Needs to be ran as Administrator' } - if (-not $SkipDotNet) { - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 - if (-not ($DotNet -like '3.5.*')) { - Write-Output '.NET Framework 3.5 installation needed.' - $OSVersion = [System.Environment]::OSVersion.Version - - if ([version]$OSVersion -gt [version]'6.2') { - Try { - if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { - $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' - if ($Install.State -ne 'EnablePending') { - $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart - } - if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { - Write-Output '.NET Framework 3.5 installed but a reboot is needed.' - } - } - } - Catch { - Write-Error ".NET 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Install -ErrorAction Stop } - } - } - Elseif ([version]$OSVersion -gt [version]'6.1') { - if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } - Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } - Catch { Write-Output 'Error calling Dism.exe.'; $Result = $Null } - if ($Result -contains 'State : Enabled') { - Write-Warning ".Net Framework 3.5 has been installed and enabled." - } - Elseif ($Result -contains 'State : Enable Pending') { - Write-Warning ".Net Framework 3.5 installed but a reboot is needed." - } - else { - Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Result -ErrorAction Stop } - } - } - } - - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse | Get-ItemProperty -Name Version -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version - } - - if (-not ($DotNet -like '3.5.*')) { - if ($Force) { - if ($DotNet -match '(?m)^[2-4].\d') { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue - } - else { - Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop - } - } - else { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop - } - } - } + $Null = Test-CWAADotNetPrerequisite -SkipDotNet:$SkipDotNet -Force:$Force $InstallBase = $Script:CWAAInstallerTempPath $logfile = 'LTAgentInstall' $curlog = "$InstallBase\$logfile.log" - if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } if (-not (Test-Path -PathType Container -Path "$InstallBase\Installer")) { New-Item "$InstallBase\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null } @@ -191,13 +161,17 @@ function Install-CWAA { } Process { + # Escape double quotes in ServerPassword for MSI argument safety. + # Placed in Process (not Begin) because ServerPassword may arrive via pipeline binding. + if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } if (-not ($LocationID -or $PSCmdlet.ParameterSetName -eq 'installertoken')) { $LocationID = '1' } if (-not ($TrayPort) -or -not ($TrayPort -ge 1 -and $TrayPort -le 65535)) { - $TrayPort = '42000' + $TrayPort = $Script:CWAATrayPortDefault } # Resolve the first reachable server and its advertised version + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 22 } $serverResult = Resolve-CWAAServer -Server $Server if ($serverResult) { $serverUrl = $serverResult.ServerUrl @@ -216,7 +190,7 @@ function Install-CWAA { # - Pre-110.374: Legacy deployment URL with per-location MSI targeting if ($PSCmdlet.ParameterSetName -eq 'installertoken') { $installer = "$serverUrl/LabTech/Deployment.aspx?InstallerToken=$InstallerToken" - if ([System.Version]$serverVersion -ge [System.Version]'240.331') { + if ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { Write-Debug "New MSI Installer Format Needed" $InstallMSI = 'Agent_Install.zip' } @@ -224,7 +198,7 @@ function Install-CWAA { Elseif ($ServerPassword) { $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" } - Elseif ([System.Version]$serverVersion -ge [System.Version]'110.374') { + Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionAnonymousChange) { $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=1" } else { @@ -234,7 +208,7 @@ function Install-CWAA { # Vulnerability test June 10, 2020: ConnectWise Automate API Vulnerability # Servers below v200.197 may allow unauthenticated access to Deployment.aspx - if ([System.Version]$serverVersion -lt [System.Version]'200.197') { + if ([System.Version]$serverVersion -lt [System.Version]$Script:CWAAVersionVulnerabilityFix) { Try { $HTTP_Request = [System.Net.WebRequest]::Create("$serverUrl/LabTech/Deployment.aspx") if ($HTTP_Request.GetResponse().StatusCode -eq 'OK') { @@ -251,6 +225,7 @@ function Install-CWAA { } if ($PSCmdlet.ShouldProcess($installer, 'DownloadFile')) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading agent installer' -PercentComplete 33 } Write-Debug "Downloading $InstallMSI from $installer" $Script:LTServiceNetWebClient.DownloadFile($installer, "$InstallBase\Installer\$InstallMSI") if (-not (Test-CWAADownloadIntegrity -FilePath "$InstallBase\Installer\$InstallMSI" -FileName $InstallMSI)) { @@ -265,7 +240,7 @@ function Install-CWAA { Elseif (Test-Path "$InstallBase\Installer\$InstallMSI") { $GoodServer = $serverUrl Write-Verbose "$InstallMSI downloaded successfully from server $serverUrl." - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { Expand-Archive "$InstallBase\Installer\$InstallMSI" -DestinationPath "$InstallBase\Installer" -Force Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False $InstallMSI = 'Agent_Install.msi' @@ -279,182 +254,149 @@ function Install-CWAA { } End { - if ($GoodServer) { + try { + if ($GoodServer) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Preparing installation environment' -PercentComplete 44 } - if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { - Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" - } - else { - if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { - Write-Warning "Previous installation detected. Calling Uninstall-CWAA" - Uninstall-CWAA -Server $GoodServer -Force - Start-Sleep 10 + if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { + Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" } - } - - if ($WhatIfPreference -ne $True) { - # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) - # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. - # If the requested port is occupied by another process, we scan sequentially through - # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. - $GoodTrayPort = $Null - $TestTrayPort = $TrayPort - For ($i = 0; $i -le 10; $i++) { - if (-not $GoodTrayPort) { - if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { - $TestTrayPort++ - if ($TestTrayPort -gt 42009) { $TestTrayPort = 42000 } - } - else { - $GoodTrayPort = $TestTrayPort - } + else { + if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { + Write-Warning "Previous installation detected. Calling Uninstall-CWAA" + Uninstall-CWAA -Server $GoodServer -Force + Start-Sleep $Script:CWAAUninstallWaitSeconds } } - if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { - Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" - $TrayPort = $GoodTrayPort - } - Write-Output 'Starting Install.' - } - - # Build parameter string - $installerArguments = ($( - "/i `"$InstallBase\Installer\$InstallMSI`"" - "SERVERADDRESS=$GoodServer" - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]'240.331') { "TRANSFORMS=`"Agent_Install.mst`"" } - if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } - if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } - if ($TrayPort -and $TrayPort -ne 42000) { "SERVICEPORT=$TrayPort" } - "/qn" - "/l `"$InstallBase\$logfile.log`"" - ) | Where-Object { $_ }) -join ' ' - Try { - if ($PSCmdlet.ShouldProcess("msiexec.exe $installerArguments", 'Execute Install')) { - $InstallAttempt = 0 - Do { - if ($InstallAttempt -gt 0) { - Write-Warning "Service Failed to Install. Retrying in 30 seconds." -WarningAction 'Continue' - $timeout = New-TimeSpan -Seconds 30 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for service to become available...' - Do { - Start-Sleep 5 - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) - $stopwatch.Stop() - Write-Verbose 'Service wait completed.' - } - $InstallAttempt++ - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -eq 0) { - $redactedArguments = ($installerArguments -join '') -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' - Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" - Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $installerArguments -WorkingDirectory $env:TEMP - Start-Sleep 5 + if ($WhatIfPreference -ne $True) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving TrayPort' -PercentComplete 55 } + # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) + # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. + # If the requested port is occupied by another process, we scan sequentially through + # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. + $GoodTrayPort = $Null + $TestTrayPort = $TrayPort + For ($i = 0; $i -le 10; $i++) { + if (-not $GoodTrayPort) { + if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { + $TestTrayPort++ + if ($TestTrayPort -gt $Script:CWAATrayPortMax) { $TestTrayPort = $Script:CWAATrayPortMin } + } + else { + $GoodTrayPort = $TestTrayPort + } } - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($InstallAttempt -ge 3 -or $runningServiceCount -eq 1) - if ($runningServiceCount -eq 0) { - Write-Error "LTService was not installed. Installation failed." - Return } + if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { + Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" + $TrayPort = $GoodTrayPort + } + Write-Output 'Starting Install.' } - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' - if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -ne 0) { - $timeout = New-TimeSpan -Minutes 2 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for service to start...' - Do { - Start-Sleep 2 - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.elapsed -gt $timeout -or $runningServiceCount -eq 1) - $stopwatch.Stop() - if ($runningServiceCount -eq 1) { + + # Build parameter string + $installerArguments = ($( + "/i `"$InstallBase\Installer\$InstallMSI`"" + "SERVERADDRESS=$GoodServer" + if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { "TRANSFORMS=`"Agent_Install.mst`"" } + if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } + if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } + if ($TrayPort -and $TrayPort -ne $Script:CWAATrayPortDefault) { "SERVICEPORT=$TrayPort" } + "/qn" + "/l `"$InstallBase\$logfile.log`"" + ) | Where-Object { $_ }) -join ' ' + + Try { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI installer' -PercentComplete 66 } + $installSuccess = Invoke-CWAAMsiInstaller -InstallerArguments $installerArguments + if (-not $installSuccess) { Return } + + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for services to start' -PercentComplete 77 } + if (($Script:LTProxy.Enabled) -eq $True) { + Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' + if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { + $serviceRunning = Wait-CWAACondition -Condition { + $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count + $count -eq 1 + } -TimeoutSeconds $Script:CWAAServiceStartTimeoutSec -IntervalSeconds 2 -Activity 'LTService initial startup' + if ($serviceRunning) { Write-Debug "LTService Initial Startup Successful." } else { Write-Debug "LTService Initial Startup failed to complete within expected period." } - Write-Verbose 'Service wait completed.' + Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False } - Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False } - } - else { - Write-Verbose 'No Proxy Configuration has been specified - Continuing.' - } - if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { - $timeout = New-TimeSpan -Minutes 15 - $stopwatch = [diagnostics.stopwatch]::StartNew() - Write-Verbose 'Waiting for agent to register...' - Do { - Start-Sleep 5 - $tempServiceInfo = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0) - } Until ($stopwatch.elapsed -gt $timeout -or $tempServiceInfo -ge 1) - $stopwatch.Stop() - Write-Verbose "Agent registration wait completed after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." - $Null = Get-CWAAProxy -ErrorAction Continue - } - if ($Hide) { Hide-CWAAAddRemove } - } - - Catch { - Write-Error "There was an error during the install process. $_" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" - Return - } + else { + Write-Verbose 'No Proxy Configuration has been specified - Continuing.' + } - if ($WhatIfPreference -ne $True) { - # Cleanup install files - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False - @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { - if (Test-Path -PathType Leaf -LiteralPath $_) { - $logcontents = Get-Content -Path $_ - $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' - if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for agent registration' -PercentComplete 88 } + if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { + $Null = Wait-CWAACondition -Condition { + $agentId = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0 + $agentId -ge 1 + } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 5 -Activity 'Agent registration' + $Null = Get-CWAAProxy -ErrorAction Continue } + if ($Hide) { Hide-CWAAAddRemove } } - $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($tempServiceInfo) { - if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { - Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - } - Elseif (-not $NoWait) { - Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue - Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." - } - else { - Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue - } + Catch { + Write-Error "There was an error during the install process. $_" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" + Return } - else { - if ($Error) { - Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" - Return + + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Completing installation' -PercentComplete 100 } + if ($WhatIfPreference -ne $True) { + # Cleanup install files + Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False + Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False + @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { + if (Test-Path -PathType Leaf -LiteralPath $_) { + $logcontents = Get-Content -Path $_ + $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' + if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } + } } - Elseif (-not $NoWait) { - Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" - Return + + $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + if ($tempServiceInfo) { + if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { + Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" + Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" + } + Elseif (-not $NoWait) { + Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue + Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." + } + else { + Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue + } } else { - Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue + if ($Error.Count -gt $errorCountAtStart -or (-not $NoWait)) { + Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" + Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" + Return + } + else { + Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue + } } } + if ($Rename) { Rename-CWAAAddRemove -Name $Rename } + } + Elseif ($WhatIfPreference -ne $True) { + Write-Error "No valid server was reached to use for the install." } - if ($Rename -and $Rename -notmatch 'False') { Rename-CWAAAddRemove -Name $Rename } } - Elseif ($WhatIfPreference -ne $True) { - Write-Error "No valid server was reached to use for the install." + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" } - Write-Debug "Exiting $($myInvocation.InvocationName)" } } diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 index 9c4a239..1dc9d8c 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 @@ -46,6 +46,9 @@ .EXAMPLE Redo-CWAA -Backup -Force Backs up settings, then forces reinstallation even if a probe agent is detected. + .EXAMPLE + Get-CWAAInfo | Redo-CWAA -InstallerToken 'token' + Reinstalls the agent using Server and LocationID from the current installation via pipeline. .NOTES Author: Chris Taylor Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService @@ -89,21 +92,7 @@ Write-Debug "Failed to retrieve current Agent Settings: $_" } - # Probe protection — outside Try/Catch so the terminating error propagates to caller. - # Matches the pattern in Reset-CWAA and Uninstall-CWAA. - if ($Null -ne $Settings -and ($Settings | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { - Write-Output 'Probe Agent Detected. Re-Install Forced.' - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - else { - Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Re-Install Denied." -ErrorAction Stop - } - } - } + Assert-CWAANotProbeAgent -ServiceInfo $Settings -ActionName 'Re-Install' -Force:$Force if ($Null -eq $Settings) { Write-Debug "Unable to retrieve current Agent Settings. Testing for Backup Settings." Try { diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 index 1681648..2bdda3e 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 @@ -32,6 +32,12 @@ .PARAMETER Force Forces uninstallation even when a probe agent is detected. Use with extreme caution, as probe agents are typically critical infrastructure components. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing uninstall progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Uninstall-CWAA Uninstalls the agent using the server URL from the agent's registry settings. @@ -50,6 +56,9 @@ .EXAMPLE Uninstall-CWAA -WhatIf Simulates the uninstall process without making any actual changes. + .EXAMPLE + Get-CWAAInfo | Uninstall-CWAA + Pipes the installed agent's Server property into Uninstall-CWAA via pipeline. .NOTES Author: Chris Taylor Alias: Uninstall-LTService @@ -63,10 +72,10 @@ [Parameter(ValueFromPipelineByPropertyName = $true)] [AllowNull()] [string[]]$Server, - [Parameter(ValueFromPipelineByPropertyName = $true)] [switch]$Backup, [switch]$Force, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { @@ -81,14 +90,7 @@ } $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force -eq $True) { - Write-Output 'Probe Agent Detected. UnInstall Forced.' - } - else { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. UnInstall Denied." -ErrorAction Stop - } - } + Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'UnInstall' -Force:$Force if ($Backup) { if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { @@ -151,10 +153,14 @@ } # Resolve the first reachable server and its advertised version + $progressId = 2 + $progressActivity = 'Uninstalling ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 12 } $serverResult = Resolve-CWAAServer -Server $Server if (-not $serverResult) { return } $serverUrl = $serverResult.ServerUrl + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading uninstaller files' -PercentComplete 25 } Try { # Download the uninstall MSI (same URL for all server versions) $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" @@ -223,9 +229,11 @@ } End { + try { if ($GoodServer -match 'https?://.+' -or $AlternateServer -match 'https?://.+') { Try { Write-Output 'Starting Uninstall.' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services and processes' -PercentComplete 37 } Try { Stop-CWAA -ErrorAction SilentlyContinue } Catch { Write-Debug "Stop-CWAA encountered an error: $_" } @@ -248,6 +256,7 @@ } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI uninstaller' -PercentComplete 50 } if ($PSCmdlet.ShouldProcess("msiexec.exe $uninstallArguments", 'Execute MSI Uninstall')) { if (Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") { Write-Verbose 'Launching MSI Uninstall.' @@ -260,6 +269,7 @@ } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running agent uninstaller' -PercentComplete 62 } if ($PSCmdlet.ShouldProcess("${env:windir}\temp\Agent_Uninstall.exe", 'Execute Agent Uninstall')) { if (Test-Path "${env:windir}\temp\Agent_Uninstall.exe") { # Remove previously extracted SFX files to prevent UnRAR overwrite prompts @@ -274,8 +284,9 @@ } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Removing services' -PercentComplete 75 } Write-Verbose 'Removing Services if found.' - @('LTService', 'LTSvcMon', 'LabVNC') | ForEach-Object { + $Script:CWAAAllServiceNames | ForEach-Object { if (Get-Service $_ -EA 0) { if ($PSCmdlet.ShouldProcess($_, 'Remove Service')) { Write-Debug "Removing Service: $_" @@ -290,6 +301,7 @@ } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning up files and registry' -PercentComplete 87 } Write-Verbose 'Cleaning Files remaining if found.' # Depth-first removal to get as much removed as possible if complete removal fails @($BasePath, "${env:windir}\temp\_ltupdate") | ForEach-Object { @@ -337,6 +349,7 @@ Write-Error "There was an error during the uninstall process. $($_.Exception.Message)" -ErrorAction Stop } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Verifying uninstall' -PercentComplete 100 } if ($WhatIfPreference -ne $True) { # Post Uninstall Check If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { @@ -355,6 +368,10 @@ Elseif ($WhatIfPreference -ne $True) { Write-Error "No valid server was reached to use for the uninstall." -ErrorAction Stop } - Write-Debug "Exiting $($myInvocation.InvocationName)" + } + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" + } } } diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 index bfdbd28..dc50195 100644 --- a/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 @@ -21,6 +21,12 @@ function Update-CWAA { The target agent version to update to. Example: 120.240 If omitted, the version advertised by the server will be used. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. + .PARAMETER ShowProgress + Displays a Write-Progress bar showing update progress. Off by default + to avoid interference with unattended execution (RMM tools, GPO scripts). .EXAMPLE Update-CWAA -Version 120.240 Updates the agent to the specific version requested. @@ -39,7 +45,8 @@ function Update-CWAA { [parameter(Position = 0)] [AllowNull()] [string]$Version, - [switch]$SkipCertificateCheck + [switch]$SkipCertificateCheck, + [switch]$ShowProgress ) Begin { @@ -63,6 +70,9 @@ function Update-CWAA { } # Resolve the first reachable server and its advertised version + $progressId = 3 + $progressActivity = 'Updating ConnectWise Automate Agent' + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 14 } if (-not $Server) { return } $serverResult = Resolve-CWAAServer -Server $Server if ($serverResult) { @@ -75,7 +85,7 @@ function Update-CWAA { if ($Version -match '[1-9][0-9]{2}\.[0-9]{1,3}') { $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" } - Elseif ([System.Version]$serverVersion -ge [System.Version]'105.001') { + Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionUpdateMinimum) { $Version = $serverVersion Write-Verbose "Using detected version ($Version) from server: $GoodServer." $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" @@ -94,6 +104,7 @@ function Update-CWAA { } # Remove stale updater directory using depth-first removal + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning previous update files' -PercentComplete 28 } Remove-CWAAFolderRecursive -Path $updaterPath Try { @@ -115,6 +126,7 @@ function Update-CWAA { } else { if ($PSCmdlet.ShouldProcess($updater, 'DownloadFile')) { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading update package' -PercentComplete 42 } Write-Debug "Downloading LabtechUpdate.exe from $updater" $Script:LTServiceNetWebClient.DownloadFile($updater, "$updaterPath\LabtechUpdate.exe") if (-not (Test-CWAADownloadIntegrity -FilePath "$updaterPath\LabtechUpdate.exe" -FileName 'LabtechUpdate.exe')) { @@ -141,6 +153,7 @@ function Update-CWAA { } End { + try { $detectedVersion = $Settings | Select-Object -Expand 'Version' -EA 0 if ($Null -eq $detectedVersion) { Write-Error "No existing installation was found." -ErrorAction Stop @@ -159,6 +172,7 @@ function Update-CWAA { Return } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services' -PercentComplete 57 } Try { Stop-CWAA } @@ -170,6 +184,7 @@ function Update-CWAA { Write-Output "Updating Agent with the following information: Server $GoodServer, Version $Version" Try { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Extracting update' -PercentComplete 71 } if ($PSCmdlet.ShouldProcess("LabtechUpdate.exe $extractArguments", 'Extracting update files')) { if (Test-Path "$updaterPath\LabtechUpdate.exe") { Write-Verbose 'Launching LabtechUpdate Self-Extractor.' @@ -187,6 +202,7 @@ function Update-CWAA { } } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Applying update' -PercentComplete 85 } if ($PSCmdlet.ShouldProcess("Update.exe $updaterArguments", 'Launching Updater')) { if (Test-Path "$updaterPath\Update.exe") { Write-Verbose 'Launching Labtech Updater' @@ -205,6 +221,7 @@ function Update-CWAA { Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update process failed. Error: $($_.Exception.Message)" } + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Restarting services' -PercentComplete 100 } Try { Start-CWAA } @@ -215,6 +232,10 @@ function Update-CWAA { } Write-CWAAEventLog -EventId 1030 -EntryType Information -Message "Agent updated successfully to version $Version." - Write-Debug "Exiting $($myInvocation.InvocationName)" + } + finally { + if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } + Write-Debug "Exiting $($myInvocation.InvocationName)" + } } } diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 index 7cd5be1..e25f4f8 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 @@ -27,8 +27,11 @@ function Get-CWAALogLevel { [Alias('Get-LTLogging')] Param () - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { Try { # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. $logLevel = Get-CWAASettings | Select-Object -Expand Debuging -EA 0 diff --git a/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 b/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 index f9b9ed2..c3b1201 100644 --- a/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 +++ b/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 @@ -21,6 +21,9 @@ function Set-CWAALogLevel { .EXAMPLE Set-CWAALogLevel -Level Verbose -WhatIf Shows what changes would be made without applying them. + .EXAMPLE + 'Verbose' | Set-CWAALogLevel + Sets the log level to Verbose via pipeline input. .NOTES Author: Chris Taylor Alias: Set-LTLogging @@ -30,12 +33,16 @@ function Set-CWAALogLevel { [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTLogging')] Param ( + [Parameter(ValueFromPipeline = $True)] [ValidateSet('Normal', 'Verbose')] $Level = 'Normal' ) - Process { + Begin { Write-Debug "Starting $($MyInvocation.InvocationName)" + } + + Process { Try { # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. $registryPath = "$Script:CWAARegistrySettings" diff --git a/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 b/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 index 8fa23da..6b5ae0b 100644 --- a/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 +++ b/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 @@ -30,10 +30,17 @@ function Set-CWAAProxy { Automatically detect system proxy settings for module operations. Discovered settings are applied to the installed agent (if present). Cannot be used with other parameters. + .PARAMETER ProxyCredential + A PSCredential object containing the proxy username and password. + This is the preferred secure alternative to passing -ProxyUsername + and -ProxyPassword separately. Must be used with -ProxyServerURL. .PARAMETER ResetProxy Clears any currently defined proxy settings for module operations. Changes are applied to the installed agent (if present). Cannot be used with other parameters. + .PARAMETER SkipCertificateCheck + Bypasses SSL/TLS certificate validation for server connections. + Use in lab or test environments with self-signed certificates. .EXAMPLE Set-CWAAProxy -DetectProxy Automatically detects and configures the system proxy. @@ -63,6 +70,10 @@ function Set-CWAAProxy { [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] [SecureString]$EncodedProxyPassword, [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] + [System.Management.Automation.PSCredential] + [System.Management.Automation.Credential()] + $ProxyCredential, + [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] [alias('Detect')] [alias('AutoDetect')] [switch]$DetectProxy, @@ -88,6 +99,12 @@ function Set-CWAAProxy { } Process { + # If a PSCredential was provided, extract username and password. + # This is the preferred secure alternative to passing plain text proxy credentials. + if ($ProxyCredential) { + $ProxyUsername = $ProxyCredential.UserName + $ProxyPassword = $ProxyCredential.GetNetworkCredential().Password + } if ( (($ResetProxy -eq $True) -and (($DetectProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) -or @@ -224,7 +241,7 @@ function Set-CWAAProxy { } if ($settingsChanged -eq $True) { $serviceRestartNeeded = $False - if ((Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { + if ((Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { $serviceRestartNeeded = $True try { Stop-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to stop services before proxy update. $_" } } diff --git a/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 b/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 index ce095b8..779d4a4 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 @@ -37,6 +37,9 @@ function Register-CWAAHealthCheckTask { .EXAMPLE Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' Creates a custom-named task running every 12 hours. + .EXAMPLE + Get-CWAAInfo | Register-CWAAHealthCheckTask -InstallerToken 'token' + Uses Server and LocationID from the installed agent via pipeline to register a health check task. .NOTES Author: Chris Taylor Alias: Register-LTHealthCheckTask @@ -50,9 +53,11 @@ function Register-CWAAHealthCheckTask { [ValidatePattern('(?s:^[0-9a-z]+$)')] [string]$InstallerToken, + [Parameter(ValueFromPipelineByPropertyName = $True)] [ValidatePattern('^[a-zA-Z0-9\.\-\:\/]+$')] - [string]$Server, + [string[]]$Server, + [Parameter(ValueFromPipelineByPropertyName = $True)] [int]$LocationID, [ValidatePattern('^[\w\-\. ]+$')] @@ -101,7 +106,10 @@ function Register-CWAAHealthCheckTask { # Build the PowerShell command for the scheduled task action # Use Install mode if Server and LocationID are provided, otherwise Checkup mode if ($Server -and $LocationID) { - $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server '$Server' -LocationID $LocationID -InstallerToken '$InstallerToken'" + # Build a proper PowerShell array literal for the Server argument. + # Handles both single-server and multi-server arrays from Get-CWAAInfo pipeline. + $serverArgument = ($Server | ForEach-Object { "'$_'" }) -join ',' + $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server $serverArgument -LocationID $LocationID -InstallerToken '$InstallerToken'" } else { $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -InstallerToken '$InstallerToken'" diff --git a/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 index 5fca6b3..372a662 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 @@ -53,14 +53,14 @@ [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Repair-LTService')] Param( - [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] [ValidateScript({ if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } })] [string]$Server, - [Parameter(ParameterSetName = 'Install', Mandatory = $True)] + [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] [ValidateRange(1, [int]::MaxValue)] [int]$LocationID, diff --git a/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 index 8e9690d..a83db09 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 @@ -26,15 +26,7 @@ function Restart-CWAA { } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart Service')) { Try { Stop-CWAA diff --git a/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 index 5a69fc1..60c4856 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 @@ -35,15 +35,7 @@ function Start-CWAA { } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } Try { if ((('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object | Select-Object -Expand Count) -gt 0) { Try { $netstat = & "$env:windir\system32\netstat.exe" -a -o -n 2>'' | Select-String -Pattern " .*[0-9\.]+:$($Port).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 } @@ -62,7 +54,7 @@ function Start-CWAA { # TrayPort wraps within the 42000-42009 range. If a protected process holds # the current port, increment and wrap back to 42000 after 42009. $newPort = [int]$Port + 1 - if ($newPort -gt 42009) { $newPort = 42000 } + if ($newPort -gt $Script:CWAATrayPortMax) { $newPort = $Script:CWAATrayPortMin } Write-Warning "Setting tray port to $newPort." New-ItemProperty -Path $Script:CWAARegistryRoot -Name TrayPort -PropertyType String -Value $newPort -Force -WhatIf:$False -Confirm:$False | Out-Null } @@ -85,15 +77,11 @@ function Start-CWAA { # Wait for services if we issued start commands $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count if ($stoppedServiceCount -gt 0 -and $startedSvcCount -eq 2) { - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - Write-Verbose 'Waiting for services to start...' - Do { - Start-Sleep 2 - $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.Elapsed -gt $timeout -or $stoppedServiceCount -eq 0) - $stopwatch.Stop() - Write-Verbose 'Service start wait completed.' + $Null = Wait-CWAACondition -Condition { + $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count + $count -eq 0 + } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services starting' + $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count } # Report final state diff --git a/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 index 744607f..abb1b04 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 @@ -28,15 +28,7 @@ function Stop-CWAA { } Process { - if (-not (Get-Service 'LTService', 'LTSvcMon' -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - return - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Stop-Service')) { $Null = Invoke-CWAACommand ('Kill VNC', 'Kill Trays') -EA 0 -WhatIf:$False -Confirm:$False Write-Verbose 'Stopping Automate agent services.' @@ -50,19 +42,14 @@ function Stop-CWAA { } Catch { Write-Debug "Failed to call sc.exe stop for service $_." } } - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - Write-Verbose 'Waiting for services to stop...' - Do { - Start-Sleep 2 - $runningServiceCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count - } Until ($stopwatch.Elapsed -gt $timeout -or $runningServiceCount -eq 0) - $stopwatch.Stop() - Write-Verbose 'Service stop wait completed.' - if ($runningServiceCount -gt 0) { - Write-Verbose "Services did not stop. Terminating processes after $(([int32]$stopwatch.Elapsed.TotalSeconds).ToString()) seconds." + $servicesStopped = Wait-CWAACondition -Condition { + $count = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count + $count -eq 0 + } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services stopping' + if (-not $servicesStopped) { + Write-Verbose 'Services did not stop in time. Terminating processes.' } - Get-Process | Where-Object { @('LTTray', 'LTSVC', 'LTSvcMon') -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False + Get-Process | Where-Object { $Script:CWAAAgentProcessNames -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False # Verify final state and report $remainingCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count diff --git a/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 b/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 index eac537f..4e7a853 100644 --- a/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 +++ b/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 @@ -35,6 +35,9 @@ .EXAMPLE if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } Uses the Healthy boolean for conditional logic. + .EXAMPLE + Get-CWAAInfo | Test-CWAAHealth + Pipes the installed agent's Server property into Test-CWAAHealth via pipeline. .NOTES Author: Chris Taylor Alias: Test-LTHealth @@ -45,7 +48,7 @@ [Alias('Test-LTHealth')] Param( [Parameter(ValueFromPipelineByPropertyName = $True)] - [string]$Server, + [string[]]$Server, [switch]$TestServerConnectivity ) @@ -106,14 +109,15 @@ Write-Verbose 'HeartbeatLastSent not available or not a valid datetime.' } - # If a Server was provided, check if it matches the installed configuration + # If a Server was provided, check if any matches the installed configuration. + # Server is string[] to handle Get-CWAAInfo pipeline output (which returns Server as an array). if ($Server) { $installedServers = @($agentInfo | Select-Object -Expand 'Server' -EA 0) - $cleanServer = $Server -replace 'https?://', '' -replace '/$', '' + $cleanProvided = @($Server | ForEach-Object { $_ -replace 'https?://', '' -replace '/$', '' }) $serverMatch = $False foreach ($installedServer in $installedServers) { $cleanInstalled = $installedServer -replace 'https?://', '' -replace '/$', '' - if ($cleanInstalled -eq $cleanServer) { + if ($cleanProvided -contains $cleanInstalled) { $serverMatch = $True break } diff --git a/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 b/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 index 69151fd..7302b2d 100644 --- a/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 +++ b/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 @@ -57,33 +57,12 @@ function Reset-CWAA { } $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($serviceInfo -and ($serviceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force) { - Write-Output 'Probe Agent Detected. Reset Forced.' - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception [System.OperationCanceledException]"Probe Agent Detected. Reset Denied." -ErrorAction Stop - } - else { - Write-Error -Exception [System.OperationCanceledException]"What If: Probe Agent Detected. Reset Denied." -ErrorAction Stop - } - } - } + Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'Reset' -Force:$Force Write-Output "OLD ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" } Process { - if (-not (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue)) { - if ($WhatIfPreference -ne $True) { - Write-Error "Automate agent services NOT Found." - return - } - else { - Write-Error "What If: Stopping: Automate agent services NOT Found." - return - } - } + if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } Try { if ($ID -or $Location -or $MAC) { @@ -111,20 +90,12 @@ function Reset-CWAA { End { if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Discover new settings after Service Start')) { - $timeout = New-TimeSpan -Minutes 1 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Write-Verbose 'Waiting for agent to register...' - while ( - (-not ($serviceInfo | Select-Object -Expand ID -EA 0) -or - -not ($serviceInfo | Select-Object -Expand LocationID -EA 0) -or - -not ($serviceInfo | Select-Object -Expand MAC -EA 0)) -and - $stopwatch.Elapsed -lt $timeout - ) { - Start-Sleep 2 - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - } - Write-Verbose 'Agent registration wait complete.' + $Null = Wait-CWAACondition -Condition { + $svcInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False + ($svcInfo | Select-Object -Expand ID -EA 0) -and + ($svcInfo | Select-Object -Expand LocationID -EA 0) -and + ($svcInfo | Select-Object -Expand MAC -EA 0) + } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 2 -Activity 'Agent re-registration' $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False Write-Output "NEW ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" Write-CWAAEventLog -EventId 3000 -EntryType Information -Message "Agent reset successfully. New ID: $($serviceInfo | Select-Object -Expand ID -EA 0), LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0)" diff --git a/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 b/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 index 8f29401..b4c1d28 100644 --- a/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 +++ b/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 @@ -22,6 +22,9 @@ function Test-CWAAPort { .EXAMPLE Test-CWAAPort -Quiet Returns $True if the TrayPort is available, $False otherwise. + .EXAMPLE + Get-CWAAInfo | Test-CWAAPort + Pipes the installed agent's Server and TrayPort into Test-CWAAPort via pipeline. .NOTES Author: Chris Taylor Alias: Test-LTPorts diff --git a/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml b/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml index 5bb4df4..515f244 100644 --- a/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml +++ b/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml @@ -50,18 +50,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - @@ -101,18 +89,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - @@ -159,7 +135,7 @@ ConvertTo-CWAASecurity - + InputString The string to be encoded. @@ -183,22 +159,10 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + InputString The string to be encoded. @@ -222,18 +186,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - @@ -281,34 +233,9 @@ Get-CWAAError - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -355,18 +282,6 @@ Get-CWAAInfo - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -392,18 +307,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -475,34 +378,9 @@ Get-CWAAInfoBackup - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -549,34 +427,9 @@ Get-CWAALogLevel - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -623,34 +476,9 @@ Get-CWAAProbeError - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -696,34 +524,9 @@ Get-CWAAProxy - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -764,39 +567,14 @@ - Reads agent settings from the Automate agent service settings registry subkey (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. These settings are separate from the main agent configuration returned by Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, ProxyPassword), logging level, and other operational parameters written by the agent or Set-CWAAProxy. + Reads agent settings from the Automate agent service Settings registry subkey (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. These settings are separate from the main agent configuration returned by Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, ProxyPassword), logging level, and other operational parameters written by the agent or Set-CWAAProxy. Get-CWAASettings - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + @@ -842,18 +620,6 @@ Hide-CWAAAddRemove - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -879,18 +645,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -956,27 +710,28 @@ - Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. + Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles prerequisite checks for .NET Framework 3.5, MSI download with file integrity validation, proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. The -Force parameter allows installation even when services are already present or when only .NET 4.0+ is available without 3.5. Install-CWAA - Force + Credential - Disables safety checks including existing service detection and .NET version requirements. + A PSCredential object containing the server password for deployment authentication. The password is extracted and used as the ServerPassword. This is the preferred secure alternative to passing -ServerPassword as plain text. + PSCredential - SwitchParameter + PSCredential - False + None - Hide + Force - Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. + Disables safety checks including existing service detection and .NET version requirements. SwitchParameter @@ -985,16 +740,15 @@ False - InstallerToken + Hide - An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. + Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. - String - String + SwitchParameter - None + False LocationID @@ -1006,7 +760,7 @@ Int32 - None + 0 NoWait @@ -1019,18 +773,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Rename @@ -1067,6 +809,17 @@ None + + ShowProgress + + Displays a Write-Progress bar showing installation progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). + + + SwitchParameter + + + False + SkipCertificateCheck @@ -1099,7 +852,7 @@ Int32 - None + 0 Confirm @@ -1148,6 +901,18 @@ False + + InstallerToken + + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken + + String + + String + + + None + LocationID @@ -1158,7 +923,7 @@ Int32 - None + 0 NoWait @@ -1171,18 +936,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Rename @@ -1219,6 +972,17 @@ None + + ShowProgress + + Displays a Write-Progress bar showing installation progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). + + + SwitchParameter + + + False + SkipCertificateCheck @@ -1251,7 +1015,7 @@ Int32 - None + 0 Confirm @@ -1278,6 +1042,18 @@ + + Credential + + A PSCredential object containing the server password for deployment authentication. The password is extracted and used as the ServerPassword. This is the preferred secure alternative to passing -ServerPassword as plain text. + + PSCredential + + PSCredential + + + None + Force @@ -1305,7 +1081,7 @@ InstallerToken - An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. + An installer token for authenticated agent deployment. This is the preferred authentication method over ServerPassword. See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken String @@ -1324,7 +1100,7 @@ Int32 - None + 0 NoWait @@ -1338,18 +1114,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Rename @@ -1386,6 +1150,18 @@ None + + ShowProgress + + Displays a Write-Progress bar showing installation progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). + + SwitchParameter + + SwitchParameter + + + False + SkipCertificateCheck @@ -1420,7 +1196,7 @@ Int32 - None + 0 Confirm @@ -1447,42 +1223,8 @@ False - - - - System.String[] - - - - - - - - System.String - - - - - - - - System.Int32 - - - - - - - - - - System.Object - - - - - - + + Author: Chris Taylor Alias: Install-LTService @@ -1513,7 +1255,7 @@ - Online Version: + https://github.com/christaylorcodes/ConnectWiseAutomateAgent https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -1545,18 +1287,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -1594,18 +1324,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -1677,18 +1395,6 @@ New-CWAABackup - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -1714,18 +1420,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -1852,19 +1546,7 @@ Int32 - None - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None + 0 Rename @@ -1981,19 +1663,7 @@ Int32 - None - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None + 0 Rename @@ -2120,24 +1790,12 @@ The LocationID of the location the agent will be assigned to. If not provided, reads from the current agent configuration or prompts interactively. - Int32 - - Int32 - - - None - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference + Int32 - ActionPreference + Int32 - None + 0 Rename @@ -2279,19 +1937,19 @@ None - + Server Optional server URL. When provided, the scheduled task passes this to Repair-CWAA in Install mode (with Server, LocationID, and InstallerToken). - String + String[] - String + String[] None - + LocationID Optional location ID. Required when Server is provided. @@ -2338,18 +1996,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -2411,7 +2057,7 @@ 6 - + LocationID Optional location ID. Required when Server is provided. @@ -2423,26 +2069,14 @@ 0 - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + Server Optional server URL. When provided, the scheduled task passes this to Repair-CWAA in Install mode (with Server, LocationID, and InstallerToken). - String + String[] - String + String[] None @@ -2536,7 +2170,7 @@ Rename-CWAAAddRemove - + Name The display name for the Automate agent as shown in the list of installed software. @@ -2560,18 +2194,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -2597,7 +2219,7 @@ - + Name The display name for the Automate agent as shown in the list of installed software. @@ -2609,18 +2231,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - PublisherName @@ -2699,7 +2309,7 @@ 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 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 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 or from backup settings. + 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 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 or from backup settings. All remediation actions are logged to the Windows Event Log (Application log, source ConnectWiseAutomateAgent) for visibility in unattended scheduled task runs. Designed to be called periodically via Register-CWAAHealthCheckTask or any external scheduler. @@ -2742,7 +2352,7 @@ None - + LocationID The LocationID for fresh agent installs. Required with the Install parameter set. @@ -2754,19 +2364,7 @@ 0 - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + Server The ConnectWise Automate server URL for fresh installs or server mismatch correction. Required when using the Install parameter set. @@ -2839,7 +2437,7 @@ None - + LocationID The LocationID for fresh agent installs. Required with the Install parameter set. @@ -2851,19 +2449,7 @@ 0 - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - - + Server The ConnectWise Automate server URL for fresh installs or server mismatch correction. Required when using the Install parameter set. @@ -3009,18 +2595,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3106,18 +2680,6 @@ False - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3195,18 +2757,6 @@ Restart-CWAA - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3232,18 +2782,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3315,7 +2853,7 @@ Set-CWAALogLevel - + Level The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. Normal sets registry value 1; Verbose sets registry value 1000. @@ -3327,18 +2865,6 @@ Normal - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3364,7 +2890,7 @@ - + Level The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. Normal sets registry value 1; Verbose sets registry value 1000. @@ -3376,18 +2902,6 @@ Normal - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3536,14 +3050,14 @@ None - - ProgressAction + + ProxyCredential - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + A PSCredential object containing the proxy username and password. This is the preferred secure alternative to passing -ProxyUsername and -ProxyPassword separately. Must be used with -ProxyServerURL. - ActionPreference + PSCredential - ActionPreference + PSCredential None @@ -3562,7 +3076,7 @@ SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -3631,14 +3145,14 @@ None - - ProgressAction + + ProxyCredential - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + A PSCredential object containing the proxy username and password. This is the preferred secure alternative to passing -ProxyUsername and -ProxyPassword separately. Must be used with -ProxyServerURL. - ActionPreference + PSCredential - ActionPreference + PSCredential None @@ -3694,7 +3208,7 @@ SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -3780,18 +3294,6 @@ Show-CWAAAddRemove - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3817,18 +3319,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3899,18 +3389,6 @@ Start-CWAA - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -3936,18 +3414,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -4018,18 +3484,6 @@ Stop-CWAA - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -4055,18 +3509,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -4149,21 +3591,9 @@ An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points to this server. If omitted, ServerMatch is null. - String - - String - - - None - - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference + String[] - ActionPreference + String[] None @@ -4182,26 +3612,14 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Server An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points to this server. If omitted, ServerMatch is null. - String + String[] - String + String[] None @@ -4295,18 +3713,6 @@ 0 - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Quiet @@ -4321,18 +3727,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Quiet @@ -4429,18 +3823,6 @@ None - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Quiet @@ -4455,18 +3837,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Quiet @@ -4558,7 +3928,7 @@ None - + Backup Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. @@ -4580,22 +3950,21 @@ False - - ProgressAction + + ShowProgress - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + Displays a Write-Progress bar showing uninstall progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). - ActionPreference - ActionPreference + SwitchParameter - None + False SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -4628,7 +3997,7 @@ - + Backup Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. @@ -4652,34 +4021,34 @@ False - - ProgressAction + + Server - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. If that fails, prompts interactively for a server URL. Example: https://automate.domain.com - ActionPreference + String[] - ActionPreference + String[] None - - Server + + ShowProgress - One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. If that fails, prompts interactively for a server URL. Example: https://automate.domain.com + Displays a Write-Progress bar showing uninstall progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). - String[] + SwitchParameter - String[] + SwitchParameter - None + False SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -4798,18 +4167,6 @@ CWAAHealthCheck - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - Confirm @@ -4835,18 +4192,6 @@ - - ProgressAction - - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. - - ActionPreference - - ActionPreference - - - None - TaskName @@ -4943,22 +4288,21 @@ None - - ProgressAction + + ShowProgress - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + Displays a Write-Progress bar showing update progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). - ActionPreference - ActionPreference + SwitchParameter - None + False SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter @@ -4991,22 +4335,22 @@ - - ProgressAction + + ShowProgress - Determines how the cmdlet responds to progress updates generated by a script, the Write-Progress cmdlet, or the progress bar in the Windows PowerShell ISE. + Displays a Write-Progress bar showing update progress. Off by default to avoid interference with unattended execution (RMM tools, GPO scripts). - ActionPreference + SwitchParameter - ActionPreference + SwitchParameter - None + False SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. Use when connecting to ConnectWise Automate servers with self-signed or internal CA certificates. This affects ALL HTTPS connections in the session, not just Automate operations. See the graduated SSL trust model in Docs/Security.md for details. + Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. SwitchParameter diff --git a/Docs/Help/ConnectWiseAutomateAgent.md b/Docs/Help/ConnectWiseAutomateAgent.md index 7f067c0..0656ae9 100644 --- a/Docs/Help/ConnectWiseAutomateAgent.md +++ b/Docs/Help/ConnectWiseAutomateAgent.md @@ -1,4 +1,4 @@ ---- +--- Module Name: ConnectWiseAutomateAgent Module Guid: 37424fc5-48d4-4d15-8b19-e1c2bf4bab67 Download Help Link: {{ Update Download Link }} diff --git a/Docs/Help/ConvertFrom-CWAASecurity.md b/Docs/Help/ConvertFrom-CWAASecurity.md index 871aa76..efab167 100644 --- a/Docs/Help/ConvertFrom-CWAASecurity.md +++ b/Docs/Help/ConvertFrom-CWAASecurity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Decodes a Base64-encoded string using TripleDES decryption. ## SYNTAX ``` -ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] - [-ProgressAction ] [] +ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] [] ``` ## DESCRIPTION @@ -89,21 +88,6 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/ConvertTo-CWAASecurity.md b/Docs/Help/ConvertTo-CWAASecurity.md index 918d0fa..02acbef 100644 --- a/Docs/Help/ConvertTo-CWAASecurity.md +++ b/Docs/Help/ConvertTo-CWAASecurity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Encodes a string using TripleDES encryption compatible with Automate operations. ## SYNTAX ``` -ConvertTo-CWAASecurity [-InputString] [[-Key] ] [-ProgressAction ] - [] +ConvertTo-CWAASecurity [-InputString] [[-Key] ] [] ``` ## DESCRIPTION @@ -51,7 +50,7 @@ Aliases: Required: True Position: 1 Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` @@ -71,21 +70,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAError.md b/Docs/Help/Get-CWAAError.md index 0215eea..7a2aa37 100644 --- a/Docs/Help/Get-CWAAError.md +++ b/Docs/Help/Get-CWAAError.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Reads the ConnectWise Automate Agent error log into structured objects. ## SYNTAX ``` -Get-CWAAError [-ProgressAction ] [] +Get-CWAAError [] ``` ## DESCRIPTION @@ -43,21 +43,6 @@ Opens the error log in a sortable, searchable grid view window. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAInfo.md b/Docs/Help/Get-CWAAInfo.md index 3a3c404..94e6cdf 100644 --- a/Docs/Help/Get-CWAAInfo.md +++ b/Docs/Help/Get-CWAAInfo.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves ConnectWise Automate agent configuration from the registry. ## SYNTAX ``` -Get-CWAAInfo [-ProgressAction ] [-WhatIf] [-Confirm] [] +Get-CWAAInfo [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -45,21 +45,6 @@ Retrieves agent info with ShouldProcess suppressed, as used by internal callers. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Get-CWAAInfoBackup.md b/Docs/Help/Get-CWAAInfoBackup.md index 29faa20..2316d1b 100644 --- a/Docs/Help/Get-CWAAInfoBackup.md +++ b/Docs/Help/Get-CWAAInfoBackup.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves backed-up ConnectWise Automate agent configuration from the registry. ## SYNTAX ``` -Get-CWAAInfoBackup [-ProgressAction ] [] +Get-CWAAInfoBackup [] ``` ## DESCRIPTION @@ -43,21 +43,6 @@ Returns only the server addresses from the backup configuration. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAALogLevel.md b/Docs/Help/Get-CWAALogLevel.md index eac4da2..ef18873 100644 --- a/Docs/Help/Get-CWAALogLevel.md +++ b/Docs/Help/Get-CWAALogLevel.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves the current logging level for the ConnectWise Automate Agent. ## SYNTAX ``` -Get-CWAALogLevel [-ProgressAction ] [] +Get-CWAALogLevel [] ``` ## DESCRIPTION @@ -44,21 +44,6 @@ Typical troubleshooting workflow: check level, enable verbose, verify the change ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAProbeError.md b/Docs/Help/Get-CWAAProbeError.md index a1db635..d763057 100644 --- a/Docs/Help/Get-CWAAProbeError.md +++ b/Docs/Help/Get-CWAAProbeError.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Reads the ConnectWise Automate Agent probe error log into structured objects. ## SYNTAX ``` -Get-CWAAProbeError [-ProgressAction ] [] +Get-CWAAProbeError [] ``` ## DESCRIPTION @@ -43,21 +43,6 @@ Opens the probe error log in a sortable, searchable grid view window. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAProxy.md b/Docs/Help/Get-CWAAProxy.md index e759bcb..291dbc2 100644 --- a/Docs/Help/Get-CWAAProxy.md +++ b/Docs/Help/Get-CWAAProxy.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves the current agent proxy settings for module operations. ## SYNTAX ``` -Get-CWAAProxy [-ProgressAction ] [] +Get-CWAAProxy [] ``` ## DESCRIPTION @@ -44,21 +44,6 @@ Checks whether a proxy is configured and displays the URL. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAASettings.md b/Docs/Help/Get-CWAASettings.md index f07f622..07f0bd7 100644 --- a/Docs/Help/Get-CWAASettings.md +++ b/Docs/Help/Get-CWAASettings.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,11 +13,11 @@ Retrieves ConnectWise Automate agent settings from the registry. ## SYNTAX ``` -Get-CWAASettings [-ProgressAction ] [] +Get-CWAASettings [] ``` ## DESCRIPTION -Reads agent settings from the Automate agent service settings registry subkey +Reads agent settings from the Automate agent service Settings registry subkey (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. These settings are separate from the main agent configuration returned by Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, @@ -42,21 +42,6 @@ Returns just the configured proxy URL, if any. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Hide-CWAAAddRemove.md b/Docs/Help/Hide-CWAAAddRemove.md index 6b878b7..0c4efe7 100644 --- a/Docs/Help/Hide-CWAAAddRemove.md +++ b/Docs/Help/Hide-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Hides the Automate agent from the Add/Remove Programs list. ## SYNTAX ``` -Hide-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] +Hide-CWAAAddRemove [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -39,21 +39,6 @@ Shows what registry changes would be made without applying them. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Install-CWAA.md b/Docs/Help/Install-CWAA.md index fd4a015..0da73f8 100644 --- a/Docs/Help/Install-CWAA.md +++ b/Docs/Help/Install-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -14,25 +14,29 @@ Installs the ConnectWise Automate Agent on the local computer. ### deployment (Default) ``` -Install-CWAA [-Server ] [-ServerPassword ] [-LocationID ] [-TrayPort ] - [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] - [-ProgressAction ] [-WhatIf] [-Confirm] [] +Install-CWAA [-Server ] [-ServerPassword ] [-Credential ] [-LocationID ] + [-TrayPort ] [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] + [-ShowProgress] [-WhatIf] [-Confirm] [] ``` ### installertoken ``` Install-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] [-TrayPort ] [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] - [-ProgressAction ] [-WhatIf] [-Confirm] [] + [-ShowProgress] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION Downloads and installs the ConnectWise Automate agent from the specified server URL. Supports authentication via InstallerToken (preferred) or ServerPassword. -The function handles .NET Framework 3.5 prerequisite checks, MSI download with file integrity validation, proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. +The function handles +prerequisite checks for .NET Framework 3.5, MSI download with file integrity validation, +proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. -If a previous installation is detected, the function will automatically call Uninstall-LTService before proceeding. -The -Force parameter allows installation even when services are already present or when only .NET 4.0+ is available without 3.5. +If a previous installation is detected, the function will automatically call Uninstall-LTService +before proceeding. +The -Force parameter allows installation even when services are already present +or when only .NET 4.0+ is available without 3.5. ## EXAMPLES @@ -59,6 +63,24 @@ Installs the agent without waiting for registration to complete. ## PARAMETERS +### -Credential +A PSCredential object containing the server password for deployment authentication. +The password is extracted and used as the ServerPassword. +This is the preferred +secure alternative to passing -ServerPassword as plain text. + +```yaml +Type: PSCredential +Parameter Sets: deployment +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Force Disables safety checks including existing service detection and .NET version requirements. @@ -91,7 +113,9 @@ Accept wildcard characters: False ### -InstallerToken An installer token for authenticated agent deployment. -This is the preferred authentication method over ServerPassword. +This is the preferred +authentication method over ServerPassword. +See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken ```yaml Type: String @@ -115,7 +139,7 @@ Aliases: Required: False Position: Named -Default value: None +Default value: 0 Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` @@ -136,21 +160,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Rename Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. @@ -168,7 +177,8 @@ Accept wildcard characters: False ### -Server One or more ConnectWise Automate server URLs to download the installer from. -Example: https://automate.domain.com The function tries each server in order until a successful download occurs. +Example: https://automate.domain.com +The function tries each server in order until a successful download occurs. ```yaml Type: String[] @@ -211,6 +221,23 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ShowProgress +Displays a Write-Progress bar showing installation progress. +Off by default +to avoid interference with unattended execution (RMM tools, GPO scripts). + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -SkipCertificateCheck Bypasses SSL/TLS certificate validation for server connections. Use in lab or test environments with self-signed certificates. @@ -255,7 +282,7 @@ Aliases: Required: False Position: Named -Default value: None +Default value: 0 Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` @@ -270,7 +297,7 @@ Aliases: cf Required: False Position: Named -Default value: False +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -286,7 +313,7 @@ Aliases: wi Required: False Position: Named -Default value: False +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -296,13 +323,13 @@ This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable ## INPUTS -### System.String[] -### System.String -### System.Int32 ## OUTPUTS -### System.Object ## NOTES -Author: Chris Taylor Alias: Install-LTService +Author: Chris Taylor +Alias: Install-LTService ## RELATED LINKS + +[https://github.com/christaylorcodes/ConnectWiseAutomateAgent](https://github.com/christaylorcodes/ConnectWiseAutomateAgent) + diff --git a/Docs/Help/Invoke-CWAACommand.md b/Docs/Help/Invoke-CWAACommand.md index b78b27b..688316c 100644 --- a/Docs/Help/Invoke-CWAACommand.md +++ b/Docs/Help/Invoke-CWAACommand.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Sends a service command to the ConnectWise Automate agent. ## SYNTAX ``` -Invoke-CWAACommand [-Command] [-ProgressAction ] [-WhatIf] [-Confirm] - [] +Invoke-CWAACommand [-Command] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -61,21 +60,6 @@ Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/New-CWAABackup.md b/Docs/Help/New-CWAABackup.md index c7f1cce..b48328b 100644 --- a/Docs/Help/New-CWAABackup.md +++ b/Docs/Help/New-CWAABackup.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Creates a complete backup of the ConnectWise Automate agent installation. ## SYNTAX ``` -New-CWAABackup [-ProgressAction ] [-WhatIf] [-Confirm] [] +New-CWAABackup [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -55,21 +55,6 @@ Shows what the backup operation would do without actually creating the backup. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Redo-CWAA.md b/Docs/Help/Redo-CWAA.md index e0a45ae..c895b5a 100644 --- a/Docs/Help/Redo-CWAA.md +++ b/Docs/Help/Redo-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -15,15 +15,13 @@ Reinstalls the ConnectWise Automate Agent on the local computer. ### deployment ``` Redo-CWAA [-Server ] [-ServerPassword ] [-LocationID ] [-Backup] [-Hide] - [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] [-Confirm] - [] + [-Rename ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] [] ``` ### installertoken ``` Redo-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] - [-Backup] [-Hide] [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] - [-Confirm] [] + [-Backup] [-Hide] [-Rename ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -143,26 +141,11 @@ Aliases: Required: False Position: Named -Default value: None +Default value: 0 Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Rename Renames the agent entry in Add/Remove Programs after reinstallation. diff --git a/Docs/Help/Register-CWAAHealthCheckTask.md b/Docs/Help/Register-CWAAHealthCheckTask.md index 879d883..1990a12 100644 --- a/Docs/Help/Register-CWAAHealthCheckTask.md +++ b/Docs/Help/Register-CWAAHealthCheckTask.md @@ -13,9 +13,8 @@ Creates or updates a scheduled task for periodic ConnectWise Automate agent heal ## SYNTAX ``` -Register-CWAAHealthCheckTask [-InstallerToken] [[-Server] ] [[-LocationID] ] - [[-TaskName] ] [[-IntervalHours] ] [-Force] [-ProgressAction ] [-WhatIf] - [-Confirm] [] +Register-CWAAHealthCheckTask [-InstallerToken] [[-Server] ] [[-LocationID] ] + [[-TaskName] ] [[-IntervalHours] ] [-Force] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -117,22 +116,7 @@ Aliases: Required: False Position: 3 Default value: 0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` @@ -142,14 +126,14 @@ When provided, the scheduled task passes this to Repair-CWAA in Install mode (with Server, LocationID, and InstallerToken). ```yaml -Type: String +Type: String[] Parameter Sets: (All) Aliases: Required: False Position: 2 Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` diff --git a/Docs/Help/Rename-CWAAAddRemove.md b/Docs/Help/Rename-CWAAAddRemove.md index b2ea864..5107979 100644 --- a/Docs/Help/Rename-CWAAAddRemove.md +++ b/Docs/Help/Rename-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Renames the Automate agent entry in the Add/Remove Programs list. ## SYNTAX ``` -Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-ProgressAction ] - [-WhatIf] [-Confirm] [] +Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -51,22 +50,7 @@ Aliases: Required: True Position: 1 Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` diff --git a/Docs/Help/Repair-CWAA.md b/Docs/Help/Repair-CWAA.md index fd66265..1c38e45 100644 --- a/Docs/Help/Repair-CWAA.md +++ b/Docs/Help/Repair-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -15,13 +15,13 @@ Performs escalating remediation of the ConnectWise Automate agent. ### Install ``` Repair-CWAA -Server -LocationID -InstallerToken [-HoursRestart ] - [-HoursReinstall ] [-ProgressAction ] [-WhatIf] [-Confirm] [] + [-HoursReinstall ] [-WhatIf] [-Confirm] [] ``` ### Checkup ``` -Repair-CWAA -InstallerToken [-HoursRestart ] [-HoursReinstall ] - [-ProgressAction ] [-WhatIf] [-Confirm] [] +Repair-CWAA -InstallerToken [-HoursRestart ] [-HoursReinstall ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -29,19 +29,19 @@ 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. +If the agent is installed and healthy - no action taken. 2. -If the agent is installed but has not checked in within HoursRestart â€" restarts +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 +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. +If the agent configuration is unreadable - uninstalls and reinstalls. 5. -If the installed agent points to the wrong server â€" reinstalls with the correct server. +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 +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, @@ -141,22 +141,7 @@ Aliases: Required: True Position: Named Default value: 0 -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` @@ -172,7 +157,7 @@ Aliases: Required: True Position: Named Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` diff --git a/Docs/Help/Reset-CWAA.md b/Docs/Help/Reset-CWAA.md index 908264d..3893756 100644 --- a/Docs/Help/Reset-CWAA.md +++ b/Docs/Help/Reset-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Removes local agent identity settings to force re-registration. ## SYNTAX ``` -Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-ProgressAction ] [-WhatIf] - [-Confirm] [] +Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -131,21 +130,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Restart-CWAA.md b/Docs/Help/Restart-CWAA.md index edb80e9..10b7ac8 100644 --- a/Docs/Help/Restart-CWAA.md +++ b/Docs/Help/Restart-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Restarts the ConnectWise Automate agent services. ## SYNTAX ``` -Restart-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] +Restart-CWAA [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -38,21 +38,6 @@ Shows what would happen without actually restarting the services. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Set-CWAALogLevel.md b/Docs/Help/Set-CWAALogLevel.md index 8bd631d..68e4ea1 100644 --- a/Docs/Help/Set-CWAALogLevel.md +++ b/Docs/Help/Set-CWAALogLevel.md @@ -13,8 +13,7 @@ Sets the logging level for the ConnectWise Automate Agent. ## SYNTAX ``` -Set-CWAALogLevel [[-Level] ] [-ProgressAction ] [-WhatIf] [-Confirm] - [] +Set-CWAALogLevel [[-Level] ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -65,22 +64,7 @@ Aliases: Required: False Position: 1 Default value: Normal -Accept pipeline input: False -Accept wildcard characters: False -``` - -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` diff --git a/Docs/Help/Set-CWAAProxy.md b/Docs/Help/Set-CWAAProxy.md index b3ad5ef..a6005ec 100644 --- a/Docs/Help/Set-CWAAProxy.md +++ b/Docs/Help/Set-CWAAProxy.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -14,8 +14,8 @@ Configures module proxy settings for all operations during the current session. ``` Set-CWAAProxy [[-ProxyServerURL] ] [[-ProxyUsername] ] [[-ProxyPassword] ] - [-EncodedProxyUsername ] [-EncodedProxyPassword ] [-DetectProxy] [-ResetProxy] - [-SkipCertificateCheck] [-ProgressAction ] [-WhatIf] [-Confirm] [] + [-EncodedProxyUsername ] [-EncodedProxyPassword ] [-ProxyCredential ] + [-DetectProxy] [-ResetProxy] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -102,18 +102,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} +### -ProxyCredential +A PSCredential object containing the proxy username and password. +This is the preferred secure alternative to passing -ProxyUsername +and -ProxyPassword separately. +Must be used with -ProxyServerURL. ```yaml -Type: ActionPreference +Type: PSCredential Parameter Sets: (All) -Aliases: proga +Aliases: Required: False Position: Named Default value: None -Accept pipeline input: False +Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` @@ -185,7 +188,8 @@ Accept wildcard characters: False ``` ### -SkipCertificateCheck -{{ Fill SkipCertificateCheck Description }} +Bypasses SSL/TLS certificate validation for server connections. +Use in lab or test environments with self-signed certificates. ```yaml Type: SwitchParameter diff --git a/Docs/Help/Show-CWAAAddRemove.md b/Docs/Help/Show-CWAAAddRemove.md index 2df4a92..7d35662 100644 --- a/Docs/Help/Show-CWAAAddRemove.md +++ b/Docs/Help/Show-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Shows the Automate agent in the Add/Remove Programs list. ## SYNTAX ``` -Show-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] +Show-CWAAAddRemove [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -39,21 +39,6 @@ Shows what registry changes would be made without applying them. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Start-CWAA.md b/Docs/Help/Start-CWAA.md index 3913594..4578a8e 100644 --- a/Docs/Help/Start-CWAA.md +++ b/Docs/Help/Start-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Starts the ConnectWise Automate agent services. ## SYNTAX ``` -Start-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] +Start-CWAA [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -46,21 +46,6 @@ Shows what would happen without actually starting the services. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Stop-CWAA.md b/Docs/Help/Stop-CWAA.md index adce3be..421679d 100644 --- a/Docs/Help/Stop-CWAA.md +++ b/Docs/Help/Stop-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Stops the ConnectWise Automate agent services. ## SYNTAX ``` -Stop-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] +Stop-CWAA [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -42,21 +42,6 @@ Shows what would happen without actually stopping the services. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Test-CWAAHealth.md b/Docs/Help/Test-CWAAHealth.md index f75b04e..ff47aa8 100644 --- a/Docs/Help/Test-CWAAHealth.md +++ b/Docs/Help/Test-CWAAHealth.md @@ -13,8 +13,7 @@ Performs a read-only health assessment of the ConnectWise Automate agent. ## SYNTAX ``` -Test-CWAAHealth [[-Server] ] [-TestServerConnectivity] [-ProgressAction ] - [] +Test-CWAAHealth [[-Server] ] [-TestServerConnectivity] [] ``` ## DESCRIPTION @@ -61,21 +60,6 @@ Uses the Healthy boolean for conditional logic. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Server An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points @@ -83,7 +67,7 @@ to this server. If omitted, ServerMatch is null. ```yaml -Type: String +Type: String[] Parameter Sets: (All) Aliases: diff --git a/Docs/Help/Test-CWAAPort.md b/Docs/Help/Test-CWAAPort.md index 7f52dc8..8224b9f 100644 --- a/Docs/Help/Test-CWAAPort.md +++ b/Docs/Help/Test-CWAAPort.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Tests connectivity to TCP ports required by the ConnectWise Automate agent. ## SYNTAX ``` -Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [-ProgressAction ] - [] +Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [] ``` ## DESCRIPTION @@ -42,21 +41,6 @@ Returns $True if the TrayPort is available, $False otherwise. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Quiet Returns a boolean connectivity result instead of verbose output. diff --git a/Docs/Help/Test-CWAAServerConnectivity.md b/Docs/Help/Test-CWAAServerConnectivity.md index 14750ba..8ca55fd 100644 --- a/Docs/Help/Test-CWAAServerConnectivity.md +++ b/Docs/Help/Test-CWAAServerConnectivity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Tests connectivity to a ConnectWise Automate server's agent endpoint. ## SYNTAX ``` -Test-CWAAServerConnectivity [[-Server] ] [-Quiet] [-ProgressAction ] - [] +Test-CWAAServerConnectivity [[-Server] ] [-Quiet] [] ``` ## DESCRIPTION @@ -54,21 +53,6 @@ Tests connectivity to the server configured on the installed agent via pipeline. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Quiet Returns $True if all servers are reachable, $False otherwise. diff --git a/Docs/Help/Uninstall-CWAA.md b/Docs/Help/Uninstall-CWAA.md index f12091a..ce051ba 100644 --- a/Docs/Help/Uninstall-CWAA.md +++ b/Docs/Help/Uninstall-CWAA.md @@ -13,8 +13,8 @@ Completely uninstalls the ConnectWise Automate Agent from the local computer. ## SYNTAX ``` -Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-SkipCertificateCheck] - [-ProgressAction ] [-WhatIf] [-Confirm] [] +Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-SkipCertificateCheck] [-ShowProgress] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION @@ -108,7 +108,7 @@ Aliases: Required: False Position: Named Default value: False -Accept pipeline input: True (ByPropertyName) +Accept pipeline input: False Accept wildcard characters: False ``` @@ -129,21 +129,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Server One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. @@ -162,8 +147,26 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ShowProgress +Displays a Write-Progress bar showing uninstall progress. +Off by default +to avoid interference with unattended execution (RMM tools, GPO scripts). + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -SkipCertificateCheck -{{ Fill SkipCertificateCheck Description }} +Bypasses SSL/TLS certificate validation for server connections. +Use in lab or test environments with self-signed certificates. ```yaml Type: SwitchParameter diff --git a/Docs/Help/Unregister-CWAAHealthCheckTask.md b/Docs/Help/Unregister-CWAAHealthCheckTask.md index 7fb7eb8..0cf25d8 100644 --- a/Docs/Help/Unregister-CWAAHealthCheckTask.md +++ b/Docs/Help/Unregister-CWAAHealthCheckTask.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,7 @@ Removes the ConnectWise Automate agent health check scheduled task. ## SYNTAX ``` -Unregister-CWAAHealthCheckTask [[-TaskName] ] [-ProgressAction ] [-WhatIf] [-Confirm] - [] +Unregister-CWAAHealthCheckTask [[-TaskName] ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -39,21 +38,6 @@ Removes a custom-named health check task. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} - -```yaml -Type: ActionPreference -Parameter Sets: (All) -Aliases: proga - -Required: False -Position: Named -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -TaskName Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. diff --git a/Docs/Help/Update-CWAA.md b/Docs/Help/Update-CWAA.md index 55b1d6b..0e4fb39 100644 --- a/Docs/Help/Update-CWAA.md +++ b/Docs/Help/Update-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,8 @@ Manually updates the ConnectWise Automate Agent to a specified version. ## SYNTAX ``` -Update-CWAA [[-Version] ] [-SkipCertificateCheck] [-ProgressAction ] [-WhatIf] - [-Confirm] [] +Update-CWAA [[-Version] ] [-SkipCertificateCheck] [-ShowProgress] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -57,23 +57,26 @@ Updates the agent to the current version advertised by the server. ## PARAMETERS -### -ProgressAction -{{ Fill ProgressAction Description }} +### -ShowProgress +Displays a Write-Progress bar showing update progress. +Off by default +to avoid interference with unattended execution (RMM tools, GPO scripts). ```yaml -Type: ActionPreference +Type: SwitchParameter Parameter Sets: (All) -Aliases: proga +Aliases: Required: False Position: Named -Default value: None +Default value: False Accept pipeline input: False Accept wildcard characters: False ``` ### -SkipCertificateCheck -{{ Fill SkipCertificateCheck Description }} +Bypasses SSL/TLS certificate validation for server connections. +Use in lab or test environments with self-signed certificates. ```yaml Type: SwitchParameter diff --git a/Examples/PipelineUsage.ps1 b/Examples/PipelineUsage.ps1 index 2b4acc5..adf1840 100644 --- a/Examples/PipelineUsage.ps1 +++ b/Examples/PipelineUsage.ps1 @@ -181,3 +181,56 @@ catch { # Match = $current.$_ -eq $backup.$_ # } # } | Format-Table -AutoSize + +# --- Example 10: Encrypt Multiple Values via Pipeline ------------------------- +# +# ConvertTo-CWAASecurity accepts pipeline input, so you can encrypt an array +# of strings in one expression. ConvertFrom-CWAASecurity also supports pipeline, +# enabling a full round-trip chain. + +# 'Password1', 'Secret2', 'Token3' | ConvertTo-CWAASecurity +# +# # Round-trip: encrypt then decrypt in a single pipeline +# 'Password1', 'Secret2', 'Token3' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity + +# --- Example 11: Pipe Agent Info to Repair ------------------------------------ +# +# Get-CWAAInfo returns an object with a Server property. Repair-CWAA accepts +# Server via ValueFromPipelineByPropertyName, enabling direct piping. + +# Get-CWAAInfo | Repair-CWAA -InstallerToken 'abc123def456' -LocationID 42 + +# --- Example 12: Rename Agent from Pipeline ----------------------------------- +# +# Rename-CWAAAddRemove accepts the Name parameter from pipeline input. + +# 'My Managed Agent' | Rename-CWAAAddRemove + +# --- Example 13: Test Server Connectivity from Agent Config ------------------- +# +# Test-CWAAServerConnectivity accepts Server via ValueFromPipelineByPropertyName. +# Pipe the installed agent's info to verify its configured server is reachable. + +# Get-CWAAInfo | Test-CWAAServerConnectivity + +# --- Example 14: Test Required Ports from Agent Config ------------------------ +# +# Test-CWAAPort accepts Server and TrayPort via ValueFromPipelineByPropertyName. +# Pipe agent info to test all required ports using the agent's own configuration. + +# Get-CWAAInfo | Test-CWAAPort + +# --- Example 15: Set Log Level from Pipeline ----------------------------------- +# +# Set-CWAALogLevel accepts Level via ValueFromPipeline. +# Useful for scripted toggle or conditional log level changes. + +# 'Verbose' | Set-CWAALogLevel + +# --- Example 16: Register Health Check from Agent Config ---------------------- +# +# Register-CWAAHealthCheckTask accepts Server and LocationID via +# ValueFromPipelineByPropertyName. Pipe agent info to register a health check +# task using the agent's current server and location. + +# Get-CWAAInfo | Register-CWAAHealthCheckTask -InstallerToken 'abc123def456' diff --git a/TODO.md b/TODO.md index a5746f8..2558f77 100644 --- a/TODO.md +++ b/TODO.md @@ -147,11 +147,15 @@ These tasks address security concerns and improve code quality. - Windows Event Log integration via `Write-CWAAEventLog` - Organized event ID ranges: 1000s install, 2000s service, 3000s config, 4000s health -- [ ] **Add pipeline support** - - [ ] Review all ValueFromPipeline attributes - - [ ] Test pipeline scenarios - - [ ] Document pipeline usage in examples - - **Why**: PowerShell users expect good pipeline support +- [x] **Add pipeline support** (Completed 2026-02-01) + - [x] Audited all 30 functions for ValueFromPipeline/ValueFromPipelineByPropertyName correctness + - [x] Fixed Begin/Process placement: moved ServerPassword escaping to Process in Install-CWAA, removed misleading pipeline attr from Uninstall-CWAA Backup switch + - [x] Added ValueFromPipeline to Set-CWAALogLevel Level parameter + - [x] Added ValueFromPipelineByPropertyName to Register-CWAAHealthCheckTask Server and LocationID + - [x] Added pipeline .EXAMPLE blocks to 7 functions (Test-CWAAHealth, Test-CWAAPort, Install-CWAA, Redo-CWAA, Uninstall-CWAA, Set-CWAALogLevel, Register-CWAAHealthCheckTask) + - [x] Added 6 new pipeline tests (Invoke-CWAACommand array, Set-CWAALogLevel, Test-CWAAServerConnectivity, Test-CWAAHealth, Test-CWAAPort property-based) + - [x] Added 4 new examples to Examples/PipelineUsage.ps1 (examples 13-16) + - [x] 417 tests pass, PSScriptAnalyzer clean, single-file build and docs regenerated ## Priority 4: Developer Experience @@ -299,6 +303,16 @@ Track completed items here for historical reference. - [x] **Added documentation structure tests** — 11 new Pester tests validating folder layout, function doc coverage, MAML help, and build script config (403 total tests, up from 392) - [x] **Full verification** — 403 tests pass, PSScriptAnalyzer clean +### Pipeline Support (2026-02-01) + +- [x] **Pipeline attribute audit** — reviewed all 30 functions, 14 have pipeline attributes, 16 are read-only/no-param (not applicable) +- [x] **Fixed Begin/Process issues** — moved `$ServerPassword` escaping from Begin to Process in `Install-CWAA`, removed misleading `ValueFromPipelineByPropertyName` from `Uninstall-CWAA` `$Backup` switch +- [x] **New pipeline attributes** — `Set-CWAALogLevel` Level (`ValueFromPipeline`), `Register-CWAAHealthCheckTask` Server/LocationID (`ValueFromPipelineByPropertyName`) +- [x] **Pipeline examples in help** — 7 functions received new `.EXAMPLE` blocks showing pipeline usage +- [x] **Pipeline tests** — 6 new tests covering value pipeline, array pipeline, and property-based pipeline patterns (417 total tests, up from 403 + 8 structure-related) +- [x] **Updated PipelineUsage.ps1** — 4 new examples (13-16): Test-CWAAServerConnectivity, Test-CWAAPort, Set-CWAALogLevel, Register-CWAAHealthCheckTask +- [x] **Full verification** — 417 tests pass, PSScriptAnalyzer clean, single-file build (4684 lines, 244.5 KB), docs regenerated + --- ## How to Use This TODO List @@ -330,5 +344,5 @@ When working on this codebase: --- -**Last Updated**: 2026-01-31 (Phase 5) +**Last Updated**: 2026-02-01 (Pipeline Support) **Module Version**: 1.0.0-alpha001 diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 index cdd48f9..048e227 100644 --- a/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 @@ -2640,3 +2640,680 @@ Describe 'Resolve-CWAAServer' { } } } + +# ============================================================================= +# Wait-CWAACondition Tests +# ============================================================================= + +Describe 'Wait-CWAACondition' { + + Context 'when condition is met immediately' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $callCount = 0 + Wait-CWAACondition -Condition { + $script:callCount++ + $true + } -TimeoutSeconds 10 -IntervalSeconds 1 + } + $result | Should -Be $true + } + } + + Context 'when condition is met after initial failure' { + It 'returns $true after retrying' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $script:waitTestCounter = 0 + Wait-CWAACondition -Condition { + $script:waitTestCounter++ + $script:waitTestCounter -ge 2 + } -TimeoutSeconds 30 -IntervalSeconds 1 + } + $result | Should -Be $true + } + } + + Context 'when timeout is reached' { + It 'returns $false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $false } -TimeoutSeconds 2 -IntervalSeconds 1 + } + $result | Should -Be $false + } + } + + Context 'parameter validation' { + It 'rejects TimeoutSeconds of 0' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $true } -TimeoutSeconds 0 -IntervalSeconds 1 + } + } | Should -Throw + } + + It 'rejects IntervalSeconds of 0' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $true } -TimeoutSeconds 10 -IntervalSeconds 0 + } + } | Should -Throw + } + } +} + +# ============================================================================= +# Test-CWAADotNetPrerequisite Tests +# ============================================================================= + +Describe 'Test-CWAADotNetPrerequisite' { + + Context 'when -SkipDotNet is specified' { + It 'returns $true immediately without checking registry' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem {} + Test-CWAADotNetPrerequisite -SkipDotNet + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is already installed' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '3.5.30729'; Release = $null; PSChildName = 'Full' } + } + Test-CWAADotNetPrerequisite -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is missing and -Force allows .NET 2.0+' { + It 'returns $true with a non-terminating error when .NET 4.x is present' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # First call: initial check returns only 4.x + # Second call (after install attempt): still only 4.x + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction SilentlyContinue + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is missing and no .NET 2.0+ with -Force' { + It 'throws a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '1.1.4322'; Release = $null; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*2.0*' + } + } + + Context 'when .NET 3.5 is missing without -Force' { + It 'throws a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*3.5*' + } + } +} + +# ============================================================================= +# Invoke-CWAAMsiInstaller Tests +# ============================================================================= + +Describe 'Invoke-CWAAMsiInstaller' { + + Context 'when service starts on first attempt' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $script:msiCallCount = 0 + Mock Start-Process { + $script:msiCallCount++ + } + Mock Start-Sleep {} + # First call returns 0 (pre-install check), second call returns 1 (post-install) + $script:getServiceCallCount = 0 + Mock Get-Service { + $script:getServiceCallCount++ + if ($script:getServiceCallCount -ge 2) { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + } + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when service starts on retry' { + It 'returns $true after retrying' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Mock Start-Sleep {} + Mock Wait-CWAACondition { $false } + # Service not present for first 4 calls (2 attempts x 2 checks each), then present + $script:svcCounter = 0 + Mock Get-Service { + $script:svcCounter++ + if ($script:svcCounter -ge 5) { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + } + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 3 -RetryDelaySeconds 1 -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when service never starts' { + It 'returns $false after max attempts' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Mock Start-Sleep {} + Mock Wait-CWAACondition { $false } + Mock Get-Service {} + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 2 -RetryDelaySeconds 1 -Confirm:$false -ErrorAction SilentlyContinue + } + $result | Should -Be $false + } + } + + Context 'when -WhatIf is specified' { + It 'returns $true without calling Start-Process' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -WhatIf + } + $result | Should -Be $true + InModuleScope 'ConnectWiseAutomateAgent' { + Should -Invoke Start-Process -Times 0 + } + } + } +} + +# ============================================================================= +# Pipeline Support Tests +# ============================================================================= + +Describe 'Pipeline Support' { + + Context 'ConvertTo-CWAASecurity pipeline input' { + + It 'accepts a single string from pipeline' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + 'TestValue' | ConvertTo-CWAASecurity + } + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts multiple strings from pipeline' { + $results = InModuleScope 'ConnectWiseAutomateAgent' { + 'Value1', 'Value2', 'Value3' | ConvertTo-CWAASecurity + } + $results | Should -HaveCount 3 + $results[0] | Should -Not -Be $results[1] + } + + It 'round-trips through pipeline with ConvertFrom-CWAASecurity' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + 'PipelineRoundTrip' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity + } + $result | Should -Be 'PipelineRoundTrip' + } + + It 'round-trips multiple values through pipeline' { + $results = InModuleScope 'ConnectWiseAutomateAgent' { + 'Alpha', 'Bravo', 'Charlie' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity + } + $results | Should -HaveCount 3 + $results[0] | Should -Be 'Alpha' + $results[1] | Should -Be 'Bravo' + $results[2] | Should -Be 'Charlie' + } + } + + Context 'Rename-CWAAAddRemove pipeline input' { + + It 'accepts Name from pipeline' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + 'Piped Agent Name' | Rename-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'Piped Agent Name' } + } + } + } + + Context 'Repair-CWAA Server ValueFromPipelineByPropertyName' { + + It 'accepts Server and LocationID from piped object' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Status = 'Running'; Name = 'LTService' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = 'https://automate.example.com' + LastSuccessStatus = (Get-Date).ToString() + HeartbeatLastSent = (Get-Date).ToString() + HeartbeatLastReceived = (Get-Date).ToString() + } + } + Mock Write-CWAAEventLog {} + Mock Get-CimInstance { return @() } + + # Pipe an object with Server and LocationID — bind via ValueFromPipelineByPropertyName + # InstallerToken is provided explicitly (it wouldn't come from Get-CWAAInfo output) + $inputObject = [PSCustomObject]@{ + Server = 'https://automate.example.com' + LocationID = 1 + } + $result = $inputObject | Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -WarningAction SilentlyContinue + $result | Should -Not -BeNullOrEmpty + $result.ActionTaken | Should -Be 'None' + } + } + } + + Context 'Invoke-CWAACommand multiple values from pipeline' { + + It 'processes multiple commands piped as an array' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + 'Send Inventory', 'Send Apps' | Invoke-CWAACommand -Confirm:$false + } + ($result | Measure-Object).Count | Should -Be 2 + $result[0] | Should -Match 'Send Inventory' + $result[1] | Should -Match 'Send Apps' + } + } + + Context 'Set-CWAALogLevel pipeline input' { + + It 'accepts Level from pipeline' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Verbose' } + + 'Verbose' | Set-CWAALogLevel -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } + } + } + } + + Context 'Test-CWAAServerConnectivity property-based pipeline' { + + It 'accepts Server from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAServerConnectivity + } + $result.Available | Should -BeTrue + $result.Version | Should -Be '220.105' + } + } + + Context 'Test-CWAAHealth property-based pipeline' { + + It 'accepts Server from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.Healthy | Should -BeTrue + } + } + + Context 'Test-CWAAPort property-based pipeline' { + + It 'accepts Server and TrayPort from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Invoke-Expression { return $null } + function netstat { return @() } + + [PSCustomObject]@{ Server = 'automate.example.com'; TrayPort = 42000 } | Test-CWAAPort -Quiet + } + $result | Should -BeTrue + } + } + + Context 'Multi-server array pipeline binding' { + + It 'Test-CWAAHealth accepts Server as string[] from pipeline and matches correctly' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('primary.example.com', 'backup.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + # Pipe an object with Server as a multi-element array (matching Get-CWAAInfo output) + [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAHealth + } + $result.Healthy | Should -BeTrue + $result.ServerMatch | Should -BeTrue + } + + It 'Test-CWAAServerConnectivity accepts Server as string[] from pipeline' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAServerConnectivity + } + # Should return results for both servers + ($result | Measure-Object).Count | Should -Be 2 + } + + It 'Register-CWAAHealthCheckTask accepts Server as string[] and builds valid command' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { return $null } + Mock New-CWAABackup {} + + [PSCustomObject]@{ + Server = @('primary.example.com', 'backup.example.com') + LocationID = 42 + } | Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result | Should -Not -BeNullOrEmpty + $result.Created | Should -BeTrue + } + } +} + +# ============================================================================= +# Credential Hardening Tests +# ============================================================================= + +Describe 'PSCredential Parameter Support' { + + Context 'Install-CWAA Credential parameter' { + + It 'has a Credential parameter of type PSCredential' { + $cmd = Get-Command Install-CWAA + $param = $cmd.Parameters['Credential'] + $param | Should -Not -BeNullOrEmpty + $param.ParameterType.Name | Should -Be 'PSCredential' + } + + It 'Credential parameter is in the deployment parameter set' { + $cmd = Get-Command Install-CWAA + $param = $cmd.Parameters['Credential'] + $param.ParameterSets.Keys | Should -Contain 'deployment' + } + } + + Context 'Set-CWAAProxy ProxyCredential parameter' { + + It 'has a ProxyCredential parameter of type PSCredential' { + $cmd = Get-Command Set-CWAAProxy + $param = $cmd.Parameters['ProxyCredential'] + $param | Should -Not -BeNullOrEmpty + $param.ParameterType.Name | Should -Be 'PSCredential' + } + } +} + +# ============================================================================= +# Phase 1+2 Constants and Helpers +# ============================================================================= + +Describe 'Initialize-CWAA constants (Phase 1)' { + + Context 'version threshold constants' { + + It 'defines CWAAVersionZipInstaller' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionZipInstaller | Should -Be '240.331' + } + } + + It 'defines CWAAVersionAnonymousChange' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionAnonymousChange | Should -Be '110.374' + } + } + + It 'defines CWAAVersionVulnerabilityFix' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionVulnerabilityFix | Should -Be '200.197' + } + } + + It 'defines CWAAVersionUpdateMinimum' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionUpdateMinimum | Should -Be '105.001' + } + } + } + + Context 'service and process name constants' { + + It 'defines CWAAAgentProcessNames with 3 entries' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAAgentProcessNames | Should -HaveCount 3 + $Script:CWAAAgentProcessNames | Should -Contain 'LTTray' + $Script:CWAAAgentProcessNames | Should -Contain 'LTSVC' + $Script:CWAAAgentProcessNames | Should -Contain 'LTSvcMon' + } + } + + It 'defines CWAAAllServiceNames with 3 entries including LabVNC' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAAllServiceNames | Should -HaveCount 3 + $Script:CWAAAllServiceNames | Should -Contain 'LTService' + $Script:CWAAAllServiceNames | Should -Contain 'LTSvcMon' + $Script:CWAAAllServiceNames | Should -Contain 'LabVNC' + } + } + } + + Context 'timeout constants' { + + It 'defines CWAAServiceWaitTimeoutSec as 60' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAServiceWaitTimeoutSec | Should -Be 60 + } + } + + It 'defines CWAARedoSettleDelaySeconds as 20' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAARedoSettleDelaySeconds | Should -Be 20 + } + } + } +} + +Describe 'Test-CWAAServiceExists' { + + Context 'when services exist' { + + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + Test-CWAAServiceExists + } + $result | Should -Be $true + } + + It 'does not write an error' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -BeNullOrEmpty + } + } + } + + Context 'when services do not exist' { + + It 'returns $false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + Test-CWAAServiceExists + } + $result | Should -Be $false + } + + It 'does not write error without -WriteErrorOnMissing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $err = $null + $null = Test-CWAAServiceExists -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -BeNullOrEmpty + } + } + + It 'writes error with -WriteErrorOnMissing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty + "$err" | Should -Match 'Services NOT Found' + } + } + + It 'writes WhatIf-prefixed error when WhatIfPreference is true' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $WhatIfPreference = $true + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + "$err" | Should -Match 'What If.*Services NOT Found' + } + } + } +} + +Describe 'Assert-CWAANotProbeAgent' { + + Context 'when ServiceInfo is null' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + { Assert-CWAANotProbeAgent -ServiceInfo $null -ActionName 'Test' } | Should -Not -Throw + } + } + } + + Context 'when agent is not a probe' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '0' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw + } + } + } + + Context 'when agent is a probe without -Force' { + + It 'throws with action name in message' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' + } + } | Should -Throw '*Probe Agent Detected*UnInstall Denied*' + } + + It 'uses Reset action name' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Reset' + } + } | Should -Throw '*Reset Denied*' + } + } + + Context 'when agent is a probe with -Force' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' -Force } | Should -Not -Throw + } + } + + It 'writes Forced output message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Re-Install' -Force + } + $result | Should -Match 'Probe Agent Detected.*Re-Install Forced' + } + } + + Context 'when ServiceInfo has no Probe property' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Server = 'test.com' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw + } + } + } +} From 3c52ed19069a26ba2c02602f0d100bf7d6ebc33e Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Sun, 1 Feb 2026 15:30:00 -0700 Subject: [PATCH 3/5] Add GitHub Releases, version locking, test refactoring, and project infrastructure CI/CD: Add release-prerelease and release-stable jobs to ci-publish.yml that create GitHub Releases with the single-file artifact attached after successful PSGallery publish. Add Build/Extract-ChangelogEntry.ps1 for extracting version-specific release notes from CHANGELOG.md. Version locking: Update all 7 example scripts to pin module versions with $ModuleVersion variable, -RequiredVersion on Install/Import-Module, and version-locked GitHub Release URLs for the single-file fallback. Add version locking section to Docs/Security.md, FAQ entry, and README guidance recommending pinned versions for production deployments. Test refactoring: Split monolithic test files (3319-line Mocked.Tests.ps1 and 688-line Tests.ps1) into 9 focused test suites by domain (Commands, CrossCutting, DataReaders, Installation, PrivateHelpers, ServiceOps, Module, Documentation, Security). Add shared TestBootstrap.ps1 for dual-mode loading (Module vs SingleFile), MockHelpers.ps1, test-local.ps1 pre-push validation script, and Invoke-AllTests.ps1 dual-mode runner. Extend CrossVersion tests with SingleFile dot-source and IEX contexts. Documentation: Add AGENTS.md as concise AI agent orientation doc (226 lines) replacing verbose CLAUDE.md content. Slim CLAUDE.md to a pointer. Update CONTRIBUTING.md to reference AGENTS.md for conventions. Remove MAP.md and TODO.md (planning moved to GitHub Issues/Milestones). Fix stale MAP.md reference in README.md. Community: Add GitHub issue templates (bug report, feature request, AI task), PR template, CODE_OF_CONDUCT.md, SECURITY.md, and copilot-instructions.md. Add Scripts/Invoke-QuickTest.ps1 for fast targeted testing during development loops. Co-Authored-By: Claude Opus 4.5 --- .github/CODE_OF_CONDUCT.md | 107 + .github/ISSUE_TEMPLATE/ai_task.md | 49 + .github/ISSUE_TEMPLATE/bug_report.md | 45 + .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/feature_request.md | 50 + .github/PULL_REQUEST_TEMPLATE.md | 33 + .github/SECURITY.md | 124 + .github/copilot-instructions.md | 7 + .github/workflows/ci-publish.yml | 167 +- AGENTS.md | 225 ++ Build/Extract-ChangelogEntry.ps1 | 110 + CLAUDE.md | 130 +- CONTRIBUTING.md | 41 +- Docs/CommonParameters.md | 1 + Docs/FAQ.md | 28 +- Docs/Security.md | 52 + Examples/AgentInstall.ps1 | 39 +- Examples/AgentInstallWithHealthCheck.ps1 | 39 +- Examples/GPOScheduledTaskDeployment.ps1 | 43 +- Examples/HealthCheck-Monitoring.ps1 | 39 +- Examples/PipelineUsage.ps1 | 39 +- Examples/ProxyConfiguration.ps1 | 39 +- Examples/Troubleshooting-QuickDiagnostic.ps1 | 39 +- MAP.md | 170 - README.md | 28 +- Scripts/Invoke-QuickTest.ps1 | 293 ++ TODO.md | 348 -- ...ctWiseAutomateAgent.CrossVersion.Tests.ps1 | 311 +- ...tWiseAutomateAgent.Documentation.Tests.ps1 | 200 + Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 | 7 +- ...iseAutomateAgent.Mocked.Commands.Tests.ps1 | 374 ++ ...utomateAgent.Mocked.CrossCutting.Tests.ps1 | 479 +++ ...AutomateAgent.Mocked.DataReaders.Tests.ps1 | 496 +++ ...utomateAgent.Mocked.Installation.Tests.ps1 | 1001 +++++ ...omateAgent.Mocked.PrivateHelpers.Tests.ps1 | 616 +++ ...eAutomateAgent.Mocked.ServiceOps.Tests.ps1 | 467 +++ .../ConnectWiseAutomateAgent.Mocked.Tests.ps1 | 3319 ----------------- ...ConnectWiseAutomateAgent.Module.Tests.ps1} | 305 +- ...onnectWiseAutomateAgent.Security.Tests.ps1 | 166 + Tests/Helpers/MockHelpers.ps1 | 96 + Tests/Invoke-AllTests.ps1 | 151 + Tests/TestBootstrap.ps1 | 86 + Tests/test-local.ps1 | 134 + 43 files changed, 6114 insertions(+), 4393 deletions(-) create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE/ai_task.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 Build/Extract-ChangelogEntry.ps1 delete mode 100644 MAP.md create mode 100644 Scripts/Invoke-QuickTest.ps1 delete mode 100644 TODO.md create mode 100644 Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1 create mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1 delete mode 100644 Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 rename Tests/{ConnectWiseAutomateAgent.Tests.ps1 => ConnectWiseAutomateAgent.Module.Tests.ps1} (65%) create mode 100644 Tests/ConnectWiseAutomateAgent.Security.Tests.ps1 create mode 100644 Tests/Helpers/MockHelpers.ps1 create mode 100644 Tests/Invoke-AllTests.ps1 create mode 100644 Tests/TestBootstrap.ps1 create mode 100644 Tests/test-local.ps1 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..63ab46e --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,107 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community +* Using welcoming and inclusive language +* Being patient with newcomers and those learning + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +This includes: + +- GitHub repositories (issues, pull requests, discussions, code reviews) +- Community Slack channels +- Project-related social media +- Email communications + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at: + +- **Email:** conduct@christaylor.codes +- **Slack:** @CTaylor on [MSPGeek Slack](https://join.mspgeek.com/) +- **GitHub:** Open a confidential issue via the Security Advisory feature + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence:** A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact:** A violation through a single incident or series of actions. + +**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact:** A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public communication with the community for a specified period of time. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity + +## Questions? + +If you have questions about this Code of Conduct, please: + +1. Review the [Contributor Covenant FAQ](https://www.contributor-covenant.org/faq) +2. Ask in [GitHub Discussions](../../discussions) +3. Contact the maintainers privately + +--- + +**Last Updated:** 2025-01-22 +**Version:** 2.1 (Contributor Covenant) diff --git a/.github/ISSUE_TEMPLATE/ai_task.md b/.github/ISSUE_TEMPLATE/ai_task.md new file mode 100644 index 0000000..626cb73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ai_task.md @@ -0,0 +1,49 @@ +--- +name: AI Task +about: Create a structured task for AI coding agents +title: '[AI] ' +labels: 'ai-task, ai-ready' +assignees: '' +--- + +## Objective + + +## Context + + + +## Requirements + + +- [ ] +- [ ] +- [ ] + +## Acceptance Criteria + + +- [ ] All existing tests pass (`./Tests/test-local.ps1`) +- [ ] PSScriptAnalyzer reports zero errors +- [ ] New/changed functionality has test coverage +- [ ] + +## Files to Modify + + + +- +- + +## Out of Scope + + +- + +## Technical Notes + + +## Related Issues + + +- Related to # diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..718d927 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,45 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: 'bug' +assignees: '' +--- + +## Bug Description + + +## Steps to Reproduce + + +1. +2. +3. +4. + +## Expected Behavior + + +## Actual Behavior + + +## Screenshots + + +## Environment + + +- **OS**: [e.g., Windows 11, Windows Server 2022] +- **Module Version**: [e.g., 1.0.0] +- **PowerShell Version**: [e.g., 5.1, 7.4.0] +- **Loading Method**: [Gallery (Install-Module) or SingleFile (Invoke-Expression)] +- **Automate Server Version** (if applicable): [e.g., v200.197] + +## Additional Context + + +## Possible Solution + + +## Related Issues + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..87c1550 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: + - name: Community Support + url: https://github.com/christaylorcodes/ConnectWiseAutomateAgent/discussions + about: Ask questions and discuss with the community + - name: Documentation + url: https://github.com/christaylorcodes/ConnectWiseAutomateAgent/blob/main/README.md + about: Check out our documentation + - name: Contributing Guide + url: https://github.com/christaylorcodes/ConnectWiseAutomateAgent/blob/main/CONTRIBUTING.md + about: Learn how to contribute to this project + - name: AI Contributor Guide + url: https://github.com/christaylorcodes/ConnectWiseAutomateAgent/blob/main/AGENTS.md + about: Instructions for AI coding agents contributing to this project diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..38e306f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,50 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' +--- + +## Feature Description + + +## Problem Statement + + + +## Proposed Solution + + +## Alternative Solutions + + +## Use Cases + + +1. +2. +3. + +## Benefits + + +- +- +- + +## Implementation Ideas + + +## Additional Context + + +## Related Issues/PRs + + +## Would you be willing to contribute this feature? + + +- [ ] Yes, I'd like to contribute +- [ ] No, but I'm happy to help test +- [ ] No, but I can provide more details diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4366584 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ +# Pull Request + +## Description + + + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation +- [ ] Refactoring / tests + +## Testing + + + +- [ ] `./Tests/test-local.ps1` passes (build + analyze + test) + +## Checklist + +- [ ] Code follows project conventions ([AGENTS.md](../AGENTS.md)) +- [ ] Tests cover new/changed behavior +- [ ] PSScriptAnalyzer reports zero errors +- [ ] Documentation updated (if applicable) +- [ ] Single-file rebuilt (`Build\SingleFileBuild.ps1`) + +## AI Contribution + + + +- [ ] AI-assisted (tool: ___, issue: #___) diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..6ae8708 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,124 @@ +# Security Policy + +## Supported Versions + +We actively support the following versions of this project with security updates: + +| Version | Supported | +| ------- | ------------------ | +| Latest | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of this module and its users seriously. If you believe you have found a security vulnerability, please report it to us as described below. + +> **Note:** For documentation of the module's security model (SSL certificate validation, TripleDES encryption, credential redaction), see [Docs/Security.md](../Docs/Security.md). + +### Where to Report + +**Please do NOT report security vulnerabilities through public GitHub issues.** + +Instead, please report them via one of the following methods: + +1. **GitHub Security Advisories** (Preferred) + - Navigate to the repository's Security tab + - Click "Report a vulnerability" + - Fill out the security advisory form with details + +2. **Direct Email** + - Send an email to: **security@christaylor.codes** + - Use the subject line: `[SECURITY] ConnectWiseAutomateAgent - Brief Description` + +3. **Private Message on Slack** + - Contact **@CTaylor** on [MSPGeek Slack](https://join.mspgeek.com/) + - Clearly mark the message as security-related + +### What to Include + +Please include the following information in your report to help us better understand and resolve the issue: + +- **Type of issue** (e.g., code injection, privilege escalation, information disclosure) +- **Full paths of source file(s)** related to the manifestation of the issue +- **Location of the affected source code** (tag/branch/commit or direct URL) +- **Step-by-step instructions to reproduce the issue** +- **Proof-of-concept or exploit code** (if possible) +- **Impact of the issue**, including how an attacker might exploit it +- **Any special configuration required** to reproduce the issue +- **Your assessment of severity** (Critical, High, Medium, Low) + +### What to Expect + +After you submit a vulnerability report: + +1. **Acknowledgment** - We will acknowledge receipt of your vulnerability report within **48 hours** +2. **Initial Assessment** - We will provide an initial assessment of the vulnerability within **5 business days** +3. **Updates** - We will keep you informed of the progress toward a fix and full announcement +4. **Verification** - We may ask you to verify that our fix resolves the vulnerability +5. **Public Disclosure** - We will coordinate with you on the timing of public disclosure +6. **Credit** - We will credit you in the security advisory (unless you prefer to remain anonymous) + +### Response Timeline + +| Phase | Timeline | +|-------|----------| +| Acknowledgment | 48 hours | +| Initial Assessment | 5 business days | +| Fix Development | Varies by severity | +| Release | Coordinated with reporter | + +### Severity Levels + +We use the [CVSS v3.1](https://www.first.org/cvss/calculator/3.1) scoring system to assess vulnerability severity: + +- **Critical (9.0-10.0)** - Fix within 24-48 hours +- **High (7.0-8.9)** - Fix within 1 week +- **Medium (4.0-6.9)** - Fix within 2-4 weeks +- **Low (0.1-3.9)** - Fix in next regular release + +## Security Best Practices for Users + +When using this module, please: + +1. **Use InstallerToken over ServerPassword** + - InstallerToken is scoped and revocable + - ServerPassword is server-wide and cannot be revoked independently + +2. **Avoid `-SkipCertificateCheck` in production** + - It bypasses ALL SSL validation for the entire PowerShell session + - If needed, run the operation in an isolated PowerShell session + +3. **Keep the Module Updated** + - Regularly update via `Update-Module ConnectWiseAutomateAgent` + - Enable Dependabot alerts in your repository if forking + +4. **Run with Least Privilege** + - While admin is required for most operations, avoid running as SYSTEM unless necessary + - Use dedicated service accounts for automated deployments + +5. **Protect Credentials** + - Never commit InstallerTokens or ServerPasswords to source control + - Use environment variables or secure secret storage for automated scripts + +## Known Security Considerations + +This module manages a privileged Windows agent and necessarily interacts with: + +- Windows registry (credential storage in TripleDES-encrypted format) +- Windows services (start, stop, install, uninstall) +- Network downloads (agent installer MSI from Automate server) +- SSL/TLS connections (with graduated certificate validation) + +For full details on the security model, see [Docs/Security.md](../Docs/Security.md). + +## Questions? + +If you have questions about this security policy, please: + +1. Check the [Discussions](../../discussions) section +2. Open a general (non-security) issue +3. Contact the maintainers through community Slack channels + +--- + +**Last Updated:** 2025-01-22 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..cec43b0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,7 @@ +# Copilot Instructions + +This is a PowerShell 3.0+ module for managing the ConnectWise Automate Windows agent. For full conventions, architecture, build commands, and contribution workflow, read [AGENTS.md](../AGENTS.md). + +Key file locations: `ConnectWiseAutomateAgent/Public/` (exported), `ConnectWiseAutomateAgent/Private/` (internal), `Tests/`, `Build/`. + +Before committing: `./Tests/test-local.ps1` diff --git a/.github/workflows/ci-publish.yml b/.github/workflows/ci-publish.yml index 4cc73f0..14d85c8 100644 --- a/.github/workflows/ci-publish.yml +++ b/.github/workflows/ci-publish.yml @@ -3,20 +3,25 @@ # Philosophy: # Full testing (Pester, PSScriptAnalyzer) is done locally before pushing. # This workflow is intentionally lightweight — it smoke-tests the module, -# builds the single-file artifact, and publishes to PSGallery. +# builds the single-file artifact, publishes to PSGallery, and creates +# a GitHub Release with the single-file asset attached. # # Branch strategy: -# develop push → smoke test → build → publish prerelease to PSGallery -# main push → smoke test → build → publish stable to PSGallery -# pull requests → smoke test → build (no publish) +# develop push → smoke test → build → publish prerelease → GitHub Release (prerelease) +# main push → smoke test → build → publish stable → GitHub Release (stable) +# pull requests → smoke test → build (no publish, no release) # # Publish gating: # - Prerelease publish requires a Prerelease tag in the manifest (e.g., 'alpha001') # - Stable publish requires the Prerelease tag to be removed from the manifest # - Both publish jobs use the PSGallery environment for optional protection rules +# - GitHub Releases are created only after successful PSGallery publish # # Required secrets: # PSGALLERY_API_KEY — NuGet API key for the PowerShell Gallery +# +# Required permissions: +# contents: write — for creating git tags and GitHub Releases (set per-job) name: CI / Publish @@ -120,3 +125,157 @@ jobs: env: NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} run: .\Build\Publish-CWAAModule.ps1 -NuGetApiKey $env:NUGET_API_KEY + + # ─── GitHub Release jobs ────────────────────────────────────────────────── + # These mirror the publish jobs above: release-prerelease creates a + # prerelease GitHub Release on develop, release-stable creates a stable + # GitHub Release on main. Tags are created explicitly to avoid gh CLI + # --target bugs. Each job has an explicit if condition for clarity. + + release-prerelease: + name: GitHub Release (Prerelease) + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' && !failure() && !cancelled() + needs: publish-prerelease + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Read version from manifest + id: version + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 + $moduleVersion = $manifest.ModuleVersion + $prerelease = $manifest.PrivateData.PSData.Prerelease + $fullVersion = if ($prerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } + $tag = "v$fullVersion" + Write-Host "Version: $fullVersion" + Write-Host "Tag: $tag" + "full_version=$fullVersion" >> $env:GITHUB_OUTPUT + "tag=$tag" >> $env:GITHUB_OUTPUT + + - name: Check if release already exists + id: check + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = '${{ steps.version.outputs.tag }}' + $existing = gh release view $tag 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "Release $tag already exists. Skipping." + "skip=true" >> $env:GITHUB_OUTPUT + } + else { + Write-Host "Release $tag does not exist. Proceeding." + "skip=false" >> $env:GITHUB_OUTPUT + } + + - name: Extract release notes from CHANGELOG + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: .\Build\Extract-ChangelogEntry.ps1 -Version '${{ steps.version.outputs.full_version }}' -OutputPath release-notes.md + + - name: Download build artifact + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: ConnectWiseAutomateAgent-SingleFile + path: artifact + + - name: Create git tag + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + git tag ${{ steps.version.outputs.tag }} ${{ github.sha }} + git push origin ${{ steps.version.outputs.tag }} + + - name: Create GitHub Release + if: steps.check.outputs.skip != 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create '${{ steps.version.outputs.tag }}' ` + artifact/ConnectWiseAutomateAgent.ps1 ` + --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` + --notes-file release-notes.md ` + --prerelease ` + --verify-tag + + release-stable: + name: GitHub Release (Stable) + if: github.ref == 'refs/heads/main' && github.event_name == 'push' && !failure() && !cancelled() + needs: publish-stable + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Read version from manifest + id: version + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 + $moduleVersion = $manifest.ModuleVersion + $fullVersion = $moduleVersion + $tag = "v$fullVersion" + Write-Host "Version: $fullVersion" + Write-Host "Tag: $tag" + "full_version=$fullVersion" >> $env:GITHUB_OUTPUT + "tag=$tag" >> $env:GITHUB_OUTPUT + + - name: Check if release already exists + id: check + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = '${{ steps.version.outputs.tag }}' + $existing = gh release view $tag 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "Release $tag already exists. Skipping." + "skip=true" >> $env:GITHUB_OUTPUT + } + else { + Write-Host "Release $tag does not exist. Proceeding." + "skip=false" >> $env:GITHUB_OUTPUT + } + + - name: Extract release notes from CHANGELOG + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: .\Build\Extract-ChangelogEntry.ps1 -Version '${{ steps.version.outputs.full_version }}' -OutputPath release-notes.md + + - name: Download build artifact + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: ConnectWiseAutomateAgent-SingleFile + path: artifact + + - name: Create git tag + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + git tag ${{ steps.version.outputs.tag }} ${{ github.sha }} + git push origin ${{ steps.version.outputs.tag }} + + - name: Create GitHub Release + if: steps.check.outputs.skip != 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create '${{ steps.version.outputs.tag }}' ` + artifact/ConnectWiseAutomateAgent.ps1 ` + --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` + --notes-file release-notes.md ` + --verify-tag diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..acfdc4d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,225 @@ +# AGENTS.md + +## Start Here + +1. Read **[Project Overview](#project-overview)** for what the module does +2. Run the **[Quick Start](#quick-start)** commands to load and test locally +3. Skim **[Architecture](#architecture)** for directory layout and the two-phase loading model +4. Check **[Code Conventions](#code-conventions)** before writing any code +5. Follow **[AI Contribution Workflow](#ai-contribution-workflow)** to find and claim work + +## Project Overview + +**ConnectWiseAutomateAgent** is a PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Used by MSPs to install, configure, troubleshoot, and manage the Automate agent on Windows systems. + +- **Language**: PowerShell 3.0+ (2.0 with limitations) +- **Build System**: Custom scripts (`Build/SingleFileBuild.ps1`) +- **Test Framework**: Pester 5.6+ +- **Linter**: PSScriptAnalyzer +- **License**: MIT +- **Platform**: Windows only + +## Quick Start + +```powershell +# Import module for local testing +Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force + +# Run all tests (excluding live integration tests) +Invoke-Pester Tests\ -ExcludeTag 'Live' + +# Run PSScriptAnalyzer +Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning + +# Local pre-push validation (build + analyze + test) +./Tests/test-local.ps1 +``` + +## Architecture + +### Directory Layout + +```text +ConnectWiseAutomateAgent/ + ConnectWiseAutomateAgent/ + ConnectWiseAutomateAgent.psd1 # Module manifest + ConnectWiseAutomateAgent.psm1 # Root module (auto-loads subdirectories) + en-US/ # Localized help (MAML XML) + Private/ # Internal helper functions (not exported) + Initialize/ # Module bootstrapping + Public/ # Exported functions (one file per function) + AddRemovePrograms/ # Control Panel visibility + InstallUninstall/ # Install, Uninstall, Update, Redo + Logging/ # Error logs, log levels + Proxy/ # Proxy configuration + Service/ # Service control, health checks + Settings/ # Agent configuration and backup + Build/ + SingleFileBuild.ps1 # Concatenate module into single .ps1 + Build-Documentation.ps1 # PlatyPS markdown + MAML generation + Publish-CWAAModule.ps1 # PSGallery publishing + Extract-ChangelogEntry.ps1 # Changelog parser for CI releases + Tests/ + *.Tests.ps1 # 11 test suites (Module, Mocked.*, Docs, Security, CrossVersion, Live) + Helpers/ # Shared mock helpers + TestBootstrap.ps1 # Shared module loading bootstrap + Invoke-AllTests.ps1 # Dual-mode test runner + test-local.ps1 # Pre-push validation script + Scripts/ + Invoke-QuickTest.ps1 # Fast targeted test runner for dev loops + Docs/ # Hand-written guides + Help/ # Auto-generated function reference (PlatyPS) + Examples/ # Ready-to-use deployment scripts + Output/ # Build output (gitignored) +``` + +### Module Loading (Two-Phase) + +**Phase 1 (module import -- fast, no side effects):** `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively, emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. + +**Phase 2 (on-demand -- first networking call):** `Initialize-CWAANetworking` (private) is called in the `Begin` block of networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`). On first call it performs SSL certificate validation bypass, TLS protocol enablement, creates `$Script:LTWebProxy` and `$Script:LTServiceNetWebClient`, and runs `Get-CWAAProxy` to discover proxy settings from the installed agent. The `$Script:CWAANetworkInitialized` flag ensures this runs only once per session. + +### Dual Naming System + +Every function uses the `CWAA` prefix (e.g., `Install-CWAA`) but also declares an `LT` alias (e.g., `Install-LTService`) for backward compatibility with the legacy LabTech naming. Aliases are declared both in function `[Alias()]` attributes and in the manifest's `AliasesToExport`. + +### Build Scripts + +- `Build/SingleFileBuild.ps1` -- concatenates all `.ps1` files from the module directory into `ConnectWiseAutomateAgent.ps1` at the repo root, appending `Initialize-CWAA` at the end. This flat file is the distribution artifact for direct-invoke scenarios. +- `Build/Build-Documentation.ps1` -- PlatyPS markdown + MAML generation. Outputs to `Docs/Help/`. +- `Build/Publish-CWAAModule.ps1` -- publishes to PowerShell Gallery. +- `Build/Extract-ChangelogEntry.ps1` -- extracts version-specific release notes from CHANGELOG.md for GitHub Releases. + +### CI/CD + +CI is intentionally lightweight (smoke test, build, publish). Full testing is local. See the header comments in `.github/workflows/ci-publish.yml` for branch strategy and gating rules. + +### Common Patterns + +**WOW64 Handling.** The module detects 32-bit PowerShell on 64-bit OS. In single-file mode, it auto-relaunches under 64-bit PowerShell. In module mode, it emits a warning (cannot relaunch `Import-Module`). Registry and file operations must account for WOW64 redirection. + +**SSL Callback Persistence.** The SSL certificate validation callback is compiled via `Add-Type` in `Initialize-CWAANetworking`. Because compiled .NET types cannot be unloaded from an AppDomain, the callback persists for the lifetime of the PowerShell process -- even across module re-imports. + +**Dual-Mode Testing.** The module ships as both a PSGallery module and a single `.ps1` file. Both loading methods must be tested. See `Get-Help .\Tests\TestBootstrap.ps1` for details on load methods. + +## Code Conventions + +For the full contributing guide covering development setup, coding standards, and PR workflow, see [CONTRIBUTING.md](CONTRIBUTING.md). + +### Quick Reference + +- **Naming:** `Verb-CWAA` prefix with `[Alias('Verb-LT')]` +- **Parameters:** `[CmdletBinding()]`. Add `SupportsShouldProcess=$True` on destructive operations +- **Debug output:** `Write-Debug "Starting $($MyInvocation.InvocationName)"` in Process block, `"Exiting ..."` in End block +- **Error handling:** `Write-Error "Failed to at ''. . Error: $($_.Exception.Message)"`. Use `$_` in Catch blocks, not `$Error[0]` +- **Constants:** Use `$Script:CWAA*` constants for paths/registry keys instead of hardcoded strings +- **Returns:** Functions return `[PSCustomObject]` for pipeline compatibility +- **Help:** Comment-based help (`.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK`) on all public functions +- **Variable names:** Verbose, descriptive names (`$automateServerUrl` not `$srvUrl`) +- **PSScriptAnalyzer:** Zero errors required. Settings in `.PSScriptAnalyzerSettings.psd1` +- **PowerShell files:** UTF-8 with BOM, CRLF line endings + +### Documentation and Commits + +Source of truth for docs is comment-based help in `.ps1` files. Run `Build\Build-Documentation.ps1 -UpdateExisting` after changes. + +**Known pitfall:** Lines starting with `.WORD` (e.g., `.NET`) in comment-based help are parsed as help keywords. Keep such terms mid-line. + +For git commit style and the full contributing guide, see [CONTRIBUTING.md](CONTRIBUTING.md). + +## Adding New Functions + +1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory +2. Add `[Alias('Verb-LT')]` in the function declaration +3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1` +4. Rebuild documentation: `Build\Build-Documentation.ps1` +5. Rebuild single-file: `Build\SingleFileBuild.ps1` + +When modifying existing functions: + +- Maintain the `LT` alias +- Rebuild documentation: `Build\Build-Documentation.ps1 -UpdateExisting` +- Rebuild single-file after changes +- Consider 32-bit/64-bit WOW64 behavior for registry/file operations + +## Testing + +Local tests are the CI gate. Run these before pushing. + +### Development Loop + +```powershell +./Scripts/Invoke-QuickTest.ps1 -FunctionName -IncludeAnalyzer -OutputFormat Structured +``` + +Parse the JSON `success` field. If `false`, read `failedTests` and `analyzerErrors`, fix, re-run. See `Get-Help .\Scripts\Invoke-QuickTest.ps1 -Full` for all parameters and short-name mappings. + +### Pre-Push Validation + +```powershell +./Tests/test-local.ps1 # Full: build + analyze + test +./Tests/test-local.ps1 -DualMode # Also test SingleFile loading +``` + +See `Get-Help .\Tests\test-local.ps1` for flags: `-SkipBuild`, `-SkipTests`, `-SkipAnalyze`, `-Quick`. + +### Key Rules + +- No code is complete without passing tests. A function without a test is unfinished work. +- PSScriptAnalyzer zero errors required. Always use `-IncludeAnalyzer` during development. +- Dual-mode testing details: `Get-Help .\Tests\Invoke-AllTests.ps1` +- Test bootstrap and load methods: `Get-Help .\Tests\TestBootstrap.ps1` + +## AI Contribution Workflow + +### Finding and Claiming Work + +```powershell +gh issue list --label ai-ready --state open +gh issue edit --add-label ai-in-progress --remove-label ai-ready +``` + +### Working on an Issue + +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 `./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` + +### Guardrails + +- Do not modify CI workflow files without explicit instruction +- Do not add dependencies without discussing in an issue first +- Do not commit secrets or push directly to `main` + +### Issue Labels + +| Label | Meaning | +| --- | --- | +| `ai-ready` | Available for pickup | +| `ai-in-progress` | Claimed, actively working | +| `ai-review` | PR submitted, awaiting review | +| `ai-blocked` | Needs human input (comment on the issue with details) | + +## Security + +For SSL certificate validation, TripleDES encryption, credential redaction, and InstallerToken vs ServerPassword, see [Docs/Security.md](Docs/Security.md). + +## Key System Locations + +These are centralized as `$Script:` constants in `Initialize-CWAA`: + +- **Registry:** `$Script:CWAARegistryRoot` (`HKLM:\SOFTWARE\LabTech\Service`), `$Script:CWAARegistrySettings` (`HKLM:\SOFTWARE\LabTech\Service\Settings`) +- **Files:** `$Script:CWAAInstallPath` (`C:\Windows\LTSVC`), `$Script:CWAAInstallerTempPath` (`C:\Windows\Temp\LabTech`) +- **Services:** `$Script:CWAAServiceNames` (`LTService`, `LTSvcMon`), plus `LabVNC` (remote control) +- **Ports:** TCP 70, 80, 443, 8002 (server), 42000-42009 (local TrayPort) + +## Terminology + +- **CWAA** = ConnectWise Automate Agent (current prefix) +- **LT/LabTech** = legacy name (alias prefix) +- **InstallerToken** = modern auth method for deployment (preferred over ServerPassword) +- **TrayPort** = local agent communication port (42000-42009) +- **MSP** = Managed Service Provider (target user base) diff --git a/Build/Extract-ChangelogEntry.ps1 b/Build/Extract-ChangelogEntry.ps1 new file mode 100644 index 0000000..ca4a931 --- /dev/null +++ b/Build/Extract-ChangelogEntry.ps1 @@ -0,0 +1,110 @@ +<# +.SYNOPSIS + Extracts a version's release notes from CHANGELOG.md. + +.DESCRIPTION + Parses a Keep a Changelog formatted CHANGELOG.md and extracts the content + for a specific version heading. Returns the markdown content between the + target version heading and the next version heading (or end of file). + + Used by the CI/CD workflow to populate GitHub Release descriptions. + +.PARAMETER Version + The version string to extract (e.g., '1.0.0-alpha001', '1.0.0'). + Must match a heading in the format ## [Version] in CHANGELOG.md. + +.PARAMETER ChangelogPath + Path to the CHANGELOG.md file. Defaults to CHANGELOG.md in the repository root. + +.PARAMETER OutputPath + If specified, writes the extracted content to this file instead of stdout. + Used by CI to pass release notes to gh release create via --notes-file. + +.EXAMPLE + ./Extract-ChangelogEntry.ps1 -Version '1.0.0-alpha001' + Outputs the release notes for version 1.0.0-alpha001 to stdout. + +.EXAMPLE + ./Extract-ChangelogEntry.ps1 -Version '1.0.0' -OutputPath 'release-notes.md' + Writes release notes to release-notes.md for use in CI. + +.LINK + https://keepachangelog.com/en/1.1.0/ + +.NOTES + Exit codes: + 0 - Success + 1 - CHANGELOG.md not found + 2 - Version heading not found or empty in CHANGELOG.md +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $True)] + [string]$Version, + + [string]$ChangelogPath, + + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' + +$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } +$RepoRoot = Split-Path $ScriptRoot -Parent + +if (-not $ChangelogPath) { + $ChangelogPath = Join-Path $RepoRoot 'CHANGELOG.md' +} + +# ─── Validate CHANGELOG exists ──────────────────────────────────────────── +if (-not (Test-Path $ChangelogPath)) { + Write-Error "CHANGELOG.md not found at '$ChangelogPath'. Cannot extract release notes." + exit 1 +} + +# ─── Read and parse ─────────────────────────────────────────────────────── +$changelogLines = Get-Content $ChangelogPath + +# Find the line index of the target version heading: ## [1.0.0-alpha001] - 2026-01-31 +$escapedVersion = [regex]::Escape($Version) +$targetPattern = "^## \[$escapedVersion\]" +$startIndex = -1 + +for ($i = 0; $i -lt $changelogLines.Count; $i++) { + if ($changelogLines[$i] -match $targetPattern) { + $startIndex = $i + break + } +} + +if ($startIndex -eq -1) { + Write-Error "Version '$Version' not found in CHANGELOG.md. Expected a heading like '## [$Version] - YYYY-MM-DD'." + exit 2 +} + +# Find the next version heading (## [...]) or end of file +$endIndex = $changelogLines.Count +for ($i = $startIndex + 1; $i -lt $changelogLines.Count; $i++) { + if ($changelogLines[$i] -match '^## \[') { + $endIndex = $i + break + } +} + +# Extract lines between headings (exclusive of both), trim leading/trailing blank lines +$contentLines = $changelogLines[($startIndex + 1)..($endIndex - 1)] +$content = ($contentLines -join "`n").Trim() + +if (-not $content) { + Write-Error "Version '$Version' heading found but has no content in CHANGELOG.md." + exit 2 +} + +# ─── Output ─────────────────────────────────────────────────────────────── +if ($OutputPath) { + $content | Out-File -FilePath $OutputPath -Encoding UTF8 -Force + Write-Host "Release notes for v$Version written to: $OutputPath" +} +else { + Write-Output $content +} diff --git a/CLAUDE.md b/CLAUDE.md index a041830..f99baf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,129 +1,19 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +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. -## Project Overview +## Session State -PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Used by MSPs to install, configure, troubleshoot, and manage the Automate agent on Windows systems. Version 1.0.0-alpha001, MIT licensed, Windows-only, requires PowerShell 3.0+ (2.0 with limitations). +Use `.claude/plan.md` as a personal scratchpad for the current session. It is gitignored and not shared across agents or users. -## Commands +- Note what you are working on and any in-progress reasoning +- This file is ephemeral -- do not use it for project planning +- Project planning lives in GitHub Issues and Milestones -```powershell -# Run all local tests (primary CI — run before every push) -Invoke-Pester Tests\ -ExcludeTag 'Live' - -# Run PSScriptAnalyzer (static analysis) -Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning - -# Build single-file distribution -powershell -File Build\SingleFileBuild.ps1 +## Finding Work -# Import module for local testing -Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force +```powershell +gh issue list --label ai-ready --state open ``` -## Local Testing (Primary CI) - -Local tests are the real CI gate. GitHub Actions is intentionally lightweight (smoke test + build + publish). All substantive testing happens locally before pushing. - -**Test suites (all run via `Invoke-Pester Tests\ -ExcludeTag 'Live'`):** - -- `ConnectWiseAutomateAgent.Tests.ps1` — module structure, exports, basic unit tests -- `ConnectWiseAutomateAgent.Mocked.Tests.ps1` — unit tests with Pester mocks (no system deps) -- `ConnectWiseAutomateAgent.CrossVersion.Tests.ps1` — PowerShell 5.1 + 7 compatibility -- `ConnectWiseAutomateAgent.Live.Tests.ps1` — full integration (excluded by tag, requires admin + test server) - -## AI Testing Requirements - -**These rules are mandatory when Claude or any AI assistant modifies code in this repo.** - -- **After modifying any function:** run `Invoke-Pester Tests\ -ExcludeTag 'Live'` and fix all failures before considering the task done. -- **After adding a new function:** add corresponding tests to `ConnectWiseAutomateAgent.Mocked.Tests.ps1` and run them. -- **After changing behavior:** update existing tests to match the new behavior, run the full suite, confirm all pass. -- **After any code change:** run `Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning` and fix any issues. -- **Never claim "done"** without a passing test run. Tests are the proof. - -## Architecture - -**Module loading flow (two-phase):** - -*Phase 1 (module import — fast, no side effects):* `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively, emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. - -*Phase 2 (on-demand — first networking call):* `Initialize-CWAANetworking` (private) is called in the `Begin` block of networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`). On first call it performs SSL certificate validation bypass, TLS protocol enablement, creates `$Script:LTWebProxy` and `$Script:LTServiceNetWebClient`, and runs `Get-CWAAProxy` to discover proxy settings from the installed agent. The `$Script:CWAANetworkInitialized` flag ensures this runs only once per session. - -**Public functions** (25 exported) are organized one-per-file in subdirectories by category: `AddRemovePrograms/`, `InstallUninstall/`, `Service/`, `Logging/`, `Proxy/`, `Settings/`, plus standalone files for security, commands, and port testing. - -**Private functions** (2) live in `Private/Initialize/` and handle module bootstrapping (`Initialize-CWAA`) and lazy networking setup (`Initialize-CWAANetworking`). - -**Single-file build:** `Build/SingleFileBuild.ps1` concatenates all `.ps1` files from the module directory into `ConnectWiseAutomateAgent.ps1` at the repo root, appending `Initialize-CWAA` at the end. This flat file is the distribution artifact for direct-invoke scenarios. - -**Dual naming system:** Every function uses the `CWAA` prefix (e.g., `Install-CWAA`) but also declares an `LT` alias (e.g., `Install-LTService`) for backward compatibility with the legacy LabTech naming. Aliases are declared both in function `[Alias()]` attributes and in the manifest's `AliasesToExport`. - -## Key Conventions - -**Readability-first philosophy.** This project optimizes for human understanding over brevity. Use verbose, descriptive variable names (`$automateServerUrl` not `$srvUrl`). Comment the "why" (business logic, API quirks, security trade-offs), not the "what". - -**Required code patterns:** -- `[CmdletBinding(SupportsShouldProcess=$True)]` on destructive operations -- Debug output: `Write-Debug "Starting $($MyInvocation.InvocationName)"` in Process block, `Write-Debug "Exiting $($MyInvocation.InvocationName)"` in End block -- Error handling with context: `Write-Error "Failed to at ''. . Error: $($_.Exception.Message)"` -- Use `$_` (current exception) in Catch blocks, not `$Error[0]` -- Use `$Script:CWAA*` constants for paths/registry keys instead of hardcoded strings -- Functions return `[PSCustomObject]` for pipeline compatibility, not formatted strings -- Comment-based help (`.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK`) on all public functions - -**When adding a new function:** -1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory -2. Add `[Alias('Verb-LT')]` in the function declaration -3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1` -4. Rebuild documentation: `Build\Build-Documentation.ps1` (generates `Docs/Help/` markdown and MAML help) -5. Rebuild single-file: `Build\SingleFileBuild.ps1` - -**When modifying existing functions:** -- Maintain the `LT` alias -- Rebuild documentation: `Build\Build-Documentation.ps1 -UpdateExisting` -- Rebuild single-file after changes -- Consider 32-bit/64-bit WOW64 behavior for registry/file operations -- Verify compatibility with PowerShell 2.0 and 5.1+ - -### Documentation Workflow - -**Source of truth:** Comment-based help in `.ps1` source files (`.SYNOPSIS`, `.DESCRIPTION`, `.PARAMETER`, `.EXAMPLE`, etc.). - -**Generated artifacts (do not edit manually):** - -- `Docs/Help/*.md` — Markdown docs generated by PlatyPS via `Build\Build-Documentation.ps1` -- `ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml` — MAML XML generated from the markdown - -**Flow:** `.ps1` comment-based help → `Get-Help` → PlatyPS → Markdown → MAML XML. To update documentation, edit only the comment-based help in the source `.ps1` files, then run `Build\Build-Documentation.ps1 -UpdateExisting` (or without `-UpdateExisting` for a full regeneration). - -**Known PowerShell help parser pitfall:** In comment-based help blocks, any line that starts with a dot followed by a word (e.g., `.NET`, `.config`) is interpreted as a help keyword by PowerShell's parser, breaking the entire help block. Always ensure terms like `.NET Framework` appear mid-line, never at the start of a line (after indentation). Example fix: reword `".NET Framework 3.5 checks"` to `"prerequisite checks for .NET Framework 3.5"`. - -### Security Considerations - -**Graduated SSL certificate validation.** `Initialize-CWAANetworking` registers a `ServerCertificateValidationCallback` with graduated trust rather than blanket bypass. IP address targets auto-bypass (IPs cannot have properly signed certificates). Hostname name mismatches are tolerated (trusted cert but CN/SAN differs). Chain/trust errors on hostnames are rejected unless `-SkipCertificateCheck` is passed, which sets a `SkipAll` flag for full bypass. This graduated approach is necessary because many MSP Automate servers use self-signed or internal CA certificates. The callback is registered once per session and survives module re-import (compiled .NET types cannot be unloaded). - -**TripleDES encryption for agent keys.** `ConvertFrom-CWAASecurity` and `ConvertTo-CWAASecurity` use TripleDES with an MD5-derived key and a fixed 8-byte initialization vector. This is the encryption format the LabTech/Automate agent uses in its registry values (`ServerPasswordString`, `PasswordString`, proxy credentials). We did not choose this scheme -- the agent requires it for interoperability. The default key is `'Thank you for using LabTech.'`. Crypto objects are disposed in `Finally` blocks with a `Dispose()`/`Clear()` fallback for older .NET runtimes. - -**Credential redaction in logs.** `Get-CWAARedactedValue` (private) returns `[SHA256:a1b2c3d4]` (first 8 hex chars of the SHA256 hash) for non-empty strings and `[EMPTY]` for null/empty strings. This logs that a credential value is present and whether it changed, without exposing the actual content. Used in debug/verbose output when comparing proxy passwords, server passwords, and usernames in `Set-CWAAProxy` and related functions. - -**ConvertTo-SecureString -AsPlainText usage.** `Set-CWAAProxy` converts proxy passwords from plain text to `SecureString` via `ConvertTo-SecureString $proxyPass -AsPlainText -Force`. This is necessary because the password originates as plain text (either from the user parameter or decrypted from the agent's TripleDES-encrypted registry value) and must be wrapped in a `SecureString` for the `PSCredential` object used by `System.Net.WebProxy.Credentials`. - -**InstallerToken vs ServerPassword.** `InstallerToken` is the modern, preferred method for agent deployment authentication. `ServerPassword` is the legacy method. Both are supported by `Install-CWAA`, but `InstallerToken` should be recommended in all documentation and examples. `InstallerToken` uses a URL-based download path (`Deployment.aspx?InstallerToken=...`), while `ServerPassword` is passed as an MSI property during installation. - -## Key System Locations - -These are centralized as `$Script:` constants in `Initialize-CWAA` and referenced by many functions: - -- **Registry:** `$Script:CWAARegistryRoot` (`HKLM:\SOFTWARE\LabTech\Service`), `$Script:CWAARegistrySettings` (`HKLM:\SOFTWARE\LabTech\Service\Settings`) -- **Files:** `$Script:CWAAInstallPath` (`C:\Windows\LTSVC`), `$Script:CWAAInstallerTempPath` (`C:\Windows\Temp\LabTech`) -- **Services:** `$Script:CWAAServiceNames` (`LTService`, `LTSvcMon`), plus `LabVNC` (remote control) -- **Ports:** TCP 70, 80, 443, 8002 (server), 42000-42009 (local TrayPort) - -## Terminology - -- **CWAA** = ConnectWise Automate Agent (current prefix) -- **LT/LabTech** = legacy name (alias prefix) -- **InstallerToken** = modern auth method for deployment (preferred over ServerPassword) -- **TrayPort** = local agent communication port (42000-42009) -- **MSP** = Managed Service Provider (target user base) +Read the issue, self-assign with `gh issue edit --add-label ai-in-progress --remove-label ai-ready`, then follow the workflow in AGENTS.md. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3108c24..27098c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,14 +24,9 @@ Open an issue describing what you would like to see and why. Include use cases s 2. **Make your changes** following the coding conventions below. 3. **Test** your changes: ```powershell - Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force - Invoke-Pester Tests\ConnectWiseAutomateAgent.Tests.ps1 -Output Detailed + ./Tests/test-local.ps1 ``` -4. **Rebuild** the single-file distribution: - ```powershell - powershell -File Build\SingleFileBuild.ps1 - ``` -5. **Submit** a pull request with a clear description of what you changed and why. +4. **Submit** a pull request with a clear description of what you changed and why. ## Development Setup @@ -43,8 +38,11 @@ cd ConnectWiseAutomateAgent # Import the module locally Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force -# Run the test suite -Invoke-Pester Tests\ConnectWiseAutomateAgent.Tests.ps1 -Output Detailed +# Run all local checks (build + analyze + test) +./Tests/test-local.ps1 + +# Or run tests only (faster, no build) +./Tests/test-local.ps1 -Quick # Enable pre-commit hooks (runs PSScriptAnalyzer + tests before each commit) git config core.hooksPath .githooks @@ -52,22 +50,21 @@ git config core.hooksPath .githooks ## Coding Conventions -- **Naming:** Use the `CWAA` prefix for function names (`Verb-CWAA`). Add an `[Alias('Verb-LT')]` for backward compatibility. -- **Parameters:** Use `[CmdletBinding()]`. Add `SupportsShouldProcess=$True` on destructive operations. -- **Debug output:** Include `Write-Debug "Starting $($MyInvocation.InvocationName)"` in the Process block and `Write-Debug "Exiting $($MyInvocation.InvocationName)"` in the End block. -- **Error handling:** Use `Write-Error "Failed to at ''. . Error: $($_.Exception.Message)"`. Use `$_` in Catch blocks, not `$Error[0]`. -- **Constants:** Use `$Script:CWAA*` constants for paths and registry keys instead of hardcoded strings. -- **Help:** Include comment-based help (`.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK`) on all public functions. -- **Variable names:** Prefer verbose, descriptive names (`$automateServerUrl` not `$srvUrl`). -- **Returns:** Functions return `[PSCustomObject]` for pipeline compatibility. +See the [Code Conventions](AGENTS.md#code-conventions) section in AGENTS.md for the full quick-reference covering naming, parameters, debug output, error handling, constants, help, variable names, and PSScriptAnalyzer requirements. + +The key points: + +- Functions use `Verb-CWAA` naming with `[Alias('Verb-LT')]` for backward compatibility +- `[CmdletBinding()]` on everything; `SupportsShouldProcess=$True` on destructive operations +- Zero PSScriptAnalyzer errors required (settings in `.PSScriptAnalyzerSettings.psd1`) +- PowerShell files use UTF-8 with BOM, CRLF line endings ### Adding a New Function -1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory. -2. Add `[Alias('Verb-LT')]` in the function declaration. -3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1`. -4. Rebuild documentation: `Build\Build-Documentation.ps1` (outputs to `Docs/Help/`) -5. Rebuild single-file: `Build\SingleFileBuild.ps1` +See [Adding New Functions](AGENTS.md#adding-new-functions) in AGENTS.md for the full checklist. Summary: + +1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory +2. Add the `LT` alias, update the manifest, rebuild docs and single-file ## Versioning diff --git a/Docs/CommonParameters.md b/Docs/CommonParameters.md index 1ffa70a..d14ecf5 100644 --- a/Docs/CommonParameters.md +++ b/Docs/CommonParameters.md @@ -173,3 +173,4 @@ Calls `New-CWAABackup` internally, which copies the agent's registry keys to `HK | Function | Notes | | --- | --- | | `Uninstall-CWAA` | Backs up before removing the agent | +| `Redo-CWAA` | Backs up before uninstall/reinstall cycle | diff --git a/Docs/FAQ.md b/Docs/FAQ.md index 9698e56..ab9286b 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -28,10 +28,11 @@ Yes. Any tool that can execute a PowerShell script with administrator privileges Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 ``` -For environments without PowerShell Gallery access, use the single-file version: +For environments without PowerShell Gallery access, use the version-locked single-file from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases): ```powershell -Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +# Pin to a specific version — replace v1.0.0 with your tested version +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 ``` @@ -162,14 +163,33 @@ Update-Module ConnectWiseAutomateAgent -AllowPrerelease ### Can I use this in a CI/CD pipeline? -Yes. The module is published on the PowerShell Gallery and can be installed non-interactively: +Yes. The module is published on the PowerShell Gallery and can be installed non-interactively. Pin to a specific version for reproducible builds: ```powershell -Install-Module ConnectWiseAutomateAgent -Force -Scope AllUsers -AllowPrerelease +Install-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' -Force -Scope AllUsers ``` Note that most functions require administrator privileges and a Windows target. Functions that read agent state (`Get-CWAAInfo`, `Test-CWAAHealth`) are most useful in CI/CD for validation steps. +### Should I pin the module to a specific version? + +**Yes, for production and deployment scripts.** Pinning to a tested version prevents untested updates from rolling out to endpoints and mitigates supply-chain risk. + +```powershell +# PowerShell Gallery — pin to a specific version +Install-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' +Import-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' + +# Single-file — use a version-locked GitHub Release URL +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +``` + +Update the version number deliberately after validating new releases in a test environment. All example scripts in this repository use version-locked patterns by default. + +**When floating versions are acceptable:** Interactive troubleshooting, development, and one-off diagnostics where you always want the latest features. In these cases, `Install-Module ConnectWiseAutomateAgent` (without `-RequiredVersion`) is fine. + +See [Security Model — Version Locking](Security.md#version-locking) for the full rationale. + ### What is the `-Force` parameter doing? It varies by function. See [Common Parameters](CommonParameters.md#-force) for the full breakdown. In general, it overrides safety checks (existing installation detection, probe agent protection, existing scheduled tasks). diff --git a/Docs/Security.md b/Docs/Security.md index d125b66..7f52157 100644 --- a/Docs/Security.md +++ b/Docs/Security.md @@ -133,6 +133,58 @@ Install-CWAA -Server 'automate.example.com' -LocationID 1 -ServerPassword 'Legac --- +## Version Locking + +### Why It Matters + +This module runs with administrator privileges on managed endpoints. Scripts that always pull the latest version (`Update-Module`, `Install-Module` without `-RequiredVersion`, or downloading from the `main` branch) are vulnerable to: + +- **Supply-chain compromise** — if the PowerShell Gallery package or GitHub repository were compromised, every endpoint running a "latest" script would execute the malicious code on its next run. +- **Breaking changes** — a major version update could change behavior in ways that break existing deployment workflows. +- **Unreproducible deployments** — without a pinned version, two endpoints running the same script a day apart could get different module versions, making troubleshooting difficult. + +### Recommended Practice + +Pin every production script to a specific version you have tested: + +**PowerShell Gallery:** + +```powershell +# Install a specific version +Install-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' -Force -Scope AllUsers + +# Import a specific version (when multiple versions are installed side by side) +Import-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' +``` + +**Single-file (restricted networks):** + +```powershell +# Download from a version-locked GitHub Release — the URL is immutable after publication +$ModuleVersion = '1.0.0' +$URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" +Invoke-RestMethod $URI | Invoke-Expression +``` + +### Update Workflow + +1. A new module version is released. +2. Install and test the new version in a lab or pilot environment. +3. Once validated, update the `$ModuleVersion` variable (or `-RequiredVersion` value) in your deployment scripts. +4. Roll out the updated scripts to production. + +This gives you an explicit approval step between "new version available" and "new version running on all endpoints." + +### When Floating Versions Are Acceptable + +- **Interactive troubleshooting** — running `Install-Module ConnectWiseAutomateAgent` in a one-off PowerShell session to diagnose an issue. +- **Development and testing** — working on the module itself or evaluating new features. +- **Non-production environments** — lab machines where unexpected changes are acceptable. + +All [example scripts](../Examples/) in this repository use version-locked patterns by default. + +--- + ## Vulnerability Awareness `Install-CWAA` checks the Automate server version during installation. If the server reports a version below **v200.197** (the June 2020 security patch), a warning is emitted: diff --git a/Examples/AgentInstall.ps1 b/Examples/AgentInstall.ps1 index b1e5386..edd4a48 100644 --- a/Examples/AgentInstall.ps1 +++ b/Examples/AgentInstall.ps1 @@ -6,33 +6,38 @@ $InstallParameters = @{ # ^^ This info is sensitive take precautions to secure it ^^ # ============================================================================== -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' # ============================================================================== +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/AgentInstallWithHealthCheck.ps1 b/Examples/AgentInstallWithHealthCheck.ps1 index 6cc419e..fd13ef0 100644 --- a/Examples/AgentInstallWithHealthCheck.ps1 +++ b/Examples/AgentInstallWithHealthCheck.ps1 @@ -37,32 +37,37 @@ $TaskName = 'CWAAHealthCheck' # Scheduled task name (default: CWAAHealthCheck) # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/GPOScheduledTaskDeployment.ps1 b/Examples/GPOScheduledTaskDeployment.ps1 index 710296b..56cd3c4 100644 --- a/Examples/GPOScheduledTaskDeployment.ps1 +++ b/Examples/GPOScheduledTaskDeployment.ps1 @@ -112,35 +112,40 @@ Get-CimInstance Win32_Process | Where-Object { # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null - Write-Log "Module '$Module' loaded." + Write-Log "Module '$Module' v$ModuleVersion loaded." } catch { - Write-Log 'PowerShell Gallery unavailable. Falling back to single-file download.' + Write-Log 'PowerShell Gallery unavailable. Falling back to version-locked single-file download.' # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/HealthCheck-Monitoring.ps1 b/Examples/HealthCheck-Monitoring.ps1 index 20cd4c1..2585ab7 100644 --- a/Examples/HealthCheck-Monitoring.ps1 +++ b/Examples/HealthCheck-Monitoring.ps1 @@ -45,32 +45,37 @@ $HealthCheckInterval = 6 # Hours between health ch # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/PipelineUsage.ps1 b/Examples/PipelineUsage.ps1 index adf1840..f719b59 100644 --- a/Examples/PipelineUsage.ps1 +++ b/Examples/PipelineUsage.ps1 @@ -19,32 +19,37 @@ # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/ProxyConfiguration.ps1 b/Examples/ProxyConfiguration.ps1 index e8da236..d7cbb4e 100644 --- a/Examples/ProxyConfiguration.ps1 +++ b/Examples/ProxyConfiguration.ps1 @@ -32,32 +32,37 @@ # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/Troubleshooting-QuickDiagnostic.ps1 b/Examples/Troubleshooting-QuickDiagnostic.ps1 index 4a415e1..3ad3379 100644 --- a/Examples/Troubleshooting-QuickDiagnostic.ps1 +++ b/Examples/Troubleshooting-QuickDiagnostic.ps1 @@ -26,32 +26,37 @@ # --- Module Loading ---------------------------------------------------------- -# SECURITY WARNING: The fallback method below uses Invoke-Expression to load -# code downloaded from the internet at runtime. This is convenient but carries -# inherent risk -- a compromised source or man-in-the-middle attack could -# execute arbitrary code on this machine. +# SECURITY NOTE: Version locking # -# RECOMMENDED: Use Install-Module from the PowerShell Gallery instead: -# Install-Module 'ConnectWiseAutomateAgent' -Scope AllUsers +# Production scripts should pin to a specific module version. This prevents +# untested updates from rolling out to endpoints and mitigates supply-chain +# risk. Update $ModuleVersion deliberately after validating new releases. # -# The Invoke-Expression fallback is provided ONLY for systems where the -# PowerShell Gallery is unavailable (e.g., PS 2.0, restricted networks). +# The Invoke-Expression fallback downloads and executes code at runtime. +# It is provided ONLY for systems where the PowerShell Gallery is unavailable +# (e.g., PS 2.0, restricted networks). The fallback URL is version-locked to +# a GitHub Release so the code is immutable after publication. +# +# PREFERRED: Use Install-Module from the PowerShell Gallery instead: +# Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' + +$Module = 'ConnectWiseAutomateAgent' +$ModuleVersion = '1.0.0' # Pin to a tested version — update after validating new releases [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 try { - $Module = 'ConnectWiseAutomateAgent' - try { Update-Module $Module -ErrorAction Stop } - catch { Install-Module $Module -Force -Scope AllUsers -SkipPublisherCheck } - - Get-Module $Module -ListAvailable | - Sort-Object Version -Descending | - Select-Object -First 1 | - Import-Module *>$null + $installed = Get-Module $Module -ListAvailable | + Where-Object { $_.Version -eq $ModuleVersion } + if (-not $installed) { + Install-Module $Module -RequiredVersion $ModuleVersion -Force -Scope AllUsers + } + Import-Module $Module -RequiredVersion $ModuleVersion *>$null } catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. - $URI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' + # The URL is pinned to a specific release tag — it will not change after publication. + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/MAP.md b/MAP.md deleted file mode 100644 index 92f15d0..0000000 --- a/MAP.md +++ /dev/null @@ -1,170 +0,0 @@ -# ConnectWiseAutomateAgent - Strategic Roadmap - -**Last Updated**: 2026-01-31 -**Current Module Version**: 1.0.0 -**Purpose**: Track project evolution from initial release through production hardening - ---- - -## Overview - -ConnectWiseAutomateAgent PowerShell module for managing the ConnectWise Automate agent (formerly LabTech) on Windows systems. Used by MSPs for agent installation, configuration, troubleshooting, and automated health monitoring. - ---- - -## Phase 1: Documentation & Testing ✅ Complete - -**Status**: Complete -**Completed**: Development branch (current) - -### Delivered - -- **Comment-based help** — all 30 public functions have full `.SYNOPSIS`, `.DESCRIPTION`, `.EXAMPLE`, `.NOTES`, `.LINK` -- **Pester test suite** — 377+ tests across 4 tiers (structure, mocked, cross-version, live), 100% function coverage -- **Mocked unit tests** — all 30 functions isolated with Pester mocks (no system dependencies) -- **Cross-version tests** — PowerShell 5.1 + 7+ validated -- **Live integration tests** — 112 tests with full install/exercise/uninstall lifecycle -- **CONTRIBUTING.md** — development setup, coding conventions, PR workflow -- **Blog posts** — introduction, troubleshooting guide, mass deployment, use cases - -### Remaining - -*All remaining Phase 1 items were completed in Phase 4 (2026-01-31):* - -- [x] PSScriptAnalyzer settings file (`.PSScriptAnalyzerSettings.psd1`) — completed with 6 documented suppressions -- [x] Expand mocked test coverage to remaining 13 functions — all 30 functions now have mocked tests -- [x] Fix stub doc for `Docs/Private/Initialize-CWAA.md` — replaced placeholder with real documentation - ---- - -## Phase 2: CI/CD & Build Infrastructure ✅ Complete - -**Status**: Complete -**Completed**: Development branch (current) - -### What Was Delivered - -- **GitHub Actions CI/CD** — smoke test → build → publish pipeline - - Prerelease publish from `develop` branch - - Stable publish from `main` branch - - PSGallery environment gating -- **Build scripts** — `SingleFileBuild.ps1`, `Build-Documentation.ps1`, `Publish-CWAAModule.ps1` -- **Module version** — bumped from `0.1.4.0` to `1.0.0` - -### Still Needed - -- [ ] Changelog generation (CHANGELOG.md) -- [ ] Version bump automation -- [ ] EditorConfig for consistent formatting -- [ ] Pre-commit hooks (PSScriptAnalyzer + tests) - ---- - -## Phase 3: v1.0 Feature Completion ✅ Complete - -**Status**: Complete -**Completed**: Development branch (current) - -### Features Shipped - -- **Health check system** — `Test-CWAAHealth`, `Repair-CWAA`, `Register-CWAAHealthCheckTask`, `Unregister-CWAAHealthCheckTask` -- **Server connectivity testing** — `Test-CWAAServerConnectivity` with auto-discovery -- **Windows Event Log integration** — `Write-CWAAEventLog` with categorized event IDs (1000-4039) -- **Lazy networking initialization** — `Initialize-CWAANetworking` with graduated SSL trust -- **Installer cleanup** — `Clear-CWAAInstallerArtifacts` -- **Credential redaction** — `Get-CWAARedactedValue` (SHA256 hash prefix) -- **Error handling standardization** — consistent Try-Catch-Finally throughout -- **Variable naming cleanup** — all cryptic names replaced with descriptive names -- **WhatIf/Confirm** — all destructive operations support `ShouldProcess` -- **PowerShell Core compatibility** — PS 5.1 and 7+ validated -- **Debug/logging overhaul** — Write-Debug throughout + Windows Event Log - ---- - -## Phase 4: Production Hardening ✅ Complete - -**Status**: Complete -**Completed**: 2026-01-31 - -### Delivered - -- **PSScriptAnalyzer compliance** — `.PSScriptAnalyzerSettings.psd1` with 6 documented suppressions, all issues fixed (empty catch blocks, global variable removal) -- **Expanded mocked tests** — 13 functions added, all 30 functions now covered (377+ tests total, up from 272) -- **Security documentation** — SSL graduation strategy, TripleDES usage, credential redaction, SecureString handling, InstallerToken vs ServerPassword documented in CLAUDE.md -- **Input validation hardening** — `ValidateScript` on mandatory Server params (`Install-CWAA`, `Repair-CWAA`), `ValidateRange` on LocationID, `ValidatePattern` on TaskName, LocationID type fix (`[string]`→`[int]` on `Redo-CWAA`) -- **Expanded examples** — 5 new scripts: health check monitoring, proxy configuration, troubleshooting diagnostic, GPO deployment, install with health check -- **Inline comments** — TrayPort selection logic (42000-42009), server version detection thresholds (110.374/200.197/240.331), regex breakdown in Initialize-CWAA -- **Documentation fixes** — Initialize-CWAA.md replaced PlatyPS placeholder with real docs, all 31 markdown docs + MAML regenerated -- **Single-file build** — verified at 4,465 lines / 236.5 KB - -### Remaining - -- [ ] **Merge develop → main** — ship v1.0.0 stable release to PSGallery - ---- - -## Phase 5: Code Quality & Documentation ✅ Complete - -**Status**: Complete -**Completed**: 2026-01-31 - -### Delivered - -- **Architecture diagrams** — [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams (module init, install workflow, health check escalation, registry/file interaction map) -- **Duplicate code refactoring** — 3 new private helpers extracted: - - `Resolve-CWAAServer` — server validation loop (~300 duplicated lines eliminated) - - `Test-CWAADownloadIntegrity` — download file size validation (~18 lines) - - `Remove-CWAAFolderRecursive` — depth-first folder deletion (~6 lines) -- **Caller updates** — `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA` refactored to use helpers -- **EditorConfig** — `.editorconfig` with PS 4-space, YAML/JSON 2-space, CRLF, UTF-8 -- **CHANGELOG.md** — Keep a Changelog format with v1.0.0-alpha001 and v0.1.4.0 entries -- **Versioning documentation** — CONTRIBUTING.md updated with semver bump criteria and prerelease tag progression -- **Version validation** — `SingleFileBuild.ps1` checks manifest version vs CHANGELOG latest entry -- **18 new mocked tests** — private helper functions fully tested (392 total, up from 377) -- **Documentation restructure** — separated generated vs hand-written docs into distinct folders - - `Docs/` for hand-written guides (Architecture.md) - - `Docs/Help/` for auto-generated function reference (PlatyPS output) - - `.blog/` for gitignored blog drafts - - `Build-Documentation.ps1` updated to output to `Docs/Help/` - - README.md restructured with clear "Guides" and "Function Reference (Auto-Generated)" sections - - 11 new documentation structure tests (403 total, up from 392) - ---- - -## Phase 6: Community & Polish 🔮 Planned - -**Status**: Planned -**Target**: Q2-Q3 2026 - -### Targets - -1. **Pre-commit hooks** — PSScriptAnalyzer + Pester before commit -2. **Pipeline support review** — audit `ValueFromPipeline` attributes, test and document -3. **FAQ section** — common installation errors, proxy issues, version compatibility -4. **Progress indicators** — `Write-Progress` for long-running operations - ---- - -## Scorecard - -| Area | Status | Detail | -| --- | --- | --- | -| Public functions | 30 | 25 original + 5 new | -| Private functions | 6 | Initialize, Networking, Cleanup, Resolve, Integrity, FolderRemove | -| Legacy aliases | 32 | Full backward compatibility | -| Test cases | 403+ | Structure + mocked + cross-version + live + doc structure | -| Test files | 4 | ~5,200 lines total | -| Build scripts | 3 | Single-file, docs, publish | -| CI/CD | GitHub Actions | Smoke, build, prerelease/stable publish | -| PS compatibility | 5.1 + 7+ | Cross-version tested | -| Comment-based help | 100% | All public functions documented | -| PSScriptAnalyzer | Clean | `.PSScriptAnalyzerSettings.psd1` with 6 suppressions | -| Event log integration | Yes | Categorized IDs (1000-4039) | -| Health monitoring | Yes | Test, Repair, Scheduled Task | -| Example scripts | 6 | Install, health check, proxy, troubleshooting, GPO, install+health | -| Architecture docs | Yes | 4 Mermaid diagrams in Docs/Architecture.md | -| Doc structure | Separated | Hand-written (Docs/) vs auto-generated (Docs/Help/) | -| Changelog | Yes | CHANGELOG.md (Keep a Changelog format) | -| EditorConfig | Yes | .editorconfig for consistent formatting | - ---- diff --git a/README.md b/README.md index 290a373..015b443 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,38 @@ Install-Module 'ConnectWiseAutomateAgent' -AllowPrerelease > Having issues with the Gallery? Try this [repair script](https://github.com/christaylorcodes/Initialize-PSGallery). +### Version Locking (Recommended for Production) + +For deployment scripts, automated tasks, and anything running unattended on endpoints, pin to a specific version you have tested: + +```powershell +Install-Module 'ConnectWiseAutomateAgent' -RequiredVersion '1.0.0' +``` + +This prevents untested updates from rolling out to production machines. Update the version number deliberately after validating new releases in a test environment. + +> **Why version lock?** Scripts that always pull the latest version are vulnerable to supply-chain risk -- a compromised update or a breaking change could affect every endpoint at once. Pinning to a tested version gives you control over when updates roll out. See [Security Model — Version Locking](Docs/Security.md#version-locking) for details. + ### Single-File Usage For older machines or environments without PowerShell Gallery access, a standalone `.ps1` file is available. This is a fallback -- prefer `Install-Module` above whenever possible. +**Version-locked** (from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases) -- recommended for production scripts): + +```powershell +# Pin to a specific version for reproducible deployments +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +``` + +**Latest** (tracks `main` branch -- use only for interactive testing, not production): + ```powershell Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' | Invoke-Expression ``` -> **Note:** This downloads and executes code at runtime. Use `Install-Module` when the Gallery is available. +> **Tip:** Browse all versions on the [Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases) page. Replace `v1.0.0` with your desired version tag. +> +> **Note:** Both methods download and execute code at runtime. Use `Install-Module` when the Gallery is available. Version-locked URLs are strongly preferred for production because they are immutable after release. ## Getting Started @@ -155,7 +178,6 @@ Ready-to-use scripts in the [Examples/](Examples/) directory: | | | | --- | --- | -| [Roadmap](MAP.md) | Where the project has been and where it's going | | [Changelog](CHANGELOG.md) | Version history and release notes | | [Contributing](CONTRIBUTING.md) | How to report bugs, suggest features, and submit PRs | @@ -167,7 +189,7 @@ If you're an AI assistant helping a user with this module, start here: | Resource | What it contains | | --- | --- | -| [CLAUDE.md](CLAUDE.md) | Architecture, module loading flow, coding conventions, key system paths, security considerations, and testing requirements | +| [AGENTS.md](AGENTS.md) | Architecture, build commands, coding conventions, testing workflow, AI contribution workflow, security considerations | | [Docs/Help/ConnectWiseAutomateAgent.md](Docs/Help/ConnectWiseAutomateAgent.md) | Complete function reference with links to per-function documentation (parameters, examples, syntax) | | [Docs/Help/](Docs/Help/) | Individual function docs -- one file per function (e.g., `Docs/Help/Install-CWAA.md`) | | [Examples/](Examples/) | Ready-to-use scripts covering common deployment and troubleshooting scenarios | diff --git a/Scripts/Invoke-QuickTest.ps1 b/Scripts/Invoke-QuickTest.ps1 new file mode 100644 index 0000000..fa5ea48 --- /dev/null +++ b/Scripts/Invoke-QuickTest.ps1 @@ -0,0 +1,293 @@ +<# +.SYNOPSIS + Fast targeted test runner for AI agent closed-loop development. + +.DESCRIPTION + Runs Pester tests for a specific function, test file, or the full suite + without the overhead of the full build pipeline. Designed for rapid + write-test-fix-retest cycles. + + Does NOT build the single-file distribution or compute code coverage. + For full validation, use Tests/test-local.ps1. + +.PARAMETER FunctionName + Name of the function to test. Uses Pester's FullName filter to match + tests across all test files (e.g., -FunctionName Install-CWAA matches + all Describe/Context/It blocks containing 'Install-CWAA'). + +.PARAMETER TestFile + Run a specific test file by short name or full path. + Short names: Module, DataReaders, Commands, ServiceOps, CrossCutting, + PrivateHelpers, Installation, Documentation, Security, CrossVersion, Live. + +.PARAMETER IncludeAnalyzer + Also run PSScriptAnalyzer on the function source file (with -FunctionName) + or the entire module directory (without -FunctionName). + +.PARAMETER OutputFormat + 'Human' for colored console output with clear PASS/FAIL markers (default). + 'Structured' for JSON output that AI agents can parse. + +.PARAMETER ExcludeTag + Pester tags to exclude. Defaults to 'Live'. + +.EXAMPLE + .\Scripts\Invoke-QuickTest.ps1 -FunctionName Install-CWAA + Quick-test a single function across all test files. + +.EXAMPLE + .\Scripts\Invoke-QuickTest.ps1 -FunctionName Install-CWAA -IncludeAnalyzer -OutputFormat Structured + Quick-test with lint check, JSON output for AI parsing. + +.EXAMPLE + .\Scripts\Invoke-QuickTest.ps1 -TestFile Installation + Run only the installation mocked test suite. + +.EXAMPLE + .\Scripts\Invoke-QuickTest.ps1 + Run all tests without build or coverage overhead. +#> +[CmdletBinding(DefaultParameterSetName = 'All')] +param( + [Parameter(ParameterSetName = 'ByFunction', Position = 0)] + [string]$FunctionName, + + [Parameter(ParameterSetName = 'ByTestFile', Position = 0)] + [string]$TestFile, + + [switch]$IncludeAnalyzer, + + [ValidateSet('Human', 'Structured')] + [string]$OutputFormat = 'Human', + + [string[]]$ExcludeTag = @('Live') +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$TestsPath = Join-Path $ProjectRoot 'Tests' +$SourcePath = Join-Path $ProjectRoot 'ConnectWiseAutomateAgent' + +# --- Short name mapping for test files --- + +$testFileMap = @{ + 'Module' = 'ConnectWiseAutomateAgent.Module.Tests.ps1' + 'DataReaders' = 'ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1' + 'Commands' = 'ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1' + 'ServiceOps' = 'ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1' + 'CrossCutting' = 'ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1' + 'PrivateHelpers' = 'ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1' + 'Installation' = 'ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1' + 'Documentation' = 'ConnectWiseAutomateAgent.Documentation.Tests.ps1' + 'Security' = 'ConnectWiseAutomateAgent.Security.Tests.ps1' + 'CrossVersion' = 'ConnectWiseAutomateAgent.CrossVersion.Tests.ps1' + 'Live' = 'ConnectWiseAutomateAgent.Live.Tests.ps1' +} + +# --- Resolve test path and filter --- + +$testFilePath = $TestsPath +$fullNameFilter = $null + +if ($PSCmdlet.ParameterSetName -eq 'ByFunction') { + # Run all test files but filter to tests matching the function name + $fullNameFilter = "*$FunctionName*" +} +elseif ($PSCmdlet.ParameterSetName -eq 'ByTestFile') { + # Resolve short name or use as-is + if ($testFileMap.ContainsKey($TestFile)) { + $testFilePath = Join-Path $TestsPath $testFileMap[$TestFile] + } + elseif (Test-Path $TestFile) { + $testFilePath = $TestFile + } + else { + $msg = "ERROR: Test file '$TestFile' not found. Valid short names: $($testFileMap.Keys -join ', ')" + if ($OutputFormat -eq 'Structured') { + @{ + success = $false + totalTests = 0 + passed = 0 + failed = 0 + skipped = 0 + duration = '0s' + failedTests = @() + analyzerErrors = @() + analyzerWarnings = @() + summary = $msg + } | ConvertTo-Json -Depth 5 + } + else { + Write-Host $msg -ForegroundColor Red + } + exit 1 + } +} + +# --- Run Pester --- + +$config = New-PesterConfiguration +$config.Run.Path = $testFilePath +$config.Run.PassThru = $true +$config.CodeCoverage.Enabled = $false +$config.TestResult.Enabled = $false + +if ($ExcludeTag) { + $config.Filter.ExcludeTag = $ExcludeTag +} + +if ($fullNameFilter) { + $config.Filter.FullName = $fullNameFilter +} + +if ($OutputFormat -eq 'Structured') { + $config.Output.Verbosity = 'None' +} +else { + $config.Output.Verbosity = 'Detailed' +} + +$results = Invoke-Pester -Configuration $config + +# --- Optional analyzer --- + +$analyzerErrors = @() +$analyzerWarnings = @() + +if ($IncludeAnalyzer) { + $settingsFile = Join-Path $ProjectRoot '.PSScriptAnalyzerSettings.psd1' + $analyzePath = $null + + if ($FunctionName) { + # Search recursively for the specific function source file + $sourceFiles = @(Get-ChildItem -Path $SourcePath -Filter "$FunctionName.ps1" -Recurse -File) + if ($sourceFiles.Count -gt 0) { + $analyzePath = $sourceFiles[0].FullName + } + } + else { + # Analyze entire module directory + $analyzePath = $SourcePath + } + + if ($analyzePath) { + $analyzeParams = @{ Path = $analyzePath; Recurse = $true } + if (Test-Path $settingsFile) { + $analyzeParams['Settings'] = $settingsFile + } + + $analyzeResults = Invoke-ScriptAnalyzer @analyzeParams + + if ($analyzeResults) { + $analyzerErrors = @($analyzeResults | Where-Object Severity -eq 'Error' | ForEach-Object { + @{ + rule = $_.RuleName + message = $_.Message + line = $_.Line + file = $_.ScriptName + } + }) + $analyzerWarnings = @($analyzeResults | Where-Object Severity -eq 'Warning' | ForEach-Object { + @{ + rule = $_.RuleName + message = $_.Message + line = $_.Line + file = $_.ScriptName + } + }) + } + } +} + +# --- Build output --- + +$failedTests = @() +if ($results.FailedCount -gt 0) { + $failedTests = @($results.Failed | ForEach-Object { + $relativePath = if ($_.ScriptBlock.File) { + $_.ScriptBlock.File -replace [regex]::Escape($ProjectRoot + [IO.Path]::DirectorySeparatorChar), '' + } else { 'unknown' } + @{ + name = $_.Name + block = $_.Path -join ' > ' + error = $_.ErrorRecord.DisplayErrorMessage + file = $relativePath + line = if ($_.ScriptBlock.StartPosition) { $_.ScriptBlock.StartPosition.StartLine } else { 0 } + } + }) +} + +$totalDuration = '{0:N2}s' -f $results.Duration.TotalSeconds + +$success = ($results.FailedCount -eq 0) -and ($analyzerErrors.Count -eq 0) +$summaryParts = @() +if ($results.PassedCount -gt 0) { $summaryParts += "$($results.PassedCount) passed" } +if ($results.FailedCount -gt 0) { $summaryParts += "$($results.FailedCount) failed" } +if ($results.SkippedCount -gt 0) { $summaryParts += "$($results.SkippedCount) skipped" } +$summaryText = if ($success) { "PASSED ($($summaryParts -join ', '))" } else { "FAILED ($($summaryParts -join ', '))" } + +# --- Output --- + +if ($OutputFormat -eq 'Structured') { + $output = @{ + success = $success + totalTests = $results.TotalCount + passed = $results.PassedCount + failed = $results.FailedCount + skipped = $results.SkippedCount + duration = $totalDuration + failedTests = $failedTests + analyzerErrors = $analyzerErrors + analyzerWarnings = $analyzerWarnings + summary = $summaryText + } + $output | ConvertTo-Json -Depth 5 +} +else { + # Human-readable output + $label = if ($FunctionName) { $FunctionName } + elseif ($TestFile) { $TestFile } + else { 'All Tests' } + Write-Host "" + Write-Host "=== QUICK TEST: $label ===" -ForegroundColor Cyan + + # Failed test details + if ($results.FailedCount -gt 0) { + Write-Host "" + Write-Host "FAILED TESTS:" -ForegroundColor Red + foreach ($ft in $failedTests) { + Write-Host " FAILED $($ft.name)" -ForegroundColor Red + Write-Host " ERROR: $($ft.error)" -ForegroundColor Yellow + Write-Host " FILE: $($ft.file):$($ft.line)" -ForegroundColor Gray + } + } + + # Analyzer results + if ($analyzerErrors.Count -gt 0) { + Write-Host "" + Write-Host "ANALYZER ERRORS:" -ForegroundColor Red + foreach ($ae in $analyzerErrors) { + Write-Host " [$($ae.rule)] $($ae.message)" -ForegroundColor Red + Write-Host " FILE: $($ae.file):$($ae.line)" -ForegroundColor Gray + } + } + if ($analyzerWarnings.Count -gt 0) { + Write-Host "" + Write-Host "ANALYZER WARNINGS:" -ForegroundColor Yellow + foreach ($aw in $analyzerWarnings) { + Write-Host " [$($aw.rule)] $($aw.message)" -ForegroundColor Yellow + Write-Host " FILE: $($aw.file):$($aw.line)" -ForegroundColor Gray + } + } + + # Summary + Write-Host "" + $color = if ($success) { 'Green' } else { 'Red' } + Write-Host "=== RESULT: $summaryText ($totalDuration) ===" -ForegroundColor $color + Write-Host "" +} + +# --- Exit code --- + +if (-not $success) { exit 1 } +exit 0 diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 2558f77..0000000 --- a/TODO.md +++ /dev/null @@ -1,348 +0,0 @@ -# ConnectWiseAutomateAgent - TODO List - -This document tracks improvements and tasks for the ConnectWiseAutomateAgent project. Tasks are organized by priority and category to help contributors and AI assistants work more effectively with the codebase. - -## Priority 1: Polish & Remaining Gaps - -These items close out the last gaps from the major development push. - -### Documentation - -- [x] **Fix incomplete function documentation** (Completed 2025-11-04) - - [x] Complete synopsis for `Get-CWAALogLevel` - - [x] Complete synopsis for `New-CWAABackup` - - [x] Complete synopsis for `Uninstall-CWAA` - - [x] 25 of 26 public function docs fully complete with MAML format - - [x] Fix stub doc for [Docs/Private/Initialize-CWAA.md](Docs/Private/Initialize-CWAA.md) (replaced PlatyPS placeholder with real documentation) - -- [x] **Add inline code comments for complex logic** (Mostly complete) - - [x] Encryption/decryption algorithms in `ConvertFrom-CWAASecurity.ps1` and `ConvertTo-CWAASecurity.ps1` - - [x] WOW64 redirection handling - - [x] Install-CWAA business logic (vulnerability checks, parameter building) - - [x] Document the TrayPort selection logic (42000-42009 range) - - [x] Explain the server version detection mechanism in more detail - -- [x] **Create architecture diagram** (Completed 2026-01-31) - - [x] Module initialization flow (two-phase: import vs. on-demand networking) - - [x] Agent installation workflow - - [x] Health check escalation flow (Test-CWAAHealth → Repair-CWAA) - - [x] Registry/file system interaction map - - Created [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams - -### Code Quality - -- [x] **Fix manifest encoding issues** (Resolved) - - Manifest is clean ASCII/CRLF, no BOM issues - -- [x] **Add PSScriptAnalyzer configuration** (Completed 2026-01-31) - - [x] Create `.PSScriptAnalyzerSettings.psd1` with project-specific rules - - [x] Run analyzer and fix critical/error issues - - [x] Document any intentional rule suppressions with justifications (6 suppressions documented) - - **Why**: Static analysis catches common errors and enforces best practices - -- [x] **Standardize error handling patterns** (Complete) - - All functions use consistent Try-Catch-Finally with context-aware error messages - - Standard `$_` usage in Catch blocks throughout - - Consistent `-ErrorAction` parameter usage - -### Testing - -- [x] **Create comprehensive Pester tests** (Complete — expanded in Phase 4) - - 377+ tests across 4 files - - `ConnectWiseAutomateAgent.Tests.ps1` — 58 tests: module structure, exports, security round-trip - - `ConnectWiseAutomateAgent.Mocked.Tests.ps1` — 193+ tests: 30 functions with Pester mocks - - `ConnectWiseAutomateAgent.CrossVersion.Tests.ps1` — 14 tests: PS 5.1 + 7+ compatibility - - `ConnectWiseAutomateAgent.Live.Tests.ps1` — 112 tests: full lifecycle with real Automate server - - All 30 public functions and 32 aliases covered across test tiers - -- [x] **Expand mocked test coverage** (Completed 2026-01-31) - - All 30 functions now have mocked tests (was 17 of 30) - - [x] Add mocked tests for `Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA` - - [x] Add mocked tests for `Test-CWAAPort`, `Test-CWAAServerConnectivity` - - [x] Add mocked tests for `Set-CWAAProxy`, `New-CWAABackup` - - [x] Add mocked tests for `Repair-CWAA`, `Test-CWAAHealth` - - [x] Add mocked tests for `Register-CWAAHealthCheckTask`, `Unregister-CWAAHealthCheckTask` - - [x] Add edge-case tests for `ConvertTo-CWAASecurity`, `ConvertFrom-CWAASecurity` - - **Why**: Mocked tests run without system dependencies, making CI faster and more reliable - -## Priority 2: Code Maintainability - -These tasks improve long-term maintainability and reduce technical debt. - -### Code Organization - -- [x] **Refactor long functions** (Completed 2026-01-31) - - [x] Extracted `Resolve-CWAAServer` — eliminated ~300 duplicated lines across Install/Uninstall/Update - - [x] Extracted `Test-CWAADownloadIntegrity` — centralized file size validation (was inline in 3 files) - - [x] Extracted `Remove-CWAAFolderRecursive` — centralized depth-first folder deletion (was inline in 2 files) - - [x] Updated Install-CWAA, Uninstall-CWAA, Update-CWAA to use new helpers - - **Note**: Install-CWAA still has Install-specific logic inline (auth branching, vulnerability check, MSI execution) — these are not duplicated elsewhere - -- [x] **Consolidate duplicate code** (Completed 2026-01-31) - - [x] Server validation loop extracted to `Resolve-CWAAServer` - - [x] Download integrity check extracted to `Test-CWAADownloadIntegrity` - - [x] Folder cleanup extracted to `Remove-CWAAFolderRecursive` - - Registry operations remain inline (context-dependent, not worth abstracting) - -- [x] **Improve variable naming** (Complete) - - All cryptic names (`$Svr`, `$SVer`, `$tmpLTSI`) replaced with descriptive names - - Examples: `$automateServerUrl`, `$serverVersionResponse`, `$restartThreshold` - -### Module Structure - -- [x] **Add build automation** (Complete) - - `Build/SingleFileBuild.ps1` — single-file distribution builder - - `Build/Build-Documentation.ps1` — PlatyPS doc generation - - `Build/Publish-CWAAModule.ps1` — PSGallery publishing with dry-run support - - GitHub Actions CI/CD with smoke test → build → prerelease/stable publish - -- [x] **Implement proper semantic versioning** (Completed 2026-01-31) - - [x] Version bumped from `0.1.4.0` to `1.0.0` - - [x] Document version bump criteria (added to [CONTRIBUTING.md](CONTRIBUTING.md)) - - [x] Add version validation in build process (`SingleFileBuild.ps1` checks manifest vs CHANGELOG) - - [x] Add changelog generation ([CHANGELOG.md](CHANGELOG.md) with Keep a Changelog format) - -- [x] **Add EditorConfig** (Completed 2026-01-31) - - [x] Create `.editorconfig` for consistent formatting - - [x] 4-space indentation for PowerShell, 2-space for YAML/JSON/XML - - [x] CRLF line endings, UTF-8 encoding, trim trailing whitespace - -## Priority 3: Security & Best Practices - -These tasks address security concerns and improve code quality. - -### Security - -- [x] **Document security considerations** (Completed 2026-01-31) - - [x] Document graduated SSL certificate validation strategy (implemented in `Initialize-CWAANetworking`) - - [x] Explain TripleDES usage and migration path - - [x] Add security checklist for contributors (added to CLAUDE.md) - - **Why**: Users need to understand security tradeoffs - -- [x] **Implement secure credential handling** (Partially complete) - - [x] `Get-CWAARedactedValue` added — SHA256 hash prefix for credential logging - - [x] Password redaction in installer arguments output - - [ ] Review `$Script:LTServiceKeys` for further hardening - - [ ] Consider PSCredential objects for password parameters - - **Why**: Reduces risk of credential exposure - -- [x] **Add input validation** (Completed 2026-01-31) - - [x] `InstallerToken` — `ValidatePattern('(?s:^[0-9a-z]+$)')` - - [x] `IntervalHours` — `ValidateRange(1, 168)` - - [x] Validate URL parameters against injection (`ValidateScript` on mandatory `Server` params in `Install-CWAA`, `Repair-CWAA`) - - [x] Validate LocationID ranges (`ValidateRange(1, [int]::MaxValue)` on `Repair-CWAA`, type fix `[string]`→`[int]` on `Redo-CWAA`) - - [x] Validate TaskName (`ValidatePattern` on `Register-CWAAHealthCheckTask`) - - **Why**: Prevents injection attacks and improves error messages - - **Note**: `ValidateScript` intentionally omitted from optional `Server` params — PowerShell fires validation on internal variable assignment, breaking auto-discovery - -### Modern PowerShell Practices - -- [x] **Add PowerShell Core compatibility** (Complete) - - Cross-version tests validate PS 5.1 + 7+ - - Module requires PS 3.0+, works on 5.1 and 7+ - - .NET 6+ obsolescence warnings handled with pragma directives - -- [x] **Implement proper logging** (Complete) - - Write-Debug in Begin/Process/End blocks throughout all functions - - Windows Event Log integration via `Write-CWAAEventLog` - - Organized event ID ranges: 1000s install, 2000s service, 3000s config, 4000s health - -- [x] **Add pipeline support** (Completed 2026-02-01) - - [x] Audited all 30 functions for ValueFromPipeline/ValueFromPipelineByPropertyName correctness - - [x] Fixed Begin/Process placement: moved ServerPassword escaping to Process in Install-CWAA, removed misleading pipeline attr from Uninstall-CWAA Backup switch - - [x] Added ValueFromPipeline to Set-CWAALogLevel Level parameter - - [x] Added ValueFromPipelineByPropertyName to Register-CWAAHealthCheckTask Server and LocationID - - [x] Added pipeline .EXAMPLE blocks to 7 functions (Test-CWAAHealth, Test-CWAAPort, Install-CWAA, Redo-CWAA, Uninstall-CWAA, Set-CWAALogLevel, Register-CWAAHealthCheckTask) - - [x] Added 6 new pipeline tests (Invoke-CWAACommand array, Set-CWAALogLevel, Test-CWAAServerConnectivity, Test-CWAAHealth, Test-CWAAPort property-based) - - [x] Added 4 new examples to Examples/PipelineUsage.ps1 (examples 13-16) - - [x] 417 tests pass, PSScriptAnalyzer clean, single-file build and docs regenerated - -## Priority 4: Developer Experience - -These tasks improve the experience for contributors and users. - -### Development Environment - -- [x] **Create development setup guide** (Complete) - - [CONTRIBUTING.md](CONTRIBUTING.md) covers setup, coding conventions, PR workflow, and new function checklist - -- [ ] **Add pre-commit hooks** - - [ ] Run PSScriptAnalyzer before commit - - [ ] Run tests before commit - - [ ] Validate formatting - - **Why**: Catches issues before they reach the repository - -- [x] **Improve debugging experience** (Complete) - - Write-Debug throughout all functions with Begin/End markers - - Windows Event Log for production debugging - - Event IDs organized by category for easy filtering - -### Examples & Documentation - -- [x] **Expand examples** (Completed 2026-01-31) - - [x] [Examples/AgentInstall.ps1](Examples/AgentInstall.ps1) — basic installation workflow - - [x] [Examples/HealthCheck-Monitoring.ps1](Examples/HealthCheck-Monitoring.ps1) — health check task lifecycle - - [x] [Examples/ProxyConfiguration.ps1](Examples/ProxyConfiguration.ps1) — proxy configuration walkthrough - - [x] [Examples/Troubleshooting-QuickDiagnostic.ps1](Examples/Troubleshooting-QuickDiagnostic.ps1) — all-in-one diagnostic script - - [x] [Examples/AgentInstallWithHealthCheck.ps1](Examples/AgentInstallWithHealthCheck.ps1) — installation with health monitoring - - [x] [Examples/GPOScheduledTaskDeployment.ps1](Examples/GPOScheduledTaskDeployment.ps1) — GPO-based deployment - - **Why**: Examples are the fastest way for users to learn - -- [ ] **Add FAQ section** - - [ ] Common installation errors - - [ ] Proxy configuration issues - - [ ] Version compatibility questions - - **Why**: Reduces support burden - -## Priority 5: Features & Enhancements - -These tasks add new capabilities to the module. - -### New Features - -- [x] **Add WhatIf/Confirm to all destructive operations** (Complete) - - All destructive functions declare `SupportsShouldProcess=$True` - - `$PSCmdlet.ShouldProcess()` checks before all destructive actions - -- [ ] **Implement progress indicators** - - [ ] Add Write-Progress to long-running operations (Install, Uninstall, Update) - - [ ] Show download progress for installers - - [ ] Display installation steps - - **Why**: Better user experience during long operations - -- [ ] **Add parallel server testing** - - [ ] Test multiple servers simultaneously - - [ ] Return fastest responding server - - **Why**: Improves installation speed - -### Integration - -- [x] **Add CI/CD pipeline** (Complete) - - GitHub Actions workflow: smoke test → build → publish - - Prerelease publish from `develop` branch - - Stable publish from `main` branch - - PSGallery environment gating - -- [x] **Create integration tests** (Complete) - - 112-test live suite with full install → exercise → uninstall lifecycle - - Three-phase testing: fresh install, restore/reinstall, idempotency check - - All 30 functions and 32 aliases exercised against real Automate server - -## Completed Tasks - -Track completed items here for historical reference. - -### 2025-11-03 - -- [x] Create CLAUDE.md with comprehensive codebase context -- [x] Create TODO.md with prioritized improvement tasks -- [x] Create blog posts highlighting the module and use cases - - [BLOG-Introduction.md](BLOG-Introduction.md) - Module introduction and overview - - [BLOG-TroubleshootingGuide.md](BLOG-TroubleshootingGuide.md) - Troubleshooting guide - - [BLOG-MassDeployment.md](BLOG-MassDeployment.md) - Mass deployment strategies - - [BLOG-UseCases.md](BLOG-UseCases.md) - 10 real-world use cases - -### 2025-11-04 - -- [x] Complete function documentation for `Get-CWAALogLevel`, `New-CWAABackup`, `Uninstall-CWAA` - -### Development Branch (Current) - -- [x] **Health check and auto-remediation system** - - `Test-CWAAHealth` — read-only health assessment - - `Repair-CWAA` — escalating remediation (restart → reinstall → fresh install) - - `Register-CWAAHealthCheckTask` / `Unregister-CWAAHealthCheckTask` — scheduled task management -- [x] **Server connectivity testing** — `Test-CWAAServerConnectivity` with auto-discovery and `-Quiet` flag -- [x] **Windows Event Log integration** — `Write-CWAAEventLog` with categorized event IDs (1000-4039) -- [x] **Lazy networking initialization** — `Initialize-CWAANetworking` with graduated SSL trust -- [x] **Installer cleanup utility** — `Clear-CWAAInstallerArtifacts` -- [x] **Credential redaction** — `Get-CWAARedactedValue` (SHA256 hash prefix) -- [x] **CONTRIBUTING.md** — comprehensive contributor guide -- [x] **GitHub Actions CI/CD** — smoke test, build artifact, prerelease/stable publish -- [x] **Comprehensive test suite** — 377+ tests across 4 files (structure, mocked, cross-version, live) -- [x] **Variable naming cleanup** — all cryptic names replaced with descriptive names -- [x] **Error handling standardization** — consistent Try-Catch-Finally with context -- [x] **Module version bump** — `0.1.4.0` → `1.0.0` -- [x] **WhatIf/Confirm on all destructive operations** -- [x] **PowerShell Core compatibility** — validated on PS 5.1 and 7+ -- [x] **Debug/logging overhaul** — Write-Debug throughout + Windows Event Log - -### Phase 4 Production Hardening (2026-01-31) - -- [x] **PSScriptAnalyzer configuration** — `.PSScriptAnalyzerSettings.psd1` with 6 documented suppressions, all issues fixed -- [x] **Input validation hardening** — `ValidateScript` on mandatory Server params, `ValidateRange` on LocationID, `ValidatePattern` on TaskName, LocationID type fix -- [x] **Inline comments** — TrayPort selection logic, server version detection thresholds, regex breakdown in Initialize-CWAA -- [x] **Initialize-CWAA.md doc** — replaced PlatyPS placeholder with real documentation -- [x] **Expanded examples** — 5 new example scripts (health check, proxy, troubleshooting, GPO deployment, install with health check) -- [x] **Expanded mocked tests** — 13 functions added, all 30 functions now have mocked tests (377+ total tests) -- [x] **Security documentation** — SSL graduation strategy, TripleDES usage, credential redaction, SecureString handling documented in CLAUDE.md -- [x] **Full verification** — 377 tests pass, PSScriptAnalyzer clean, single-file build (4465 lines, 236.5 KB), docs regenerated - -### Phase 5 Code Quality & Documentation (2026-01-31) - -- [x] **Architecture diagrams** — [Docs/Architecture.md](Docs/Architecture.md) with 4 Mermaid diagrams (module init, install workflow, health check escalation, registry/file interaction map) -- [x] **Refactored duplicate code** — 3 new private helpers: `Resolve-CWAAServer` (~300 lines deduplicated), `Test-CWAADownloadIntegrity` (~18 lines), `Remove-CWAAFolderRecursive` (~6 lines) -- [x] **Updated callers** — Install-CWAA, Uninstall-CWAA, Update-CWAA refactored to use helpers -- [x] **EditorConfig** — `.editorconfig` with PS 4-space, YAML/JSON 2-space, CRLF, UTF-8 -- [x] **CHANGELOG.md** — Keep a Changelog format, v1.0.0-alpha001 and v0.1.4.0 entries -- [x] **Versioning docs** — CONTRIBUTING.md updated with semver bump criteria and prerelease tag progression -- [x] **Version validation** — `SingleFileBuild.ps1` checks manifest version vs CHANGELOG latest entry -- [x] **New mocked tests** — 18 tests for private helpers (392 total tests, up from 377) -- [x] **Full verification** — 392 tests pass, PSScriptAnalyzer clean, single-file build (4552 lines, 235 KB), docs regenerated - -### Documentation Restructure (2026-01-31) - -- [x] **Separated generated vs hand-written docs** — clear folder structure distinguishing auto-generated reference from hand-written guides - - `Docs/` — hand-written documentation (Architecture.md) - - `Docs/Help/` — auto-generated function reference (26 function docs + module overview, generated by PlatyPS) - - `Docs/Help/Private/` — private function reference (Initialize-CWAA.md) - - `.blog/` — gitignored blog drafts (Introduction, UseCases, MassDeployment, TroubleshootingGuide) -- [x] **Updated Build-Documentation.ps1** — default output path now `Docs\Help` -- [x] **Updated cross-references** — README.md, CLAUDE.md, CONTRIBUTING.md paths updated to `Docs/Help/` -- [x] **Restructured README.md** — new "Documentation" section with distinct "Guides" (hand-written) and "Function Reference (Auto-Generated)" subsections -- [x] **Added documentation structure tests** — 11 new Pester tests validating folder layout, function doc coverage, MAML help, and build script config (403 total tests, up from 392) -- [x] **Full verification** — 403 tests pass, PSScriptAnalyzer clean - -### Pipeline Support (2026-02-01) - -- [x] **Pipeline attribute audit** — reviewed all 30 functions, 14 have pipeline attributes, 16 are read-only/no-param (not applicable) -- [x] **Fixed Begin/Process issues** — moved `$ServerPassword` escaping from Begin to Process in `Install-CWAA`, removed misleading `ValueFromPipelineByPropertyName` from `Uninstall-CWAA` `$Backup` switch -- [x] **New pipeline attributes** — `Set-CWAALogLevel` Level (`ValueFromPipeline`), `Register-CWAAHealthCheckTask` Server/LocationID (`ValueFromPipelineByPropertyName`) -- [x] **Pipeline examples in help** — 7 functions received new `.EXAMPLE` blocks showing pipeline usage -- [x] **Pipeline tests** — 6 new tests covering value pipeline, array pipeline, and property-based pipeline patterns (417 total tests, up from 403 + 8 structure-related) -- [x] **Updated PipelineUsage.ps1** — 4 new examples (13-16): Test-CWAAServerConnectivity, Test-CWAAPort, Set-CWAALogLevel, Register-CWAAHealthCheckTask -- [x] **Full verification** — 417 tests pass, PSScriptAnalyzer clean, single-file build (4684 lines, 244.5 KB), docs regenerated - ---- - -## How to Use This TODO List - -### For AI Assistants - -When working on this codebase: - -1. **Check Priority 1 tasks first** — these close remaining gaps -2. **Run tests after changes** — `Invoke-Pester Tests\ -ExcludeTag 'Live'` -3. **Run analyzer after changes** — `Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning` -4. **Rebuild after changes** — `Build\SingleFileBuild.ps1` and `Build\Build-Documentation.ps1` - -### For Contributors - -1. Pick tasks that match your skill level -2. Reference [CLAUDE.md](CLAUDE.md) for codebase context and conventions -3. Reference [CONTRIBUTING.md](CONTRIBUTING.md) for workflow and standards -4. Create an issue before starting major work -5. Submit small, focused PRs rather than large changes -6. Update this TODO.md as you complete tasks - -### For Maintainers - -1. Review and update priorities quarterly -2. Move completed tasks to "Completed Tasks" section -3. Add new tasks as issues are discovered -4. Link to GitHub issues where applicable - ---- - -**Last Updated**: 2026-02-01 (Pipeline Support) -**Module Version**: 1.0.0-alpha001 diff --git a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 index 0b5ec19..adde414 100644 --- a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 @@ -7,8 +7,13 @@ .DESCRIPTION Verifies that the module loads and core functions work correctly under every PowerShell version available on the test machine. Each version is tested by - spawning a child process (powershell.exe for 5.1, pwsh.exe for 7+) that - imports the module and returns structured results as JSON. + spawning a child process (powershell.exe for 5.1, pwsh.exe for 7+). + + Tests three loading methods per PowerShell version: + - Module Import: Import-Module from .psd1 manifest (PSGallery install path) + - SingleFile Dot-Source: . .\ConnectWiseAutomateAgent.ps1 (local file execution) + - SingleFile Invoke-Expression: Get-Content | IEX (web download path — the primary + method for systems without gallery access: Invoke-RestMethod | IEX) The module targets PowerShell 3.0+ but these tests exercise whichever versions are installed. Versions not present are skipped automatically. @@ -42,30 +47,70 @@ BeforeDiscovery { if ((Test-Path $ps7preview) -and $ps7preview -ne $ps7) { $script:PSVersions += @{ Name = 'PowerShell 7 preview'; Exe = $ps7preview } } + + # Check if single-file build exists (needed for SingleFile contexts) + $repoRoot = Split-Path -Parent $PSScriptRoot + $script:SingleFileExists = Test-Path (Join-Path $repoRoot 'ConnectWiseAutomateAgent.ps1') } BeforeAll { + $ModuleName = 'ConnectWiseAutomateAgent' $ModuleRoot = Split-Path -Parent $PSScriptRoot - $script:ModulePsd1 = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1' + $script:ModulePsd1 = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" + $script:SingleFilePath = Join-Path $ModuleRoot "$ModuleName.ps1" + + # Helper: Run a verification script in a child process and parse JSON output. + # Defined inside BeforeAll so Pester 5 scoping makes it available to test blocks. + function script:Invoke-ChildProcessTest { + param( + [string]$Executable, + [string]$Script + ) + + $rawOutput = & $Executable -NoProfile -NonInteractive -Command $Script 2>&1 + + # Extract the JSON object (compressed JSON is a single line starting with {) + $allOutput = ($rawOutput | Out-String).Trim() + $jsonLine = ($allOutput -split "`n" | Where-Object { $_.Trim() -match '^\{' } | Select-Object -Last 1) + + if ($jsonLine) { + return ($jsonLine.Trim() | ConvertFrom-Json) + } + else { + return [PSCustomObject]@{ + Success = $false + LoadMethod = 'Unknown' + ModuleLoaded = $false + ImportError = "No JSON in child output: $allOutput" + PSVersion = 'Unknown' + PSEdition = 'Unknown' + FunctionCount = 0 + AliasCount = 0 + Functions = @() + Aliases = @() + EncryptDecrypt = $false + TestErrors = @() + } + } + } } # ============================================================================= -# Cross-Version Module Loading +# Cross-Version Module Import (PSGallery path) # ============================================================================= -Describe 'Cross-Version Compatibility' { +Describe 'Cross-Version Compatibility - Module Import' { - Context '' -ForEach $script:PSVersions { + Context ' - Module Import' -ForEach $script:PSVersions { BeforeAll { $currentExe = $Exe $modulePath = $script:ModulePsd1 - # Build the verification script as a here-string with the module path baked in. - # This script runs inside the child process, imports the module, and returns JSON. $verifyScript = @" `$ErrorActionPreference = 'Stop' `$results = [ordered]@{ Success = `$false + LoadMethod = 'Module' PSVersion = `$PSVersionTable.PSVersion.ToString() PSEdition = `$PSVersionTable.PSEdition ModuleLoaded = `$false @@ -112,32 +157,7 @@ Catch { `$results | ConvertTo-Json -Depth 3 -Compress "@ - # Spawn the child process and capture output - $rawOutput = & $currentExe -NoProfile -NonInteractive -Command $verifyScript 2>&1 - - # The output may contain warnings/progress before the JSON line. - # Extract the JSON object (the compressed JSON will be a single line starting with {). - $allOutput = ($rawOutput | Out-String).Trim() - $jsonLine = ($allOutput -split "`n" | Where-Object { $_.Trim() -match '^\{' } | Select-Object -Last 1) - - if ($jsonLine) { - $script:Result = $jsonLine.Trim() | ConvertFrom-Json - } - else { - $script:Result = [PSCustomObject]@{ - Success = $false - ModuleLoaded = $false - ImportError = "No JSON in child output: $allOutput" - PSVersion = 'Unknown' - PSEdition = 'Unknown' - FunctionCount = 0 - AliasCount = 0 - Functions = @() - Aliases = @() - EncryptDecrypt = $false - TestErrors = @() - } - } + $script:Result = Invoke-ChildProcessTest -Executable $currentExe -Script $verifyScript } It 'reports its PowerShell version' { @@ -202,6 +222,225 @@ Catch { } } +# ============================================================================= +# Cross-Version SingleFile Dot-Source (local file execution) +# ============================================================================= +Describe 'Cross-Version Compatibility - SingleFile Dot-Source' -Skip:(-not $script:SingleFileExists) { + + Context ' - Dot-Source' -ForEach $script:PSVersions { + + BeforeAll { + $currentExe = $Exe + $singleFile = $script:SingleFilePath + + # Dot-source the single file in a child process — this is how users run + # the file locally when they don't have gallery access. + $verifyScript = @" +`$ErrorActionPreference = 'Stop' +`$results = [ordered]@{ + Success = `$false + LoadMethod = 'DotSource' + PSVersion = `$PSVersionTable.PSVersion.ToString() + PSEdition = `$PSVersionTable.PSEdition + FunctionCount = 0 + AliasCount = 0 + Functions = @() + Aliases = @() + EncryptDecrypt = `$false + ImportError = '' + TestErrors = @() +} +if (-not `$results.PSEdition) { `$results.PSEdition = 'Desktop' } + +Try { + . '$($singleFile -replace "'","''")' + `$results.Success = `$true + + # In dot-source mode, functions are in the session scope — find CWAA functions + `$cwaaFuncs = @(Get-Command -CommandType Function | Where-Object { + `$_.Name -match '^(Get|Set|New|Install|Uninstall|Start|Stop|Restart|Test|Invoke|ConvertTo|ConvertFrom|Hide|Show|Rename|Redo|Reset|Update|Register|Unregister|Repair)-CWAA' + }) + `$results.Functions = @(`$cwaaFuncs.Name | Sort-Object) + `$results.FunctionCount = `$results.Functions.Count + + # Find LT aliases + `$ltAliases = @(Get-Alias -ErrorAction SilentlyContinue | Where-Object { + `$_.Name -match '-LT' -or `$_.Name -match 'Reinstall-' + }) + `$results.Aliases = @(`$ltAliases.Name | Sort-Object) + `$results.AliasCount = `$results.Aliases.Count + + Try { + `$encoded = ConvertTo-CWAASecurity -InputString 'CrossVersionTest' + `$decoded = ConvertFrom-CWAASecurity -InputString `$encoded + `$results.EncryptDecrypt = (`$decoded -eq 'CrossVersionTest') + if (-not `$results.EncryptDecrypt) { + `$results.TestErrors += "Round-trip mismatch: got '`$decoded'" + } + } + Catch { + `$results.TestErrors += "Crypto error: `$(`$_.Exception.Message)" + } +} +Catch { + `$results.ImportError = `$_.Exception.Message +} + +`$results | ConvertTo-Json -Depth 3 -Compress +"@ + + $script:Result = Invoke-ChildProcessTest -Executable $currentExe -Script $verifyScript + } + + It 'reports its PowerShell version' { + $script:Result.PSVersion | Should -Not -BeNullOrEmpty + } + + It 'loads the single file without errors' { + $script:Result.Success | Should -BeTrue -Because $script:Result.ImportError + } + + It 'makes all 30 public functions available' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + $script:Result.FunctionCount | Should -BeGreaterOrEqual 30 + } + + It 'has the ConvertTo-CWAASecurity function' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + $script:Result.Functions | Should -Contain 'ConvertTo-CWAASecurity' + } + + It 'has the Install-CWAA function' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + $script:Result.Functions | Should -Contain 'Install-CWAA' + } + + It 'has legacy aliases available' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + $script:Result.Aliases | Should -Contain 'Install-LTService' + $script:Result.Aliases | Should -Contain 'ConvertTo-LTSecurity' + } + + It 'encrypt/decrypt round-trip succeeds' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + $errorDetail = if ($script:Result.TestErrors) { $script:Result.TestErrors -join '; ' } else { 'no errors' } + $script:Result.EncryptDecrypt | Should -BeTrue -Because $errorDetail + } + } +} + +# ============================================================================= +# Cross-Version SingleFile Invoke-Expression (web download path) +# This is the primary method for systems without gallery access: +# Invoke-RestMethod 'https://.../ConnectWiseAutomateAgent.ps1' | Invoke-Expression +# ============================================================================= +Describe 'Cross-Version Compatibility - SingleFile Invoke-Expression' -Skip:(-not $script:SingleFileExists) { + + Context ' - Invoke-Expression' -ForEach $script:PSVersions { + + BeforeAll { + $currentExe = $Exe + $singleFile = $script:SingleFilePath + + # Simulate the IEX web-download path: Get-Content | Invoke-Expression + # This is different from dot-sourcing — $MyInvocation has no file context, + # matching how Invoke-RestMethod | Invoke-Expression behaves in production. + $verifyScript = @" +`$ErrorActionPreference = 'Stop' +`$results = [ordered]@{ + Success = `$false + LoadMethod = 'InvokeExpression' + PSVersion = `$PSVersionTable.PSVersion.ToString() + PSEdition = `$PSVersionTable.PSEdition + FunctionCount = 0 + AliasCount = 0 + Functions = @() + Aliases = @() + EncryptDecrypt = `$false + ImportError = '' + TestErrors = @() +} +if (-not `$results.PSEdition) { `$results.PSEdition = 'Desktop' } + +Try { + # Read file content as string and execute via IEX — simulates web download + `$scriptContent = Get-Content '$($singleFile -replace "'","''")' -Raw + Invoke-Expression `$scriptContent + `$results.Success = `$true + + # In IEX mode, functions are in the session scope — same as dot-source + `$cwaaFuncs = @(Get-Command -CommandType Function | Where-Object { + `$_.Name -match '^(Get|Set|New|Install|Uninstall|Start|Stop|Restart|Test|Invoke|ConvertTo|ConvertFrom|Hide|Show|Rename|Redo|Reset|Update|Register|Unregister|Repair)-CWAA' + }) + `$results.Functions = @(`$cwaaFuncs.Name | Sort-Object) + `$results.FunctionCount = `$results.Functions.Count + + # Find LT aliases + `$ltAliases = @(Get-Alias -ErrorAction SilentlyContinue | Where-Object { + `$_.Name -match '-LT' -or `$_.Name -match 'Reinstall-' + }) + `$results.Aliases = @(`$ltAliases.Name | Sort-Object) + `$results.AliasCount = `$results.Aliases.Count + + Try { + `$encoded = ConvertTo-CWAASecurity -InputString 'CrossVersionTest' + `$decoded = ConvertFrom-CWAASecurity -InputString `$encoded + `$results.EncryptDecrypt = (`$decoded -eq 'CrossVersionTest') + if (-not `$results.EncryptDecrypt) { + `$results.TestErrors += "Round-trip mismatch: got '`$decoded'" + } + } + Catch { + `$results.TestErrors += "Crypto error: `$(`$_.Exception.Message)" + } +} +Catch { + `$results.ImportError = `$_.Exception.Message +} + +`$results | ConvertTo-Json -Depth 3 -Compress +"@ + + $script:Result = Invoke-ChildProcessTest -Executable $currentExe -Script $verifyScript + } + + It 'reports its PowerShell version' { + $script:Result.PSVersion | Should -Not -BeNullOrEmpty + } + + It 'executes via Invoke-Expression without errors' { + $script:Result.Success | Should -BeTrue -Because $script:Result.ImportError + } + + It 'makes all 30 public functions available' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } + $script:Result.FunctionCount | Should -BeGreaterOrEqual 30 + } + + It 'has the ConvertTo-CWAASecurity function' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } + $script:Result.Functions | Should -Contain 'ConvertTo-CWAASecurity' + } + + It 'has the Install-CWAA function' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } + $script:Result.Functions | Should -Contain 'Install-CWAA' + } + + It 'has legacy aliases available' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } + $script:Result.Aliases | Should -Contain 'Install-LTService' + $script:Result.Aliases | Should -Contain 'ConvertTo-LTSecurity' + } + + It 'encrypt/decrypt round-trip succeeds' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } + $errorDetail = if ($script:Result.TestErrors) { $script:Result.TestErrors -join '; ' } else { 'no errors' } + $script:Result.EncryptDecrypt | Should -BeTrue -Because $errorDetail + } + } +} + # ============================================================================= # Version Coverage Summary # ============================================================================= @@ -216,4 +455,8 @@ Describe 'Version Coverage' { $ps7 = Get-Command 'pwsh.exe' -ErrorAction SilentlyContinue $ps7 | Should -Not -BeNullOrEmpty -Because 'PowerShell 7 should be installed for cross-version testing' } + + It 'single-file build exists for SingleFile tests' { + $script:SingleFilePath | Should -Exist -Because 'Run Build\SingleFileBuild.ps1 to create the single-file distribution' + } } diff --git a/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 new file mode 100644 index 0000000..2f38625 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 @@ -0,0 +1,200 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Documentation structure and build script tests. + +.DESCRIPTION + Tests the documentation folder layout, auto-generated function reference, + MAML help, and build script functionality including Extract-ChangelogEntry. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Documentation.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Documentation Structure Tests +# ============================================================================= +Describe 'Documentation Structure' { + + BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $DocsRoot = Join-Path $ModuleRoot 'Docs' + $DocsHelp = Join-Path $DocsRoot 'Help' + $BuildScript = Join-Path $ModuleRoot 'Build\Build-Documentation.ps1' + # Use the known list of public functions for doc checks. In single-file mode, + # ExportedFunctions includes private helpers that don't have docs in Docs/Help/. + $PublicFunctions = @( + 'Hide-CWAAAddRemove', 'Rename-CWAAAddRemove', 'Show-CWAAAddRemove', + 'Install-CWAA', 'Redo-CWAA', 'Uninstall-CWAA', 'Update-CWAA', + 'Get-CWAAError', 'Get-CWAALogLevel', 'Get-CWAAProbeError', 'Set-CWAALogLevel', + 'Get-CWAAProxy', 'Set-CWAAProxy', + 'Restart-CWAA', 'Start-CWAA', 'Stop-CWAA', 'Repair-CWAA', + 'Test-CWAAHealth', 'Register-CWAAHealthCheckTask', 'Unregister-CWAAHealthCheckTask', + 'Get-CWAAInfo', 'Get-CWAAInfoBackup', 'Get-CWAASettings', 'New-CWAABackup', 'Reset-CWAA', + 'ConvertFrom-CWAASecurity', 'ConvertTo-CWAASecurity', + 'Invoke-CWAACommand', 'Test-CWAAPort', 'Test-CWAAServerConnectivity' + ) + } + + Context 'Folder layout' { + It 'has a Docs directory' { + $DocsRoot | Should -Exist + } + + It 'has a Docs/Help directory for auto-generated reference docs' { + $DocsHelp | Should -Exist + } + + It 'has no auto-generated function docs in Docs root' { + $handWrittenGuides = @( + 'Architecture.md', + 'CommonParameters.md', + 'FAQ.md', + 'Migration.md', + 'Security.md', + 'Troubleshooting.md' + ) + $rootMdFiles = Get-ChildItem $DocsRoot -Filter '*.md' -File | + Where-Object { $_.Name -notin $handWrittenGuides } + $rootMdFiles | Should -HaveCount 0 -Because 'function docs belong in Docs/Help/, only hand-written guides in Docs/' + } + + It 'has Architecture.md in Docs root (hand-written)' { + Join-Path $DocsRoot 'Architecture.md' | Should -Exist + } + } + + Context 'Auto-generated function reference' { + It 'has a module overview page' { + Join-Path $DocsHelp 'ConnectWiseAutomateAgent.md' | Should -Exist + } + + It 'has a markdown doc for each public function' { + foreach ($function in $PublicFunctions) { + $docPath = Join-Path $DocsHelp "$function.md" + $docPath | Should -Exist -Because "$function should have a corresponding doc in Docs/Help/" + } + } + + It 'each function doc has PlatyPS YAML frontmatter' { + foreach ($function in $PublicFunctions) { + $docPath = Join-Path $DocsHelp "$function.md" + if (Test-Path $docPath) { + $firstLine = (Get-Content $docPath -TotalCount 1) + $firstLine | Should -Be '---' -Because "$function.md should start with YAML frontmatter" + } + } + } + } + + Context 'MAML help' { + It 'has a compiled MAML XML help file' { + $mamlPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\ConnectWiseAutomateAgent-help.xml' + $mamlPath | Should -Exist + } + + It 'has an about help topic' { + $aboutPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\about_ConnectWiseAutomateAgent.help.txt' + $aboutPath | Should -Exist + } + } + + Context 'Build script' { + It 'Build-Documentation.ps1 exists' { + $BuildScript | Should -Exist + } + + It 'Build-Documentation.ps1 defaults output to Docs/Help' { + $scriptContent = Get-Content $BuildScript -Raw + $scriptContent | Should -Match "Join-Path.*'Help'" -Because 'default output path should target Docs/Help' + } + + It 'Extract-ChangelogEntry.ps1 exists' { + $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' + $extractScript | Should -Exist + } + + It 'Extract-ChangelogEntry.ps1 has comment-based help' { + $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' + $scriptContent = Get-Content $extractScript -Raw + $scriptContent | Should -Match '\.SYNOPSIS' -Because 'build scripts should have comment-based help' + } + + It 'Extract-ChangelogEntry.ps1 requires -Version parameter' { + $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' + $scriptContent = Get-Content $extractScript -Raw + $scriptContent | Should -Match '\[Parameter\(Mandatory' -Because 'Version should be mandatory' + } + } +} + +# ============================================================================= +# Extract-ChangelogEntry Functional Tests +# ============================================================================= +Describe 'Extract-ChangelogEntry.ps1' { + + BeforeAll { + $ModuleRoot = Split-Path -Parent $PSScriptRoot + $ExtractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' + } + + Context 'Extracts known versions from CHANGELOG.md' { + It 'extracts the 1.0.0-alpha001 entry' { + $result = & $ExtractScript -Version '1.0.0-alpha001' + $joined = $result -join "`n" + $joined | Should -Match 'Health check and auto-remediation system' -Because 'the alpha001 entry contains health check content' + $joined | Should -Not -Match '## \[0\.1\.4\.0\]' -Because 'extraction should stop before the next version heading' + } + + It 'extracts the 0.1.4.0 entry' { + $result = & $ExtractScript -Version '0.1.4.0' + $joined = $result -join "`n" + $joined | Should -Match 'Initial public release' -Because 'the 0.1.4.0 entry describes the initial release' + $joined | Should -Not -Match '1\.0\.0-alpha001' -Because 'extraction should not include content from other versions' + } + + It 'preserves nested ### headings within the entry' { + $result = & $ExtractScript -Version '1.0.0-alpha001' + $joined = $result -join "`n" + $joined | Should -Match '### Added' -Because 'subsection headings should be preserved' + $joined | Should -Match '### Changed' -Because 'subsection headings should be preserved' + } + } + + Context 'Error handling' { + It 'fails for a nonexistent version' { + { & $ExtractScript -Version '9.9.9' -ErrorAction Stop } | Should -Throw + } + + It 'fails for a nonexistent changelog path' { + { & $ExtractScript -Version '1.0.0' -ChangelogPath 'C:\nonexistent\CHANGELOG.md' -ErrorAction Stop } | Should -Throw + } + } + + Context 'OutputPath parameter' { + It 'writes to a file when -OutputPath is specified' { + $tempFile = Join-Path ([System.IO.Path]::GetTempPath()) "changelog-test-$(Get-Random).md" + try { + & $ExtractScript -Version '1.0.0-alpha001' -OutputPath $tempFile + $tempFile | Should -Exist + $content = Get-Content $tempFile -Raw + $content | Should -Match 'Health check and auto-remediation system' + } + finally { + if (Test-Path $tempFile) { Remove-Item $tempFile -Force } + } + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 index 8d27d0a..c8e5774 100644 --- a/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Live.Tests.ps1 @@ -33,12 +33,7 @@ #> BeforeAll { - $ModuleName = 'ConnectWiseAutomateAgent' - $ModuleRoot = Split-Path -Parent $PSScriptRoot - $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" - - Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ModulePath -Force -ErrorAction Stop + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" # ---- State variables ---- $script:AgentInstalled = $false diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1 new file mode 100644 index 0000000..f42d7e6 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1 @@ -0,0 +1,374 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for command and settings functions. + +.DESCRIPTION + Tests Invoke-CWAACommand, Hide-CWAAAddRemove, Show-CWAAAddRemove, + Rename-CWAAAddRemove, and Set-CWAALogLevel using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.Commands.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Tier 2: Functions with Testable Logic +# ============================================================================= + +Describe 'Invoke-CWAACommand' { + + It 'warns when LTService is not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not found' + } + } + + It 'warns when LTService is not running' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not running' + } + } + + It 'sends command when service is running' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command 'Send Status' -Confirm:$false + } + $result | Should -Match "Sent Command 'Send Status'" + } + + It 'sends multiple commands' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command 'Send Status', 'Send Inventory' -Confirm:$false + } + ($result | Measure-Object).Count | Should -Be 2 + } + + It 'accepts all 18 valid commands' -ForEach @( + @{ Cmd = 'Update Schedule' } + @{ Cmd = 'Send Inventory' } + @{ Cmd = 'Send Drives' } + @{ Cmd = 'Send Processes' } + @{ Cmd = 'Send Spyware List' } + @{ Cmd = 'Send Apps' } + @{ Cmd = 'Send Events' } + @{ Cmd = 'Send Printers' } + @{ Cmd = 'Send Status' } + @{ Cmd = 'Send Screen' } + @{ Cmd = 'Send Services' } + @{ Cmd = 'Analyze Network' } + @{ Cmd = 'Write Last Contact Date' } + @{ Cmd = 'Kill VNC' } + @{ Cmd = 'Kill Trays' } + @{ Cmd = 'Send Patch Reboot' } + @{ Cmd = 'Run App Care Update' } + @{ Cmd = 'Start App Care Daytime Patching' } + ) { + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $Cmd { + param($CommandName) + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Invoke-CWAACommand -Command $CommandName -Confirm:$false + } + $result | Should -Match "Sent Command '$Cmd'" + } + + It 'accepts pipeline input' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + 'Send Status' | Invoke-CWAACommand -Confirm:$false + } + $result | Should -Match 'Send Status' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Hide-CWAAAddRemove' { + + It 'warns when no registry keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be hidden' + } + } + + It 'sets SystemComponent to 1 when uninstall key exists' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Hide-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 1 } + } + } + + It 'skips write when SystemComponent is already 1' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Hide-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 0 -Scope It + } + } + + It 'renames HiddenProductName to ProductName when ProductName is missing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'ProductName' } + Mock Rename-ItemProperty {} + + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null + + Should -Invoke Rename-ItemProperty -Times 1 -Scope It + } + } + + It 'removes unused HiddenProductName when ProductName already exists' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { [PSCustomObject]@{ ProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'ProductName' } + Mock Remove-ItemProperty {} + + Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null + + Should -Invoke Remove-ItemProperty -Times 1 -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Show-CWAAAddRemove' { + + It 'warns when no registry keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } + Show-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be visible' + } + } + + It 'sets SystemComponent to 0 when hidden' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 0 } + } + } + + It 'skips write when SystemComponent is already 0' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Times 0 -Scope It + } + } + + It 'outputs success message when entries changed' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } + Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } + Mock Get-Item { + $mockKey = New-Object PSObject + $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } + return $mockKey + } + Mock Set-ItemProperty {} + Mock Get-ItemProperty { return $null } + + Show-CWAAAddRemove -Confirm:$false + } + $result | Should -Match 'visible' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Rename-CWAAAddRemove' { + + It 'sets DisplayName when key is found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'My Agent' } + } + } + + It 'sets both DisplayName and Publisher when PublisherName provided' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { [PSCustomObject]@{ Publisher = 'LabTech' } } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -PublisherName 'My Company' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' } + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Publisher' -and $Value -eq 'My Company' } + } + } + + It 'warns when no matching keys are found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { return $null } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false 3>&1 | Should -Match 'not found.*Name was not changed' + } + } + + It 'updates HiddenProductName when DisplayName is absent' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'HiddenProductName' -and $Value -eq 'My Agent' } + } + } + + It 'outputs success message with new name' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + Rename-CWAAAddRemove -Name 'Custom Agent' -Confirm:$false + } + $result | Should -Match 'Custom Agent' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Set-CWAALogLevel' { + + It 'sets Debuging to 1 for Normal level' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1 } + } + } + + It 'sets Debuging to 1000 for Verbose level' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Verbose' } + + Set-CWAALogLevel -Level Verbose -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } + } + } + + It 'defaults to Normal when Level is not specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Value -eq 1 } + } + } + + It 'calls Stop-CWAA before and Start-CWAA after the registry write' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + + Should -Invoke Stop-CWAA -Times 1 -Scope It + Should -Invoke Start-CWAA -Times 1 -Scope It + } + } + + It 'calls Get-CWAALogLevel at the end to report' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Normal' } + + Set-CWAALogLevel -Level Normal -Confirm:$false + } + $result | Should -Be 'Current logging level: Normal' + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1 new file mode 100644 index 0000000..4183e88 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1 @@ -0,0 +1,479 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for cross-cutting features. + +.DESCRIPTION + Tests pipeline support, PSCredential parameters, module constants, + Test-CWAAServiceExists, and Assert-CWAANotProbeAgent using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.CrossCutting.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Pipeline Support Tests +# ============================================================================= + +Describe 'Pipeline Support' { + + Context 'ConvertTo-CWAASecurity pipeline input' { + + It 'accepts a single string from pipeline' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + 'TestValue' | ConvertTo-CWAASecurity + } + $result | Should -Not -BeNullOrEmpty + } + + It 'accepts multiple strings from pipeline' { + $results = InModuleScope 'ConnectWiseAutomateAgent' { + 'Value1', 'Value2', 'Value3' | ConvertTo-CWAASecurity + } + $results | Should -HaveCount 3 + $results[0] | Should -Not -Be $results[1] + } + + It 'round-trips through pipeline with ConvertFrom-CWAASecurity' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + 'PipelineRoundTrip' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity + } + $result | Should -Be 'PipelineRoundTrip' + } + + It 'round-trips multiple values through pipeline' { + $results = InModuleScope 'ConnectWiseAutomateAgent' { + 'Alpha', 'Bravo', 'Charlie' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity + } + $results | Should -HaveCount 3 + $results[0] | Should -Be 'Alpha' + $results[1] | Should -Be 'Bravo' + $results[2] | Should -Be 'Charlie' + } + } + + Context 'Rename-CWAAAddRemove pipeline input' { + + It 'accepts Name from pipeline' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } + Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } + Mock Set-ItemProperty {} + + 'Piped Agent Name' | Rename-CWAAAddRemove -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'Piped Agent Name' } + } + } + } + + Context 'Repair-CWAA Server ValueFromPipelineByPropertyName' { + + It 'accepts Server and LocationID from piped object' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Status = 'Running'; Name = 'LTService' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = 'https://automate.example.com' + LastSuccessStatus = (Get-Date).ToString() + HeartbeatLastSent = (Get-Date).ToString() + HeartbeatLastReceived = (Get-Date).ToString() + } + } + Mock Write-CWAAEventLog {} + Mock Get-CimInstance { return @() } + + # Pipe an object with Server and LocationID — bind via ValueFromPipelineByPropertyName + # InstallerToken is provided explicitly (it wouldn't come from Get-CWAAInfo output) + $inputObject = [PSCustomObject]@{ + Server = 'https://automate.example.com' + LocationID = 1 + } + $result = $inputObject | Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -WarningAction SilentlyContinue + $result | Should -Not -BeNullOrEmpty + $result.ActionTaken | Should -Be 'None' + } + } + } + + Context 'Invoke-CWAACommand multiple values from pipeline' { + + It 'processes multiple commands piped as an array' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + 'Send Inventory', 'Send Apps' | Invoke-CWAACommand -Confirm:$false + } + ($result | Measure-Object).Count | Should -Be 2 + $result[0] | Should -Match 'Send Inventory' + $result[1] | Should -Match 'Send Apps' + } + } + + Context 'Set-CWAALogLevel pipeline input' { + + It 'accepts Level from pipeline' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock Get-CWAALogLevel { 'Current logging level: Verbose' } + + 'Verbose' | Set-CWAALogLevel -Confirm:$false + + Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } + } + } + } + + Context 'Test-CWAAServerConnectivity property-based pipeline' { + + It 'accepts Server from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAServerConnectivity + } + $result.Available | Should -BeTrue + $result.Version | Should -Be '220.105' + } + } + + Context 'Test-CWAAHealth property-based pipeline' { + + It 'accepts Server from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.Healthy | Should -BeTrue + } + } + + Context 'Test-CWAAPort property-based pipeline' { + + It 'accepts Server and TrayPort from piped PSCustomObject' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Invoke-Expression { return $null } + function netstat { return @() } + + [PSCustomObject]@{ Server = 'automate.example.com'; TrayPort = 42000 } | Test-CWAAPort -Quiet + } + $result | Should -BeTrue + } + } + + Context 'Multi-server array pipeline binding' { + + It 'Test-CWAAHealth accepts Server as string[] from pipeline and matches correctly' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('primary.example.com', 'backup.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + # Pipe an object with Server as a multi-element array (matching Get-CWAAInfo output) + [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAHealth + } + $result.Healthy | Should -BeTrue + $result.ServerMatch | Should -BeTrue + } + + It 'Test-CWAAServerConnectivity accepts Server as string[] from pipeline' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAServerConnectivity + } + # Should return results for both servers + ($result | Measure-Object).Count | Should -Be 2 + } + + It 'Register-CWAAHealthCheckTask accepts Server as string[] and builds valid command' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { return $null } + Mock New-CWAABackup {} + + [PSCustomObject]@{ + Server = @('primary.example.com', 'backup.example.com') + LocationID = 42 + } | Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result | Should -Not -BeNullOrEmpty + $result.Created | Should -BeTrue + } + } +} + +# ============================================================================= +# Credential Hardening Tests +# ============================================================================= + +Describe 'PSCredential Parameter Support' { + + Context 'Install-CWAA Credential parameter' { + + It 'has a Credential parameter of type PSCredential' { + $cmd = Get-Command Install-CWAA + $param = $cmd.Parameters['Credential'] + $param | Should -Not -BeNullOrEmpty + $param.ParameterType.Name | Should -Be 'PSCredential' + } + + It 'Credential parameter is in the deployment parameter set' { + $cmd = Get-Command Install-CWAA + $param = $cmd.Parameters['Credential'] + $param.ParameterSets.Keys | Should -Contain 'deployment' + } + } + + Context 'Set-CWAAProxy ProxyCredential parameter' { + + It 'has a ProxyCredential parameter of type PSCredential' { + $cmd = Get-Command Set-CWAAProxy + $param = $cmd.Parameters['ProxyCredential'] + $param | Should -Not -BeNullOrEmpty + $param.ParameterType.Name | Should -Be 'PSCredential' + } + } +} + +# ============================================================================= +# Phase 1+2 Constants and Helpers +# ============================================================================= + +Describe 'Initialize-CWAA constants (Phase 1)' { + + Context 'version threshold constants' { + + It 'defines CWAAVersionZipInstaller' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionZipInstaller | Should -Be '240.331' + } + } + + It 'defines CWAAVersionAnonymousChange' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionAnonymousChange | Should -Be '110.374' + } + } + + It 'defines CWAAVersionVulnerabilityFix' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionVulnerabilityFix | Should -Be '200.197' + } + } + + It 'defines CWAAVersionUpdateMinimum' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAVersionUpdateMinimum | Should -Be '105.001' + } + } + } + + Context 'service and process name constants' { + + It 'defines CWAAAgentProcessNames with 3 entries' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAAgentProcessNames | Should -HaveCount 3 + $Script:CWAAAgentProcessNames | Should -Contain 'LTTray' + $Script:CWAAAgentProcessNames | Should -Contain 'LTSVC' + $Script:CWAAAgentProcessNames | Should -Contain 'LTSvcMon' + } + } + + It 'defines CWAAAllServiceNames with 3 entries including LabVNC' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAAllServiceNames | Should -HaveCount 3 + $Script:CWAAAllServiceNames | Should -Contain 'LTService' + $Script:CWAAAllServiceNames | Should -Contain 'LTSvcMon' + $Script:CWAAAllServiceNames | Should -Contain 'LabVNC' + } + } + } + + Context 'timeout constants' { + + It 'defines CWAAServiceWaitTimeoutSec as 60' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAAServiceWaitTimeoutSec | Should -Be 60 + } + } + + It 'defines CWAARedoSettleDelaySeconds as 20' { + InModuleScope 'ConnectWiseAutomateAgent' { + $Script:CWAARedoSettleDelaySeconds | Should -Be 20 + } + } + } +} + +Describe 'Test-CWAAServiceExists' { + + Context 'when services exist' { + + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + Test-CWAAServiceExists + } + $result | Should -Be $true + } + + It 'does not write an error' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -BeNullOrEmpty + } + } + } + + Context 'when services do not exist' { + + It 'returns $false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + Test-CWAAServiceExists + } + $result | Should -Be $false + } + + It 'does not write error without -WriteErrorOnMissing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $err = $null + $null = Test-CWAAServiceExists -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -BeNullOrEmpty + } + } + + It 'writes error with -WriteErrorOnMissing' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty + "$err" | Should -Match 'Services NOT Found' + } + } + + It 'writes WhatIf-prefixed error when WhatIfPreference is true' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service {} + $WhatIfPreference = $true + $err = $null + $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue + "$err" | Should -Match 'What If.*Services NOT Found' + } + } + } +} + +Describe 'Assert-CWAANotProbeAgent' { + + Context 'when ServiceInfo is null' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + { Assert-CWAANotProbeAgent -ServiceInfo $null -ActionName 'Test' } | Should -Not -Throw + } + } + } + + Context 'when agent is not a probe' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '0' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw + } + } + } + + Context 'when agent is a probe without -Force' { + + It 'throws with action name in message' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' + } + } | Should -Throw '*Probe Agent Detected*UnInstall Denied*' + } + + It 'uses Reset action name' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Reset' + } + } | Should -Throw '*Reset Denied*' + } + } + + Context 'when agent is a probe with -Force' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' -Force } | Should -Not -Throw + } + } + + It 'writes Forced output message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Probe = '1' } + Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Re-Install' -Force + } + $result | Should -Match 'Probe Agent Detected.*Re-Install Forced' + } + } + + Context 'when ServiceInfo has no Probe property' { + + It 'does not throw' { + InModuleScope 'ConnectWiseAutomateAgent' { + $info = [PSCustomObject]@{ Server = 'test.com' } + { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw + } + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1 new file mode 100644 index 0000000..dd775d2 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1 @@ -0,0 +1,496 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for data reader functions. + +.DESCRIPTION + Tests Get-CWAAInfo, Get-CWAASettings, Get-CWAAInfoBackup, Get-CWAALogLevel, + Get-CWAAError, and Get-CWAAProbeError using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.DataReaders.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Tier 1: Data Reader Functions +# ============================================================================= + +Describe 'Get-CWAAInfo' { + + Context 'when registry key does not exist' { + BeforeAll { + $script:result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable err -WhatIf:$false -Confirm:$false + $err + } + } + + It 'returns null' { + $result2 = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Get-CWAAInfo -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false + } + $result2 | Should -BeNullOrEmpty + } + + It 'writes an error about missing agent' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find information' + } + } + } + + Context 'when registry key exists with full data' { + It 'returns an object with expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '12345' + 'Server Address' = 'automate.example.com|backup.example.com|' + LocationID = '1' + BasePath = 'C:\Windows\LTSVC' + Version = '230.105' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result | Should -Not -BeNullOrEmpty + $result.ID | Should -Be '12345' + $result.LocationID | Should -Be '1' + } + + It 'parses pipe-delimited Server Address into array' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + 'Server Address' = 'srv1.example.com|srv2.example.com|' + BasePath = 'C:\Windows\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.Server | Should -HaveCount 2 + $result.Server | Should -Contain 'srv1.example.com' + $result.Server | Should -Contain 'srv2.example.com' + } + + It 'strips tildes from Server Address entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + 'Server Address' = '~automate.example.com~|' + BasePath = 'C:\Windows\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.Server | Should -Contain 'automate.example.com' + $result.Server | Should -Not -Contain '~automate.example.com~' + } + + It 'expands environment variables in BasePath' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + BasePath = '%windir%\LTSVC' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.BasePath | Should -Not -Match '%windir%' + $result.BasePath | Should -Match 'LTSVC' + } + + It 'excludes PS provider properties from output' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'should-be-excluded' + PSParentPath = 'should-be-excluded' + PSChildName = 'should-be-excluded' + PSDrive = 'should-be-excluded' + PSProvider = 'should-be-excluded' + } + } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $memberNames = ($result | Get-Member -MemberType NoteProperty).Name + $memberNames | Should -Not -Contain 'PSPath' + $memberNames | Should -Not -Contain 'PSParentPath' + $memberNames | Should -Not -Contain 'PSChildName' + } + } + + Context 'when BasePath is not in registry' { + It 'falls back to default install path when service key is missing' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } + Mock Test-Path { return $false } -ParameterFilter { $Path -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' } + Mock Get-ItemProperty { + [PSCustomObject]@{ ID = '1' } + } -ParameterFilter { $Path -and $Path -match 'LabTech' } + Get-CWAAInfo -WhatIf:$false -Confirm:$false + } + $result.BasePath | Should -Match 'LTSVC' + } + } + + Context 'when Get-ItemProperty throws' { + It 'writes an error and does not crash' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } + Mock Get-ItemProperty { throw 'Registry access denied' } + $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'problem reading' + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAASettings' { + + It 'writes error when settings key does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find LTSvc settings' + } + } + + It 'returns settings object when key exists' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + Mock Get-ItemProperty { + [PSCustomObject]@{ + Debuging = 1 + ServerAddress = 'automate.example.com' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAASettings + } + $result | Should -Not -BeNullOrEmpty + $result.Debuging | Should -Be 1 + } + + It 'writes error when Get-ItemProperty throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } + Mock Get-ItemProperty { throw 'Access denied' } + $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'problem reading' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAInfoBackup' { + + It 'writes error when backup registry does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + $null = Get-CWAAInfoBackup -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'New-CWAABackup' + } + } + + It 'returns backup object with expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '99999' + 'Server Address' = 'backup.example.com|' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result | Should -Not -BeNullOrEmpty + $result.ID | Should -Be '99999' + } + + It 'parses pipe-delimited Server Address' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + 'Server Address' = 'srv1.example.com|srv2.example.com|' + BasePath = 'C:\Windows\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result.Server | Should -HaveCount 2 + $result.Server[0] | Should -Be 'srv1.example.com' + $result.Server[1] | Should -Be 'srv2.example.com' + } + + It 'expands environment variables in BasePath' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + BasePath = '%windir%\LTSVC' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result.BasePath | Should -Not -Match '%windir%' + } + + It 'handles missing Server Address without crashing' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } + Mock Get-ItemProperty { + [PSCustomObject]@{ + ID = '1' + PSPath = 'fake' + PSParentPath = 'fake' + PSChildName = 'fake' + PSDrive = 'fake' + PSProvider = 'fake' + } + } + Get-CWAAInfoBackup + } + $result | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'Server' -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAALogLevel' { + + It 'returns Normal when Debuging is 1' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1 } } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Normal' + } + + It 'returns Verbose when Debuging is 1000' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1000 } } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Verbose' + } + + It 'returns Normal when Debuging is null (fresh install)' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{} } + Get-CWAALogLevel + } + $result | Should -Be 'Current logging level: Normal' + } + + It 'writes error for unexpected Debuging value' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 500 } } + $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unknown logging level' + } + } + + It 'writes error when Get-CWAASettings throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAASettings { throw 'Registry unavailable' } + $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAError' { + + It 'returns structured objects from log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0.3.42`tJan 15 2025 10:30 - `tSample error message::: 11.0.3.42`tJan 15 2025 11:00 - `tAnother error") } + Get-CWAAError + } + $result | Should -Not -BeNullOrEmpty + $first = $result | Select-Object -First 1 + $first.ServiceVersion | Should -Not -BeNullOrEmpty + $first.Message | Should -Not -BeNullOrEmpty + } + + It 'writes error when log file does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Unable to find agent error log' + } + } + + It 'falls back to default path when agent not installed' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Test-Path { return $false } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + "$testErr" | Should -Match 'LTSVC' + } + } + + It 'parses multiple ::: delimited entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tError one::: 11.0`tJan 01 2025 09:00 - `tError two::: 11.0`tJan 01 2025 10:00 - `tError three") } + Get-CWAAError + } + ($result | Measure-Object).Count | Should -BeGreaterOrEqual 3 + } + + It 'sets Timestamp to null for unparseable dates' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tNOT-A-DATE - `tSome error") } + Get-CWAAError + } + $result | Should -Not -BeNullOrEmpty + ($result | Select-Object -First 1).Timestamp | Should -BeNullOrEmpty + } + + It 'returns nothing for empty log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @('') } + Get-CWAAError -ErrorAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + + It 'writes error when Get-Content throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { throw 'File locked' } + $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Get-CWAAProbeError' { + + It 'returns structured objects from probe log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 15 2025 10:30 - `tProbe error message") } + Get-CWAAProbeError + } + $result | Should -Not -BeNullOrEmpty + ($result | Select-Object -First 1).Message | Should -Not -BeNullOrEmpty + } + + It 'writes error when probe log does not exist' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'probe error log' + } + } + + It 'falls back to default path when agent not installed' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Test-Path { return $false } + $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr + "$testErr" | Should -Match 'LTSVC' + } + } + + It 'parses multiple ::: delimited entries' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tProbe err1::: 11.0`tJan 01 2025 09:00 - `tProbe err2") } + Get-CWAAProbeError + } + ($result | Measure-Object).Count | Should -BeGreaterOrEqual 2 + } + + It 'returns nothing for empty log file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $true } + Mock Get-Content { @('') } + Get-CWAAProbeError -ErrorAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 new file mode 100644 index 0000000..de3d5f5 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 @@ -0,0 +1,1001 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for installation, health, and configuration functions. + +.DESCRIPTION + Tests Install-CWAA, Uninstall-CWAA, Update-CWAA, Repair-CWAA, Test-CWAAHealth, + Register/Unregister-CWAAHealthCheckTask, Test-CWAAServerConnectivity, Test-CWAAPort, + Set-CWAAProxy, and New-CWAABackup using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# Tier 4: Installation Functions (Batch A) +# ============================================================================= + +Describe 'Install-CWAA' { + + Context 'parameter validation' { + It 'rejects an invalid server address format' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Install-CWAA -Server 'not a valid server!@#' -LocationID 1 -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + + It 'rejects InstallerToken with invalid characters' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Install-CWAA -Server 'automate.example.com' -InstallerToken 'INVALID-TOKEN!' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + } + + Context 'when services are already installed' { + It 'writes a terminating error without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*already installed*' + } + } + + # Note: Tests requiring admin bypass (e.g., download, msiexec) are not feasible + # in mocked tests because Install-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() + # which is a .NET static method that cannot be Pester-mocked. Those paths are covered + # by the Live integration test suite instead. + Context 'when not running as administrator' { + It 'throws Needs to be ran as Administrator when services are not detected' { + # When no services are found and not running elevated, the admin check fires + $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | + Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') + if ($isAdmin) { + Set-ItResult -Skipped -Because 'Test requires non-admin context' + } + else { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-Service { return $null } + Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -SkipDotNet -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Administrator*' + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Uninstall-CWAA' { + + # Note: Uninstall-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() + # which is a .NET static method that cannot be Pester-mocked. Tests that need to get past + # the admin check are conditioned on actually running elevated, or skipped. + + Context 'when not running as administrator' { + It 'throws Needs to be ran as Administrator' { + $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | + Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') + if ($isAdmin) { + Set-ItResult -Skipped -Because 'Test requires non-admin context' + } + else { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { return $null } + Uninstall-CWAA -Server 'automate.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Administrator*' + } + } + } + + Context 'parameter validation' { + It 'rejects an invalid server address format' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Uninstall-CWAA -Server 'not a valid server!@#' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw + } + } + + Context 'probe detection logic via Redo-CWAA integration' { + # Since Uninstall-CWAA requires admin, we test probe detection through Redo-CWAA + # which calls Uninstall-CWAA internally and does its own probe check first. + It 'Redo-CWAA refuses probe uninstall without -Force (tests same probe logic)' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'Redo-CWAA proceeds with -Force past probe detection' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw + } + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Update-CWAA' { + + Context 'when no existing installation is found' { + It 'writes a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { return $null } + + Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*No existing installation*' + } + } + + Context 'when installed version is higher than requested' { + It 'writes a warning and returns' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '220.100'; Server = @('automate.example.com') } } + Mock Test-Path { return $false } + + # Capture warnings via -WarningVariable. The Process block emits download warnings, + # then the End block emits the version comparison warning. + $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn + "$testWarn" | Should -Match 'higher than or equal' + } + } + } + + Context 'when installed version equals requested version' { + It 'writes a warning about equal version' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '200.100'; Server = @('automate.example.com') } } + Mock Test-Path { return $false } + + $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn + "$testWarn" | Should -Match 'higher than or equal' + } + } + } +} + +# ============================================================================= +# Tier 5: Health & Connectivity Functions (Batch B) +# ============================================================================= + +Describe 'Repair-CWAA' { + + Context 'when agent is healthy' { + It 'returns ActionTaken=None with success' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + HeartbeatLastReceived = (Get-Date).AddMinutes(-15).ToString() + } + } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'None' + $result.Success | Should -BeTrue + $result.Message | Should -Match 'healthy' + } + } + + Context 'when agent is offline beyond restart threshold' { + It 'restarts services and reports recovery' { + $script:callCount = 0 + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + # First call returns old LastSuccessStatus, subsequent calls return recent + Mock Get-CWAAInfo { + $script:callCount++ + if ($script:callCount -le 1) { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddHours(-3).ToString() + HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() + HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() + } + } + else { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-1).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-1).ToString() + HeartbeatLastReceived = (Get-Date).AddMinutes(-1).ToString() + } + } + } + Mock Test-CWAAServerConnectivity { return $true } + Mock Restart-CWAA {} + Mock Start-Sleep {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Restart' + $result.Message | Should -Match 'recovered' + } + } + + Context 'when agent is offline beyond reinstall threshold' { + It 'triggers reinstall after failed restart' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + # Return old date consistently. The wait loop calls Get-CWAAInfo + # for up to 2 minutes. To prevent spinning for 120s, mock Get-Date + # to jump past the 2-minute window after initial threshold calculations. + $Script:RepairDateCallCount = 0 + Mock Get-Date { + $Script:RepairDateCallCount++ + if ($Script:RepairDateCallCount -le 4) { + return [datetime]::Now + } + else { + return [datetime]::Now.AddMinutes(5) + } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = [datetime]::Now.AddDays(-10).ToString() + HeartbeatLastSent = [datetime]::Now.AddDays(-10).ToString() + HeartbeatLastReceived = [datetime]::Now.AddDays(-10).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $true } + Mock Restart-CWAA {} + Mock Start-Sleep {} + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Reinstall' + } + } + + Context 'when agent is not installed' { + It 'attempts a fresh install with provided parameters' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { return $null } + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -Server 'automate.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Install' + $result.Message | Should -Match 'Fresh agent install' + } + + It 'reports error when no install settings are available' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { return $null } + Mock Get-CWAAInfo { throw 'Not installed' } + Mock Get-CWAAInfoBackup { return $null } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + } + $result.Success | Should -BeFalse + $result.Message | Should -Match 'Unable to find install settings' + } + } + + Context 'when server is not reachable' { + It 'returns error about unreachable server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddHours(-3).ToString() + HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() + HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $false } + Mock Write-CWAAEventLog {} + + Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + } + $result.Success | Should -BeFalse + $result.Message | Should -Match 'not reachable' + } + } + + Context 'when agent points to wrong server' { + It 'reinstalls with the correct server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CimInstance { @() } + Mock Stop-Process {} + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('wrong.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + Mock Redo-CWAA {} + Mock Clear-CWAAInstallerArtifacts {} + Mock Write-CWAAEventLog {} + + Repair-CWAA -Server 'correct.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false + } + $result.ActionTaken | Should -Be 'Reinstall' + $result.Message | Should -Match 'correct server' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Test-CWAAHealth' { + + Context 'when agent is fully healthy' { + It 'returns a health object with Healthy=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.ServicesRunning | Should -BeTrue + $result.Healthy | Should -BeTrue + $result.LastContact | Should -Not -BeNullOrEmpty + } + + It 'returns correct object structure with all expected properties' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() + } + } + + Test-CWAAHealth + } + $memberNames = ($result | Get-Member -MemberType NoteProperty).Name + $memberNames | Should -Contain 'AgentInstalled' + $memberNames | Should -Contain 'ServicesRunning' + $memberNames | Should -Contain 'LastContact' + $memberNames | Should -Contain 'LastHeartbeat' + $memberNames | Should -Contain 'ServerAddress' + $memberNames | Should -Contain 'ServerMatch' + $memberNames | Should -Contain 'ServerReachable' + $memberNames | Should -Contain 'Healthy' + } + } + + Context 'when services are stopped' { + It 'returns Healthy=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Stopped' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() + } + } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.ServicesRunning | Should -BeFalse + $result.Healthy | Should -BeFalse + } + } + + Context 'when agent is not installed' { + It 'returns AgentInstalled=$false and Healthy=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeFalse + $result.ServicesRunning | Should -BeFalse + $result.Healthy | Should -BeFalse + $result.LastContact | Should -BeNullOrEmpty + } + } + + Context 'when -Server parameter is provided' { + It 'sets ServerMatch=$true when server matches' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + + Test-CWAAHealth -Server 'automate.example.com' + } + $result.ServerMatch | Should -BeTrue + } + + It 'sets ServerMatch=$false when server does not match' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('other.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + + Test-CWAAHealth -Server 'automate.example.com' + } + $result.ServerMatch | Should -BeFalse + } + } + + Context 'when -TestServerConnectivity is used' { + It 'sets ServerReachable from Test-CWAAServerConnectivity' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { + [PSCustomObject]@{ + Server = @('automate.example.com') + LastSuccessStatus = (Get-Date).ToString() + } + } + Mock Test-CWAAServerConnectivity { return $true } + + Test-CWAAHealth -TestServerConnectivity + } + $result.ServerReachable | Should -BeTrue + } + } + + Context 'when agent info cannot be read' { + It 'returns Healthy=$false with null timestamps' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { + param($Name) + [PSCustomObject]@{ Name = $Name; Status = 'Running' } + } + Mock Get-CWAAInfo { throw 'Registry read error' } + + Test-CWAAHealth + } + $result.AgentInstalled | Should -BeTrue + $result.LastContact | Should -BeNullOrEmpty + $result.Healthy | Should -BeFalse + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Register-CWAAHealthCheckTask' { + + Context 'when task does not exist' { + It 'creates a new scheduled task and returns Created=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { throw 'Task not found' } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result.Created | Should -BeTrue + $result.Updated | Should -BeFalse + $result.TaskName | Should -Be 'CWAAHealthCheck' + } + } + + Context 'when task exists with matching token' { + It 'skips recreation and returns Created=$false, Updated=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { + # Return XML that contains the token in the Arguments element + return '-Command "Repair-CWAA -InstallerToken abc123"' + } + } + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false + } + $result.Created | Should -BeFalse + $result.Updated | Should -BeFalse + } + } + + Context 'when -Force is used with existing task' { + It 'recreates the task' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { + return '-Command "Repair-CWAA -InstallerToken abc123"' + } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Force -Confirm:$false + } + ($result.Created -or $result.Updated) | Should -BeTrue + } + } + + Context 'when custom parameters are provided' { + It 'accepts custom TaskName and IntervalHours' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { throw 'Task not found' } + elseif ($args -contains '/DELETE') { return $null } + elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock New-CWAABackup {} + Mock Remove-Item {} + Mock Write-CWAAEventLog {} + + Register-CWAAHealthCheckTask -InstallerToken 'abc123' -TaskName 'MyHealthCheck' -IntervalHours 12 -Confirm:$false + } + $result.TaskName | Should -Be 'MyHealthCheck' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Unregister-CWAAHealthCheckTask' { + + Context 'when task exists' { + It 'removes the task and returns Removed=$true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } + elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock Write-CWAAEventLog {} + + Unregister-CWAAHealthCheckTask -Confirm:$false + } + $result.Removed | Should -BeTrue + $result.TaskName | Should -Be 'CWAAHealthCheck' + } + } + + Context 'when task does not exist' { + It 'writes a warning and returns Removed=$false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 1; return $null } + } + + Unregister-CWAAHealthCheckTask -Confirm:$false 3>&1 | Out-Null + Unregister-CWAAHealthCheckTask -Confirm:$false + } + $result.Removed | Should -BeFalse + } + } + + Context 'when custom TaskName is provided' { + It 'targets the correct task name' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock schtasks { + if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } + elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } + } + Mock Write-CWAAEventLog {} + + Unregister-CWAAHealthCheckTask -TaskName 'CustomTask' -Confirm:$false + } + $result.TaskName | Should -Be 'CustomTask' + $result.Removed | Should -BeTrue + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Test-CWAAServerConnectivity' { + + Context 'when server responds with valid agent pattern' { + It 'returns Available=$true with version' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # The agent.aspx response pattern requires 6+ consecutive pipes before the version + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeTrue + $result.Version | Should -Be '220.105' + $result.ErrorMessage | Should -BeNullOrEmpty + } + } + + Context 'when server responds with unexpected format' { + It 'returns Available=$false with error message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return 'not a valid response' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeFalse + $result.ErrorMessage | Should -Match 'unexpected format' + } + } + + Context 'when server is unreachable' { + It 'returns Available=$false with error message' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { throw 'Connection refused' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' + } + $result.Available | Should -BeFalse + $result.ErrorMessage | Should -Match 'Connection refused' + } + } + + Context 'when -Quiet mode is used' { + It 'returns $true when server is reachable' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet + } + $result | Should -BeTrue + } + + It 'returns $false when server is unreachable' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Invoke-RestMethod { throw 'Connection refused' } + + Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet + } + $result | Should -BeFalse + } + } + + Context 'when no server is provided' { + It 'discovers server from agent config' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('discovered.example.com') } } + Mock Get-CWAAInfoBackup { return $null } + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity + } + $result.Server | Should -Be 'discovered.example.com' + $result.Available | Should -BeTrue + } + + It 'falls back to backup when agent config has no server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # Return an object without a Server property so Select-Object -Expand 'Server' returns nothing + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com') } } + Mock Invoke-RestMethod { return '||||||220.105' } + + Test-CWAAServerConnectivity + } + $result.Server | Should -Be 'backup.example.com' + } + + It 'writes error when no server can be determined' { + InModuleScope 'ConnectWiseAutomateAgent' { + # Return objects without a Server property so the function sees no server + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ ID = '1' } } + + $null = Test-CWAAServerConnectivity -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'No server could be determined' + } + } + } +} + +# ============================================================================= +# Tier 6: Remaining Functions (Batch C) +# ============================================================================= + +Describe 'Test-CWAAPort' { + + Context 'when TrayPort is available in Quiet mode' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + # netstat returns no matching output for the port + $env_windir = $env:windir + Mock Invoke-Expression { return $null } + # Mock netstat by ensuring no process is found on the port + function netstat { return @() } + Test-CWAAPort -TrayPort 42000 -Quiet + } + $result | Should -BeTrue + } + } + + Context 'when TrayPort is in use in non-Quiet mode' { + It 'outputs a message about the port being in use' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000'; Server = @('automate.example.com') } } + Mock Get-CWAAInfoBackup { return $null } + Mock Get-Process { [PSCustomObject]@{ ProcessName = 'LTSvc'; Id = 1234 } } + Mock Test-Connection { return $true } + # Mock netstat to return a line matching the port with a PID + $Script:MockNetstatOutput = " TCP 0.0.0.0:42000 0.0.0.0:0 LISTENING 1234" + + # We need to test the output message + Test-CWAAPort -TrayPort 42000 -Server 'automate.example.com' 2>&1 + } + # The function produces port-related output + $result | Should -Not -BeNullOrEmpty + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Set-CWAAProxy' { + + BeforeEach { + # Reset module proxy state and ensure LTServiceNetWebClient exists before each test. + # Set-CWAAProxy assigns to $Script:LTServiceNetWebClient.Proxy which requires a real + # object with a Proxy property (WebClient). When Initialize-CWAANetworking is mocked, + # this object may not be initialized. + $module = Get-Module 'ConnectWiseAutomateAgent' + & $module { + $Script:LTProxy.Enabled = $False + $Script:LTProxy.ProxyServerURL = '' + $Script:LTProxy.ProxyUsername = '' + $Script:LTProxy.ProxyPassword = '' + if (-not $Script:LTServiceNetWebClient) { + $Script:LTServiceNetWebClient = New-Object System.Net.WebClient + } + if (-not $Script:LTWebProxy) { + $Script:LTWebProxy = New-Object System.Net.WebProxy + } + } + } + + Context 'when -ResetProxy is used' { + It 'clears proxy settings' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + Mock Get-Service { return $null } + + # Set proxy first + $Script:LTProxy.Enabled = $True + $Script:LTProxy.ProxyServerURL = 'http://proxy.example.com:8080' + + Set-CWAAProxy -ResetProxy -Confirm:$false + + $Script:LTProxy.Enabled | Should -BeFalse + $Script:LTProxy.ProxyServerURL | Should -Be '' + } + } + } + + Context 'when -ProxyServerURL is provided' { + It 'sets the proxy URL and enables proxy' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + Mock Get-Service { return $null } + + Set-CWAAProxy -ProxyServerURL 'http://proxy.example.com:8080' -Confirm:$false + + $Script:LTProxy.Enabled | Should -BeTrue + $Script:LTProxy.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' + } + } + } + + Context 'when invalid parameter combinations are used' { + It 'throws error for ResetProxy with ProxyServerURL' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -ResetProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Invalid parameter combination*' + } + + It 'throws error for DetectProxy with ProxyServerURL' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -DetectProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Invalid parameter combination*' + } + } + + Context 'when proxy changes require service restart' { + It 'restarts services when settings change and services are running' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { + [PSCustomObject]@{ + ProxyServerURL = 'http://old-proxy.example.com:8080' + ProxyUsername = '' + ProxyPassword = '' + } + } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Set-ItemProperty {} + Mock ConvertTo-CWAASecurity { return 'encoded' } + Mock ConvertFrom-CWAASecurity { return '' } + Mock Write-CWAAEventLog {} + + Set-CWAAProxy -ProxyServerURL 'http://new-proxy.example.com:8080' -Confirm:$false + + Should -Invoke Stop-CWAA -Scope It + Should -Invoke Start-CWAA -Scope It + } + } + } + + Context 'when no parameters are provided' { + It 'writes error about missing parameters' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Initialize-CWAANetworking {} + Mock Get-CWAASettings { return $null } + + Set-CWAAProxy -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*parameters missing*' + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'New-CWAABackup' { + + Context 'when agent is not installed' { + It 'writes terminating error when BasePath is not found' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + + New-CWAABackup -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Unable to find LTSvc folder path*' + } + } + + Context 'when registry key is missing' { + It 'writes terminating error about missing registry' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + Mock Test-Path { return $false } + + New-CWAABackup -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Unable to find registry*' + } + } + + Context 'when agent is properly installed' { + It 'creates backup directory and copies files' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } + # First call for HKLM registry check, second for agent path + Mock Test-Path { return $true } + Mock New-Item {} + Mock Get-ChildItem { @() } + Mock Copy-Item {} + # Mock reg.exe operations + $env_windir = $env:windir + Mock Get-Content { @('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech]', '"Key"="Value"') } + Mock Out-File {} + Mock Write-CWAAEventLog {} + + $result = New-CWAABackup -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke New-Item -Scope It + } + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1 new file mode 100644 index 0000000..c2e350d --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1 @@ -0,0 +1,616 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for private helper and security edge-case functions. + +.DESCRIPTION + Tests ConvertTo/From-CWAASecurity edge cases, Test-CWAADownloadIntegrity, + Remove-CWAAFolderRecursive, Resolve-CWAAServer, Wait-CWAACondition, + Test-CWAADotNetPrerequisite, and Invoke-CWAAMsiInstaller using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.PrivateHelpers.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ----------------------------------------------------------------------------- + +Describe 'ConvertTo-CWAASecurity additional edge cases' { + + It 'returns empty string for empty input' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString '' + } + # Empty input still produces an encoded output (encrypted empty string) + $result | Should -Not -BeNullOrEmpty + } + + It 'returns empty string for null key (uses default)' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key $null + } + $result | Should -Not -BeNullOrEmpty + } + + It 'produces different output for different keys' { + $result1 = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' + } + $result2 = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' + } + $result1 | Should -Not -Be $result2 + } + + It 'round-trips successfully with ConvertFrom-CWAASecurity using same key' { + $originalValue = 'RoundTripTestValue' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key 'TestKey123' + ConvertFrom-CWAASecurity -InputString $encoded -Key 'TestKey123' -Force:$false + } + $result | Should -Be $originalValue + } + + It 'round-trips with default key' { + $originalValue = 'DefaultKeyRoundTrip' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue + ConvertFrom-CWAASecurity -InputString $encoded -Force:$false + } + $result | Should -Be $originalValue + } + + It 'handles special characters in input' { + $originalValue = 'P@ssw0rd!#$%^&*()' + $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { + param($testValue) + $encoded = ConvertTo-CWAASecurity -InputString $testValue + ConvertFrom-CWAASecurity -InputString $encoded -Force:$false + } + $result | Should -Be $originalValue + } +} + +# ----------------------------------------------------------------------------- + +Describe 'ConvertFrom-CWAASecurity additional edge cases' { + + It 'returns null for invalid base64 input without Force' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + ConvertFrom-CWAASecurity -InputString 'not-valid-base64!!!' -Key 'TestKey' -Force:$false + } + $result | Should -BeNullOrEmpty + } + + It 'returns null when wrong key is used without Force' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'CorrectKey' + ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$false + } + $result | Should -BeNullOrEmpty + } + + It 'falls back to alternate keys when Force is enabled and primary key fails' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # Encode with default key + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' + # Try to decode with wrong key but Force enabled (should fall back to default) + ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$true + } + $result | Should -Be 'TestValue' + } + + It 'handles empty key by using default' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key '' + ConvertFrom-CWAASecurity -InputString $encoded -Key '' -Force:$false + } + $result | Should -Be 'TestValue' + } + + It 'handles array of input strings' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $encoded1 = ConvertTo-CWAASecurity -InputString 'Value1' + $encoded2 = ConvertTo-CWAASecurity -InputString 'Value2' + ConvertFrom-CWAASecurity -InputString @($encoded1, $encoded2) -Force:$false + } + $result | Should -HaveCount 2 + $result[0] | Should -Be 'Value1' + $result[1] | Should -Be 'Value2' + } + + It 'rejects empty string due to mandatory parameter validation' { + # ConvertFrom-CWAASecurity has [parameter(Mandatory = $true)] [string[]]$InputString + # which prevents binding an empty string. This confirms the validation fires. + { + InModuleScope 'ConnectWiseAutomateAgent' { + ConvertFrom-CWAASecurity -InputString '' -Force:$false -ErrorAction Stop + } + } | Should -Throw + } +} + +# ============================================================================= +# Private Helper Functions +# ============================================================================= + +Describe 'Test-CWAADownloadIntegrity' { + + Context 'when file exists and exceeds minimum size' { + It 'returns true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestIntegrity.msi' + # Create a file larger than 1234 KB (write ~1300 KB) + $bytes = New-Object byte[] (1300 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestIntegrity.msi' + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + } + + Context 'when file exists but is below minimum size' { + It 'returns false and removes the file' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestSmall.msi' + # Create a file smaller than 1234 KB (write 10 KB) + $bytes = New-Object byte[] (10 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestSmall.msi' -WarningAction SilentlyContinue + $fileStillExists = Test-Path $testFile + [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } + } + $result.Result | Should -Be $false + $result.FileExists | Should -Be $false + } + } + + Context 'when file does not exist' { + It 'returns false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Test-CWAADownloadIntegrity -FilePath 'C:\NonExistent\FakeFile.msi' -FileName 'FakeFile.msi' + } + $result | Should -Be $false + } + } + + Context 'with custom MinimumSizeKB threshold' { + It 'uses the custom threshold for validation' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestCustom.exe' + # Create a 100 KB file, check with 80 KB threshold + $bytes = New-Object byte[] (100 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustom.exe' -MinimumSizeKB 80 + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + + It 'fails when file is below custom threshold' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestCustomFail.exe' + # Create a 50 KB file, check with 80 KB threshold + $bytes = New-Object byte[] (50 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustomFail.exe' -MinimumSizeKB 80 -WarningAction SilentlyContinue + $fileStillExists = Test-Path $testFile + [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } + } + $result.Result | Should -Be $false + $result.FileExists | Should -Be $false + } + } + + Context 'when FileName is not provided' { + It 'derives the filename from the path' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testFile = Join-Path $env:TEMP 'CWAATestDerived.msi' + $bytes = New-Object byte[] (1300 * 1024) + [System.IO.File]::WriteAllBytes($testFile, $bytes) + try { + Test-CWAADownloadIntegrity -FilePath $testFile + } + finally { + Remove-Item $testFile -Force -ErrorAction SilentlyContinue + } + } + $result | Should -Be $true + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Remove-CWAAFolderRecursive' { + + Context 'when folder exists with nested content' { + It 'removes the folder and all contents' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testRoot = Join-Path $env:TEMP 'CWAATestRemoveFolder' + $subDir = Join-Path $testRoot 'SubFolder' + New-Item -Path $subDir -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $testRoot 'file1.txt') -Value 'test' + Set-Content -Path (Join-Path $subDir 'file2.txt') -Value 'test' + Remove-CWAAFolderRecursive -Path $testRoot -Confirm:$false + Test-Path $testRoot + } + $result | Should -Be $false + } + } + + Context 'when folder does not exist' { + It 'completes without error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Remove-CWAAFolderRecursive -Path 'C:\NonExistent\CWAATestFolder' -Confirm:$false + } + } | Should -Not -Throw + } + } + + Context 'when called with -WhatIf' { + It 'does not actually remove the folder' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $testRoot = Join-Path $env:TEMP 'CWAATestWhatIf' + New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $testRoot 'file.txt') -Value 'test' + Remove-CWAAFolderRecursive -Path $testRoot -WhatIf -Confirm:$false + $exists = Test-Path $testRoot + # Clean up for real + Remove-Item $testRoot -Recurse -Force -ErrorAction SilentlyContinue + $exists + } + $result | Should -Be $true + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Resolve-CWAAServer' { + + Context 'when server responds with valid version' { + It 'returns the server URL and version' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||220.105' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') + } + $result | Should -Not -BeNullOrEmpty + $result.ServerUrl | Should -Match 'automate\.example\.com' + $result.ServerVersion | Should -Be '220.105' + } + } + + Context 'when server URL has no scheme' { + It 'normalizes the URL and still resolves' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||230.001' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('automate.example.com') + } + $result | Should -Not -BeNullOrEmpty + $result.ServerVersion | Should -Be '230.001' + } + } + + Context 'when server returns no parseable version' { + It 'returns null' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return 'no version data here' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } + + Context 'when server is unreachable' { + It 'returns null' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + throw 'Connection refused' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } + + Context 'when first server fails but second succeeds' { + It 'returns the second server' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $callCount = 0 + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + # Use the URL to determine behavior since $callCount scope is tricky + if ($url -match 'bad\.example\.com') { + throw 'Connection refused' + } + return '||||||210.050' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://bad.example.com', 'https://good.example.com') -WarningAction SilentlyContinue + } + $result | Should -Not -BeNullOrEmpty + $result.ServerUrl | Should -Match 'good\.example\.com' + $result.ServerVersion | Should -Be '210.050' + } + } + + Context 'when server URL is invalid format' { + It 'returns null and writes a warning' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $mockWebClient = New-Object PSObject + $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { + param($url) + return '||||||220.105' + } + $Script:LTServiceNetWebClient = $mockWebClient + Resolve-CWAAServer -Server @('https://automate.example.com/some/path') -WarningAction SilentlyContinue + } + $result | Should -BeNullOrEmpty + } + } +} + +# ============================================================================= +# Wait-CWAACondition Tests +# ============================================================================= + +Describe 'Wait-CWAACondition' { + + Context 'when condition is met immediately' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $callCount = 0 + Wait-CWAACondition -Condition { + $script:callCount++ + $true + } -TimeoutSeconds 10 -IntervalSeconds 1 + } + $result | Should -Be $true + } + } + + Context 'when condition is met after initial failure' { + It 'returns $true after retrying' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $script:waitTestCounter = 0 + Wait-CWAACondition -Condition { + $script:waitTestCounter++ + $script:waitTestCounter -ge 2 + } -TimeoutSeconds 30 -IntervalSeconds 1 + } + $result | Should -Be $true + } + } + + Context 'when timeout is reached' { + It 'returns $false' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $false } -TimeoutSeconds 2 -IntervalSeconds 1 + } + $result | Should -Be $false + } + } + + Context 'parameter validation' { + It 'rejects TimeoutSeconds of 0' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $true } -TimeoutSeconds 0 -IntervalSeconds 1 + } + } | Should -Throw + } + + It 'rejects IntervalSeconds of 0' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Wait-CWAACondition -Condition { $true } -TimeoutSeconds 10 -IntervalSeconds 0 + } + } | Should -Throw + } + } +} + +# ============================================================================= +# Test-CWAADotNetPrerequisite Tests +# ============================================================================= + +Describe 'Test-CWAADotNetPrerequisite' { + + Context 'when -SkipDotNet is specified' { + It 'returns $true immediately without checking registry' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem {} + Test-CWAADotNetPrerequisite -SkipDotNet + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is already installed' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '3.5.30729'; Release = $null; PSChildName = 'Full' } + } + Test-CWAADotNetPrerequisite -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is missing and -Force allows .NET 2.0+' { + It 'returns $true with a non-terminating error when .NET 4.x is present' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + # First call: initial check returns only 4.x + # Second call (after install attempt): still only 4.x + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction SilentlyContinue + } + $result | Should -Be $true + } + } + + Context 'when .NET 3.5 is missing and no .NET 2.0+ with -Force' { + It 'throws a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '1.1.4322'; Release = $null; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*2.0*' + } + } + + Context 'when .NET 3.5 is missing without -Force' { + It 'throws a terminating error' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-ChildItem { + [PSCustomObject]@{ PSChildName = 'Full' } + } + Mock Get-ItemProperty { + [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } + } + Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } + Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } + Test-CWAADotNetPrerequisite -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*3.5*' + } + } +} + +# ============================================================================= +# Invoke-CWAAMsiInstaller Tests +# ============================================================================= + +Describe 'Invoke-CWAAMsiInstaller' { + + Context 'when service starts on first attempt' { + It 'returns $true' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + $script:msiCallCount = 0 + Mock Start-Process { + $script:msiCallCount++ + } + Mock Start-Sleep {} + # First call returns 0 (pre-install check), second call returns 1 (post-install) + $script:getServiceCallCount = 0 + Mock Get-Service { + $script:getServiceCallCount++ + if ($script:getServiceCallCount -ge 2) { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + } + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when service starts on retry' { + It 'returns $true after retrying' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Mock Start-Sleep {} + Mock Wait-CWAACondition { $false } + # Service not present for first 4 calls (2 attempts x 2 checks each), then present + $script:svcCounter = 0 + Mock Get-Service { + $script:svcCounter++ + if ($script:svcCounter -ge 5) { + [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } + } + } + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 3 -RetryDelaySeconds 1 -Confirm:$false + } + $result | Should -Be $true + } + } + + Context 'when service never starts' { + It 'returns $false after max attempts' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Mock Start-Sleep {} + Mock Wait-CWAACondition { $false } + Mock Get-Service {} + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 2 -RetryDelaySeconds 1 -Confirm:$false -ErrorAction SilentlyContinue + } + $result | Should -Be $false + } + } + + Context 'when -WhatIf is specified' { + It 'returns $true without calling Start-Process' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Start-Process {} + Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -WhatIf + } + $result | Should -Be $true + InModuleScope 'ConnectWiseAutomateAgent' { + Should -Invoke Start-Process -Times 0 + } + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1 new file mode 100644 index 0000000..3227e04 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1 @@ -0,0 +1,467 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Mocked behavioral tests for service operation functions. + +.DESCRIPTION + Tests Get-CWAAProxy, Restart-CWAA, Stop-CWAA, Start-CWAA, Reset-CWAA, + and Redo-CWAA using Pester mocks. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.ServiceOps.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# --- + +Describe 'Get-CWAAProxy' { + + BeforeEach { + # Reset module proxy state before each test to prevent cross-test pollution + $module = Get-Module 'ConnectWiseAutomateAgent' + & $module { + $Script:LTProxy.Enabled = $False + $Script:LTProxy.ProxyServerURL = '' + $Script:LTProxy.ProxyUsername = '' + $Script:LTProxy.ProxyPassword = '' + $Script:LTServiceKeys.ServerPasswordString = '' + $Script:LTServiceKeys.PasswordString = '' + } + } + + It 'returns proxy with Enabled=$false when no agent installed' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAASettings { return $null } + Get-CWAAProxy + } + $result.Enabled | Should -BeFalse + $result.ProxyServerURL | Should -Be '' + } + + It 'returns proxy with Enabled=$false when no proxy configured' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } + Mock Get-CWAASettings { + [PSCustomObject]@{ ServerAddress = 'automate.example.com' } + } + Get-CWAAProxy + } + $result.Enabled | Should -BeFalse + } + + It 'enables proxy when ProxyServerURL matches http pattern' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } + Mock Get-CWAASettings { + [PSCustomObject]@{ ProxyServerURL = 'http://proxy.example.com:8080' } + } + Get-CWAAProxy + } + $result.Enabled | Should -BeTrue + $result.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' + } + + It 'decodes proxy username and password via ConvertFrom-CWAASecurity' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded'; Password = 'fakeAgentPwd' } + } + Mock ConvertFrom-CWAASecurity { return 'decryptedValue' } + Mock Get-CWAASettings { + [PSCustomObject]@{ + ProxyServerURL = 'http://proxy.example.com:8080' + ProxyUsername = 'encryptedUser' + ProxyPassword = 'encryptedPass' + } + } + Get-CWAAProxy + + # ConvertFrom-CWAASecurity should be called for ServerPassword, Password, ProxyUsername, and ProxyPassword + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 4 + + # Verify the decryption chain: ServerPassword decoded first, then Password uses it as Key + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { + $InputString -eq 'fakeEncoded' + } + Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { + $InputString -eq 'fakeAgentPwd' + } + } + } + + It 'populates ServerPasswordString in LTServiceKeys' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { + [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } + } + Mock ConvertFrom-CWAASecurity { return 'theServerPwd' } + Mock Get-CWAASettings { [PSCustomObject]@{} } + + Get-CWAAProxy + + $Script:LTServiceKeys.ServerPasswordString | Should -Be 'theServerPwd' + } + } + + It 'returns the $Script:LTProxy object' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAASettings { return $null } + Get-CWAAProxy + } + $result | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'Enabled' | Should -Not -BeNullOrEmpty + $result | Get-Member -Name 'ProxyServerURL' | Should -Not -BeNullOrEmpty + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Restart-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'calls Stop-CWAA then Start-CWAA on success' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + + Restart-CWAA -Confirm:$false + } + $result | Should -Match 'restarted successfully' + } + + It 'writes error and stops when Stop-CWAA throws' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA { throw 'Stop failed' } + Mock Start-CWAA {} + + $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'error stopping' + Should -Invoke Start-CWAA -Times 0 -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Stop-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { return $null } + $null = Stop-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs success message when services stop' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + } + $result | Should -Match 'stopped successfully' + } + + It 'sends Kill VNC and Kill Trays commands' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + + Should -Invoke Invoke-CWAACommand -Times 1 -Scope It + } + } + + It 'attempts to terminate LabTech processes' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } + Mock Invoke-CWAACommand {} + Mock Get-Process { @() } + Mock Stop-Process {} + Mock Start-Sleep {} + + Stop-CWAA -Confirm:$false + + Should -Invoke Get-Process -Scope It + } + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Start-CWAA' { + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-Service { return $null } + $null = Start-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs success when services reach running state' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Set-Service {} + Mock Invoke-CWAACommand {} + Mock Start-Sleep {} + Mock Stop-Process {} + + Start-CWAA -Confirm:$false + } + $result | Should -Match 'started successfully' + } + + It 'sends Send Status command after successful start' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Set-Service {} + Mock Invoke-CWAACommand {} + Mock Start-Sleep {} + Mock Stop-Process {} + + Start-CWAA -Confirm:$false + + Should -Invoke Invoke-CWAACommand -Times 1 -Scope It -ParameterFilter { $Command -eq 'Send Status' } + } + } +} + +# ============================================================================= +# Tier 3: Orchestration Logic +# ============================================================================= + +Describe 'Reset-CWAA' { + + It 'resets all three values when no switches specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } + } + } + + It 'resets only ID when -ID specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -ID -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 + } + } + + It 'resets only LocationID when -Location specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -Location -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 + } + } + + It 'resets only MAC when -MAC specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -MAC -NoWait -Confirm:$false + + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 1 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 + Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 + } + } + + It 'throws terminating error when probe detected without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + + Reset-CWAA -NoWait -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'proceeds when probe detected with -Force' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + { Reset-CWAA -Force -NoWait -Confirm:$false } | Should -Not -Throw + } + } + + It 'writes error when services are not found' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } + Mock Get-Service { return $null } + + $null = Reset-CWAA -NoWait -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr + $testErr | Should -Not -BeNullOrEmpty + "$testErr" | Should -Match 'Services NOT Found' + } + } + + It 'outputs OLD ID line with current values' { + $result = InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '42'; LocationID = '7'; MAC = 'DE:AD:BE:EF' } } + Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } + Mock Stop-CWAA {} + Mock Start-CWAA {} + Mock Remove-ItemProperty {} + Mock Start-Sleep {} + + Reset-CWAA -NoWait -Confirm:$false + } + $result | Should -Contain 'OLD ID: 42 LocationID: 7 MAC: DE:AD:BE:EF' + } +} + +# ----------------------------------------------------------------------------- + +Describe 'Redo-CWAA' { + + It 'reads server from current agent settings' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke Uninstall-CWAA -Times 1 -Scope It + Should -Invoke Install-CWAA -Times 1 -Scope It + } + } + + It 'falls back to backup settings when current is null' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { return $null } + Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com'); LocationID = '2' } } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke Install-CWAA -Times 1 -Scope It + } + } + + It 'throws terminating error when probe detected without -Force' { + { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + + Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop + } + } | Should -Throw '*Probe*Denied*' + } + + It 'proceeds when probe detected with -Force' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw + } + } + + It 'calls New-CWAABackup when -Backup specified' { + InModuleScope 'ConnectWiseAutomateAgent' { + Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } + Mock Get-CWAAInfoBackup { return $null } + Mock New-CWAABackup {} + Mock Uninstall-CWAA {} + Mock Install-CWAA {} + Mock Start-Sleep {} + + Redo-CWAA -InstallerToken 'abc123' -Backup -Confirm:$false -ErrorAction SilentlyContinue + + Should -Invoke New-CWAABackup -Times 1 -Scope It + } + } +} diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 deleted file mode 100644 index 048e227..0000000 --- a/Tests/ConnectWiseAutomateAgent.Mocked.Tests.ps1 +++ /dev/null @@ -1,3319 +0,0 @@ -#Requires -Module Pester - -<# -.SYNOPSIS - Mocked behavioral tests for the ConnectWiseAutomateAgent module. - -.DESCRIPTION - Tests the logic paths of public functions using Pester mocks to isolate from - system dependencies (registry, services, files, network). Designed to run fast - on any Windows machine without admin privileges or a real Automate agent. - - Functions that are primarily system-call wrappers (Install-CWAA, Uninstall-CWAA, - Update-CWAA, Set-CWAAProxy, New-CWAABackup, Test-CWAAPort) are tested by the - Live integration test suite instead. - -.NOTES - Run with: - Invoke-Pester Tests\ConnectWiseAutomateAgent.Mocked.Tests.ps1 -Output Detailed -#> - -BeforeAll { - $ModuleName = 'ConnectWiseAutomateAgent' - $ModuleRoot = Split-Path -Parent $PSScriptRoot - $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" - - Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ModulePath -Force -ErrorAction Stop -} - -AfterAll { - Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force -} - -# ============================================================================= -# Tier 1: Data Reader Functions -# ============================================================================= - -Describe 'Get-CWAAInfo' { - - Context 'when registry key does not exist' { - BeforeAll { - $script:result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } - Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable err -WhatIf:$false -Confirm:$false - $err - } - } - - It 'returns null' { - $result2 = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } - Get-CWAAInfo -ErrorAction SilentlyContinue -WhatIf:$false -Confirm:$false - } - $result2 | Should -BeNullOrEmpty - } - - It 'writes an error about missing agent' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } - $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Unable to find information' - } - } - } - - Context 'when registry key exists with full data' { - It 'returns an object with expected properties' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '12345' - 'Server Address' = 'automate.example.com|backup.example.com|' - LocationID = '1' - BasePath = 'C:\Windows\LTSVC' - Version = '230.105' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $result | Should -Not -BeNullOrEmpty - $result.ID | Should -Be '12345' - $result.LocationID | Should -Be '1' - } - - It 'parses pipe-delimited Server Address into array' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '1' - 'Server Address' = 'srv1.example.com|srv2.example.com|' - BasePath = 'C:\Windows\LTSVC' - } - } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $result.Server | Should -HaveCount 2 - $result.Server | Should -Contain 'srv1.example.com' - $result.Server | Should -Contain 'srv2.example.com' - } - - It 'strips tildes from Server Address entries' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '1' - 'Server Address' = '~automate.example.com~|' - BasePath = 'C:\Windows\LTSVC' - } - } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $result.Server | Should -Contain 'automate.example.com' - $result.Server | Should -Not -Contain '~automate.example.com~' - } - - It 'expands environment variables in BasePath' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '1' - BasePath = '%windir%\LTSVC' - } - } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $result.BasePath | Should -Not -Match '%windir%' - $result.BasePath | Should -Match 'LTSVC' - } - - It 'excludes PS provider properties from output' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '1' - BasePath = 'C:\Windows\LTSVC' - PSPath = 'should-be-excluded' - PSParentPath = 'should-be-excluded' - PSChildName = 'should-be-excluded' - PSDrive = 'should-be-excluded' - PSProvider = 'should-be-excluded' - } - } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $memberNames = ($result | Get-Member -MemberType NoteProperty).Name - $memberNames | Should -Not -Contain 'PSPath' - $memberNames | Should -Not -Contain 'PSParentPath' - $memberNames | Should -Not -Contain 'PSChildName' - } - } - - Context 'when BasePath is not in registry' { - It 'falls back to default install path when service key is missing' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryRoot } - Mock Test-Path { return $false } -ParameterFilter { $Path -eq 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' } - Mock Get-ItemProperty { - [PSCustomObject]@{ ID = '1' } - } -ParameterFilter { $Path -and $Path -match 'LabTech' } - Get-CWAAInfo -WhatIf:$false -Confirm:$false - } - $result.BasePath | Should -Match 'LTSVC' - } - } - - Context 'when Get-ItemProperty throws' { - It 'writes an error and does not crash' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } - Mock Get-ItemProperty { throw 'Registry access denied' } - $null = Get-CWAAInfo -ErrorAction SilentlyContinue -ErrorVariable testErr -WhatIf:$false -Confirm:$false - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'problem reading' - } - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAASettings' { - - It 'writes error when settings key does not exist' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } - $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Unable to find LTSvc settings' - } - } - - It 'returns settings object when key exists' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } - Mock Get-ItemProperty { - [PSCustomObject]@{ - Debuging = 1 - ServerAddress = 'automate.example.com' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAASettings - } - $result | Should -Not -BeNullOrEmpty - $result.Debuging | Should -Be 1 - } - - It 'writes error when Get-ItemProperty throws' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistrySettings } - Mock Get-ItemProperty { throw 'Access denied' } - $null = Get-CWAASettings -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'problem reading' - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAAInfoBackup' { - - It 'writes error when backup registry does not exist' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } - $null = Get-CWAAInfoBackup -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'New-CWAABackup' - } - } - - It 'returns backup object with expected properties' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '99999' - 'Server Address' = 'backup.example.com|' - BasePath = 'C:\Windows\LTSVC' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAAInfoBackup - } - $result | Should -Not -BeNullOrEmpty - $result.ID | Should -Be '99999' - } - - It 'parses pipe-delimited Server Address' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } - Mock Get-ItemProperty { - [PSCustomObject]@{ - 'Server Address' = 'srv1.example.com|srv2.example.com|' - BasePath = 'C:\Windows\LTSVC' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAAInfoBackup - } - $result.Server | Should -HaveCount 2 - $result.Server[0] | Should -Be 'srv1.example.com' - $result.Server[1] | Should -Be 'srv2.example.com' - } - - It 'expands environment variables in BasePath' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } - Mock Get-ItemProperty { - [PSCustomObject]@{ - BasePath = '%windir%\LTSVC' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAAInfoBackup - } - $result.BasePath | Should -Not -Match '%windir%' - } - - It 'handles missing Server Address without crashing' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Path -eq $Script:CWAARegistryBackup } - Mock Get-ItemProperty { - [PSCustomObject]@{ - ID = '1' - PSPath = 'fake' - PSParentPath = 'fake' - PSChildName = 'fake' - PSDrive = 'fake' - PSProvider = 'fake' - } - } - Get-CWAAInfoBackup - } - $result | Should -Not -BeNullOrEmpty - $result | Get-Member -Name 'Server' -ErrorAction SilentlyContinue | Should -BeNullOrEmpty - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAALogLevel' { - - It 'returns Normal when Debuging is 1' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1 } } - Get-CWAALogLevel - } - $result | Should -Be 'Current logging level: Normal' - } - - It 'returns Verbose when Debuging is 1000' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 1000 } } - Get-CWAALogLevel - } - $result | Should -Be 'Current logging level: Verbose' - } - - It 'returns Normal when Debuging is null (fresh install)' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAASettings { [PSCustomObject]@{} } - Get-CWAALogLevel - } - $result | Should -Be 'Current logging level: Normal' - } - - It 'writes error for unexpected Debuging value' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAASettings { [PSCustomObject]@{ Debuging = 500 } } - $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Unknown logging level' - } - } - - It 'writes error when Get-CWAASettings throws' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAASettings { throw 'Registry unavailable' } - $null = Get-CWAALogLevel -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAAError' { - - It 'returns structured objects from log file' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @("11.0.3.42`tJan 15 2025 10:30 - `tSample error message::: 11.0.3.42`tJan 15 2025 11:00 - `tAnother error") } - Get-CWAAError - } - $result | Should -Not -BeNullOrEmpty - $first = $result | Select-Object -First 1 - $first.ServiceVersion | Should -Not -BeNullOrEmpty - $first.Message | Should -Not -BeNullOrEmpty - } - - It 'writes error when log file does not exist' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $false } - $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Unable to find agent error log' - } - } - - It 'falls back to default path when agent not installed' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Test-Path { return $false } - $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr - "$testErr" | Should -Match 'LTSVC' - } - } - - It 'parses multiple ::: delimited entries' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tError one::: 11.0`tJan 01 2025 09:00 - `tError two::: 11.0`tJan 01 2025 10:00 - `tError three") } - Get-CWAAError - } - ($result | Measure-Object).Count | Should -BeGreaterOrEqual 3 - } - - It 'sets Timestamp to null for unparseable dates' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @("11.0`tNOT-A-DATE - `tSome error") } - Get-CWAAError - } - $result | Should -Not -BeNullOrEmpty - ($result | Select-Object -First 1).Timestamp | Should -BeNullOrEmpty - } - - It 'returns nothing for empty log file' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @('') } - Get-CWAAError -ErrorAction SilentlyContinue - } - $result | Should -BeNullOrEmpty - } - - It 'writes error when Get-Content throws' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { throw 'File locked' } - $null = Get-CWAAError -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAAProbeError' { - - It 'returns structured objects from probe log file' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @("11.0`tJan 15 2025 10:30 - `tProbe error message") } - Get-CWAAProbeError - } - $result | Should -Not -BeNullOrEmpty - ($result | Select-Object -First 1).Message | Should -Not -BeNullOrEmpty - } - - It 'writes error when probe log does not exist' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $false } - $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'probe error log' - } - } - - It 'falls back to default path when agent not installed' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Test-Path { return $false } - $null = Get-CWAAProbeError -ErrorAction SilentlyContinue -ErrorVariable testErr - "$testErr" | Should -Match 'LTSVC' - } - } - - It 'parses multiple ::: delimited entries' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @("11.0`tJan 01 2025 08:00 - `tProbe err1::: 11.0`tJan 01 2025 09:00 - `tProbe err2") } - Get-CWAAProbeError - } - ($result | Measure-Object).Count | Should -BeGreaterOrEqual 2 - } - - It 'returns nothing for empty log file' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $true } - Mock Get-Content { @('') } - Get-CWAAProbeError -ErrorAction SilentlyContinue - } - $result | Should -BeNullOrEmpty - } -} - -# ============================================================================= -# Tier 2: Functions with Testable Logic -# ============================================================================= - -Describe 'Invoke-CWAACommand' { - - It 'warns when LTService is not found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { return $null } - Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not found' - } - } - - It 'warns when LTService is not running' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } - Invoke-CWAACommand -Command 'Send Status' -Confirm:$false 3>&1 | Should -Match 'not running' - } - } - - It 'sends command when service is running' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Invoke-CWAACommand -Command 'Send Status' -Confirm:$false - } - $result | Should -Match "Sent Command 'Send Status'" - } - - It 'sends multiple commands' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Invoke-CWAACommand -Command 'Send Status', 'Send Inventory' -Confirm:$false - } - ($result | Measure-Object).Count | Should -Be 2 - } - - It 'accepts all 18 valid commands' -ForEach @( - @{ Cmd = 'Update Schedule' } - @{ Cmd = 'Send Inventory' } - @{ Cmd = 'Send Drives' } - @{ Cmd = 'Send Processes' } - @{ Cmd = 'Send Spyware List' } - @{ Cmd = 'Send Apps' } - @{ Cmd = 'Send Events' } - @{ Cmd = 'Send Printers' } - @{ Cmd = 'Send Status' } - @{ Cmd = 'Send Screen' } - @{ Cmd = 'Send Services' } - @{ Cmd = 'Analyze Network' } - @{ Cmd = 'Write Last Contact Date' } - @{ Cmd = 'Kill VNC' } - @{ Cmd = 'Kill Trays' } - @{ Cmd = 'Send Patch Reboot' } - @{ Cmd = 'Run App Care Update' } - @{ Cmd = 'Start App Care Daytime Patching' } - ) { - $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $Cmd { - param($CommandName) - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Invoke-CWAACommand -Command $CommandName -Confirm:$false - } - $result | Should -Match "Sent Command '$Cmd'" - } - - It 'accepts pipeline input' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - 'Send Status' | Invoke-CWAACommand -Confirm:$false - } - $result | Should -Match 'Send Status' - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Hide-CWAAAddRemove' { - - It 'warns when no registry keys are found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } - Hide-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be hidden' - } - } - - It 'sets SystemComponent to 1 when uninstall key exists' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-Item { - $mockKey = New-Object PSObject - $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } - return $mockKey - } - Mock Set-ItemProperty {} - Mock Get-ItemProperty { return $null } - - Hide-CWAAAddRemove -Confirm:$false - - Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 1 } - } - } - - It 'skips write when SystemComponent is already 1' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-Item { - $mockKey = New-Object PSObject - $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } - return $mockKey - } - Mock Set-ItemProperty {} - Mock Get-ItemProperty { return $null } - - Hide-CWAAAddRemove -Confirm:$false - - Should -Invoke Set-ItemProperty -Times 0 -Scope It - } - } - - It 'renames HiddenProductName to ProductName when ProductName is missing' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'ProductName' } - Mock Rename-ItemProperty {} - - Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null - - Should -Invoke Rename-ItemProperty -Times 1 -Scope It - } - } - - It 'removes unused HiddenProductName when ProductName already exists' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { [PSCustomObject]@{ ProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'ProductName' } - Mock Remove-ItemProperty {} - - Hide-CWAAAddRemove -Confirm:$false 3>&1 | Out-Null - - Should -Invoke Remove-ItemProperty -Times 1 -Scope It - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Show-CWAAAddRemove' { - - It 'warns when no registry keys are found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } - Show-CWAAAddRemove -Confirm:$false 3>&1 | Should -Match 'may not be visible' - } - } - - It 'sets SystemComponent to 0 when hidden' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-Item { - $mockKey = New-Object PSObject - $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } - return $mockKey - } - Mock Set-ItemProperty {} - Mock Get-ItemProperty { return $null } - - Show-CWAAAddRemove -Confirm:$false - - Should -Invoke Set-ItemProperty -Times 1 -Scope It -ParameterFilter { $Value -eq 0 } - } - } - - It 'skips write when SystemComponent is already 0' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-Item { - $mockKey = New-Object PSObject - $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 0 }; if ($name -eq 'DisplayName') { return 'LabTech' } } - return $mockKey - } - Mock Set-ItemProperty {} - Mock Get-ItemProperty { return $null } - - Show-CWAAAddRemove -Confirm:$false - - Should -Invoke Set-ItemProperty -Times 0 -Scope It - } - } - - It 'outputs success message when entries changed' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Test-Path { return $false } -ParameterFilter { $Script:CWAAInstallerProductKeys -contains $Path } - Mock Test-Path { return $true } -ParameterFilter { $Script:CWAAUninstallKeys -contains $Path } - Mock Get-Item { - $mockKey = New-Object PSObject - $mockKey | Add-Member -MemberType ScriptMethod -Name GetValue -Value { param($name) if ($name -eq 'SystemComponent') { return 1 }; if ($name -eq 'DisplayName') { return 'LabTech' } } - return $mockKey - } - Mock Set-ItemProperty {} - Mock Get-ItemProperty { return $null } - - Show-CWAAAddRemove -Confirm:$false - } - $result | Should -Match 'visible' - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Rename-CWAAAddRemove' { - - It 'sets DisplayName when key is found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } - Mock Set-ItemProperty {} - - Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'My Agent' } - } - } - - It 'sets both DisplayName and Publisher when PublisherName provided' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { [PSCustomObject]@{ Publisher = 'LabTech' } } -ParameterFilter { $Name -eq 'Publisher' } - Mock Set-ItemProperty {} - - Rename-CWAAAddRemove -Name 'My Agent' -PublisherName 'My Company' -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' } - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Publisher' -and $Value -eq 'My Company' } - } - } - - It 'warns when no matching keys are found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { return $null } - Mock Set-ItemProperty {} - - Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false 3>&1 | Should -Match 'not found.*Name was not changed' - } - } - - It 'updates HiddenProductName when DisplayName is absent' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'DisplayName' } - Mock Get-ItemProperty { [PSCustomObject]@{ HiddenProductName = 'LabTech' } } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } - Mock Set-ItemProperty {} - - Rename-CWAAAddRemove -Name 'My Agent' -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'HiddenProductName' -and $Value -eq 'My Agent' } - } - } - - It 'outputs success message with new name' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } - Mock Set-ItemProperty {} - - Rename-CWAAAddRemove -Name 'Custom Agent' -Confirm:$false - } - $result | Should -Match 'Custom Agent' - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Set-CWAALogLevel' { - - It 'sets Debuging to 1 for Normal level' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Normal' } - - Set-CWAALogLevel -Level Normal -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1 } - } - } - - It 'sets Debuging to 1000 for Verbose level' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Verbose' } - - Set-CWAALogLevel -Level Verbose -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } - } - } - - It 'defaults to Normal when Level is not specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Normal' } - - Set-CWAALogLevel -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Value -eq 1 } - } - } - - It 'calls Stop-CWAA before and Start-CWAA after the registry write' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Normal' } - - Set-CWAALogLevel -Level Normal -Confirm:$false - - Should -Invoke Stop-CWAA -Times 1 -Scope It - Should -Invoke Start-CWAA -Times 1 -Scope It - } - } - - It 'calls Get-CWAALogLevel at the end to report' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Normal' } - - Set-CWAALogLevel -Level Normal -Confirm:$false - } - $result | Should -Be 'Current logging level: Normal' - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Get-CWAAProxy' { - - BeforeEach { - # Reset module proxy state before each test to prevent cross-test pollution - $module = Get-Module 'ConnectWiseAutomateAgent' - & $module { - $Script:LTProxy.Enabled = $False - $Script:LTProxy.ProxyServerURL = '' - $Script:LTProxy.ProxyUsername = '' - $Script:LTProxy.ProxyPassword = '' - $Script:LTServiceKeys.ServerPasswordString = '' - $Script:LTServiceKeys.PasswordString = '' - } - } - - It 'returns proxy with Enabled=$false when no agent installed' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Get-CWAASettings { return $null } - Get-CWAAProxy - } - $result.Enabled | Should -BeFalse - $result.ProxyServerURL | Should -Be '' - } - - It 'returns proxy with Enabled=$false when no proxy configured' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { - [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } - } - Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } - Mock Get-CWAASettings { - [PSCustomObject]@{ ServerAddress = 'automate.example.com' } - } - Get-CWAAProxy - } - $result.Enabled | Should -BeFalse - } - - It 'enables proxy when ProxyServerURL matches http pattern' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { - [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } - } - Mock ConvertFrom-CWAASecurity { return 'decryptedpwd' } - Mock Get-CWAASettings { - [PSCustomObject]@{ ProxyServerURL = 'http://proxy.example.com:8080' } - } - Get-CWAAProxy - } - $result.Enabled | Should -BeTrue - $result.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' - } - - It 'decodes proxy username and password via ConvertFrom-CWAASecurity' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { - [PSCustomObject]@{ ServerPassword = 'fakeEncoded'; Password = 'fakeAgentPwd' } - } - Mock ConvertFrom-CWAASecurity { return 'decryptedValue' } - Mock Get-CWAASettings { - [PSCustomObject]@{ - ProxyServerURL = 'http://proxy.example.com:8080' - ProxyUsername = 'encryptedUser' - ProxyPassword = 'encryptedPass' - } - } - Get-CWAAProxy - - # ConvertFrom-CWAASecurity should be called for ServerPassword, Password, ProxyUsername, and ProxyPassword - Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 4 - - # Verify the decryption chain: ServerPassword decoded first, then Password uses it as Key - Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { - $InputString -eq 'fakeEncoded' - } - Should -Invoke ConvertFrom-CWAASecurity -Scope It -Times 1 -ParameterFilter { - $InputString -eq 'fakeAgentPwd' - } - } - } - - It 'populates ServerPasswordString in LTServiceKeys' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { - [PSCustomObject]@{ ServerPassword = 'fakeEncoded' } - } - Mock ConvertFrom-CWAASecurity { return 'theServerPwd' } - Mock Get-CWAASettings { [PSCustomObject]@{} } - - Get-CWAAProxy - - $Script:LTServiceKeys.ServerPasswordString | Should -Be 'theServerPwd' - } - } - - It 'returns the $Script:LTProxy object' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Get-CWAASettings { return $null } - Get-CWAAProxy - } - $result | Should -Not -BeNullOrEmpty - $result | Get-Member -Name 'Enabled' | Should -Not -BeNullOrEmpty - $result | Get-Member -Name 'ProxyServerURL' | Should -Not -BeNullOrEmpty - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Restart-CWAA' { - - It 'writes error when services are not found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { return $null } - $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Services NOT Found' - } - } - - It 'calls Stop-CWAA then Start-CWAA on success' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - - Restart-CWAA -Confirm:$false - } - $result | Should -Match 'restarted successfully' - } - - It 'writes error and stops when Stop-CWAA throws' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA { throw 'Stop failed' } - Mock Start-CWAA {} - - $null = Restart-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'error stopping' - Should -Invoke Start-CWAA -Times 0 -Scope It - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Stop-CWAA' { - - It 'writes error when services are not found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { return $null } - $null = Stop-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Services NOT Found' - } - } - - It 'outputs success message when services stop' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } - Mock Invoke-CWAACommand {} - Mock Get-Process { @() } - Mock Stop-Process {} - Mock Start-Sleep {} - - Stop-CWAA -Confirm:$false - } - $result | Should -Match 'stopped successfully' - } - - It 'sends Kill VNC and Kill Trays commands' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } - Mock Invoke-CWAACommand {} - Mock Get-Process { @() } - Mock Stop-Process {} - Mock Start-Sleep {} - - Stop-CWAA -Confirm:$false - - Should -Invoke Invoke-CWAACommand -Times 1 -Scope It - } - } - - It 'attempts to terminate LabTech processes' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Stopped' } } - Mock Invoke-CWAACommand {} - Mock Get-Process { @() } - Mock Stop-Process {} - Mock Start-Sleep {} - - Stop-CWAA -Confirm:$false - - Should -Invoke Get-Process -Scope It - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Start-CWAA' { - - It 'writes error when services are not found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Get-Service { return $null } - $null = Start-CWAA -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Services NOT Found' - } - } - - It 'outputs success when services reach running state' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Set-Service {} - Mock Invoke-CWAACommand {} - Mock Start-Sleep {} - Mock Stop-Process {} - - Start-CWAA -Confirm:$false - } - $result | Should -Match 'started successfully' - } - - It 'sends Send Status command after successful start' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Set-Service {} - Mock Invoke-CWAACommand {} - Mock Start-Sleep {} - Mock Stop-Process {} - - Start-CWAA -Confirm:$false - - Should -Invoke Invoke-CWAACommand -Times 1 -Scope It -ParameterFilter { $Command -eq 'Send Status' } - } - } -} - -# ============================================================================= -# Tier 3: Orchestration Logic -# ============================================================================= - -Describe 'Reset-CWAA' { - - It 'resets all three values when no switches specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - Reset-CWAA -NoWait -Confirm:$false - - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } - } - } - - It 'resets only ID when -ID specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - Reset-CWAA -ID -NoWait -Confirm:$false - - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 1 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 - } - } - - It 'resets only LocationID when -Location specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - Reset-CWAA -Location -NoWait -Confirm:$false - - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 1 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 0 - } - } - - It 'resets only MAC when -MAC specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - Reset-CWAA -MAC -NoWait -Confirm:$false - - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'MAC' } -Times 1 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'ID' } -Times 0 - Should -Invoke Remove-ItemProperty -Scope It -ParameterFilter { $Name -eq 'LocationID' } -Times 0 - } - } - - It 'throws terminating error when probe detected without -Force' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - - Reset-CWAA -NoWait -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Probe*Denied*' - } - - It 'proceeds when probe detected with -Force' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; Probe = '1'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - { Reset-CWAA -Force -NoWait -Confirm:$false } | Should -Not -Throw - } - } - - It 'writes error when services are not found' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '100'; LocationID = '1'; MAC = 'AA:BB:CC' } } - Mock Get-Service { return $null } - - $null = Reset-CWAA -NoWait -Confirm:$false -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'Services NOT Found' - } - } - - It 'outputs OLD ID line with current values' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '42'; LocationID = '7'; MAC = 'DE:AD:BE:EF' } } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Remove-ItemProperty {} - Mock Start-Sleep {} - - Reset-CWAA -NoWait -Confirm:$false - } - $result | Should -Contain 'OLD ID: 42 LocationID: 7 MAC: DE:AD:BE:EF' - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Redo-CWAA' { - - It 'reads server from current agent settings' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - Mock Uninstall-CWAA {} - Mock Install-CWAA {} - Mock Start-Sleep {} - - Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue - - Should -Invoke Uninstall-CWAA -Times 1 -Scope It - Should -Invoke Install-CWAA -Times 1 -Scope It - } - } - - It 'falls back to backup settings when current is null' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com'); LocationID = '2' } } - Mock Uninstall-CWAA {} - Mock Install-CWAA {} - Mock Start-Sleep {} - - Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue - - Should -Invoke Install-CWAA -Times 1 -Scope It - } - } - - It 'throws terminating error when probe detected without -Force' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - - Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Probe*Denied*' - } - - It 'proceeds when probe detected with -Force' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - Mock Uninstall-CWAA {} - Mock Install-CWAA {} - Mock Start-Sleep {} - - { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw - } - } - - It 'calls New-CWAABackup when -Backup specified' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - Mock New-CWAABackup {} - Mock Uninstall-CWAA {} - Mock Install-CWAA {} - Mock Start-Sleep {} - - Redo-CWAA -InstallerToken 'abc123' -Backup -Confirm:$false -ErrorAction SilentlyContinue - - Should -Invoke New-CWAABackup -Times 1 -Scope It - } - } -} - -# ============================================================================= -# Tier 4: Installation Functions (Batch A) -# ============================================================================= - -Describe 'Install-CWAA' { - - Context 'parameter validation' { - It 'rejects an invalid server address format' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Install-CWAA -Server 'not a valid server!@#' -LocationID 1 -Confirm:$false -ErrorAction Stop - } - } | Should -Throw - } - - It 'rejects InstallerToken with invalid characters' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Install-CWAA -Server 'automate.example.com' -InstallerToken 'INVALID-TOKEN!' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw - } - } - - Context 'when services are already installed' { - It 'writes a terminating error without -Force' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*already installed*' - } - } - - # Note: Tests requiring admin bypass (e.g., download, msiexec) are not feasible - # in mocked tests because Install-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() - # which is a .NET static method that cannot be Pester-mocked. Those paths are covered - # by the Live integration test suite instead. - Context 'when not running as administrator' { - It 'throws Needs to be ran as Administrator when services are not detected' { - # When no services are found and not running elevated, the admin check fires - $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | - Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') - if ($isAdmin) { - Set-ItResult -Skipped -Because 'Test requires non-admin context' - } - else { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-Service { return $null } - Install-CWAA -Server 'automate.example.com' -InstallerToken 'abc123' -SkipDotNet -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Administrator*' - } - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Uninstall-CWAA' { - - # Note: Uninstall-CWAA uses [System.Security.Principal.WindowsIdentity]::GetCurrent() - # which is a .NET static method that cannot be Pester-mocked. Tests that need to get past - # the admin check are conditioned on actually running elevated, or skipped. - - Context 'when not running as administrator' { - It 'throws Needs to be ran as Administrator' { - $isAdmin = [bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | - Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544') - if ($isAdmin) { - Set-ItResult -Skipped -Because 'Test requires non-admin context' - } - else { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAAInfo { return $null } - Uninstall-CWAA -Server 'automate.example.com' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Administrator*' - } - } - } - - Context 'parameter validation' { - It 'rejects an invalid server address format' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Uninstall-CWAA -Server 'not a valid server!@#' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw - } - } - - Context 'probe detection logic via Redo-CWAA integration' { - # Since Uninstall-CWAA requires admin, we test probe detection through Redo-CWAA - # which calls Uninstall-CWAA internally and does its own probe check first. - It 'Redo-CWAA refuses probe uninstall without -Force (tests same probe logic)' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - Redo-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Probe*Denied*' - } - - It 'Redo-CWAA proceeds with -Force past probe detection' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Probe = '1'; Server = @('automate.example.com'); LocationID = '1' } } - Mock Get-CWAAInfoBackup { return $null } - Mock Uninstall-CWAA {} - Mock Install-CWAA {} - Mock Start-Sleep {} - - { Redo-CWAA -InstallerToken 'abc123' -Force -Confirm:$false -ErrorAction SilentlyContinue } | Should -Not -Throw - } - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Update-CWAA' { - - Context 'when no existing installation is found' { - It 'writes a terminating error' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAAInfo { return $null } - - Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*No existing installation*' - } - } - - Context 'when installed version is higher than requested' { - It 'writes a warning and returns' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '220.100'; Server = @('automate.example.com') } } - Mock Test-Path { return $false } - - # Capture warnings via -WarningVariable. The Process block emits download warnings, - # then the End block emits the version comparison warning. - $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn - "$testWarn" | Should -Match 'higher than or equal' - } - } - } - - Context 'when installed version equals requested version' { - It 'writes a warning about equal version' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAAInfo { [PSCustomObject]@{ Version = '200.100'; Server = @('automate.example.com') } } - Mock Test-Path { return $false } - - $null = Update-CWAA -Version '200.100' -Confirm:$false -ErrorAction SilentlyContinue -WarningVariable testWarn - "$testWarn" | Should -Match 'higher than or equal' - } - } - } -} - -# ============================================================================= -# Tier 5: Health & Connectivity Functions (Batch B) -# ============================================================================= - -Describe 'Repair-CWAA' { - - Context 'when agent is healthy' { - It 'returns ActionTaken=None with success' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - HeartbeatLastReceived = (Get-Date).AddMinutes(-15).ToString() - } - } - Mock Write-CWAAEventLog {} - - Repair-CWAA -InstallerToken 'abc123' -Confirm:$false - } - $result.ActionTaken | Should -Be 'None' - $result.Success | Should -BeTrue - $result.Message | Should -Match 'healthy' - } - } - - Context 'when agent is offline beyond restart threshold' { - It 'restarts services and reports recovery' { - $script:callCount = 0 - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - # First call returns old LastSuccessStatus, subsequent calls return recent - Mock Get-CWAAInfo { - $script:callCount++ - if ($script:callCount -le 1) { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddHours(-3).ToString() - HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() - HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() - } - } - else { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-1).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-1).ToString() - HeartbeatLastReceived = (Get-Date).AddMinutes(-1).ToString() - } - } - } - Mock Test-CWAAServerConnectivity { return $true } - Mock Restart-CWAA {} - Mock Start-Sleep {} - Mock Write-CWAAEventLog {} - - Repair-CWAA -InstallerToken 'abc123' -Confirm:$false - } - $result.ActionTaken | Should -Be 'Restart' - $result.Message | Should -Match 'recovered' - } - } - - Context 'when agent is offline beyond reinstall threshold' { - It 'triggers reinstall after failed restart' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - # Return old date consistently. The wait loop calls Get-CWAAInfo - # for up to 2 minutes. To prevent spinning for 120s, mock Get-Date - # to jump past the 2-minute window after initial threshold calculations. - $Script:RepairDateCallCount = 0 - Mock Get-Date { - $Script:RepairDateCallCount++ - if ($Script:RepairDateCallCount -le 4) { - return [datetime]::Now - } - else { - return [datetime]::Now.AddMinutes(5) - } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = [datetime]::Now.AddDays(-10).ToString() - HeartbeatLastSent = [datetime]::Now.AddDays(-10).ToString() - HeartbeatLastReceived = [datetime]::Now.AddDays(-10).ToString() - } - } - Mock Test-CWAAServerConnectivity { return $true } - Mock Restart-CWAA {} - Mock Start-Sleep {} - Mock Redo-CWAA {} - Mock Clear-CWAAInstallerArtifacts {} - Mock Write-CWAAEventLog {} - - Repair-CWAA -InstallerToken 'abc123' -Confirm:$false - } - $result.ActionTaken | Should -Be 'Reinstall' - } - } - - Context 'when agent is not installed' { - It 'attempts a fresh install with provided parameters' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { return $null } - Mock Redo-CWAA {} - Mock Clear-CWAAInstallerArtifacts {} - Mock Write-CWAAEventLog {} - - Repair-CWAA -Server 'automate.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false - } - $result.ActionTaken | Should -Be 'Install' - $result.Message | Should -Match 'Fresh agent install' - } - - It 'reports error when no install settings are available' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { return $null } - Mock Get-CWAAInfo { throw 'Not installed' } - Mock Get-CWAAInfoBackup { return $null } - Mock Write-CWAAEventLog {} - - Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue - } - $result.Success | Should -BeFalse - $result.Message | Should -Match 'Unable to find install settings' - } - } - - Context 'when server is not reachable' { - It 'returns error about unreachable server' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddHours(-3).ToString() - HeartbeatLastSent = (Get-Date).AddHours(-3).ToString() - HeartbeatLastReceived = (Get-Date).AddHours(-3).ToString() - } - } - Mock Test-CWAAServerConnectivity { return $false } - Mock Write-CWAAEventLog {} - - Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -ErrorAction SilentlyContinue - } - $result.Success | Should -BeFalse - $result.Message | Should -Match 'not reachable' - } - } - - Context 'when agent points to wrong server' { - It 'reinstalls with the correct server' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CimInstance { @() } - Mock Stop-Process {} - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('wrong.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - } - } - Mock Redo-CWAA {} - Mock Clear-CWAAInstallerArtifacts {} - Mock Write-CWAAEventLog {} - - Repair-CWAA -Server 'correct.example.com' -LocationID 42 -InstallerToken 'abc123' -Confirm:$false - } - $result.ActionTaken | Should -Be 'Reinstall' - $result.Message | Should -Match 'correct server' - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Test-CWAAHealth' { - - Context 'when agent is fully healthy' { - It 'returns a health object with Healthy=$true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - } - } - - Test-CWAAHealth - } - $result.AgentInstalled | Should -BeTrue - $result.ServicesRunning | Should -BeTrue - $result.Healthy | Should -BeTrue - $result.LastContact | Should -Not -BeNullOrEmpty - } - - It 'returns correct object structure with all expected properties' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - } - } - - Test-CWAAHealth - } - $memberNames = ($result | Get-Member -MemberType NoteProperty).Name - $memberNames | Should -Contain 'AgentInstalled' - $memberNames | Should -Contain 'ServicesRunning' - $memberNames | Should -Contain 'LastContact' - $memberNames | Should -Contain 'LastHeartbeat' - $memberNames | Should -Contain 'ServerAddress' - $memberNames | Should -Contain 'ServerMatch' - $memberNames | Should -Contain 'ServerReachable' - $memberNames | Should -Contain 'Healthy' - } - } - - Context 'when services are stopped' { - It 'returns Healthy=$false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Stopped' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - } - } - - Test-CWAAHealth - } - $result.AgentInstalled | Should -BeTrue - $result.ServicesRunning | Should -BeFalse - $result.Healthy | Should -BeFalse - } - } - - Context 'when agent is not installed' { - It 'returns AgentInstalled=$false and Healthy=$false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { return $null } - - Test-CWAAHealth - } - $result.AgentInstalled | Should -BeFalse - $result.ServicesRunning | Should -BeFalse - $result.Healthy | Should -BeFalse - $result.LastContact | Should -BeNullOrEmpty - } - } - - Context 'when -Server parameter is provided' { - It 'sets ServerMatch=$true when server matches' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).ToString() - } - } - - Test-CWAAHealth -Server 'automate.example.com' - } - $result.ServerMatch | Should -BeTrue - } - - It 'sets ServerMatch=$false when server does not match' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('other.example.com') - LastSuccessStatus = (Get-Date).ToString() - } - } - - Test-CWAAHealth -Server 'automate.example.com' - } - $result.ServerMatch | Should -BeFalse - } - } - - Context 'when -TestServerConnectivity is used' { - It 'sets ServerReachable from Test-CWAAServerConnectivity' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).ToString() - } - } - Mock Test-CWAAServerConnectivity { return $true } - - Test-CWAAHealth -TestServerConnectivity - } - $result.ServerReachable | Should -BeTrue - } - } - - Context 'when agent info cannot be read' { - It 'returns Healthy=$false with null timestamps' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { throw 'Registry read error' } - - Test-CWAAHealth - } - $result.AgentInstalled | Should -BeTrue - $result.LastContact | Should -BeNullOrEmpty - $result.Healthy | Should -BeFalse - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Register-CWAAHealthCheckTask' { - - Context 'when task does not exist' { - It 'creates a new scheduled task and returns Created=$true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { throw 'Task not found' } - elseif ($args -contains '/DELETE') { return $null } - elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } - } - Mock New-CWAABackup {} - Mock Remove-Item {} - Mock Write-CWAAEventLog {} - - Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false - } - $result.Created | Should -BeTrue - $result.Updated | Should -BeFalse - $result.TaskName | Should -Be 'CWAAHealthCheck' - } - } - - Context 'when task exists with matching token' { - It 'skips recreation and returns Created=$false, Updated=$false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { - # Return XML that contains the token in the Arguments element - return '-Command "Repair-CWAA -InstallerToken abc123"' - } - } - - Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false - } - $result.Created | Should -BeFalse - $result.Updated | Should -BeFalse - } - } - - Context 'when -Force is used with existing task' { - It 'recreates the task' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { - return '-Command "Repair-CWAA -InstallerToken abc123"' - } - elseif ($args -contains '/DELETE') { return $null } - elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } - } - Mock New-CWAABackup {} - Mock Remove-Item {} - Mock Write-CWAAEventLog {} - - Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Force -Confirm:$false - } - ($result.Created -or $result.Updated) | Should -BeTrue - } - } - - Context 'when custom parameters are provided' { - It 'accepts custom TaskName and IntervalHours' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { throw 'Task not found' } - elseif ($args -contains '/DELETE') { return $null } - elseif ($args -contains '/CREATE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } - } - Mock New-CWAABackup {} - Mock Remove-Item {} - Mock Write-CWAAEventLog {} - - Register-CWAAHealthCheckTask -InstallerToken 'abc123' -TaskName 'MyHealthCheck' -IntervalHours 12 -Confirm:$false - } - $result.TaskName | Should -Be 'MyHealthCheck' - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Unregister-CWAAHealthCheckTask' { - - Context 'when task exists' { - It 'removes the task and returns Removed=$true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } - elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } - } - Mock Write-CWAAEventLog {} - - Unregister-CWAAHealthCheckTask -Confirm:$false - } - $result.Removed | Should -BeTrue - $result.TaskName | Should -Be 'CWAAHealthCheck' - } - } - - Context 'when task does not exist' { - It 'writes a warning and returns Removed=$false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { $global:LASTEXITCODE = 1; return $null } - } - - Unregister-CWAAHealthCheckTask -Confirm:$false 3>&1 | Out-Null - Unregister-CWAAHealthCheckTask -Confirm:$false - } - $result.Removed | Should -BeFalse - } - } - - Context 'when custom TaskName is provided' { - It 'targets the correct task name' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { - if ($args -contains '/QUERY') { $global:LASTEXITCODE = 0; return 'TaskInfo' } - elseif ($args -contains '/DELETE') { $global:LASTEXITCODE = 0; return 'SUCCESS' } - } - Mock Write-CWAAEventLog {} - - Unregister-CWAAHealthCheckTask -TaskName 'CustomTask' -Confirm:$false - } - $result.TaskName | Should -Be 'CustomTask' - $result.Removed | Should -BeTrue - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Test-CWAAServerConnectivity' { - - Context 'when server responds with valid agent pattern' { - It 'returns Available=$true with version' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - # The agent.aspx response pattern requires 6+ consecutive pipes before the version - Mock Invoke-RestMethod { return '||||||220.105' } - - Test-CWAAServerConnectivity -Server 'automate.example.com' - } - $result.Available | Should -BeTrue - $result.Version | Should -Be '220.105' - $result.ErrorMessage | Should -BeNullOrEmpty - } - } - - Context 'when server responds with unexpected format' { - It 'returns Available=$false with error message' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { return 'not a valid response' } - - Test-CWAAServerConnectivity -Server 'automate.example.com' - } - $result.Available | Should -BeFalse - $result.ErrorMessage | Should -Match 'unexpected format' - } - } - - Context 'when server is unreachable' { - It 'returns Available=$false with error message' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { throw 'Connection refused' } - - Test-CWAAServerConnectivity -Server 'automate.example.com' - } - $result.Available | Should -BeFalse - $result.ErrorMessage | Should -Match 'Connection refused' - } - } - - Context 'when -Quiet mode is used' { - It 'returns $true when server is reachable' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { return '||||||220.105' } - - Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet - } - $result | Should -BeTrue - } - - It 'returns $false when server is unreachable' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { throw 'Connection refused' } - - Test-CWAAServerConnectivity -Server 'automate.example.com' -Quiet - } - $result | Should -BeFalse - } - } - - Context 'when no server is provided' { - It 'discovers server from agent config' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ Server = @('discovered.example.com') } } - Mock Get-CWAAInfoBackup { return $null } - Mock Invoke-RestMethod { return '||||||220.105' } - - Test-CWAAServerConnectivity - } - $result.Server | Should -Be 'discovered.example.com' - $result.Available | Should -BeTrue - } - - It 'falls back to backup when agent config has no server' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - # Return an object without a Server property so Select-Object -Expand 'Server' returns nothing - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } - Mock Get-CWAAInfoBackup { [PSCustomObject]@{ Server = @('backup.example.com') } } - Mock Invoke-RestMethod { return '||||||220.105' } - - Test-CWAAServerConnectivity - } - $result.Server | Should -Be 'backup.example.com' - } - - It 'writes error when no server can be determined' { - InModuleScope 'ConnectWiseAutomateAgent' { - # Return objects without a Server property so the function sees no server - Mock Get-CWAAInfo { [PSCustomObject]@{ ID = '1' } } - Mock Get-CWAAInfoBackup { [PSCustomObject]@{ ID = '1' } } - - $null = Test-CWAAServerConnectivity -ErrorAction SilentlyContinue -ErrorVariable testErr - $testErr | Should -Not -BeNullOrEmpty - "$testErr" | Should -Match 'No server could be determined' - } - } - } -} - -# ============================================================================= -# Tier 6: Remaining Functions (Batch C) -# ============================================================================= - -Describe 'Test-CWAAPort' { - - Context 'when TrayPort is available in Quiet mode' { - It 'returns $true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } - # netstat returns no matching output for the port - $env_windir = $env:windir - Mock Invoke-Expression { return $null } - # Mock netstat by ensuring no process is found on the port - function netstat { return @() } - Test-CWAAPort -TrayPort 42000 -Quiet - } - $result | Should -BeTrue - } - } - - Context 'when TrayPort is in use in non-Quiet mode' { - It 'outputs a message about the port being in use' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000'; Server = @('automate.example.com') } } - Mock Get-CWAAInfoBackup { return $null } - Mock Get-Process { [PSCustomObject]@{ ProcessName = 'LTSvc'; Id = 1234 } } - Mock Test-Connection { return $true } - # Mock netstat to return a line matching the port with a PID - $Script:MockNetstatOutput = " TCP 0.0.0.0:42000 0.0.0.0:0 LISTENING 1234" - - # We need to test the output message - Test-CWAAPort -TrayPort 42000 -Server 'automate.example.com' 2>&1 - } - # The function produces port-related output - $result | Should -Not -BeNullOrEmpty - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Set-CWAAProxy' { - - BeforeEach { - # Reset module proxy state and ensure LTServiceNetWebClient exists before each test. - # Set-CWAAProxy assigns to $Script:LTServiceNetWebClient.Proxy which requires a real - # object with a Proxy property (WebClient). When Initialize-CWAANetworking is mocked, - # this object may not be initialized. - $module = Get-Module 'ConnectWiseAutomateAgent' - & $module { - $Script:LTProxy.Enabled = $False - $Script:LTProxy.ProxyServerURL = '' - $Script:LTProxy.ProxyUsername = '' - $Script:LTProxy.ProxyPassword = '' - if (-not $Script:LTServiceNetWebClient) { - $Script:LTServiceNetWebClient = New-Object System.Net.WebClient - } - if (-not $Script:LTWebProxy) { - $Script:LTWebProxy = New-Object System.Net.WebProxy - } - } - } - - Context 'when -ResetProxy is used' { - It 'clears proxy settings' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { return $null } - Mock Get-Service { return $null } - - # Set proxy first - $Script:LTProxy.Enabled = $True - $Script:LTProxy.ProxyServerURL = 'http://proxy.example.com:8080' - - Set-CWAAProxy -ResetProxy -Confirm:$false - - $Script:LTProxy.Enabled | Should -BeFalse - $Script:LTProxy.ProxyServerURL | Should -Be '' - } - } - } - - Context 'when -ProxyServerURL is provided' { - It 'sets the proxy URL and enables proxy' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { return $null } - Mock Get-Service { return $null } - - Set-CWAAProxy -ProxyServerURL 'http://proxy.example.com:8080' -Confirm:$false - - $Script:LTProxy.Enabled | Should -BeTrue - $Script:LTProxy.ProxyServerURL | Should -Be 'http://proxy.example.com:8080' - } - } - } - - Context 'when invalid parameter combinations are used' { - It 'throws error for ResetProxy with ProxyServerURL' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { return $null } - - Set-CWAAProxy -ResetProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Invalid parameter combination*' - } - - It 'throws error for DetectProxy with ProxyServerURL' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { return $null } - - Set-CWAAProxy -DetectProxy -ProxyServerURL 'http://proxy.example.com' -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Invalid parameter combination*' - } - } - - Context 'when proxy changes require service restart' { - It 'restarts services when settings change and services are running' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { - [PSCustomObject]@{ - ProxyServerURL = 'http://old-proxy.example.com:8080' - ProxyUsername = '' - ProxyPassword = '' - } - } - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock ConvertTo-CWAASecurity { return 'encoded' } - Mock ConvertFrom-CWAASecurity { return '' } - Mock Write-CWAAEventLog {} - - Set-CWAAProxy -ProxyServerURL 'http://new-proxy.example.com:8080' -Confirm:$false - - Should -Invoke Stop-CWAA -Scope It - Should -Invoke Start-CWAA -Scope It - } - } - } - - Context 'when no parameters are provided' { - It 'writes error about missing parameters' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Initialize-CWAANetworking {} - Mock Get-CWAASettings { return $null } - - Set-CWAAProxy -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*parameters missing*' - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'New-CWAABackup' { - - Context 'when agent is not installed' { - It 'writes terminating error when BasePath is not found' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { return $null } - - New-CWAABackup -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Unable to find LTSvc folder path*' - } - } - - Context 'when registry key is missing' { - It 'writes terminating error about missing registry' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - Mock Test-Path { return $false } - - New-CWAABackup -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*Unable to find registry*' - } - } - - Context 'when agent is properly installed' { - It 'creates backup directory and copies files' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ BasePath = 'TestDrive:\LTSVC' } } - # First call for HKLM registry check, second for agent path - Mock Test-Path { return $true } - Mock New-Item {} - Mock Get-ChildItem { @() } - Mock Copy-Item {} - # Mock reg.exe operations - $env_windir = $env:windir - Mock Get-Content { @('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech]', '"Key"="Value"') } - Mock Out-File {} - Mock Write-CWAAEventLog {} - - $result = New-CWAABackup -Confirm:$false -ErrorAction SilentlyContinue - - Should -Invoke New-Item -Scope It - } - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'ConvertTo-CWAASecurity additional edge cases' { - - It 'returns empty string for empty input' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - ConvertTo-CWAASecurity -InputString '' - } - # Empty input still produces an encoded output (encrypted empty string) - $result | Should -Not -BeNullOrEmpty - } - - It 'returns empty string for null key (uses default)' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - ConvertTo-CWAASecurity -InputString 'TestValue' -Key $null - } - $result | Should -Not -BeNullOrEmpty - } - - It 'produces different output for different keys' { - $result1 = InModuleScope 'ConnectWiseAutomateAgent' { - ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' - } - $result2 = InModuleScope 'ConnectWiseAutomateAgent' { - ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' - } - $result1 | Should -Not -Be $result2 - } - - It 'round-trips successfully with ConvertFrom-CWAASecurity using same key' { - $originalValue = 'RoundTripTestValue' - $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { - param($testValue) - $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key 'TestKey123' - ConvertFrom-CWAASecurity -InputString $encoded -Key 'TestKey123' -Force:$false - } - $result | Should -Be $originalValue - } - - It 'round-trips with default key' { - $originalValue = 'DefaultKeyRoundTrip' - $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { - param($testValue) - $encoded = ConvertTo-CWAASecurity -InputString $testValue - ConvertFrom-CWAASecurity -InputString $encoded -Force:$false - } - $result | Should -Be $originalValue - } - - It 'handles special characters in input' { - $originalValue = 'P@ssw0rd!#$%^&*()' - $result = InModuleScope 'ConnectWiseAutomateAgent' -ArgumentList $originalValue { - param($testValue) - $encoded = ConvertTo-CWAASecurity -InputString $testValue - ConvertFrom-CWAASecurity -InputString $encoded -Force:$false - } - $result | Should -Be $originalValue - } -} - -# ----------------------------------------------------------------------------- - -Describe 'ConvertFrom-CWAASecurity additional edge cases' { - - It 'returns null for invalid base64 input without Force' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - ConvertFrom-CWAASecurity -InputString 'not-valid-base64!!!' -Key 'TestKey' -Force:$false - } - $result | Should -BeNullOrEmpty - } - - It 'returns null when wrong key is used without Force' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'CorrectKey' - ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$false - } - $result | Should -BeNullOrEmpty - } - - It 'falls back to alternate keys when Force is enabled and primary key fails' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - # Encode with default key - $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' - # Try to decode with wrong key but Force enabled (should fall back to default) - ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$true - } - $result | Should -Be 'TestValue' - } - - It 'handles empty key by using default' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $encoded = ConvertTo-CWAASecurity -InputString 'TestValue' -Key '' - ConvertFrom-CWAASecurity -InputString $encoded -Key '' -Force:$false - } - $result | Should -Be 'TestValue' - } - - It 'handles array of input strings' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $encoded1 = ConvertTo-CWAASecurity -InputString 'Value1' - $encoded2 = ConvertTo-CWAASecurity -InputString 'Value2' - ConvertFrom-CWAASecurity -InputString @($encoded1, $encoded2) -Force:$false - } - $result | Should -HaveCount 2 - $result[0] | Should -Be 'Value1' - $result[1] | Should -Be 'Value2' - } - - It 'rejects empty string due to mandatory parameter validation' { - # ConvertFrom-CWAASecurity has [parameter(Mandatory = $true)] [string[]]$InputString - # which prevents binding an empty string. This confirms the validation fires. - { - InModuleScope 'ConnectWiseAutomateAgent' { - ConvertFrom-CWAASecurity -InputString '' -Force:$false -ErrorAction Stop - } - } | Should -Throw - } -} - -# ============================================================================= -# Private Helper Functions -# ============================================================================= - -Describe 'Test-CWAADownloadIntegrity' { - - Context 'when file exists and exceeds minimum size' { - It 'returns true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testFile = Join-Path $env:TEMP 'CWAATestIntegrity.msi' - # Create a file larger than 1234 KB (write ~1300 KB) - $bytes = New-Object byte[] (1300 * 1024) - [System.IO.File]::WriteAllBytes($testFile, $bytes) - try { - Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestIntegrity.msi' - } - finally { - Remove-Item $testFile -Force -ErrorAction SilentlyContinue - } - } - $result | Should -Be $true - } - } - - Context 'when file exists but is below minimum size' { - It 'returns false and removes the file' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testFile = Join-Path $env:TEMP 'CWAATestSmall.msi' - # Create a file smaller than 1234 KB (write 10 KB) - $bytes = New-Object byte[] (10 * 1024) - [System.IO.File]::WriteAllBytes($testFile, $bytes) - $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestSmall.msi' -WarningAction SilentlyContinue - $fileStillExists = Test-Path $testFile - [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } - } - $result.Result | Should -Be $false - $result.FileExists | Should -Be $false - } - } - - Context 'when file does not exist' { - It 'returns false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Test-CWAADownloadIntegrity -FilePath 'C:\NonExistent\FakeFile.msi' -FileName 'FakeFile.msi' - } - $result | Should -Be $false - } - } - - Context 'with custom MinimumSizeKB threshold' { - It 'uses the custom threshold for validation' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testFile = Join-Path $env:TEMP 'CWAATestCustom.exe' - # Create a 100 KB file, check with 80 KB threshold - $bytes = New-Object byte[] (100 * 1024) - [System.IO.File]::WriteAllBytes($testFile, $bytes) - try { - Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustom.exe' -MinimumSizeKB 80 - } - finally { - Remove-Item $testFile -Force -ErrorAction SilentlyContinue - } - } - $result | Should -Be $true - } - - It 'fails when file is below custom threshold' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testFile = Join-Path $env:TEMP 'CWAATestCustomFail.exe' - # Create a 50 KB file, check with 80 KB threshold - $bytes = New-Object byte[] (50 * 1024) - [System.IO.File]::WriteAllBytes($testFile, $bytes) - $checkResult = Test-CWAADownloadIntegrity -FilePath $testFile -FileName 'CWAATestCustomFail.exe' -MinimumSizeKB 80 -WarningAction SilentlyContinue - $fileStillExists = Test-Path $testFile - [PSCustomObject]@{ Result = $checkResult; FileExists = $fileStillExists } - } - $result.Result | Should -Be $false - $result.FileExists | Should -Be $false - } - } - - Context 'when FileName is not provided' { - It 'derives the filename from the path' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testFile = Join-Path $env:TEMP 'CWAATestDerived.msi' - $bytes = New-Object byte[] (1300 * 1024) - [System.IO.File]::WriteAllBytes($testFile, $bytes) - try { - Test-CWAADownloadIntegrity -FilePath $testFile - } - finally { - Remove-Item $testFile -Force -ErrorAction SilentlyContinue - } - } - $result | Should -Be $true - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Remove-CWAAFolderRecursive' { - - Context 'when folder exists with nested content' { - It 'removes the folder and all contents' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testRoot = Join-Path $env:TEMP 'CWAATestRemoveFolder' - $subDir = Join-Path $testRoot 'SubFolder' - New-Item -Path $subDir -ItemType Directory -Force | Out-Null - Set-Content -Path (Join-Path $testRoot 'file1.txt') -Value 'test' - Set-Content -Path (Join-Path $subDir 'file2.txt') -Value 'test' - Remove-CWAAFolderRecursive -Path $testRoot -Confirm:$false - Test-Path $testRoot - } - $result | Should -Be $false - } - } - - Context 'when folder does not exist' { - It 'completes without error' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Remove-CWAAFolderRecursive -Path 'C:\NonExistent\CWAATestFolder' -Confirm:$false - } - } | Should -Not -Throw - } - } - - Context 'when called with -WhatIf' { - It 'does not actually remove the folder' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $testRoot = Join-Path $env:TEMP 'CWAATestWhatIf' - New-Item -Path $testRoot -ItemType Directory -Force | Out-Null - Set-Content -Path (Join-Path $testRoot 'file.txt') -Value 'test' - Remove-CWAAFolderRecursive -Path $testRoot -WhatIf -Confirm:$false - $exists = Test-Path $testRoot - # Clean up for real - Remove-Item $testRoot -Recurse -Force -ErrorAction SilentlyContinue - $exists - } - $result | Should -Be $true - } - } -} - -# ----------------------------------------------------------------------------- - -Describe 'Resolve-CWAAServer' { - - Context 'when server responds with valid version' { - It 'returns the server URL and version' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - return '||||||220.105' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('https://automate.example.com') - } - $result | Should -Not -BeNullOrEmpty - $result.ServerUrl | Should -Match 'automate\.example\.com' - $result.ServerVersion | Should -Be '220.105' - } - } - - Context 'when server URL has no scheme' { - It 'normalizes the URL and still resolves' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - return '||||||230.001' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('automate.example.com') - } - $result | Should -Not -BeNullOrEmpty - $result.ServerVersion | Should -Be '230.001' - } - } - - Context 'when server returns no parseable version' { - It 'returns null' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - return 'no version data here' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue - } - $result | Should -BeNullOrEmpty - } - } - - Context 'when server is unreachable' { - It 'returns null' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - throw 'Connection refused' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('https://automate.example.com') -WarningAction SilentlyContinue - } - $result | Should -BeNullOrEmpty - } - } - - Context 'when first server fails but second succeeds' { - It 'returns the second server' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $callCount = 0 - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - # Use the URL to determine behavior since $callCount scope is tricky - if ($url -match 'bad\.example\.com') { - throw 'Connection refused' - } - return '||||||210.050' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('https://bad.example.com', 'https://good.example.com') -WarningAction SilentlyContinue - } - $result | Should -Not -BeNullOrEmpty - $result.ServerUrl | Should -Match 'good\.example\.com' - $result.ServerVersion | Should -Be '210.050' - } - } - - Context 'when server URL is invalid format' { - It 'returns null and writes a warning' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $mockWebClient = New-Object PSObject - $mockWebClient | Add-Member -MemberType ScriptMethod -Name DownloadString -Value { - param($url) - return '||||||220.105' - } - $Script:LTServiceNetWebClient = $mockWebClient - Resolve-CWAAServer -Server @('https://automate.example.com/some/path') -WarningAction SilentlyContinue - } - $result | Should -BeNullOrEmpty - } - } -} - -# ============================================================================= -# Wait-CWAACondition Tests -# ============================================================================= - -Describe 'Wait-CWAACondition' { - - Context 'when condition is met immediately' { - It 'returns $true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $callCount = 0 - Wait-CWAACondition -Condition { - $script:callCount++ - $true - } -TimeoutSeconds 10 -IntervalSeconds 1 - } - $result | Should -Be $true - } - } - - Context 'when condition is met after initial failure' { - It 'returns $true after retrying' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $script:waitTestCounter = 0 - Wait-CWAACondition -Condition { - $script:waitTestCounter++ - $script:waitTestCounter -ge 2 - } -TimeoutSeconds 30 -IntervalSeconds 1 - } - $result | Should -Be $true - } - } - - Context 'when timeout is reached' { - It 'returns $false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Wait-CWAACondition -Condition { $false } -TimeoutSeconds 2 -IntervalSeconds 1 - } - $result | Should -Be $false - } - } - - Context 'parameter validation' { - It 'rejects TimeoutSeconds of 0' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Wait-CWAACondition -Condition { $true } -TimeoutSeconds 0 -IntervalSeconds 1 - } - } | Should -Throw - } - - It 'rejects IntervalSeconds of 0' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Wait-CWAACondition -Condition { $true } -TimeoutSeconds 10 -IntervalSeconds 0 - } - } | Should -Throw - } - } -} - -# ============================================================================= -# Test-CWAADotNetPrerequisite Tests -# ============================================================================= - -Describe 'Test-CWAADotNetPrerequisite' { - - Context 'when -SkipDotNet is specified' { - It 'returns $true immediately without checking registry' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ChildItem {} - Test-CWAADotNetPrerequisite -SkipDotNet - } - $result | Should -Be $true - } - } - - Context 'when .NET 3.5 is already installed' { - It 'returns $true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ChildItem { - [PSCustomObject]@{ PSChildName = 'Full' } - } - Mock Get-ItemProperty { - [PSCustomObject]@{ Version = '3.5.30729'; Release = $null; PSChildName = 'Full' } - } - Test-CWAADotNetPrerequisite -Confirm:$false - } - $result | Should -Be $true - } - } - - Context 'when .NET 3.5 is missing and -Force allows .NET 2.0+' { - It 'returns $true with a non-terminating error when .NET 4.x is present' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - # First call: initial check returns only 4.x - # Second call (after install attempt): still only 4.x - Mock Get-ChildItem { - [PSCustomObject]@{ PSChildName = 'Full' } - } - Mock Get-ItemProperty { - [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } - } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } - Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } - Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction SilentlyContinue - } - $result | Should -Be $true - } - } - - Context 'when .NET 3.5 is missing and no .NET 2.0+ with -Force' { - It 'throws a terminating error' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ChildItem { - [PSCustomObject]@{ PSChildName = 'Full' } - } - Mock Get-ItemProperty { - [PSCustomObject]@{ Version = '1.1.4322'; Release = $null; PSChildName = 'Full' } - } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } - Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } - Test-CWAADotNetPrerequisite -Force -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*2.0*' - } - } - - Context 'when .NET 3.5 is missing without -Force' { - It 'throws a terminating error' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ChildItem { - [PSCustomObject]@{ PSChildName = 'Full' } - } - Mock Get-ItemProperty { - [PSCustomObject]@{ Version = '4.8.03761'; Release = 528040; PSChildName = 'Full' } - } - Mock Get-WindowsOptionalFeature { [PSCustomObject]@{ State = 'Disabled' } } - Mock Enable-WindowsOptionalFeature { [PSCustomObject]@{ RestartNeeded = $false; State = 'Enabled' } } - Test-CWAADotNetPrerequisite -Confirm:$false -ErrorAction Stop - } - } | Should -Throw '*3.5*' - } - } -} - -# ============================================================================= -# Invoke-CWAAMsiInstaller Tests -# ============================================================================= - -Describe 'Invoke-CWAAMsiInstaller' { - - Context 'when service starts on first attempt' { - It 'returns $true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $script:msiCallCount = 0 - Mock Start-Process { - $script:msiCallCount++ - } - Mock Start-Sleep {} - # First call returns 0 (pre-install check), second call returns 1 (post-install) - $script:getServiceCallCount = 0 - Mock Get-Service { - $script:getServiceCallCount++ - if ($script:getServiceCallCount -ge 2) { - [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } - } - } - Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -Confirm:$false - } - $result | Should -Be $true - } - } - - Context 'when service starts on retry' { - It 'returns $true after retrying' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Start-Process {} - Mock Start-Sleep {} - Mock Wait-CWAACondition { $false } - # Service not present for first 4 calls (2 attempts x 2 checks each), then present - $script:svcCounter = 0 - Mock Get-Service { - $script:svcCounter++ - if ($script:svcCounter -ge 5) { - [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } - } - } - Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 3 -RetryDelaySeconds 1 -Confirm:$false - } - $result | Should -Be $true - } - } - - Context 'when service never starts' { - It 'returns $false after max attempts' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Start-Process {} - Mock Start-Sleep {} - Mock Wait-CWAACondition { $false } - Mock Get-Service {} - Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -MaxAttempts 2 -RetryDelaySeconds 1 -Confirm:$false -ErrorAction SilentlyContinue - } - $result | Should -Be $false - } - } - - Context 'when -WhatIf is specified' { - It 'returns $true without calling Start-Process' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Start-Process {} - Invoke-CWAAMsiInstaller -InstallerArguments '/i "test.msi" /qn' -WhatIf - } - $result | Should -Be $true - InModuleScope 'ConnectWiseAutomateAgent' { - Should -Invoke Start-Process -Times 0 - } - } - } -} - -# ============================================================================= -# Pipeline Support Tests -# ============================================================================= - -Describe 'Pipeline Support' { - - Context 'ConvertTo-CWAASecurity pipeline input' { - - It 'accepts a single string from pipeline' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - 'TestValue' | ConvertTo-CWAASecurity - } - $result | Should -Not -BeNullOrEmpty - } - - It 'accepts multiple strings from pipeline' { - $results = InModuleScope 'ConnectWiseAutomateAgent' { - 'Value1', 'Value2', 'Value3' | ConvertTo-CWAASecurity - } - $results | Should -HaveCount 3 - $results[0] | Should -Not -Be $results[1] - } - - It 'round-trips through pipeline with ConvertFrom-CWAASecurity' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - 'PipelineRoundTrip' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity - } - $result | Should -Be 'PipelineRoundTrip' - } - - It 'round-trips multiple values through pipeline' { - $results = InModuleScope 'ConnectWiseAutomateAgent' { - 'Alpha', 'Bravo', 'Charlie' | ConvertTo-CWAASecurity | ConvertFrom-CWAASecurity - } - $results | Should -HaveCount 3 - $results[0] | Should -Be 'Alpha' - $results[1] | Should -Be 'Bravo' - $results[2] | Should -Be 'Charlie' - } - } - - Context 'Rename-CWAAAddRemove pipeline input' { - - It 'accepts Name from pipeline' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-ItemProperty { [PSCustomObject]@{ DisplayName = 'LabTech' } } -ParameterFilter { $Name -eq 'DisplayName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'HiddenProductName' } - Mock Get-ItemProperty { return $null } -ParameterFilter { $Name -eq 'Publisher' } - Mock Set-ItemProperty {} - - 'Piped Agent Name' | Rename-CWAAAddRemove -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'DisplayName' -and $Value -eq 'Piped Agent Name' } - } - } - } - - Context 'Repair-CWAA Server ValueFromPipelineByPropertyName' { - - It 'accepts Server and LocationID from piped object' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Status = 'Running'; Name = 'LTService' } } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = 'https://automate.example.com' - LastSuccessStatus = (Get-Date).ToString() - HeartbeatLastSent = (Get-Date).ToString() - HeartbeatLastReceived = (Get-Date).ToString() - } - } - Mock Write-CWAAEventLog {} - Mock Get-CimInstance { return @() } - - # Pipe an object with Server and LocationID — bind via ValueFromPipelineByPropertyName - # InstallerToken is provided explicitly (it wouldn't come from Get-CWAAInfo output) - $inputObject = [PSCustomObject]@{ - Server = 'https://automate.example.com' - LocationID = 1 - } - $result = $inputObject | Repair-CWAA -InstallerToken 'abc123' -Confirm:$false -WarningAction SilentlyContinue - $result | Should -Not -BeNullOrEmpty - $result.ActionTaken | Should -Be 'None' - } - } - } - - Context 'Invoke-CWAACommand multiple values from pipeline' { - - It 'processes multiple commands piped as an array' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } } - 'Send Inventory', 'Send Apps' | Invoke-CWAACommand -Confirm:$false - } - ($result | Measure-Object).Count | Should -Be 2 - $result[0] | Should -Match 'Send Inventory' - $result[1] | Should -Match 'Send Apps' - } - } - - Context 'Set-CWAALogLevel pipeline input' { - - It 'accepts Level from pipeline' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Stop-CWAA {} - Mock Start-CWAA {} - Mock Set-ItemProperty {} - Mock Get-CWAALogLevel { 'Current logging level: Verbose' } - - 'Verbose' | Set-CWAALogLevel -Confirm:$false - - Should -Invoke Set-ItemProperty -Scope It -ParameterFilter { $Name -eq 'Debuging' -and $Value -eq 1000 } - } - } - } - - Context 'Test-CWAAServerConnectivity property-based pipeline' { - - It 'accepts Server from piped PSCustomObject' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { return '||||||220.105' } - - [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAServerConnectivity - } - $result.Available | Should -BeTrue - $result.Version | Should -Be '220.105' - } - } - - Context 'Test-CWAAHealth property-based pipeline' { - - It 'accepts Server from piped PSCustomObject' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('automate.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - } - } - - [PSCustomObject]@{ Server = 'automate.example.com' } | Test-CWAAHealth - } - $result.AgentInstalled | Should -BeTrue - $result.Healthy | Should -BeTrue - } - } - - Context 'Test-CWAAPort property-based pipeline' { - - It 'accepts Server and TrayPort from piped PSCustomObject' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-CWAAInfo { [PSCustomObject]@{ TrayPort = '42000' } } - Mock Invoke-Expression { return $null } - function netstat { return @() } - - [PSCustomObject]@{ Server = 'automate.example.com'; TrayPort = 42000 } | Test-CWAAPort -Quiet - } - $result | Should -BeTrue - } - } - - Context 'Multi-server array pipeline binding' { - - It 'Test-CWAAHealth accepts Server as string[] from pipeline and matches correctly' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - param($Name) - [PSCustomObject]@{ Name = $Name; Status = 'Running' } - } - Mock Get-CWAAInfo { - [PSCustomObject]@{ - Server = @('primary.example.com', 'backup.example.com') - LastSuccessStatus = (Get-Date).AddMinutes(-30).ToString() - HeartbeatLastSent = (Get-Date).AddMinutes(-15).ToString() - } - } - - # Pipe an object with Server as a multi-element array (matching Get-CWAAInfo output) - [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAHealth - } - $result.Healthy | Should -BeTrue - $result.ServerMatch | Should -BeTrue - } - - It 'Test-CWAAServerConnectivity accepts Server as string[] from pipeline' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Invoke-RestMethod { return '||||||220.105' } - - [PSCustomObject]@{ Server = @('primary.example.com', 'backup.example.com') } | Test-CWAAServerConnectivity - } - # Should return results for both servers - ($result | Measure-Object).Count | Should -Be 2 - } - - It 'Register-CWAAHealthCheckTask accepts Server as string[] and builds valid command' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock schtasks { return $null } - Mock New-CWAABackup {} - - [PSCustomObject]@{ - Server = @('primary.example.com', 'backup.example.com') - LocationID = 42 - } | Register-CWAAHealthCheckTask -InstallerToken 'abc123' -Confirm:$false - } - $result | Should -Not -BeNullOrEmpty - $result.Created | Should -BeTrue - } - } -} - -# ============================================================================= -# Credential Hardening Tests -# ============================================================================= - -Describe 'PSCredential Parameter Support' { - - Context 'Install-CWAA Credential parameter' { - - It 'has a Credential parameter of type PSCredential' { - $cmd = Get-Command Install-CWAA - $param = $cmd.Parameters['Credential'] - $param | Should -Not -BeNullOrEmpty - $param.ParameterType.Name | Should -Be 'PSCredential' - } - - It 'Credential parameter is in the deployment parameter set' { - $cmd = Get-Command Install-CWAA - $param = $cmd.Parameters['Credential'] - $param.ParameterSets.Keys | Should -Contain 'deployment' - } - } - - Context 'Set-CWAAProxy ProxyCredential parameter' { - - It 'has a ProxyCredential parameter of type PSCredential' { - $cmd = Get-Command Set-CWAAProxy - $param = $cmd.Parameters['ProxyCredential'] - $param | Should -Not -BeNullOrEmpty - $param.ParameterType.Name | Should -Be 'PSCredential' - } - } -} - -# ============================================================================= -# Phase 1+2 Constants and Helpers -# ============================================================================= - -Describe 'Initialize-CWAA constants (Phase 1)' { - - Context 'version threshold constants' { - - It 'defines CWAAVersionZipInstaller' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAVersionZipInstaller | Should -Be '240.331' - } - } - - It 'defines CWAAVersionAnonymousChange' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAVersionAnonymousChange | Should -Be '110.374' - } - } - - It 'defines CWAAVersionVulnerabilityFix' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAVersionVulnerabilityFix | Should -Be '200.197' - } - } - - It 'defines CWAAVersionUpdateMinimum' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAVersionUpdateMinimum | Should -Be '105.001' - } - } - } - - Context 'service and process name constants' { - - It 'defines CWAAAgentProcessNames with 3 entries' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAAgentProcessNames | Should -HaveCount 3 - $Script:CWAAAgentProcessNames | Should -Contain 'LTTray' - $Script:CWAAAgentProcessNames | Should -Contain 'LTSVC' - $Script:CWAAAgentProcessNames | Should -Contain 'LTSvcMon' - } - } - - It 'defines CWAAAllServiceNames with 3 entries including LabVNC' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAAllServiceNames | Should -HaveCount 3 - $Script:CWAAAllServiceNames | Should -Contain 'LTService' - $Script:CWAAAllServiceNames | Should -Contain 'LTSvcMon' - $Script:CWAAAllServiceNames | Should -Contain 'LabVNC' - } - } - } - - Context 'timeout constants' { - - It 'defines CWAAServiceWaitTimeoutSec as 60' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAAServiceWaitTimeoutSec | Should -Be 60 - } - } - - It 'defines CWAARedoSettleDelaySeconds as 20' { - InModuleScope 'ConnectWiseAutomateAgent' { - $Script:CWAARedoSettleDelaySeconds | Should -Be 20 - } - } - } -} - -Describe 'Test-CWAAServiceExists' { - - Context 'when services exist' { - - It 'returns $true' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } - } - Test-CWAAServiceExists - } - $result | Should -Be $true - } - - It 'does not write an error' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service { - [PSCustomObject]@{ Name = 'LTService'; Status = 'Running' } - } - $err = $null - $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue - $err | Should -BeNullOrEmpty - } - } - } - - Context 'when services do not exist' { - - It 'returns $false' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service {} - Test-CWAAServiceExists - } - $result | Should -Be $false - } - - It 'does not write error without -WriteErrorOnMissing' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service {} - $err = $null - $null = Test-CWAAServiceExists -ErrorVariable err -ErrorAction SilentlyContinue - $err | Should -BeNullOrEmpty - } - } - - It 'writes error with -WriteErrorOnMissing' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service {} - $err = $null - $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue - $err | Should -Not -BeNullOrEmpty - "$err" | Should -Match 'Services NOT Found' - } - } - - It 'writes WhatIf-prefixed error when WhatIfPreference is true' { - InModuleScope 'ConnectWiseAutomateAgent' { - Mock Get-Service {} - $WhatIfPreference = $true - $err = $null - $null = Test-CWAAServiceExists -WriteErrorOnMissing -ErrorVariable err -ErrorAction SilentlyContinue - "$err" | Should -Match 'What If.*Services NOT Found' - } - } - } -} - -Describe 'Assert-CWAANotProbeAgent' { - - Context 'when ServiceInfo is null' { - - It 'does not throw' { - InModuleScope 'ConnectWiseAutomateAgent' { - { Assert-CWAANotProbeAgent -ServiceInfo $null -ActionName 'Test' } | Should -Not -Throw - } - } - } - - Context 'when agent is not a probe' { - - It 'does not throw' { - InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Probe = '0' } - { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw - } - } - } - - Context 'when agent is a probe without -Force' { - - It 'throws with action name in message' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Probe = '1' } - Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' - } - } | Should -Throw '*Probe Agent Detected*UnInstall Denied*' - } - - It 'uses Reset action name' { - { - InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Probe = '1' } - Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Reset' - } - } | Should -Throw '*Reset Denied*' - } - } - - Context 'when agent is a probe with -Force' { - - It 'does not throw' { - InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Probe = '1' } - { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'UnInstall' -Force } | Should -Not -Throw - } - } - - It 'writes Forced output message' { - $result = InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Probe = '1' } - Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Re-Install' -Force - } - $result | Should -Match 'Probe Agent Detected.*Re-Install Forced' - } - } - - Context 'when ServiceInfo has no Probe property' { - - It 'does not throw' { - InModuleScope 'ConnectWiseAutomateAgent' { - $info = [PSCustomObject]@{ Server = 'test.com' } - { Assert-CWAANotProbeAgent -ServiceInfo $info -ActionName 'Test' } | Should -Not -Throw - } - } - } -} diff --git a/Tests/ConnectWiseAutomateAgent.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 similarity index 65% rename from Tests/ConnectWiseAutomateAgent.Tests.ps1 rename to Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 index d57e48c..689778d 100644 --- a/Tests/ConnectWiseAutomateAgent.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 @@ -1,13 +1,34 @@ #Requires -Module Pester -BeforeAll { - $ModuleName = 'ConnectWiseAutomateAgent' - $ModuleRoot = Split-Path -Parent $PSScriptRoot - $ModulePath = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" +<# +.SYNOPSIS + Module structure, quality, and function validation tests. + +.DESCRIPTION + Tests module manifest, import, exports, aliases, function structure, + parameter validation, and single-file build validation. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD: + - 'Module' (default): Import-Module from .psd1 manifest + - 'SingleFile': Load concatenated .ps1 via dynamic module + + Module-only tests are automatically skipped in SingleFile mode. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Module.Tests.ps1 -Output Detailed +#> + +BeforeDiscovery { + $script:IsSingleFileMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') + $script:IsModuleMode = -not $script:IsSingleFileMode +} - # Remove module if already loaded, then import fresh - Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force - Import-Module $ModulePath -Force -ErrorAction Stop +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" + $script:IsSingleFileMode = ($script:BootstrapResult.LoadMethod -eq 'SingleFile') + $script:IsModuleMode = -not $script:IsSingleFileMode + $ModuleName = $script:BootstrapResult.ModuleName } AfterAll { @@ -19,7 +40,7 @@ AfterAll { # ============================================================================= Describe 'Module: ConnectWiseAutomateAgent' { - Context 'Module Manifest' { + Context 'Module Manifest' -Skip:$script:IsSingleFileMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot $ManifestPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1' @@ -138,7 +159,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { $cmd | Should -Not -BeNullOrEmpty } - It 'does not export Initialize-CWAANetworking as a public function' { + It 'does not export Initialize-CWAANetworking as a public function' -Skip:$script:IsSingleFileMode { $exported = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys $exported | Should -Not -Contain 'Initialize-CWAANetworking' } @@ -181,10 +202,14 @@ Describe 'Module: ConnectWiseAutomateAgent' { $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys } - It 'exports exactly 30 functions' { + It 'exports exactly 30 functions' -Skip:$script:IsSingleFileMode { $ExportedFunctions | Should -HaveCount 30 } + It 'exports at least 30 functions (includes private in single-file mode)' -Skip:$script:IsModuleMode { + $ExportedFunctions.Count | Should -BeGreaterOrEqual 30 + } + It 'exports <_>' -ForEach $ExpectedFunctions { $_ | Should -BeIn $ExportedFunctions } @@ -229,16 +254,20 @@ Describe 'Module: ConnectWiseAutomateAgent' { $ExportedAliases = (Get-Module 'ConnectWiseAutomateAgent').ExportedAliases.Keys } - It 'exports exactly 32 aliases' { + It 'exports exactly 32 aliases' -Skip:$script:IsSingleFileMode { $ExportedAliases | Should -HaveCount 32 } + It 'exports at least 32 aliases' -Skip:$script:IsModuleMode { + $ExportedAliases.Count | Should -BeGreaterOrEqual 32 + } + It 'exports alias <_>' -ForEach $ExpectedAliases { $_ | Should -BeIn $ExportedAliases } } - Context 'Function-to-File Mapping' { + Context 'Function-to-File Mapping' -Skip:$script:IsSingleFileMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot $PublicPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\Public' @@ -260,6 +289,28 @@ Describe 'Module: ConnectWiseAutomateAgent' { } } } + + Context 'Single-File Build Validation' -Skip:$script:IsModuleMode { + BeforeAll { + $RepoRoot = Split-Path -Parent $PSScriptRoot + $SingleFilePath = Join-Path $RepoRoot 'ConnectWiseAutomateAgent.ps1' + } + + It 'single-file build exists' { + $SingleFilePath | Should -Exist + } + + It 'single-file ends with Initialize-CWAA call' { + $lastLines = Get-Content $SingleFilePath -Tail 5 + ($lastLines -join "`n") | Should -Match 'Initialize-CWAA' -Because 'single-file must call initialization at the end' + } + + It 'private helper functions are available' { + $exported = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + $exported | Should -Contain 'Initialize-CWAA' + $exported | Should -Contain 'Initialize-CWAANetworking' + } + } } # ============================================================================= @@ -398,148 +449,6 @@ Describe 'Function Structure' { } } -# ============================================================================= -# ConvertTo-CWAASecurity Unit Tests -# ============================================================================= -Describe 'ConvertTo-CWAASecurity' { - - It 'returns a non-empty string for valid input' { - $result = ConvertTo-CWAASecurity -InputString 'TestValue' - $result | Should -Not -BeNullOrEmpty - } - - It 'returns a valid Base64-encoded string' { - $result = ConvertTo-CWAASecurity -InputString 'TestValue' - # Base64 strings contain only [A-Za-z0-9+/=] - $result | Should -Match '^[A-Za-z0-9+/=]+$' - } - - It 'produces consistent output for the same input' { - $result1 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' - $result2 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' - $result1 | Should -Be $result2 - } - - It 'produces different output for different inputs' { - $result1 = ConvertTo-CWAASecurity -InputString 'Value1' - $result2 = ConvertTo-CWAASecurity -InputString 'Value2' - $result1 | Should -Not -Be $result2 - } - - It 'produces different output with different keys' { - $result1 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' - $result2 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' - $result1 | Should -Not -Be $result2 - } - - It 'handles an empty string input' { - $result = ConvertTo-CWAASecurity -InputString '' - $result | Should -Not -BeNullOrEmpty - } - - It 'handles long string input' { - $longString = 'A' * 1000 - $result = ConvertTo-CWAASecurity -InputString $longString - $result | Should -Not -BeNullOrEmpty - } - - It 'handles special characters' { - $result = ConvertTo-CWAASecurity -InputString '!@#$%^&*()_+-={}[]|;:<>?,./~`' - $result | Should -Not -BeNullOrEmpty - } - - It 'works with a custom key' { - $result = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'MyCustomKey' - $result | Should -Not -BeNullOrEmpty - } - - It 'works via the legacy alias ConvertTo-LTSecurity' { - $result = ConvertTo-LTSecurity -InputString 'AliasTest' - $result | Should -Not -BeNullOrEmpty - } -} - -# ============================================================================= -# ConvertFrom-CWAASecurity Unit Tests -# ============================================================================= -Describe 'ConvertFrom-CWAASecurity' { - - It 'decodes a previously encoded string' { - $encoded = ConvertTo-CWAASecurity -InputString 'HelloWorld' - $decoded = ConvertFrom-CWAASecurity -InputString $encoded - $decoded | Should -Be 'HelloWorld' - } - - It 'returns null for invalid Base64 input' { - $result = ConvertFrom-CWAASecurity -InputString 'NotValidBase64!!!' -Force:$False - $result | Should -BeNullOrEmpty - } - - It 'decodes with a custom key' { - $customKey = 'MySecretKey123' - $encoded = ConvertTo-CWAASecurity -InputString 'CustomKeyTest' -Key $customKey - $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $customKey - $decoded | Should -Be 'CustomKeyTest' - } - - It 'fails to decode with the wrong key (Force disabled)' { - $encoded = ConvertTo-CWAASecurity -InputString 'WrongKeyTest' -Key 'CorrectKey' - $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$False - $decoded | Should -BeNullOrEmpty - } - - It 'works via the legacy alias ConvertFrom-LTSecurity' { - $encoded = ConvertTo-CWAASecurity -InputString 'AliasTest' - $decoded = ConvertFrom-LTSecurity -InputString $encoded - $decoded | Should -Be 'AliasTest' - } - - It 'accepts pipeline input' { - $encoded = ConvertTo-CWAASecurity -InputString 'PipelineTest' - $decoded = $encoded | ConvertFrom-CWAASecurity - $decoded | Should -Be 'PipelineTest' - } -} - -# ============================================================================= -# Security Round-Trip Tests -# ============================================================================= -Describe 'Security Encode/Decode Round-Trip' { - - It 'round-trips "" with default key' -ForEach @( - @{ TestString = 'SimpleText' } - @{ TestString = 'Hello World with spaces' } - @{ TestString = 'Special!@#$%^&*()chars' } - @{ TestString = '12345' } - @{ TestString = '' } - @{ TestString = 'https://automate.example.com' } - @{ TestString = 'P@$$w0rd!#Complex' } - ) { - $encoded = ConvertTo-CWAASecurity -InputString $TestString - $decoded = ConvertFrom-CWAASecurity -InputString $encoded - $decoded | Should -Be $TestString - } - - It 'round-trips with custom key ""' -ForEach @( - @{ Key = 'ShortKey' } - @{ Key = 'A much longer encryption key for testing purposes' } - @{ Key = '!@#$%' } - @{ Key = '12345678901234567890' } - ) { - $testValue = 'RoundTripValue' - $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key $Key - $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $Key - $decoded | Should -Be $testValue - } - - It 'encoded value differs between default key and custom key' { - $input = 'CompareKeys' - $defaultEncoded = ConvertTo-CWAASecurity -InputString $input - $customEncoded = ConvertTo-CWAASecurity -InputString $input -Key 'CustomKey' - $defaultEncoded | Should -Not -Be $customEncoded - } -} - # ============================================================================= # Parameter Validation Tests # ============================================================================= @@ -598,91 +507,3 @@ Describe 'Parameter Validation' { } } } - -# ============================================================================= -# Documentation Structure Tests -# ============================================================================= -Describe 'Documentation Structure' { - - BeforeAll { - $ModuleRoot = Split-Path -Parent $PSScriptRoot - $DocsRoot = Join-Path $ModuleRoot 'Docs' - $DocsHelp = Join-Path $DocsRoot 'Help' - $BuildScript = Join-Path $ModuleRoot 'Build\Build-Documentation.ps1' - $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys - } - - Context 'Folder layout' { - It 'has a Docs directory' { - $DocsRoot | Should -Exist - } - - It 'has a Docs/Help directory for auto-generated reference docs' { - $DocsHelp | Should -Exist - } - - It 'has no auto-generated function docs in Docs root' { - $handWrittenGuides = @( - 'Architecture.md', - 'CommonParameters.md', - 'FAQ.md', - 'Migration.md', - 'Security.md', - 'Troubleshooting.md' - ) - $rootMdFiles = Get-ChildItem $DocsRoot -Filter '*.md' -File | - Where-Object { $_.Name -notin $handWrittenGuides } - $rootMdFiles | Should -HaveCount 0 -Because 'function docs belong in Docs/Help/, only hand-written guides in Docs/' - } - - It 'has Architecture.md in Docs root (hand-written)' { - Join-Path $DocsRoot 'Architecture.md' | Should -Exist - } - } - - Context 'Auto-generated function reference' { - It 'has a module overview page' { - Join-Path $DocsHelp 'ConnectWiseAutomateAgent.md' | Should -Exist - } - - It 'has a markdown doc for each exported function' { - foreach ($function in $ExportedFunctions) { - $docPath = Join-Path $DocsHelp "$function.md" - $docPath | Should -Exist -Because "$function should have a corresponding doc in Docs/Help/" - } - } - - It 'each function doc has PlatyPS YAML frontmatter' { - foreach ($function in $ExportedFunctions) { - $docPath = Join-Path $DocsHelp "$function.md" - if (Test-Path $docPath) { - $firstLine = (Get-Content $docPath -TotalCount 1) - $firstLine | Should -Be '---' -Because "$function.md should start with YAML frontmatter" - } - } - } - } - - Context 'MAML help' { - It 'has a compiled MAML XML help file' { - $mamlPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\ConnectWiseAutomateAgent-help.xml' - $mamlPath | Should -Exist - } - - It 'has an about help topic' { - $aboutPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\about_ConnectWiseAutomateAgent.help.txt' - $aboutPath | Should -Exist - } - } - - Context 'Build script' { - It 'Build-Documentation.ps1 exists' { - $BuildScript | Should -Exist - } - - It 'Build-Documentation.ps1 defaults output to Docs/Help' { - $scriptContent = Get-Content $BuildScript -Raw - $scriptContent | Should -Match "Join-Path.*'Help'" -Because 'default output path should target Docs/Help' - } - } -} diff --git a/Tests/ConnectWiseAutomateAgent.Security.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Security.Tests.ps1 new file mode 100644 index 0000000..33832d0 --- /dev/null +++ b/Tests/ConnectWiseAutomateAgent.Security.Tests.ps1 @@ -0,0 +1,166 @@ +#Requires -Module Pester + +<# +.SYNOPSIS + Security function unit tests for ConvertTo/From-CWAASecurity. + +.DESCRIPTION + Tests the encryption and decryption round-trip behavior of + ConvertTo-CWAASecurity and ConvertFrom-CWAASecurity functions. + + Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. + +.NOTES + Run with: + Invoke-Pester Tests\ConnectWiseAutomateAgent.Security.Tests.ps1 -Output Detailed +#> + +BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" +} + +AfterAll { + Get-Module 'ConnectWiseAutomateAgent' -ErrorAction SilentlyContinue | Remove-Module -Force +} + +# ============================================================================= +# ConvertTo-CWAASecurity Unit Tests +# ============================================================================= +Describe 'ConvertTo-CWAASecurity' { + + It 'returns a non-empty string for valid input' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' + $result | Should -Not -BeNullOrEmpty + } + + It 'returns a valid Base64-encoded string' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' + # Base64 strings contain only [A-Za-z0-9+/=] + $result | Should -Match '^[A-Za-z0-9+/=]+$' + } + + It 'produces consistent output for the same input' { + $result1 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' + $result2 = ConvertTo-CWAASecurity -InputString 'ConsistencyTest' + $result1 | Should -Be $result2 + } + + It 'produces different output for different inputs' { + $result1 = ConvertTo-CWAASecurity -InputString 'Value1' + $result2 = ConvertTo-CWAASecurity -InputString 'Value2' + $result1 | Should -Not -Be $result2 + } + + It 'produces different output with different keys' { + $result1 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key1' + $result2 = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'Key2' + $result1 | Should -Not -Be $result2 + } + + It 'handles an empty string input' { + $result = ConvertTo-CWAASecurity -InputString '' + $result | Should -Not -BeNullOrEmpty + } + + It 'handles long string input' { + $longString = 'A' * 1000 + $result = ConvertTo-CWAASecurity -InputString $longString + $result | Should -Not -BeNullOrEmpty + } + + It 'handles special characters' { + $result = ConvertTo-CWAASecurity -InputString '!@#$%^&*()_+-={}[]|;:<>?,./~`' + $result | Should -Not -BeNullOrEmpty + } + + It 'works with a custom key' { + $result = ConvertTo-CWAASecurity -InputString 'TestValue' -Key 'MyCustomKey' + $result | Should -Not -BeNullOrEmpty + } + + It 'works via the legacy alias ConvertTo-LTSecurity' { + $result = ConvertTo-LTSecurity -InputString 'AliasTest' + $result | Should -Not -BeNullOrEmpty + } +} + +# ============================================================================= +# ConvertFrom-CWAASecurity Unit Tests +# ============================================================================= +Describe 'ConvertFrom-CWAASecurity' { + + It 'decodes a previously encoded string' { + $encoded = ConvertTo-CWAASecurity -InputString 'HelloWorld' + $decoded = ConvertFrom-CWAASecurity -InputString $encoded + $decoded | Should -Be 'HelloWorld' + } + + It 'returns null for invalid Base64 input' { + $result = ConvertFrom-CWAASecurity -InputString 'NotValidBase64!!!' -Force:$False + $result | Should -BeNullOrEmpty + } + + It 'decodes with a custom key' { + $customKey = 'MySecretKey123' + $encoded = ConvertTo-CWAASecurity -InputString 'CustomKeyTest' -Key $customKey + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $customKey + $decoded | Should -Be 'CustomKeyTest' + } + + It 'fails to decode with the wrong key (Force disabled)' { + $encoded = ConvertTo-CWAASecurity -InputString 'WrongKeyTest' -Key 'CorrectKey' + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key 'WrongKey' -Force:$False + $decoded | Should -BeNullOrEmpty + } + + It 'works via the legacy alias ConvertFrom-LTSecurity' { + $encoded = ConvertTo-CWAASecurity -InputString 'AliasTest' + $decoded = ConvertFrom-LTSecurity -InputString $encoded + $decoded | Should -Be 'AliasTest' + } + + It 'accepts pipeline input' { + $encoded = ConvertTo-CWAASecurity -InputString 'PipelineTest' + $decoded = $encoded | ConvertFrom-CWAASecurity + $decoded | Should -Be 'PipelineTest' + } +} + +# ============================================================================= +# Security Round-Trip Tests +# ============================================================================= +Describe 'Security Encode/Decode Round-Trip' { + + It 'round-trips "" with default key' -ForEach @( + @{ TestString = 'SimpleText' } + @{ TestString = 'Hello World with spaces' } + @{ TestString = 'Special!@#$%^&*()chars' } + @{ TestString = '12345' } + @{ TestString = '' } + @{ TestString = 'https://automate.example.com' } + @{ TestString = 'P@$$w0rd!#Complex' } + ) { + $encoded = ConvertTo-CWAASecurity -InputString $TestString + $decoded = ConvertFrom-CWAASecurity -InputString $encoded + $decoded | Should -Be $TestString + } + + It 'round-trips with custom key ""' -ForEach @( + @{ Key = 'ShortKey' } + @{ Key = 'A much longer encryption key for testing purposes' } + @{ Key = '!@#$%' } + @{ Key = '12345678901234567890' } + ) { + $testValue = 'RoundTripValue' + $encoded = ConvertTo-CWAASecurity -InputString $testValue -Key $Key + $decoded = ConvertFrom-CWAASecurity -InputString $encoded -Key $Key + $decoded | Should -Be $testValue + } + + It 'encoded value differs between default key and custom key' { + $input = 'CompareKeys' + $defaultEncoded = ConvertTo-CWAASecurity -InputString $input + $customEncoded = ConvertTo-CWAASecurity -InputString $input -Key 'CustomKey' + $defaultEncoded | Should -Not -Be $customEncoded + } +} diff --git a/Tests/Helpers/MockHelpers.ps1 b/Tests/Helpers/MockHelpers.ps1 new file mode 100644 index 0000000..a7fcfd7 --- /dev/null +++ b/Tests/Helpers/MockHelpers.ps1 @@ -0,0 +1,96 @@ +<# +.SYNOPSIS + Shared mock data factories for ConnectWiseAutomateAgent Pester tests. + +.DESCRIPTION + Provides reusable functions that create commonly-needed mock data objects. + These helpers return PSCustomObjects that tests use as mock return values, + reducing duplication of inline mock data across test files. + + Mock wiring (Mock CommandName { ... }) stays inline in each test since + the mock setup varies per test scenario. + +.NOTES + Dot-source this file in BeforeAll blocks: + . "$PSScriptRoot\Helpers\MockHelpers.ps1" +#> + +function New-MockAgentInfo { + <# + .SYNOPSIS + Creates a PSCustomObject matching the shape of Get-CWAAInfo output. + #> + param( + [string]$ID = '12345', + [string[]]$Server = @('automate.example.com'), + [string]$LocationID = '1', + [string]$BasePath = 'C:\Windows\LTSVC', + [string]$Version = '230.105', + [string]$LastSuccessStatus, + [string]$HeartbeatLastSent, + [string]$HeartbeatLastReceived, + [string]$TrayPort, + [string]$ServerPassword, + [string]$Password, + [string]$Probe, + [string]$MAC + ) + + $obj = [PSCustomObject]@{ + ID = $ID + Server = $Server + LocationID = $LocationID + BasePath = $BasePath + Version = $Version + } + + if ($LastSuccessStatus) { $obj | Add-Member -NotePropertyName 'LastSuccessStatus' -NotePropertyValue $LastSuccessStatus } + if ($HeartbeatLastSent) { $obj | Add-Member -NotePropertyName 'HeartbeatLastSent' -NotePropertyValue $HeartbeatLastSent } + if ($HeartbeatLastReceived) { $obj | Add-Member -NotePropertyName 'HeartbeatLastReceived' -NotePropertyValue $HeartbeatLastReceived } + if ($TrayPort) { $obj | Add-Member -NotePropertyName 'TrayPort' -NotePropertyValue $TrayPort } + if ($ServerPassword) { $obj | Add-Member -NotePropertyName 'ServerPassword' -NotePropertyValue $ServerPassword } + if ($Password) { $obj | Add-Member -NotePropertyName 'Password' -NotePropertyValue $Password } + if ($Probe) { $obj | Add-Member -NotePropertyName 'Probe' -NotePropertyValue $Probe } + if ($MAC) { $obj | Add-Member -NotePropertyName 'MAC' -NotePropertyValue $MAC } + + return $obj +} + +function New-MockRunningService { + <# + .SYNOPSIS + Creates a PSCustomObject matching a Windows service object. + #> + param( + [string]$Name = 'LTService', + [string]$Status = 'Running' + ) + + return [PSCustomObject]@{ + Name = $Name + Status = $Status + } +} + +function New-MockRegistryEntry { + <# + .SYNOPSIS + Creates a PSCustomObject matching Get-ItemProperty output with PS provider properties. + #> + param( + [hashtable]$Properties = @{}, + [switch]$IncludePSProperties + ) + + $obj = [PSCustomObject]$Properties + + if ($IncludePSProperties) { + $obj | Add-Member -NotePropertyName 'PSPath' -NotePropertyValue 'fake' + $obj | Add-Member -NotePropertyName 'PSParentPath' -NotePropertyValue 'fake' + $obj | Add-Member -NotePropertyName 'PSChildName' -NotePropertyValue 'fake' + $obj | Add-Member -NotePropertyName 'PSDrive' -NotePropertyValue 'fake' + $obj | Add-Member -NotePropertyName 'PSProvider' -NotePropertyValue 'fake' + } + + return $obj +} diff --git a/Tests/Invoke-AllTests.ps1 b/Tests/Invoke-AllTests.ps1 new file mode 100644 index 0000000..0355f05 --- /dev/null +++ b/Tests/Invoke-AllTests.ps1 @@ -0,0 +1,151 @@ +<# +.SYNOPSIS + Runs the full test suite against both module loading methods in separate processes. + +.DESCRIPTION + Executes the entire Pester test suite twice — once in Module mode (standard + Import-Module from manifest) and once in SingleFile mode (dynamic module from + concatenated build output). Each mode runs in its own pwsh process to prevent + .NET state leakage (SSL callbacks, compiled types, etc.). + + The single-file build is regenerated automatically before the SingleFile run. + +.PARAMETER ExcludeTag + Pester tags to exclude. Defaults to 'Live'. + +.PARAMETER TestPath + Path to test files. Defaults to the Tests directory containing this script. + +.PARAMETER SkipBuild + Skip the single-file rebuild before the SingleFile mode run. + +.EXAMPLE + .\Tests\Invoke-AllTests.ps1 + Runs both modes, excluding Live tests. + +.EXAMPLE + .\Tests\Invoke-AllTests.ps1 -ExcludeTag 'Live','Slow' + Runs both modes, excluding Live and Slow tagged tests. + +.EXAMPLE + .\Tests\Invoke-AllTests.ps1 -SkipBuild + Runs both modes without rebuilding the single-file first. + +.NOTES + Exit code is non-zero if any test fails in either mode. +#> +[CmdletBinding()] +param( + [string[]]$ExcludeTag = @('Live'), + [string]$TestPath, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' + +$RepoRoot = Split-Path -Parent $PSScriptRoot +if (-not $TestPath) { + $TestPath = $PSScriptRoot +} + +# --- Locate pwsh / powershell executables --- +$pwshExe = if ($PSVersionTable.PSEdition -eq 'Core') { + (Get-Process -Id $PID).Path +} else { + # Running in Windows PowerShell — prefer pwsh if available, else use self + $found = Get-Command pwsh -ErrorAction SilentlyContinue + if ($found) { $found.Source } else { (Get-Process -Id $PID).Path } +} + +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host " ConnectWiseAutomateAgent — Dual-Mode Test Runner" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " PowerShell: $pwshExe" -ForegroundColor Gray +Write-Host " Test path: $TestPath" -ForegroundColor Gray +Write-Host " Exclude: $($ExcludeTag -join ', ')" -ForegroundColor Gray +Write-Host "" + +# --- Build exclude tag argument --- +$excludeTagArg = ($ExcludeTag | ForEach-Object { "'$_'" }) -join ',' + +# --- Helper: run Pester in a child process --- +function Invoke-PesterInProcess { + [CmdletBinding()] + param( + [string]$Mode, + [string]$Label + ) + + Write-Host "`n----------------------------------------" -ForegroundColor Yellow + Write-Host " $Label" -ForegroundColor Yellow + Write-Host "----------------------------------------" -ForegroundColor Yellow + + $pesterCommand = @" +`$env:CWAA_TEST_LOAD_METHOD = '$Mode' +`$config = New-PesterConfiguration +`$config.Run.Path = '$($TestPath -replace "'","''")' +`$config.Filter.ExcludeTag = @($excludeTagArg) +`$config.Output.Verbosity = 'Detailed' +`$config.Run.Exit = `$true +Invoke-Pester -Configuration `$config +"@ + + & $pwshExe -NoProfile -NonInteractive -Command $pesterCommand + return $LASTEXITCODE +} + +# ============================================ +# Mode 1: Module (standard Import-Module) +# ============================================ +$moduleExitCode = Invoke-PesterInProcess -Mode 'Module' -Label 'Mode 1/2: Module Import' + +# ============================================ +# Rebuild single-file before SingleFile mode +# ============================================ +if (-not $SkipBuild) { + Write-Host "`n Rebuilding single-file distribution..." -ForegroundColor Gray + $buildScript = Join-Path $RepoRoot 'Build\SingleFileBuild.ps1' + if (Test-Path $buildScript) { + & powershell -NoProfile -NonInteractive -File $buildScript + if ($LASTEXITCODE -ne 0) { + Write-Host " WARNING: Single-file build exited with code $LASTEXITCODE" -ForegroundColor Red + } else { + Write-Host " Single-file build completed." -ForegroundColor Green + } + } else { + Write-Host " WARNING: Build script not found at '$buildScript'" -ForegroundColor Red + } +} + +# ============================================ +# Mode 2: SingleFile (dynamic module from .ps1) +# ============================================ +$singleFileExitCode = Invoke-PesterInProcess -Mode 'SingleFile' -Label 'Mode 2/2: SingleFile (Dynamic Module)' + +# ============================================ +# Summary +# ============================================ +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host " Summary" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan + +$moduleStatus = if ($moduleExitCode -eq 0) { 'PASS' } else { 'FAIL' } +$singleFileStatus = if ($singleFileExitCode -eq 0) { 'PASS' } else { 'FAIL' } +$moduleColor = if ($moduleExitCode -eq 0) { 'Green' } else { 'Red' } +$singleFileColor = if ($singleFileExitCode -eq 0) { 'Green' } else { 'Red' } + +Write-Host " Module mode: " -NoNewline; Write-Host $moduleStatus -ForegroundColor $moduleColor +Write-Host " SingleFile mode: " -NoNewline; Write-Host $singleFileStatus -ForegroundColor $singleFileColor +Write-Host "" + +# Exit with non-zero if either mode failed +$finalExitCode = if ($moduleExitCode -ne 0 -or $singleFileExitCode -ne 0) { 1 } else { 0 } + +if ($finalExitCode -eq 0) { + Write-Host " All tests passed in both modes." -ForegroundColor Green +} else { + Write-Host " One or more modes had failures." -ForegroundColor Red +} + +Write-Host "" +exit $finalExitCode diff --git a/Tests/TestBootstrap.ps1 b/Tests/TestBootstrap.ps1 new file mode 100644 index 0000000..0578158 --- /dev/null +++ b/Tests/TestBootstrap.ps1 @@ -0,0 +1,86 @@ +<# +.SYNOPSIS + Shared module loading bootstrap for ConnectWiseAutomateAgent tests. + +.DESCRIPTION + Loads the module using one of two methods based on the CWAA_TEST_LOAD_METHOD + environment variable: + + - Module (default): Import-Module ConnectWiseAutomateAgent.psd1 + Standard PSGallery loading path. + + - SingleFile: Load ConnectWiseAutomateAgent.ps1 into a dynamic module via New-Module. + Tests the concatenated single-file build used by systems without gallery access. + The dynamic module wrapper preserves InModuleScope and Get-Module compatibility. + + Call this from BeforeAll in each test file. For discovery-time flags (Context -Skip), + check $env:CWAA_TEST_LOAD_METHOD directly in BeforeDiscovery instead. + +.EXAMPLE + BeforeDiscovery { + $script:IsSingleFileMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') + } + BeforeAll { + $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" + } + +.OUTPUTS + Hashtable with keys: LoadMethod, ModuleName, ModulePath, SingleFilePath, IsLoaded +#> +[CmdletBinding()] +param() + +$ModuleName = 'ConnectWiseAutomateAgent' +$RepoRoot = Split-Path -Parent $PSScriptRoot +$ModulePsd1 = Join-Path $RepoRoot "$ModuleName\$ModuleName.psd1" +$SingleFilePath = Join-Path $RepoRoot "$ModuleName.ps1" + +$LoadMethod = if ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') { 'SingleFile' } else { 'Module' } + +# Remove any existing module (standard or dynamic) +Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force + +if ($LoadMethod -eq 'SingleFile') { + # SingleFile mode: load concatenated .ps1 into a dynamic module. + # This validates the build output while preserving InModuleScope compatibility. + if (-not (Test-Path $SingleFilePath)) { + throw "Single-file build not found at '$SingleFilePath'. Run Build\SingleFileBuild.ps1 first." + } + + $singleFileContent = Get-Content $SingleFilePath -Raw -ErrorAction Stop + + # Append Export-ModuleMember so [Alias()] attributes on functions are exported. + # Without this, dynamic modules don't export aliases from function attributes. + $singleFileContent += "`nExport-ModuleMember -Function * -Alias *" + + New-Module -Name $ModuleName -ScriptBlock ([ScriptBlock]::Create($singleFileContent)) | + Import-Module -Force -ErrorAction Stop + + Write-Verbose "TestBootstrap: Loaded single-file into dynamic module '$ModuleName'" + + return @{ + LoadMethod = 'SingleFile' + ModuleName = $ModuleName + ModulePath = $SingleFilePath + SingleFilePath = $SingleFilePath + IsLoaded = $true + } +} +else { + # Module mode: standard Import-Module from manifest + if (-not (Test-Path $ModulePsd1)) { + throw "Module manifest not found at '$ModulePsd1'." + } + + Import-Module $ModulePsd1 -Force -ErrorAction Stop + + Write-Verbose "TestBootstrap: Imported module '$ModuleName' from manifest" + + return @{ + LoadMethod = 'Module' + ModuleName = $ModuleName + ModulePath = $ModulePsd1 + SingleFilePath = $SingleFilePath + IsLoaded = $true + } +} diff --git a/Tests/test-local.ps1 b/Tests/test-local.ps1 new file mode 100644 index 0000000..e925e5f --- /dev/null +++ b/Tests/test-local.ps1 @@ -0,0 +1,134 @@ +# Local test script - run this before pushing to catch issues early +param( + [switch]$SkipBuild, + [switch]$SkipTests, + [switch]$SkipAnalyze, + + [string]$FunctionName, + + [switch]$Quick, + [switch]$DualMode +) + +$ErrorActionPreference = 'Stop' +$ProjectRoot = Split-Path -Parent $PSScriptRoot + +# Quick mode: skip build and delegate to Invoke-QuickTest.ps1 +if ($Quick) { + $quickTestScript = Join-Path $ProjectRoot 'Scripts\Invoke-QuickTest.ps1' + + if ($FunctionName) { + Write-Host "`n[QUICK] Running targeted test for: $FunctionName" -ForegroundColor Yellow + & $quickTestScript -FunctionName $FunctionName -IncludeAnalyzer:(-not $SkipAnalyze) + exit $LASTEXITCODE + } + + # Quick without function name: run all tests directly (no build, no coverage) + Write-Host "`n[QUICK] Running all tests (no build, no coverage)..." -ForegroundColor Yellow + & $quickTestScript -IncludeAnalyzer:(-not $SkipAnalyze) + exit $LASTEXITCODE +} + +# Dual mode: delegate to Invoke-AllTests.ps1 +if ($DualMode) { + Write-Host "`n[DUAL MODE] Running Module + SingleFile test modes..." -ForegroundColor Yellow + $allTestsScript = Join-Path $PSScriptRoot 'Invoke-AllTests.ps1' + & $allTestsScript + exit $LASTEXITCODE +} + +Write-Host "`n========================================" -ForegroundColor Cyan +Write-Host "LOCAL PRE-PUSH VALIDATION" -ForegroundColor Cyan +Write-Host "========================================`n" -ForegroundColor Cyan + +$stepCount = 3 +$currentStep = 0 + +# 1. BUILD +if (-not $SkipBuild) { + $currentStep++ + Write-Host "[$currentStep/$stepCount] Building single-file distribution..." -ForegroundColor Yellow + $buildScript = Join-Path $ProjectRoot 'Build\SingleFileBuild.ps1' + + if (Test-Path $buildScript) { + & powershell -NoProfile -NonInteractive -File $buildScript + if ($LASTEXITCODE -ne 0) { + Write-Host "BUILD FAILED" -ForegroundColor Red + exit 1 + } + Write-Host "BUILD PASSED`n" -ForegroundColor Green + } + else { + Write-Host "WARNING: Build script not found at '$buildScript', skipping build`n" -ForegroundColor Yellow + } +} + +# 2. PSScriptAnalyzer +if (-not $SkipAnalyze) { + $currentStep++ + Write-Host "[$currentStep/$stepCount] Running PSScriptAnalyzer..." -ForegroundColor Yellow + Import-Module PSScriptAnalyzer -ErrorAction Stop + + $sourcePath = Join-Path $ProjectRoot 'ConnectWiseAutomateAgent' + $settingsFile = Join-Path $ProjectRoot '.PSScriptAnalyzerSettings.psd1' + + $analyzeParams = @{ + Path = $sourcePath + Recurse = $true + } + if (Test-Path $settingsFile) { + $analyzeParams['Settings'] = $settingsFile + } + + $results = Invoke-ScriptAnalyzer @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 + } + } + else { + Write-Host "PSScriptAnalyzer PASSED - no issues found`n" -ForegroundColor Green + } +} + +# 3. TESTS +if (-not $SkipTests) { + $currentStep++ + Write-Host "[$currentStep/$stepCount] Running Pester tests..." -ForegroundColor Yellow + + if ($FunctionName) { + Write-Host "Targeted test for: $FunctionName" -ForegroundColor Cyan + $quickTestScript = Join-Path $ProjectRoot 'Scripts\Invoke-QuickTest.ps1' + & $quickTestScript -FunctionName $FunctionName + if ($LASTEXITCODE -ne 0) { + Write-Host "TESTS FAILED" -ForegroundColor Red + exit 1 + } + } + else { + $config = New-PesterConfiguration + $config.Run.Path = $PSScriptRoot + $config.Filter.ExcludeTag = @('Live') + $config.Output.Verbosity = 'Detailed' + $config.Run.Exit = $true + Invoke-Pester -Configuration $config + if ($LASTEXITCODE -ne 0) { + Write-Host "TESTS FAILED" -ForegroundColor Red + exit 1 + } + } + Write-Host "TESTS PASSED`n" -ForegroundColor Green +} + +Write-Host "========================================" -ForegroundColor Green +Write-Host "ALL LOCAL CHECKS PASSED!" -ForegroundColor Green +Write-Host "========================================`n" -ForegroundColor Green +Write-Host "Ready to push to GitHub`n" From 63ab3624604afe2be8f1e26997eaacf31a384509 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Sun, 1 Feb 2026 22:27:51 -0700 Subject: [PATCH 4/5] Migrate to Sampler/ModuleBuilder build pipeline Replace custom Build/ scripts with the standardized Sampler/ModuleBuilder/InvokeBuild pipeline. ModuleBuilder compiles individual source files into a single .psm1 at build time, eliminating 40+ dot-source operations at import. Key changes: - Rename ConnectWiseAutomateAgent/ to source/ for ModuleBuilder convention - Add build.ps1, build.yaml, RequiredModules.psd1, Resolve-Dependency.* - Add source/prefix.ps1 (WOW64 warning) and suffix.ps1 (Initialize-CWAA) - Add .build/Build_SingleFile_Distribution.build.ps1 for standalone .ps1 - Replace .github/workflows/ci-publish.yml with ci.yml + pr-validation.yml - Remove Build/ directory (4 scripts) and root ConnectWiseAutomateAgent.ps1 - Update all test files, hooks, docs, and CI for new source/output paths - Source manifest uses wildcard exports; ModuleBuilder writes explicit lists Build targets PS 7+; compiled output remains PS 3.0+ compatible. Verified: 496 tests pass, 0 failures, PSScriptAnalyzer clean. Co-Authored-By: Claude Opus 4.5 --- .../Build_SingleFile_Distribution.build.ps1 | 51 + .githooks/pre-commit | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/copilot-instructions.md | 2 +- .github/workflows/ci-publish.yml | 281 - .github/workflows/ci.yml | 373 ++ .github/workflows/pr-validation.yml | 51 + .gitignore | 5 + AGENTS.md | 64 +- Build/Build-Documentation.ps1 | 215 - Build/Extract-ChangelogEntry.ps1 | 110 - Build/Publish-CWAAModule.ps1 | 161 - Build/SingleFileBuild.ps1 | 101 - CONTRIBUTING.md | 24 +- ConnectWiseAutomateAgent.ps1 | 4870 ----------------- README.md | 15 +- RequiredModules.psd1 | 22 + Resolve-Dependency.ps1 | 1075 ++++ Resolve-Dependency.psd1 | 5 + Scripts/Invoke-QuickTest.ps1 | 2 +- ...ctWiseAutomateAgent.CrossVersion.Tests.ps1 | 8 +- ...tWiseAutomateAgent.Documentation.Tests.ps1 | 95 +- .../ConnectWiseAutomateAgent.Module.Tests.ps1 | 6 +- Tests/Invoke-AllTests.ps1 | 10 +- Tests/TestBootstrap.ps1 | 6 +- Tests/test-local.ps1 | 8 +- build.ps1 | 542 ++ build.yaml | 81 + .../ConnectWiseAutomateAgent.psd1 | 72 +- .../ConnectWiseAutomateAgent.psm1 | 0 .../Private/Assert-CWAANotProbeAgent.ps1 | 0 .../Private/Clear-CWAAInstallerArtifacts.ps1 | 0 .../Private/Initialize/Initialize-CWAA.ps1 | 0 .../Initialize/Initialize-CWAANetworking.ps1 | 0 .../Private/Invoke-CWAAMsiInstaller.ps1 | 0 .../Private/Remove-CWAAFolderRecursive.ps1 | 0 .../Private/Resolve-CWAAServer.ps1 | 0 .../Private/Test-CWAADotNetPrerequisite.ps1 | 0 .../Private/Test-CWAADownloadIntegrity.ps1 | 0 .../Private/Test-CWAAServiceExists.ps1 | 0 .../Private/Wait-CWAACondition.ps1 | 0 .../Private/Write-CWAAEventLog.ps1 | 0 .../AddRemovePrograms/Hide-CWAAAddRemove.ps1 | 0 .../Rename-CWAAAddRemove.ps1 | 0 .../AddRemovePrograms/Show-CWAAAddRemove.ps1 | 0 .../Public/ConvertFrom-CWAASecurity.ps1 | 0 .../Public/ConvertTo-CWAASecurity.ps1 | 0 .../Public/InstallUninstall/Install-CWAA.ps1 | 0 .../Public/InstallUninstall/Redo-CWAA.ps1 | 0 .../InstallUninstall/Uninstall-CWAA.ps1 | 0 .../Public/InstallUninstall/Update-CWAA.ps1 | 0 .../Public/Invoke-CWAACommand.ps1 | 0 .../Public/Logging/Get-CWAAError.ps1 | 0 .../Public/Logging/Get-CWAALogLevel.ps1 | 0 .../Public/Logging/Get-CWAAProbeError.ps1 | 0 .../Public/Logging/Set-CWAALogLevel.ps1 | 0 .../Public/Proxy/Get-CWAAProxy.ps1 | 0 .../Public/Proxy/Set-CWAAProxy.ps1 | 0 .../Service/Register-CWAAHealthCheckTask.ps1 | 0 .../Public/Service/Repair-CWAA.ps1 | 0 .../Public/Service/Restart-CWAA.ps1 | 0 .../Public/Service/Start-CWAA.ps1 | 0 .../Public/Service/Stop-CWAA.ps1 | 0 .../Public/Service/Test-CWAAHealth.ps1 | 0 .../Unregister-CWAAHealthCheckTask.ps1 | 0 .../Public/Settings/Get-CWAAInfo.ps1 | 0 .../Public/Settings/Get-CWAAInfoBackup.ps1 | 0 .../Public/Settings/Get-CWAASettings.ps1 | 0 .../Public/Settings/New-CWAABackup.ps1 | 0 .../Public/Settings/Reset-CWAA.ps1 | 0 .../Public/Test-CWAAPort.ps1 | 0 .../Public/Test-CWAAServerConnectivity.ps1 | 0 .../en-US/ConnectWiseAutomateAgent-help.xml | 0 .../about_ConnectWiseAutomateAgent.help.txt | 0 source/prefix.ps1 | 6 + source/suffix.ps1 | 1 + 76 files changed, 2306 insertions(+), 5960 deletions(-) create mode 100644 .build/Build_SingleFile_Distribution.build.ps1 delete mode 100644 .github/workflows/ci-publish.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-validation.yml delete mode 100644 Build/Build-Documentation.ps1 delete mode 100644 Build/Extract-ChangelogEntry.ps1 delete mode 100644 Build/Publish-CWAAModule.ps1 delete mode 100644 Build/SingleFileBuild.ps1 delete mode 100644 ConnectWiseAutomateAgent.ps1 create mode 100644 RequiredModules.psd1 create mode 100644 Resolve-Dependency.ps1 create mode 100644 Resolve-Dependency.psd1 create mode 100644 build.ps1 create mode 100644 build.yaml rename {ConnectWiseAutomateAgent => source}/ConnectWiseAutomateAgent.psd1 (52%) rename {ConnectWiseAutomateAgent => source}/ConnectWiseAutomateAgent.psm1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Assert-CWAANotProbeAgent.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Clear-CWAAInstallerArtifacts.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Initialize/Initialize-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Initialize/Initialize-CWAANetworking.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Invoke-CWAAMsiInstaller.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Remove-CWAAFolderRecursive.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Resolve-CWAAServer.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Test-CWAADotNetPrerequisite.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Test-CWAADownloadIntegrity.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Test-CWAAServiceExists.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Wait-CWAACondition.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Private/Write-CWAAEventLog.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/ConvertFrom-CWAASecurity.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/ConvertTo-CWAASecurity.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/InstallUninstall/Install-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/InstallUninstall/Redo-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/InstallUninstall/Uninstall-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/InstallUninstall/Update-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Invoke-CWAACommand.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Logging/Get-CWAAError.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Logging/Get-CWAALogLevel.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Logging/Get-CWAAProbeError.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Logging/Set-CWAALogLevel.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Proxy/Get-CWAAProxy.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Proxy/Set-CWAAProxy.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Register-CWAAHealthCheckTask.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Repair-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Restart-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Start-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Stop-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Test-CWAAHealth.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Service/Unregister-CWAAHealthCheckTask.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Settings/Get-CWAAInfo.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Settings/Get-CWAAInfoBackup.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Settings/Get-CWAASettings.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Settings/New-CWAABackup.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Settings/Reset-CWAA.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Test-CWAAPort.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/Public/Test-CWAAServerConnectivity.ps1 (100%) rename {ConnectWiseAutomateAgent => source}/en-US/ConnectWiseAutomateAgent-help.xml (100%) rename {ConnectWiseAutomateAgent => source}/en-US/about_ConnectWiseAutomateAgent.help.txt (100%) create mode 100644 source/prefix.ps1 create mode 100644 source/suffix.ps1 diff --git a/.build/Build_SingleFile_Distribution.build.ps1 b/.build/Build_SingleFile_Distribution.build.ps1 new file mode 100644 index 0000000..0c3e7d7 --- /dev/null +++ b/.build/Build_SingleFile_Distribution.build.ps1 @@ -0,0 +1,51 @@ +# Synopsis: Build the standalone single-file .ps1 distribution for GitHub Releases. +# This task takes the compiled .psm1 from ModuleBuilder output and produces a +# standalone ConnectWiseAutomateAgent.ps1 that can be executed directly. + +task Build_SingleFile_Distribution { + $moduleName = 'ConnectWiseAutomateAgent' + $outputBase = Join-Path $OutputDirectory $moduleName + + # Find the built module (latest version directory) + $builtManifest = Get-ChildItem -Path "$outputBase\*\$moduleName.psd1" -ErrorAction SilentlyContinue | + Sort-Object { [version](Split-Path (Split-Path $_.FullName -Parent) -Leaf) } -Descending | + Select-Object -First 1 + + if (-not $builtManifest) { + throw "Built module manifest not found in $outputBase. Run Build_ModuleOutput_ModuleBuilder first." + } + + $versionDir = Split-Path $builtManifest.FullName -Parent + $builtPsm1 = Join-Path $versionDir "$moduleName.psm1" + + if (-not (Test-Path $builtPsm1)) { + throw "Built .psm1 not found at $builtPsm1" + } + + # Read version from built manifest + $manifest = Import-PowerShellDataFile $builtManifest.FullName + $version = $manifest.ModuleVersion + $prerelease = $manifest.PrivateData.PSData.Prerelease + $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } + + # Build header + $header = @" +# $moduleName $fullVersion +# Single-file distribution - built $(Get-Date -Format 'yyyy-MM-dd') +# https://github.com/christaylorcodes/ConnectWiseAutomateAgent + +"@ + + # Write single-file output. + # Strip Export-ModuleMember because the .ps1 is dot-sourced or IEX'd outside + # a module context where that cmdlet is not valid. + $singleFilePath = Join-Path $OutputDirectory "$moduleName.ps1" + $header | Out-File $singleFilePath -Force -Encoding UTF8 + Get-Content $builtPsm1 | + Where-Object { $_ -notmatch '^\s*Export-ModuleMember\b' } | + Out-File $singleFilePath -Append -Encoding UTF8 + + $lineCount = (Get-Content $singleFilePath | Measure-Object).Count + $size = (Get-Item $singleFilePath).Length + Write-Build Green "Single-file built: $singleFilePath ($lineCount lines, $([math]::Round($size / 1KB, 1)) KB)" +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f023175..e327d38 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -16,7 +16,7 @@ foreach ($moduleName in @('PSScriptAnalyzer', 'Pester')) { } Write-Host '--- Pre-commit: Running PSScriptAnalyzer ---' -ForegroundColor Cyan -$analyzerResults = Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning -Settings .PSScriptAnalyzerSettings.psd1 +$analyzerResults = Invoke-ScriptAnalyzer -Path source -Recurse -Severity Error,Warning -Settings .PSScriptAnalyzerSettings.psd1 if ($analyzerResults) { Write-Host 'PSScriptAnalyzer found issues:' -ForegroundColor Red $analyzerResults | Format-Table RuleName, Severity, ScriptName, Line, Message -AutoSize diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4366584..94f8f2b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,7 +24,7 @@ - [ ] Tests cover new/changed behavior - [ ] PSScriptAnalyzer reports zero errors - [ ] Documentation updated (if applicable) -- [ ] Single-file rebuilt (`Build\SingleFileBuild.ps1`) +- [ ] Module built (`./build.ps1 -Tasks build`) ## AI Contribution diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cec43b0..a6965f6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,6 @@ This is a PowerShell 3.0+ module for managing the ConnectWise Automate Windows agent. For full conventions, architecture, build commands, and contribution workflow, read [AGENTS.md](../AGENTS.md). -Key file locations: `ConnectWiseAutomateAgent/Public/` (exported), `ConnectWiseAutomateAgent/Private/` (internal), `Tests/`, `Build/`. +Key file locations: `source/Public/` (exported), `source/Private/` (internal), `Tests/`, `.build/`. Before committing: `./Tests/test-local.ps1` diff --git a/.github/workflows/ci-publish.yml b/.github/workflows/ci-publish.yml deleted file mode 100644 index 14d85c8..0000000 --- a/.github/workflows/ci-publish.yml +++ /dev/null @@ -1,281 +0,0 @@ -# CI / Publish workflow for ConnectWiseAutomateAgent -# -# Philosophy: -# Full testing (Pester, PSScriptAnalyzer) is done locally before pushing. -# This workflow is intentionally lightweight — it smoke-tests the module, -# builds the single-file artifact, publishes to PSGallery, and creates -# a GitHub Release with the single-file asset attached. -# -# Branch strategy: -# develop push → smoke test → build → publish prerelease → GitHub Release (prerelease) -# main push → smoke test → build → publish stable → GitHub Release (stable) -# pull requests → smoke test → build (no publish, no release) -# -# Publish gating: -# - Prerelease publish requires a Prerelease tag in the manifest (e.g., 'alpha001') -# - Stable publish requires the Prerelease tag to be removed from the manifest -# - Both publish jobs use the PSGallery environment for optional protection rules -# - GitHub Releases are created only after successful PSGallery publish -# -# Required secrets: -# PSGALLERY_API_KEY — NuGet API key for the PowerShell Gallery -# -# Required permissions: -# contents: write — for creating git tags and GitHub Releases (set per-job) - -name: CI / Publish - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - -jobs: - smoke-test: - name: Smoke Test - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Validate module manifest - shell: pwsh - run: | - $manifest = Test-ModuleManifest -Path ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -ErrorAction Stop - Write-Host "Module: $($manifest.Name) v$($manifest.Version)" - $prerelease = $manifest.PrivateData.PSData.Prerelease - if ($prerelease) { Write-Host "Prerelease: $prerelease" } - - - name: Verify module imports and exports - shell: pwsh - run: | - Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force -ErrorAction Stop - $functions = (Get-Module ConnectWiseAutomateAgent).ExportedFunctions.Keys - $aliases = (Get-Module ConnectWiseAutomateAgent).ExportedAliases.Keys - Write-Host "Exported $($functions.Count) functions, $($aliases.Count) aliases" - if ($functions.Count -lt 25) { throw "Expected at least 25 exported functions, got $($functions.Count)" } - if ($aliases.Count -lt 27) { throw "Expected at least 27 exported aliases, got $($aliases.Count)" } - - build: - name: Build - needs: smoke-test - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Build single-file distribution - shell: pwsh - run: .\Build\SingleFileBuild.ps1 - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: ConnectWiseAutomateAgent-SingleFile - path: ConnectWiseAutomateAgent.ps1 - - publish-prerelease: - name: Publish Prerelease - if: github.ref == 'refs/heads/develop' && github.event_name == 'push' - needs: build - runs-on: windows-latest - environment: PSGallery - steps: - - uses: actions/checkout@v4 - - - name: Verify prerelease tag is set - shell: pwsh - run: | - $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 - $prerelease = $manifest.PrivateData.PSData.Prerelease - if (-not $prerelease) { - Write-Error "Prerelease tag is not set in the manifest. Skipping prerelease publish." - exit 1 - } - Write-Host "Prerelease tag: $prerelease" - Write-Host "Full version: $($manifest.ModuleVersion)-$prerelease" - - - name: Publish to PSGallery (prerelease) - shell: pwsh - env: - NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: .\Build\Publish-CWAAModule.ps1 -NuGetApiKey $env:NUGET_API_KEY - - publish-stable: - name: Publish Stable - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - needs: build - runs-on: windows-latest - environment: PSGallery - steps: - - uses: actions/checkout@v4 - - - name: Verify no prerelease tag - shell: pwsh - run: | - $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 - $prerelease = $manifest.PrivateData.PSData.Prerelease - if ($prerelease) { - Write-Error "Prerelease tag '$prerelease' is still set in the manifest. Remove it before publishing a stable release." - exit 1 - } - Write-Host "Version: $($manifest.ModuleVersion) (stable)" - - - name: Publish to PSGallery (stable) - shell: pwsh - env: - NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} - run: .\Build\Publish-CWAAModule.ps1 -NuGetApiKey $env:NUGET_API_KEY - - # ─── GitHub Release jobs ────────────────────────────────────────────────── - # These mirror the publish jobs above: release-prerelease creates a - # prerelease GitHub Release on develop, release-stable creates a stable - # GitHub Release on main. Tags are created explicitly to avoid gh CLI - # --target bugs. Each job has an explicit if condition for clarity. - - release-prerelease: - name: GitHub Release (Prerelease) - if: github.ref == 'refs/heads/develop' && github.event_name == 'push' && !failure() && !cancelled() - needs: publish-prerelease - runs-on: windows-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} - - - name: Read version from manifest - id: version - shell: pwsh - run: | - $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 - $moduleVersion = $manifest.ModuleVersion - $prerelease = $manifest.PrivateData.PSData.Prerelease - $fullVersion = if ($prerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } - $tag = "v$fullVersion" - Write-Host "Version: $fullVersion" - Write-Host "Tag: $tag" - "full_version=$fullVersion" >> $env:GITHUB_OUTPUT - "tag=$tag" >> $env:GITHUB_OUTPUT - - - name: Check if release already exists - id: check - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $tag = '${{ steps.version.outputs.tag }}' - $existing = gh release view $tag 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "Release $tag already exists. Skipping." - "skip=true" >> $env:GITHUB_OUTPUT - } - else { - Write-Host "Release $tag does not exist. Proceeding." - "skip=false" >> $env:GITHUB_OUTPUT - } - - - name: Extract release notes from CHANGELOG - if: steps.check.outputs.skip != 'true' - shell: pwsh - run: .\Build\Extract-ChangelogEntry.ps1 -Version '${{ steps.version.outputs.full_version }}' -OutputPath release-notes.md - - - name: Download build artifact - if: steps.check.outputs.skip != 'true' - uses: actions/download-artifact@v4 - with: - name: ConnectWiseAutomateAgent-SingleFile - path: artifact - - - name: Create git tag - if: steps.check.outputs.skip != 'true' - shell: pwsh - run: | - git tag ${{ steps.version.outputs.tag }} ${{ github.sha }} - git push origin ${{ steps.version.outputs.tag }} - - - name: Create GitHub Release - if: steps.check.outputs.skip != 'true' - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create '${{ steps.version.outputs.tag }}' ` - artifact/ConnectWiseAutomateAgent.ps1 ` - --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` - --notes-file release-notes.md ` - --prerelease ` - --verify-tag - - release-stable: - name: GitHub Release (Stable) - if: github.ref == 'refs/heads/main' && github.event_name == 'push' && !failure() && !cancelled() - needs: publish-stable - runs-on: windows-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} - - - name: Read version from manifest - id: version - shell: pwsh - run: | - $manifest = Import-PowerShellDataFile ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 - $moduleVersion = $manifest.ModuleVersion - $fullVersion = $moduleVersion - $tag = "v$fullVersion" - Write-Host "Version: $fullVersion" - Write-Host "Tag: $tag" - "full_version=$fullVersion" >> $env:GITHUB_OUTPUT - "tag=$tag" >> $env:GITHUB_OUTPUT - - - name: Check if release already exists - id: check - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - $tag = '${{ steps.version.outputs.tag }}' - $existing = gh release view $tag 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "Release $tag already exists. Skipping." - "skip=true" >> $env:GITHUB_OUTPUT - } - else { - Write-Host "Release $tag does not exist. Proceeding." - "skip=false" >> $env:GITHUB_OUTPUT - } - - - name: Extract release notes from CHANGELOG - if: steps.check.outputs.skip != 'true' - shell: pwsh - run: .\Build\Extract-ChangelogEntry.ps1 -Version '${{ steps.version.outputs.full_version }}' -OutputPath release-notes.md - - - name: Download build artifact - if: steps.check.outputs.skip != 'true' - uses: actions/download-artifact@v4 - with: - name: ConnectWiseAutomateAgent-SingleFile - path: artifact - - - name: Create git tag - if: steps.check.outputs.skip != 'true' - shell: pwsh - run: | - git tag ${{ steps.version.outputs.tag }} ${{ github.sha }} - git push origin ${{ steps.version.outputs.tag }} - - - name: Create GitHub Release - if: steps.check.outputs.skip != 'true' - shell: pwsh - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create '${{ steps.version.outputs.tag }}' ` - artifact/ConnectWiseAutomateAgent.ps1 ` - --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` - --notes-file release-notes.md ` - --verify-tag diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe77ca9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,373 @@ +# CI / Publish workflow for ConnectWiseAutomateAgent +# +# Uses Sampler/ModuleBuilder/InvokeBuild pipeline. +# Build process runs on PowerShell 7+; compiled output targets PowerShell 3.0+. +# +# 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) +# +# Required secrets: +# PSGALLERY_API_KEY — NuGet API key for the PowerShell Gallery +# +# Required permissions: +# contents: write — for creating git tags and GitHub Releases (set per-job) + +name: CI / Publish + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + MODULE_NAME: ConnectWiseAutomateAgent + +jobs: + build: + name: Build Module + runs-on: windows-latest + outputs: + module_version: ${{ steps.version.outputs.version }} + full_version: ${{ steps.version.outputs.full_version }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve dependencies + shell: pwsh + run: ./build.ps1 -ResolveDependency -Tasks noop + + - name: Build module + shell: pwsh + run: ./build.ps1 -Tasks build + + - name: Read version from manifest + id: version + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $version = $manifest.ModuleVersion + $prerelease = $manifest.PrivateData.PSData.Prerelease + $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } + $isPrerelease = if ($prerelease) { 'true' } else { 'false' } + Write-Host "Version: $fullVersion (prerelease: $isPrerelease)" + "version=$version" >> $env:GITHUB_OUTPUT + "full_version=$fullVersion" >> $env:GITHUB_OUTPUT + "is_prerelease=$isPrerelease" >> $env:GITHUB_OUTPUT + + - name: Upload built module + uses: actions/upload-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} + retention-days: 7 + + - name: Upload single-file artifact + uses: actions/upload-artifact@v4 + with: + name: single-file + path: output/ConnectWiseAutomateAgent.ps1 + retention-days: 7 + + - name: Upload required modules + uses: actions/upload-artifact@v4 + with: + name: required-modules + path: output/RequiredModules + retention-days: 1 + + test: + name: Test + needs: build + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Download built module + uses: actions/download-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} + + - name: Download required modules + uses: actions/download-artifact@v4 + with: + name: required-modules + path: output/RequiredModules + + - name: Run Pester tests + shell: pwsh + run: | + $env:PSModulePath = "$(Resolve-Path output/RequiredModules)" + [IO.Path]::PathSeparator + $env:PSModulePath + ./build.ps1 -Tasks test + + 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] + runs-on: windows-latest + environment: PSGallery + steps: + - uses: actions/checkout@v4 + + - name: Verify prerelease tag is set + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $prerelease = $manifest.PrivateData.PSData.Prerelease + if (-not $prerelease) { + Write-Error "Prerelease tag is not set in the manifest. Skipping prerelease publish." + exit 1 + } + Write-Host "Prerelease tag: $prerelease" + + - name: Download built module + uses: actions/download-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} + + - name: Publish to PSGallery (prerelease) + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + Publish-Module -Path $modulePath.FullName -NuGetApiKey $env:NUGET_API_KEY -Force + + publish-stable: + name: Publish Stable + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: [build, test, analyze] + runs-on: windows-latest + environment: PSGallery + steps: + - uses: actions/checkout@v4 + + - name: Verify no prerelease tag + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $prerelease = $manifest.PrivateData.PSData.Prerelease + if ($prerelease) { + Write-Error "Prerelease tag '$prerelease' is still set. Remove it before publishing stable." + exit 1 + } + Write-Host "Version: $($manifest.ModuleVersion) (stable)" + + - name: Download built module + uses: actions/download-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} + + - name: Publish to PSGallery (stable) + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + Publish-Module -Path $modulePath.FullName -NuGetApiKey $env:NUGET_API_KEY + + # ─── GitHub Release jobs ──────────────────────────────────────────────── + + release-prerelease: + name: GitHub Release (Prerelease) + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' && !failure() && !cancelled() + needs: publish-prerelease + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Read version from manifest + id: version + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $v = $manifest.ModuleVersion + $pre = $manifest.PrivateData.PSData.Prerelease + $full = if ($pre) { "$v-$pre" } else { $v } + "full_version=$full" >> $env:GITHUB_OUTPUT + "tag=v$full" >> $env:GITHUB_OUTPUT + + - name: Check if release already exists + id: check + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = '${{ steps.version.outputs.tag }}' + $existing = gh release view $tag 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "Release $tag already exists. Skipping." + "skip=true" >> $env:GITHUB_OUTPUT + } else { + "skip=false" >> $env:GITHUB_OUTPUT + } + + - name: Extract release notes from CHANGELOG + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + $version = '${{ steps.version.outputs.full_version }}' + $lines = Get-Content CHANGELOG.md + $pattern = "^## \[$([regex]::Escape($version))\]" + $start = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $pattern) { $start = $i; break } + } + if ($start -eq -1) { + "No changelog entry found for $version" | Out-File release-notes.md + } else { + $end = $lines.Count + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^## \[') { $end = $i; break } + } + ($lines[($start+1)..($end-1)] -join "`n").Trim() | Out-File release-notes.md -Encoding UTF8 + } + + - name: Download single-file artifact + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: single-file + path: artifact + + - name: Create git tag + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + git tag '${{ steps.version.outputs.tag }}' '${{ github.sha }}' + git push origin '${{ steps.version.outputs.tag }}' + + - name: Create GitHub Release + if: steps.check.outputs.skip != 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create '${{ steps.version.outputs.tag }}' ` + artifact/ConnectWiseAutomateAgent.ps1 ` + --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` + --notes-file release-notes.md ` + --prerelease ` + --verify-tag + + release-stable: + name: GitHub Release (Stable) + if: github.ref == 'refs/heads/main' && github.event_name == 'push' && !failure() && !cancelled() + needs: publish-stable + runs-on: windows-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Read version from manifest + id: version + shell: pwsh + run: | + $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $v = $manifest.ModuleVersion + $full = $v + "full_version=$full" >> $env:GITHUB_OUTPUT + "tag=v$full" >> $env:GITHUB_OUTPUT + + - name: Check if release already exists + id: check + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $tag = '${{ steps.version.outputs.tag }}' + $existing = gh release view $tag 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Host "Release $tag already exists. Skipping." + "skip=true" >> $env:GITHUB_OUTPUT + } else { + "skip=false" >> $env:GITHUB_OUTPUT + } + + - name: Extract release notes from CHANGELOG + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + $version = '${{ steps.version.outputs.full_version }}' + $lines = Get-Content CHANGELOG.md + $pattern = "^## \[$([regex]::Escape($version))\]" + $start = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $pattern) { $start = $i; break } + } + if ($start -eq -1) { + "No changelog entry found for $version" | Out-File release-notes.md + } else { + $end = $lines.Count + for ($i = $start + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^## \[') { $end = $i; break } + } + ($lines[($start+1)..($end-1)] -join "`n").Trim() | Out-File release-notes.md -Encoding UTF8 + } + + - name: Download single-file artifact + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: single-file + path: artifact + + - name: Create git tag + if: steps.check.outputs.skip != 'true' + shell: pwsh + run: | + git tag '${{ steps.version.outputs.tag }}' '${{ github.sha }}' + git push origin '${{ steps.version.outputs.tag }}' + + - name: Create GitHub Release + if: steps.check.outputs.skip != 'true' + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create '${{ steps.version.outputs.tag }}' ` + artifact/ConnectWiseAutomateAgent.ps1 ` + --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` + --notes-file release-notes.md ` + --verify-tag diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..b346a41 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,51 @@ +# PR Validation workflow +# Checks that PRs include changelog updates and runs basic validation. + +name: PR Validation + +on: + pull_request: + branches: [main, develop] + +jobs: + validate: + name: PR Checks + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check CHANGELOG updated + shell: pwsh + run: | + $changedFiles = git diff --name-only origin/${{ github.base_ref }}...HEAD + if ($changedFiles -contains 'CHANGELOG.md') { + Write-Host "CHANGELOG.md has been updated." -ForegroundColor Green + } else { + Write-Host "::warning::CHANGELOG.md has not been updated. Please add an entry for your changes." + } + + - name: Validate CHANGELOG format + shell: pwsh + run: | + if (Test-Path CHANGELOG.md) { + $content = Get-Content CHANGELOG.md -Raw + if ($content -match '\[Unreleased\]') { + Write-Host "CHANGELOG.md has an [Unreleased] section." -ForegroundColor Green + } else { + Write-Host "::warning::CHANGELOG.md is missing an [Unreleased] section." + } + } + + - name: Summary + shell: pwsh + run: | + $summary = @" + ## PR Validation Summary + + - Version is driven by the module manifest (source/ConnectWiseAutomateAgent.psd1) + - Remember to update CHANGELOG.md with your changes + - Prerelease tag in manifest controls whether publish goes to prerelease or stable + "@ + $summary >> $env:GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index b5f4f5a..ecfa9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,13 @@ Scratch\.ps1 # Claude Code .claude/ +# Build output +output/ +/ConnectWiseAutomateAgent.ps1 + # Build artifacts *.log +*.nupkg # OS-generated files Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index acfdc4d..45bd38f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ **ConnectWiseAutomateAgent** is a PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Used by MSPs to install, configure, troubleshoot, and manage the Automate agent on Windows systems. - **Language**: PowerShell 3.0+ (2.0 with limitations) -- **Build System**: Custom scripts (`Build/SingleFileBuild.ps1`) +- **Build System**: Sampler/ModuleBuilder/InvokeBuild (`./build.ps1`) - **Test Framework**: Pester 5.6+ - **Linter**: PSScriptAnalyzer - **License**: MIT @@ -22,14 +22,20 @@ ## Quick Start ```powershell -# Import module for local testing -Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force +# First time setup (resolve build dependencies) +./build.ps1 -ResolveDependency -Tasks noop -# Run all tests (excluding live integration tests) -Invoke-Pester Tests\ -ExcludeTag 'Live' +# Import source module for local testing +Import-Module .\source\ConnectWiseAutomateAgent.psd1 -Force + +# Build the module (output goes to output/) +./build.ps1 -Tasks build + +# Run all tests +./build.ps1 -Tasks test # Run PSScriptAnalyzer -Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Warning +Invoke-ScriptAnalyzer -Path source -Recurse -Severity Error,Warning # Local pre-push validation (build + analyze + test) ./Tests/test-local.ps1 @@ -41,7 +47,12 @@ Invoke-ScriptAnalyzer -Path ConnectWiseAutomateAgent -Recurse -Severity Error,Wa ```text ConnectWiseAutomateAgent/ - ConnectWiseAutomateAgent/ + build.ps1 # Build entry point (Invoke-Build) + build.yaml # Sampler build configuration + RequiredModules.psd1 # Build dependency manifest + Resolve-Dependency.ps1 # Bootstrap script for build deps + .build/ # Build task definitions + source/ ConnectWiseAutomateAgent.psd1 # Module manifest ConnectWiseAutomateAgent.psm1 # Root module (auto-loads subdirectories) en-US/ # Localized help (MAML XML) @@ -54,11 +65,7 @@ ConnectWiseAutomateAgent/ Proxy/ # Proxy configuration Service/ # Service control, health checks Settings/ # Agent configuration and backup - Build/ - SingleFileBuild.ps1 # Concatenate module into single .ps1 - Build-Documentation.ps1 # PlatyPS markdown + MAML generation - Publish-CWAAModule.ps1 # PSGallery publishing - Extract-ChangelogEntry.ps1 # Changelog parser for CI releases + output/ # Build output (gitignored) Tests/ *.Tests.ps1 # 11 test suites (Module, Mocked.*, Docs, Security, CrossVersion, Live) Helpers/ # Shared mock helpers @@ -70,12 +77,11 @@ ConnectWiseAutomateAgent/ Docs/ # Hand-written guides Help/ # Auto-generated function reference (PlatyPS) Examples/ # Ready-to-use deployment scripts - Output/ # Build output (gitignored) ``` ### Module Loading (Two-Phase) -**Phase 1 (module import -- fast, no side effects):** `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively, emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. +**Phase 1 (module import -- fast, no side effects):** `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively (in source mode; ModuleBuilder merges them during build), emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. **Phase 2 (on-demand -- first networking call):** `Initialize-CWAANetworking` (private) is called in the `Begin` block of networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`). On first call it performs SSL certificate validation bypass, TLS protocol enablement, creates `$Script:LTWebProxy` and `$Script:LTServiceNetWebClient`, and runs `Get-CWAAProxy` to discover proxy settings from the installed agent. The `$Script:CWAANetworkInitialized` flag ensures this runs only once per session. @@ -83,16 +89,19 @@ ConnectWiseAutomateAgent/ Every function uses the `CWAA` prefix (e.g., `Install-CWAA`) but also declares an `LT` alias (e.g., `Install-LTService`) for backward compatibility with the legacy LabTech naming. Aliases are declared both in function `[Alias()]` attributes and in the manifest's `AliasesToExport`. -### Build Scripts +### Build Pipeline + +The build uses [Sampler](https://github.com/gaelcolas/Sampler) with ModuleBuilder and Invoke-Build. Configuration lives in `build.yaml`. -- `Build/SingleFileBuild.ps1` -- concatenates all `.ps1` files from the module directory into `ConnectWiseAutomateAgent.ps1` at the repo root, appending `Initialize-CWAA` at the end. This flat file is the distribution artifact for direct-invoke scenarios. -- `Build/Build-Documentation.ps1` -- PlatyPS markdown + MAML generation. Outputs to `Docs/Help/`. -- `Build/Publish-CWAAModule.ps1` -- publishes to PowerShell Gallery. -- `Build/Extract-ChangelogEntry.ps1` -- extracts version-specific release notes from CHANGELOG.md for GitHub Releases. +- `./build.ps1 -ResolveDependency -Tasks noop` -- first-time setup; installs build dependencies listed in `RequiredModules.psd1`. +- `./build.ps1 -Tasks build` -- compiles the module from `source/` into `output/`. ModuleBuilder merges Public/Private functions into a single `.psm1` and auto-populates `FunctionsToExport` and `AliasesToExport` in the manifest. +- `./build.ps1 -Tasks test` -- runs Pester tests against the built module in `output/`. +- `./build.ps1 -Tasks publish` -- publishes to PowerShell Gallery. +- Build tasks are defined in `.build/` and referenced from `build.yaml`. ### CI/CD -CI is intentionally lightweight (smoke test, build, publish). Full testing is local. See the header comments in `.github/workflows/ci-publish.yml` for branch strategy and gating rules. +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. ### Common Patterns @@ -121,7 +130,7 @@ For the full contributing guide covering development setup, coding standards, an ### Documentation and Commits -Source of truth for docs is comment-based help in `.ps1` files. Run `Build\Build-Documentation.ps1 -UpdateExisting` after changes. +Source of truth for docs is comment-based help in `.ps1` files under `source/`. Rebuild documentation after changes using the build pipeline. **Known pitfall:** Lines starting with `.WORD` (e.g., `.NET`) in comment-based help are parsed as help keywords. Keep such terms mid-line. @@ -129,17 +138,14 @@ For git commit style and the full contributing guide, see [CONTRIBUTING.md](CONT ## Adding New Functions -1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory +1. Create `Verb-CWAA.ps1` in the appropriate `source/Public/` subdirectory 2. Add `[Alias('Verb-LT')]` in the function declaration -3. Add to `FunctionsToExport` and `AliasesToExport` in `ConnectWiseAutomateAgent.psd1` -4. Rebuild documentation: `Build\Build-Documentation.ps1` -5. Rebuild single-file: `Build\SingleFileBuild.ps1` +3. Build the module: `./build.ps1 -Tasks build` -- ModuleBuilder auto-populates `FunctionsToExport` and `AliasesToExport` in the manifest (no manual manifest update needed) When modifying existing functions: - Maintain the `LT` alias -- Rebuild documentation: `Build\Build-Documentation.ps1 -UpdateExisting` -- Rebuild single-file after changes +- Rebuild: `./build.ps1 -Tasks build` - Consider 32-bit/64-bit WOW64 behavior for registry/file operations ## Testing @@ -166,7 +172,7 @@ See `Get-Help .\Tests\test-local.ps1` for flags: `-SkipBuild`, `-SkipTests`, `-S ### Key Rules - No code is complete without passing tests. A function without a test is unfinished work. -- PSScriptAnalyzer zero errors required. Always use `-IncludeAnalyzer` during development. +- PSScriptAnalyzer zero errors required. Run against `source/`. Always use `-IncludeAnalyzer` during development. - Dual-mode testing details: `Get-Help .\Tests\Invoke-AllTests.ps1` - Test bootstrap and load methods: `Get-Help .\Tests\TestBootstrap.ps1` @@ -184,7 +190,7 @@ 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 `./Tests/test-local.ps1` -- build + analyze + test must all pass +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` diff --git a/Build/Build-Documentation.ps1 b/Build/Build-Documentation.ps1 deleted file mode 100644 index 424c62a..0000000 --- a/Build/Build-Documentation.ps1 +++ /dev/null @@ -1,215 +0,0 @@ -<# -.SYNOPSIS - Generates markdown documentation and MAML help for ConnectWiseAutomateAgent using PlatyPS. - -.DESCRIPTION - This script generates markdown help files for all exported functions - in the ConnectWiseAutomateAgent module using PlatyPS, then compiles - them into MAML XML for Get-Help support. - -.PARAMETER OutputPath - The path where markdown documentation will be generated. Defaults to 'Docs\Help'. - -.PARAMETER UpdateExisting - If specified, updates existing markdown files rather than regenerating. - -.EXAMPLE - ./Build-Documentation.ps1 - Generates fresh documentation in Docs/Help/ - -.EXAMPLE - ./Build-Documentation.ps1 -UpdateExisting - Updates existing documentation files with any changes from code. -#> -[CmdletBinding()] -param( - [string]$OutputPath, - - [switch]$UpdateExisting -) - -$ErrorActionPreference = 'Stop' - -$ModuleName = 'ConnectWiseAutomateAgent' -$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } -$RepoRoot = Split-Path $ScriptRoot -Parent -if (-not $OutputPath) { $OutputPath = Join-Path (Join-Path $RepoRoot 'Docs') 'Help' } -$ModulePath = Join-Path (Join-Path $RepoRoot $ModuleName) "$ModuleName.psd1" -$EnUsPath = Join-Path (Join-Path $RepoRoot $ModuleName) 'en-US' - -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "PLATYPS DOCUMENTATION GENERATION" -ForegroundColor Cyan -Write-Host "========================================`n" -ForegroundColor Cyan - -# Ensure platyPS is available -if (-not (Get-Module -ListAvailable -Name platyPS)) { - Write-Host "Installing platyPS module..." -ForegroundColor Yellow - Install-Module -Name platyPS -Force -Scope CurrentUser -} - -Import-Module platyPS -Force - -# Remove any existing module from session -Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue - -# Import the source module -# Note: Initialize-CWAA runs on import and may produce non-terminating errors -# on dev machines without the Automate agent installed (registry keys not found). -# This is expected and safe to suppress. -Write-Host "Importing module from: $ModulePath" -ForegroundColor Gray -Import-Module $ModulePath -Force -ErrorAction SilentlyContinue -WarningAction SilentlyContinue - -# Validate the module actually loaded -$module = Get-Module $ModuleName -if (-not $module) { - Write-Error "Failed to import module $ModuleName from $ModulePath" - return -} - -Write-Host "Module loaded: $($module.Name) v$($module.Version)" -ForegroundColor Green -Write-Host "Exported functions: $($module.ExportedFunctions.Count)" -ForegroundColor Gray - -# Create output directory if it doesn't exist -if (-not (Test-Path $OutputPath)) { - Write-Host "Creating output directory: $OutputPath" -ForegroundColor Gray - New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null -} - -if ($UpdateExisting -and (Get-ChildItem $OutputPath -Filter '*.md' -ErrorAction SilentlyContinue)) { - Write-Host "`nUpdating existing documentation..." -ForegroundColor Yellow - Update-MarkdownHelpModule -Path $OutputPath -RefreshModulePage -AlphabeticParamsOrder -} -else { - Write-Host "`nGenerating new documentation..." -ForegroundColor Yellow - - # Generate markdown for each function - $params = @{ - Module = $ModuleName - OutputFolder = $OutputPath - AlphabeticParamsOrder = $true - WithModulePage = $true - ExcludeDontShow = $true - Encoding = [System.Text.Encoding]::UTF8 - } - - New-MarkdownHelp @params -Force -} - -# Count generated markdown files -$docFiles = Get-ChildItem $OutputPath -Filter '*.md' - -Write-Host "`n========================================" -ForegroundColor Green -Write-Host "MARKDOWN DOCUMENTATION GENERATED" -ForegroundColor Green -Write-Host "========================================" -ForegroundColor Green -Write-Host "Output: $OutputPath" -ForegroundColor Gray -Write-Host "Files: $($docFiles.Count) markdown files" -ForegroundColor Gray - -# List generated files -Write-Host "`nGenerated files:" -ForegroundColor Cyan -$docFiles | ForEach-Object { - Write-Host " - $($_.Name)" -ForegroundColor Gray -} - -# --- Post-process: rewrite the module page with categorized layout --- -# PlatyPS generates a flat alphabetical list. This rewrites it as categorized -# tables with enhanced descriptions for better readability. -# When adding a new function, add it to the appropriate category below. - -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "MODULE PAGE ENHANCEMENT" -ForegroundColor Cyan -Write-Host "========================================`n" -ForegroundColor Cyan - -$modulePagePath = Join-Path $OutputPath "$ModuleName.md" - -# Category ordering for the module page -$categories = [ordered]@{ - 'Install & Uninstall' = @('Install-CWAA', 'Uninstall-CWAA', 'Update-CWAA', 'Redo-CWAA') - 'Service Management' = @('Start-CWAA', 'Stop-CWAA', 'Restart-CWAA', 'Repair-CWAA') - 'Agent Settings & Backup' = @('Get-CWAAInfo', 'Get-CWAAInfoBackup', 'Get-CWAASettings', 'New-CWAABackup', 'Reset-CWAA') - 'Logging' = @('Get-CWAAError', 'Get-CWAAProbeError', 'Get-CWAALogLevel', 'Set-CWAALogLevel') - 'Proxy' = @('Get-CWAAProxy', 'Set-CWAAProxy') - 'Add/Remove Programs' = @('Hide-CWAAAddRemove', 'Show-CWAAAddRemove', 'Rename-CWAAAddRemove') - 'Health & Monitoring' = @('Test-CWAAHealth', 'Test-CWAAServerConnectivity', 'Register-CWAAHealthCheckTask', 'Unregister-CWAAHealthCheckTask') - 'Security & Utilities' = @('ConvertFrom-CWAASecurity', 'ConvertTo-CWAASecurity', 'Invoke-CWAACommand', 'Test-CWAAPort') -} - -# Read SYNOPSIS from each function's generated markdown. -# These come from the .SYNOPSIS in each function's comment-based help. -$synopses = @{} -foreach ($funcList in $categories.Values) { - foreach ($funcName in $funcList) { - $funcFile = Join-Path $OutputPath "$funcName.md" - if (Test-Path $funcFile) { - $funcContent = Get-Content $funcFile -Raw - if ($funcContent -match '## SYNOPSIS\r?\n(.+)') { - $synopses[$funcName] = $Matches[1].Trim() - } - } - } -} - -# Preserve YAML frontmatter from the PlatyPS-generated module page -$existingContent = Get-Content $modulePagePath -Raw -$frontmatter = '' -if ($existingContent -match '(?s)^(---.*?---)') { - $frontmatter = $Matches[1] -} - -# Build the enhanced module page -$sb = New-Object System.Text.StringBuilder -[void]$sb.AppendLine($frontmatter) -[void]$sb.AppendLine('') -[void]$sb.AppendLine("# $ModuleName Module") -[void]$sb.AppendLine('') -[void]$sb.AppendLine('PowerShell module for managing the ConnectWise Automate (formerly LabTech) Windows agent. Install, configure, troubleshoot, and manage the Automate agent on Windows systems.') -[void]$sb.AppendLine('') -[void]$sb.AppendLine('> Every function below has a legacy `LT` alias (e.g., `Install-CWAA` = `Install-LTService`). Run `Get-Alias -Definition *-CWAA*` to see them all.') -[void]$sb.AppendLine('') - -foreach ($categoryName in $categories.Keys) { - [void]$sb.AppendLine("## $categoryName") - [void]$sb.AppendLine('') - [void]$sb.AppendLine('| Function | Description |') - [void]$sb.AppendLine('| --- | --- |') - - foreach ($funcName in $categories[$categoryName]) { - $desc = if ($synopses.ContainsKey($funcName)) { $synopses[$funcName] } else { '' } - [void]$sb.AppendLine("| [$funcName]($funcName.md) | $desc |") - } - - [void]$sb.AppendLine('') -} - -Set-Content -Path $modulePagePath -Value $sb.ToString().TrimEnd() -Encoding UTF8 -NoNewline - -# Warn about any exported functions missing from the category map -$categorizedFunctions = $categories.Values | ForEach-Object { $_ } -$exportedFunctions = $module.ExportedFunctions.Keys -$uncategorized = $exportedFunctions | Where-Object { $_ -notin $categorizedFunctions } -if ($uncategorized) { - Write-Host "`nWARNING: The following exported functions are not in the module page category map:" -ForegroundColor Yellow - $uncategorized | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } - Write-Host "Add them to the `$categories hashtable in Build-Documentation.ps1" -ForegroundColor Yellow -} -else { - Write-Host "Module page rewritten with categorized layout ($($exportedFunctions.Count) functions)." -ForegroundColor Green -} - -# Generate MAML XML help from markdown -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "MAML HELP GENERATION" -ForegroundColor Cyan -Write-Host "========================================`n" -ForegroundColor Cyan - -if (-not (Test-Path $EnUsPath)) { - Write-Host "Creating en-US directory: $EnUsPath" -ForegroundColor Gray - New-Item -ItemType Directory -Path $EnUsPath -Force | Out-Null -} - -Write-Host "Generating MAML XML from markdown..." -ForegroundColor Yellow -$mamlOutput = New-ExternalHelp -Path $OutputPath -OutputPath $EnUsPath -Force - -Write-Host "`n========================================" -ForegroundColor Green -Write-Host "DOCUMENTATION BUILD COMPLETE" -ForegroundColor Green -Write-Host "========================================" -ForegroundColor Green -Write-Host "Markdown: $OutputPath ($($docFiles.Count) files)" -ForegroundColor Gray -Write-Host "MAML: $($mamlOutput.FullName)" -ForegroundColor Gray diff --git a/Build/Extract-ChangelogEntry.ps1 b/Build/Extract-ChangelogEntry.ps1 deleted file mode 100644 index ca4a931..0000000 --- a/Build/Extract-ChangelogEntry.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# -.SYNOPSIS - Extracts a version's release notes from CHANGELOG.md. - -.DESCRIPTION - Parses a Keep a Changelog formatted CHANGELOG.md and extracts the content - for a specific version heading. Returns the markdown content between the - target version heading and the next version heading (or end of file). - - Used by the CI/CD workflow to populate GitHub Release descriptions. - -.PARAMETER Version - The version string to extract (e.g., '1.0.0-alpha001', '1.0.0'). - Must match a heading in the format ## [Version] in CHANGELOG.md. - -.PARAMETER ChangelogPath - Path to the CHANGELOG.md file. Defaults to CHANGELOG.md in the repository root. - -.PARAMETER OutputPath - If specified, writes the extracted content to this file instead of stdout. - Used by CI to pass release notes to gh release create via --notes-file. - -.EXAMPLE - ./Extract-ChangelogEntry.ps1 -Version '1.0.0-alpha001' - Outputs the release notes for version 1.0.0-alpha001 to stdout. - -.EXAMPLE - ./Extract-ChangelogEntry.ps1 -Version '1.0.0' -OutputPath 'release-notes.md' - Writes release notes to release-notes.md for use in CI. - -.LINK - https://keepachangelog.com/en/1.1.0/ - -.NOTES - Exit codes: - 0 - Success - 1 - CHANGELOG.md not found - 2 - Version heading not found or empty in CHANGELOG.md -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $True)] - [string]$Version, - - [string]$ChangelogPath, - - [string]$OutputPath -) - -$ErrorActionPreference = 'Stop' - -$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } -$RepoRoot = Split-Path $ScriptRoot -Parent - -if (-not $ChangelogPath) { - $ChangelogPath = Join-Path $RepoRoot 'CHANGELOG.md' -} - -# ─── Validate CHANGELOG exists ──────────────────────────────────────────── -if (-not (Test-Path $ChangelogPath)) { - Write-Error "CHANGELOG.md not found at '$ChangelogPath'. Cannot extract release notes." - exit 1 -} - -# ─── Read and parse ─────────────────────────────────────────────────────── -$changelogLines = Get-Content $ChangelogPath - -# Find the line index of the target version heading: ## [1.0.0-alpha001] - 2026-01-31 -$escapedVersion = [regex]::Escape($Version) -$targetPattern = "^## \[$escapedVersion\]" -$startIndex = -1 - -for ($i = 0; $i -lt $changelogLines.Count; $i++) { - if ($changelogLines[$i] -match $targetPattern) { - $startIndex = $i - break - } -} - -if ($startIndex -eq -1) { - Write-Error "Version '$Version' not found in CHANGELOG.md. Expected a heading like '## [$Version] - YYYY-MM-DD'." - exit 2 -} - -# Find the next version heading (## [...]) or end of file -$endIndex = $changelogLines.Count -for ($i = $startIndex + 1; $i -lt $changelogLines.Count; $i++) { - if ($changelogLines[$i] -match '^## \[') { - $endIndex = $i - break - } -} - -# Extract lines between headings (exclusive of both), trim leading/trailing blank lines -$contentLines = $changelogLines[($startIndex + 1)..($endIndex - 1)] -$content = ($contentLines -join "`n").Trim() - -if (-not $content) { - Write-Error "Version '$Version' heading found but has no content in CHANGELOG.md." - exit 2 -} - -# ─── Output ─────────────────────────────────────────────────────────────── -if ($OutputPath) { - $content | Out-File -FilePath $OutputPath -Encoding UTF8 -Force - Write-Host "Release notes for v$Version written to: $OutputPath" -} -else { - Write-Output $content -} diff --git a/Build/Publish-CWAAModule.ps1 b/Build/Publish-CWAAModule.ps1 deleted file mode 100644 index 974d3b9..0000000 --- a/Build/Publish-CWAAModule.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -<# -.SYNOPSIS - Publishes the ConnectWiseAutomateAgent module to the PowerShell Gallery. - -.DESCRIPTION - Validates the module manifest, displays version and prerelease information, - and publishes the module to the PowerShell Gallery using Publish-Module. - - Supports a dry-run mode via -WhatIf that validates the manifest and shows - what would be published without actually calling Publish-Module. - -.PARAMETER NuGetApiKey - The API key for authenticating with the PowerShell Gallery. - -.PARAMETER Force - Bypasses NuGet dependency validation during publish. Use for CI/CD pipelines - or when you've already validated dependencies separately. - -.EXAMPLE - ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' - Publishes the module to the PowerShell Gallery. - -.EXAMPLE - ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' -WhatIf - Validates the manifest and shows what would be published without publishing. - -.EXAMPLE - ./Publish-CWAAModule.ps1 -NuGetApiKey 'your-api-key-here' -Force - Publishes with NuGet dependency validation bypassed (CI/CD use). - -.LINK - https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent - -.NOTES - Requires PowerShell 5.0+ and the PowerShellGet module. - The NuGetApiKey can be generated at https://www.powershellgallery.com/account/apikeys -#> -#Requires -Version 5.0 -#Requires -Module PowerShellGet - -[CmdletBinding(SupportsShouldProcess = $True)] -param( - [Parameter(Mandatory = $True)] - [string]$NuGetApiKey, - - [switch]$Force -) - -$ErrorActionPreference = 'Stop' - -$ModuleName = 'ConnectWiseAutomateAgent' -$ScriptRoot = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition } -$RepoRoot = Split-Path $ScriptRoot -Parent -$ModulePath = Join-Path $RepoRoot $ModuleName -$ManifestPath = Join-Path $ModulePath "$ModuleName.psd1" - -# ─── Validate manifest exists ─────────────────────────────────────────────── -if (-not (Test-Path $ManifestPath)) { - throw "Module manifest not found: $ManifestPath" -} - -# ─── Read version info from manifest ──────────────────────────────────────── -$manifestData = Import-PowerShellDataFile $ManifestPath -$moduleVersion = $manifestData.ModuleVersion -$prereleaseTag = $manifestData.PrivateData.PSData.Prerelease -$isPrerelease = [bool]$prereleaseTag -$fullVersion = if ($isPrerelease) { "$moduleVersion-$prereleaseTag" } else { $moduleVersion } - -# ─── Validate manifest with Test-ModuleManifest ───────────────────────────── -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "MODULE MANIFEST VALIDATION" -ForegroundColor Cyan -Write-Host "========================================`n" -ForegroundColor Cyan - -try { - Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop | Out-Null - Write-Host "Manifest validation passed." -ForegroundColor Green -} -catch { - throw "Manifest validation failed. Error: $($_.Exception.Message)" -} - -# ─── Verify module imports cleanly ──────────────────────────────────────── -Write-Host "`nImport test..." -ForegroundColor Cyan -Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue -try { - Import-Module $ModulePath -Force -ErrorAction Stop -WarningAction SilentlyContinue - $loadedModule = Get-Module $ModuleName - if (-not $loadedModule) { throw 'Module did not load.' } - Write-Host "Import test passed ($($loadedModule.ExportedFunctions.Count) functions exported)." -ForegroundColor Green - Remove-Module $ModuleName -Force -ErrorAction SilentlyContinue -WhatIf:$False -} -catch { - throw "Module failed to import cleanly. Fix errors before publishing. Error: $($_.Exception.Message)" -} - -# ─── Check for existing version on gallery ──────────────────────────────── -try { - $galleryModule = Find-Module -Name $ModuleName -RequiredVersion $moduleVersion -AllowPrerelease -ErrorAction SilentlyContinue - if ($galleryModule) { - Write-Warning "Version $fullVersion already exists on the PowerShell Gallery. Publish will likely fail." - } -} -catch { - # Gallery lookup failed — not fatal, continue with publish attempt - Write-Host "Could not check gallery for existing version (this is non-fatal)." -ForegroundColor Gray -} - -# ─── Display publish summary ──────────────────────────────────────────────── -Write-Host "`n========================================" -ForegroundColor Cyan -Write-Host "PUBLISH SUMMARY" -ForegroundColor Cyan -Write-Host "========================================`n" -ForegroundColor Cyan - -Write-Host "Module: $ModuleName" -ForegroundColor Gray -Write-Host "Version: $fullVersion" -ForegroundColor Gray -Write-Host "Prerelease: $isPrerelease" -ForegroundColor $(if ($isPrerelease) { 'Yellow' } else { 'Gray' }) -Write-Host "Author: $($manifestData.Author)" -ForegroundColor Gray -Write-Host "Description: $($manifestData.Description)" -ForegroundColor Gray -Write-Host "Source: $ModulePath" -ForegroundColor Gray - -if ($isPrerelease) { - Write-Host "`n--- Prerelease Install Instructions ---" -ForegroundColor Yellow - Write-Host "Install-Module -Name $ModuleName -AllowPrerelease" -ForegroundColor White - Write-Host "Install-Module -Name $ModuleName -RequiredVersion $fullVersion -AllowPrerelease" -ForegroundColor White -} -else { - Write-Host "`n--- Install Instructions ---" -ForegroundColor Green - Write-Host "Install-Module -Name $ModuleName" -ForegroundColor White - Write-Host "Install-Module -Name $ModuleName -RequiredVersion $moduleVersion" -ForegroundColor White -} - -# ─── Publish or dry-run ───────────────────────────────────────────────────── -if ($PSCmdlet.ShouldProcess("$ModuleName $fullVersion", 'Publish to PowerShell Gallery')) { - Write-Host "`nPublishing $ModuleName $fullVersion to PowerShell Gallery..." -ForegroundColor Yellow - - $publishParams = @{ - Path = $ModulePath - NuGetApiKey = $NuGetApiKey - ErrorAction = 'Stop' - } - if ($Force) { $publishParams['Force'] = $True } - - try { - Publish-Module @publishParams - Write-Host "`n========================================" -ForegroundColor Green - Write-Host "PUBLISH SUCCESSFUL" -ForegroundColor Green - Write-Host "========================================" -ForegroundColor Green - Write-Host "Published: $ModuleName $fullVersion" -ForegroundColor Gray - Write-Host "Gallery: https://www.powershellgallery.com/packages/$ModuleName" -ForegroundColor Gray - } - catch { - Write-Error "Publish failed for $ModuleName $fullVersion. Error: $($_.Exception.Message)" - } -} -else { - Write-Host "`n========================================" -ForegroundColor Yellow - Write-Host "DRY RUN - NO CHANGES MADE" -ForegroundColor Yellow - Write-Host "========================================" -ForegroundColor Yellow - Write-Host "Manifest validation: Passed" -ForegroundColor Green - Write-Host "Would publish: $ModuleName $fullVersion" -ForegroundColor Gray - Write-Host "To: PowerShell Gallery" -ForegroundColor Gray -} diff --git a/Build/SingleFileBuild.ps1 b/Build/SingleFileBuild.ps1 deleted file mode 100644 index 1017dd4..0000000 --- a/Build/SingleFileBuild.ps1 +++ /dev/null @@ -1,101 +0,0 @@ -param( - [switch]$BuildDocs -) - -$ModuleName = 'ConnectWiseAutomateAgent' -$PathRoot = '.' -$Initialize = 'Initialize-CWAA' -$FileName = "$($ModuleName).ps1" -$FullPath = Join-Path $PathRoot $FileName -$ModulePath = Join-Path $PathRoot $ModuleName -$ManifestPath = Join-Path $ModulePath "$ModuleName.psd1" - -Try { - # Read version and prerelease tag from manifest - $version = $null - $prerelease = $null - if (Test-Path $ManifestPath) { - $manifest = Import-PowerShellDataFile $ManifestPath -ErrorAction SilentlyContinue - if ($manifest) { - $version = $manifest.ModuleVersion - $prerelease = $manifest.PrivateData.PSData.Prerelease - } - } - $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } - - # Build header - $header = @" -# $ModuleName $fullVersion -# Single-file distribution - built $(Get-Date -Format 'yyyy-MM-dd') -# https://github.com/christaylorcodes/ConnectWiseAutomateAgent - -"@ - - # Concatenate all .ps1 files from the module directory - $sourceFiles = Get-ChildItem (Join-Path $PathRoot $ModuleName) -Filter '*.ps1' -Recurse - if (-not $sourceFiles) { - Write-Error "No .ps1 files found in $ModulePath" - return - } - - $content = $sourceFiles | ForEach-Object { - (Get-Content $_.FullName | Where-Object { $_ }) - } - - # Write header, content, and initialization call - $header | Out-File $FullPath -Force -Encoding UTF8 - $content | Out-File $FullPath -Append -Encoding UTF8 - $Initialize | Out-File $FullPath -Append -Encoding UTF8 - - # Validate output - if (-not (Test-Path $FullPath)) { - Write-Error "Build failed: output file was not created." - return - } - - $outputSize = (Get-Item $FullPath).Length - if ($outputSize -eq 0) { - Write-Error "Build failed: output file is empty." - return - } - - $lineCount = (Get-Content $FullPath | Measure-Object).Count - Write-Output "Build successful: $FullPath ($lineCount lines, $([math]::Round($outputSize / 1KB, 1)) KB)" - - # Version consistency check: verify manifest version matches CHANGELOG latest entry - $changelogPath = Join-Path $PathRoot 'CHANGELOG.md' - if (Test-Path $changelogPath) { - $changelogContent = Get-Content $changelogPath -Raw - # Match the first version heading: ## [1.0.0] or ## [1.0.0-alpha001] - if ($changelogContent -match '## \[([^\]]+)\]') { - $changelogVersion = $Matches[1] - if ($changelogVersion -ne $fullVersion) { - Write-Warning "Version mismatch: manifest says '$fullVersion' but CHANGELOG.md latest entry is '$changelogVersion'. Update CHANGELOG.md before release." - } - else { - Write-Output "Version consistency check passed: $fullVersion" - } - } - else { - Write-Warning 'CHANGELOG.md found but no version heading detected.' - } - } - else { - Write-Warning 'CHANGELOG.md not found. Consider creating one for release tracking.' - } - - # Optionally build documentation - if ($BuildDocs) { - Write-Output 'Building documentation...' - $docBuildScript = Join-Path $PSScriptRoot 'Build-Documentation.ps1' - if (Test-Path $docBuildScript) { - & $docBuildScript -UpdateExisting - } - else { - Write-Warning "Documentation build script not found: $docBuildScript" - } - } -} -Catch { - Write-Error "Build failed. $_" -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27098c0..af39cd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Open an issue describing what you would like to see and why. Include use cases s 2. **Make your changes** following the coding conventions below. 3. **Test** your changes: ```powershell - ./Tests/test-local.ps1 + ./build.ps1 -Tasks test ``` 4. **Submit** a pull request with a clear description of what you changed and why. @@ -35,14 +35,17 @@ Open an issue describing what you would like to see and why. Include use cases s git clone https://github.com/christaylorcodes/ConnectWiseAutomateAgent.git cd ConnectWiseAutomateAgent -# Import the module locally -Import-Module .\ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1 -Force +# First time: resolve build dependencies (Sampler, ModuleBuilder, InvokeBuild, etc.) +./build.ps1 -ResolveDependency -Tasks noop -# Run all local checks (build + analyze + test) -./Tests/test-local.ps1 +# Build the module (output goes to output/) +./build.ps1 -Tasks build -# Or run tests only (faster, no build) -./Tests/test-local.ps1 -Quick +# Run all checks (build + analyze + test) +./build.ps1 -Tasks test + +# Import from source for quick dev iteration (no build required) +Import-Module .\source\ConnectWiseAutomateAgent.psd1 -Force # Enable pre-commit hooks (runs PSScriptAnalyzer + tests before each commit) git config core.hooksPath .githooks @@ -63,8 +66,9 @@ The key points: See [Adding New Functions](AGENTS.md#adding-new-functions) in AGENTS.md for the full checklist. Summary: -1. Create `Verb-CWAA.ps1` in the appropriate `Public/` subdirectory -2. Add the `LT` alias, update the manifest, rebuild docs and single-file +1. Create `Verb-CWAA.ps1` in the appropriate `source/Public/` subdirectory +2. Add the `LT` alias -- ModuleBuilder auto-discovers functions and aliases from `source/Public/`, so no manifest edits are needed for export lists +3. Rebuild: `./build.ps1 -Tasks build` ## Versioning @@ -87,7 +91,7 @@ Prerelease versions use the format `MAJOR.MINOR.PATCH-TAG` where TAG follows thi 3. `rc001`, `rc002`, ... — release candidate, final validation 4. *(no tag)* — stable release -The prerelease tag is set in `ConnectWiseAutomateAgent.psd1` under `PrivateData.PSData.Prerelease`. Remove the tag entirely for a stable release. +The prerelease tag is set in `source/ConnectWiseAutomateAgent.psd1` under `PrivateData.PSData.Prerelease`. Remove the tag entirely for a stable release. ### Changelog diff --git a/ConnectWiseAutomateAgent.ps1 b/ConnectWiseAutomateAgent.ps1 deleted file mode 100644 index b945503..0000000 --- a/ConnectWiseAutomateAgent.ps1 +++ /dev/null @@ -1,4870 +0,0 @@ -# ConnectWiseAutomateAgent 1.0.0-alpha001 -# Single-file distribution - built 2026-02-01 -# https://github.com/christaylorcodes/ConnectWiseAutomateAgent - -function Assert-CWAANotProbeAgent { - <# - .SYNOPSIS - Blocks operations on probe agents unless -Force is specified. - .DESCRIPTION - Checks the agent info object to determine if the current machine is a probe agent. - If it is and -Force is not set, writes a terminating error to prevent accidental - removal of critical infrastructure. If -Force is set, writes a warning message and - allows continuation. - The ActionName parameter produces contextual messages like - "Probe Agent Detected. UnInstall Denied." or "Probe Agent Detected. Reset Forced." - This consolidates the duplicated probe agent protection check found in - Uninstall-CWAA, Redo-CWAA, and Reset-CWAA. - .PARAMETER ServiceInfo - The agent info object from Get-CWAAInfo. If null or missing the Probe property, - the check is skipped silently. - .PARAMETER ActionName - The name of the operation for error/output messages. Used directly in the message - string, e.g., 'UnInstall', 'Re-Install', 'Reset'. - .PARAMETER Force - When set, allows the operation to proceed on a probe agent with an output message - instead of a terminating error. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - Param( - [Parameter()] - [AllowNull()] - $ServiceInfo, - [Parameter(Mandatory = $True)] - [string]$ActionName, - [switch]$Force - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if ($ServiceInfo -and ($ServiceInfo | Select-Object -Expand Probe -EA 0) -eq '1') { - if ($Force) { - Write-Output "Probe Agent Detected. $ActionName Forced." - } - else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception ([System.OperationCanceledException]"Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop - } - else { - Write-Error -Exception ([System.OperationCanceledException]"What If: Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop - } - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Clear-CWAAInstallerArtifacts { - <# - .SYNOPSIS - Cleans up stale ConnectWise Automate installer processes and temporary files. - .DESCRIPTION - Terminates any running installer-related processes and removes temporary installer - files left behind by incomplete or failed installations. This prevents conflicts - when starting a new install, reinstall, or update operation. - Process names and file paths are read from the centralized module constants - $Script:CWAAInstallerProcessNames and $Script:CWAAInstallerArtifactPaths. - All operations are best-effort with errors suppressed. This function is intended - as a defensive cleanup step, not a validated operation. - .NOTES - Version: 0.1.5.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - # Kill stale installer processes that may block new installations - foreach ($processName in $Script:CWAAInstallerProcessNames) { - Get-Process -Name $processName -ErrorAction SilentlyContinue | - Stop-Process -Force -ErrorAction SilentlyContinue - } - # Remove leftover temporary installer files - foreach ($artifactPath in $Script:CWAAInstallerArtifactPaths) { - Remove-Item -Path $artifactPath -Force -Recurse -ErrorAction SilentlyContinue - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Invoke-CWAAMsiInstaller { - <# - .SYNOPSIS - Executes the Automate agent MSI installer with retry logic. - .DESCRIPTION - Launches msiexec.exe with the provided arguments and retries up to a configurable - number of attempts if the LTService service is not detected after installation. - Between retries, polls for the service using Wait-CWAACondition. Redacts server - passwords from verbose output for security. - .PARAMETER InstallerArguments - The full argument string to pass to msiexec.exe (e.g., '/i "path\Agent_Install.msi" SERVERADDRESS=... /qn'). - .PARAMETER MaxAttempts - Maximum number of install attempts before giving up. Defaults to $Script:CWAAInstallMaxAttempts. - .PARAMETER RetryDelaySeconds - Seconds to wait (polling for service) between retry attempts. Defaults to $Script:CWAAInstallRetryDelaySeconds. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - Param( - [Parameter(Mandatory = $True)] - [string]$InstallerArguments, - [Parameter()] - [int]$MaxAttempts = $Script:CWAAInstallMaxAttempts, - [Parameter()] - [int]$RetryDelaySeconds = $Script:CWAAInstallRetryDelaySeconds - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if (-not $PSCmdlet.ShouldProcess("msiexec.exe $InstallerArguments", 'Execute Install')) { - return $true - } - $installAttempt = 0 - Do { - if ($installAttempt -gt 0) { - Write-Warning "Service Failed to Install. Retrying in $RetryDelaySeconds seconds." -WarningAction 'Continue' - $Null = Wait-CWAACondition -Condition { - $serviceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - $serviceCount -eq 1 - } -TimeoutSeconds $RetryDelaySeconds -IntervalSeconds 5 -Activity 'Waiting for service availability before retry' - } - $installAttempt++ - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -eq 0) { - $redactedArguments = $InstallerArguments -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' - Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" - Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $InstallerArguments -WorkingDirectory $env:TEMP - Start-Sleep 5 - } - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($installAttempt -ge $MaxAttempts -or $runningServiceCount -eq 1) - if ($runningServiceCount -eq 0) { - Write-Error "LTService was not installed. Installation failed after $MaxAttempts attempts." - return $false - } - return $true - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Remove-CWAAFolderRecursive { - <# - .SYNOPSIS - Performs depth-first removal of a folder and all its contents. - .DESCRIPTION - Private helper that removes a folder using a three-pass depth-first strategy: - 1. Remove files inside each subfolder (leaves first) - 2. Remove subfolders sorted by path depth (deepest first) - 3. Remove the root folder itself - This approach maximizes cleanup even when some files or folders are locked - by running processes, which is common during agent uninstall/update operations. - All removal operations use best-effort error handling (-ErrorAction SilentlyContinue). - The caller's $WhatIfPreference and $ConfirmPreference propagate automatically - through PowerShell's preference variable mechanism. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding(SupportsShouldProcess = $True)] - Param( - [Parameter(Mandatory = $True)] - [string]$Path, - [switch]$ShowProgress - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if (-not (Test-Path $Path -ErrorAction SilentlyContinue)) { - Write-Debug "Path '$Path' does not exist. Nothing to remove." - return - } - if ($PSCmdlet.ShouldProcess($Path, 'Remove Folder')) { - Write-Debug "Removing Folder: $Path" - $folderProgressId = 10 - $folderProgressActivity = "Removing folder: $Path" - Try { - # Pass 1: Remove files inside each subfolder (leaves first) - if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing files (pass 1 of 3)' -PercentComplete 33 } - Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | - Where-Object { $_.psiscontainer } | - ForEach-Object { - Get-ChildItem -Path $_.FullName -ErrorAction SilentlyContinue | - Where-Object { -not $_.psiscontainer } | - Remove-Item -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - } - # Pass 2: Remove subfolders sorted by path depth (deepest first) - if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing subfolders (pass 2 of 3)' -PercentComplete 66 } - Get-ChildItem -Path $Path -Recurse -Force -ErrorAction SilentlyContinue | - Where-Object { $_.psiscontainer } | - Sort-Object { $_.FullName.Length } -Descending | - Remove-Item -Force -ErrorAction SilentlyContinue -Recurse -Confirm:$False -WhatIf:$False - # Pass 3: Remove the root folder itself - if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Status 'Removing root folder (pass 3 of 3)' -PercentComplete 100 } - Remove-Item -Recurse -Force -Path $Path -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } - } - Catch { - if ($ShowProgress) { Write-Progress -Id $folderProgressId -Activity $folderProgressActivity -Completed } - Write-Debug "Error removing folder '$Path': $($_.Exception.Message)" - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Resolve-CWAAServer { - <# - .SYNOPSIS - Finds the first reachable ConnectWise Automate server from a list of candidates. - .DESCRIPTION - Private helper that iterates through server URLs, validates each against the - server format regex, normalizes the URL scheme, and tests reachability by - downloading the version string from /LabTech/Agent.aspx. Returns the first - server that responds with a parseable version. - Used by Install-CWAA, Uninstall-CWAA, and Update-CWAA to eliminate the - duplicated server validation loop. Callers handle their own download logic - after receiving the resolved server, since URL construction differs per operation. - Requires $Script:LTServiceNetWebClient to be initialized (via Initialize-CWAANetworking) - before calling. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param( - [Parameter(Mandatory = $True)] - [string[]]$Server - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - # Normalize: prepend https:// to bare hostnames/IPs so the loop has consistent URLs - $normalizedServers = ForEach ($serverUrl in $Server) { - if ($serverUrl -notmatch 'https?://.+') { "https://$serverUrl" } - $serverUrl - } - ForEach ($serverUrl in $normalizedServers) { - if ($serverUrl -match $Script:CWAAServerValidationRegex) { - # Ensure a scheme is present for the actual request - if ($serverUrl -notmatch 'https?://.+') { $serverUrl = "http://$serverUrl" } - Try { - $versionCheckUrl = "$serverUrl/LabTech/Agent.aspx" - Write-Debug "Testing Server Response and Version: $versionCheckUrl" - $serverVersionResponse = $Script:LTServiceNetWebClient.DownloadString($versionCheckUrl) - Write-Debug "Raw Response: $serverVersionResponse" - # Extract version from the pipe-delimited response string. - # Format: six pipe characters followed by major.minor version (e.g. '||||||220.105') - $serverVersion = $serverVersionResponse | - Select-String -Pattern '(?<=[|]{6})[0-9]{1,3}\.[0-9]{1,3}' | - ForEach-Object { $_.Matches } | - Select-Object -Expand Value -ErrorAction SilentlyContinue - if ($null -eq $serverVersion) { - Write-Verbose "Unable to test version response from $serverUrl." - Continue - } - Write-Verbose "Server $serverUrl responded with version $serverVersion." - return [PSCustomObject]@{ - ServerUrl = $serverUrl - ServerVersion = $serverVersion - } - } - Catch { - Write-Warning "Error encountered testing server $serverUrl." - Continue - } - } - else { - Write-Warning "Server address $serverUrl is not formatted correctly. Example: https://automate.domain.com" - } - } - # No server responded successfully - Write-Debug "No reachable server found from candidates: $($Server -join ', ')" - return $null - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAADotNetPrerequisite { - <# - .SYNOPSIS - Checks for and optionally installs the .NET Framework 3.5 prerequisite. - .DESCRIPTION - Verifies that .NET Framework 3.5 is installed, which is required by the ConnectWise - Automate agent. If 3.5 is missing, attempts automatic installation via - Enable-WindowsOptionalFeature (Windows 8+) or Dism.exe (Windows 7/Server 2008 R2). - With -Force, allows the agent install to proceed if .NET 2.0 or higher is present - even when 3.5 cannot be installed. Without -Force, a missing 3.5 is a terminating error. - .PARAMETER SkipDotNet - Skips the .NET Framework check entirely. Returns $true immediately. - .PARAMETER Force - Allows fallback to .NET 2.0+ if 3.5 cannot be installed. - Without -Force, missing 3.5 is a terminating error. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - Param( - [switch]$SkipDotNet, - [switch]$Force - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if ($SkipDotNet) { - Write-Debug 'SkipDotNet specified, skipping .NET prerequisite check.' - return $true - } - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse -EA 0 | Get-ItemProperty -Name Version, Release -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version -EA 0 - if ($DotNet -like '3.5.*') { - Write-Debug '.NET Framework 3.5 is already installed.' - return $true - } - Write-Warning '.NET Framework 3.5 installation needed.' - $OSVersion = [System.Environment]::OSVersion.Version - if ([version]$OSVersion -gt [version]'6.2') { - # Windows 8 / Server 2012 and later -- use Enable-WindowsOptionalFeature - Try { - if ($PSCmdlet.ShouldProcess('NetFx3', 'Enable-WindowsOptionalFeature')) { - $Install = Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3' - if ($Install.State -ne 'EnablePending') { - $Install = Enable-WindowsOptionalFeature -Online -FeatureName 'NetFx3' -All -NoRestart - } - if ($Install.RestartNeeded -or $Install.State -eq 'EnablePending') { - Write-Warning '.NET Framework 3.5 installed but a reboot is needed.' - } - } - } - Catch { - Write-Error ".NET 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Install -ErrorAction Stop } - } - } - Elseif ([version]$OSVersion -gt [version]'6.1') { - # Windows 7 / Server 2008 R2 -- use Dism.exe - if ($PSCmdlet.ShouldProcess('NetFx3', 'Add Windows Feature')) { - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /NoRestart /Online /Enable-Feature /FeatureName:NetFx3 2>'' } - Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } - Try { $Result = & "${env:windir}\system32\Dism.exe" /English /Online /Get-FeatureInfo /FeatureName:NetFx3 2>'' } - Catch { Write-Warning 'Error calling Dism.exe.'; $Result = $Null } - if ($Result -contains 'State : Enabled') { - Write-Warning ".Net Framework 3.5 has been installed and enabled." - } - Elseif ($Result -contains 'State : Enable Pending') { - Write-Warning ".Net Framework 3.5 installed but a reboot is needed." - } - else { - Write-Error ".NET Framework 3.5 install failed." -ErrorAction Continue - if (-not $Force) { Write-Error $Result -ErrorAction Stop } - } - } - } - # Re-check after install attempt - $DotNET = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP' -Recurse | Get-ItemProperty -Name Version -EA 0 | Where-Object { $_.PSChildName -match '^(?!S)\p{L}' } | Select-Object -ExpandProperty Version - if ($DotNet -like '3.5.*') { - return $true - } - # .NET 3.5 still not available after install attempt - if ($Force) { - if ($DotNet -match '(?m)^[2-4].\d') { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Continue - return $true - } - else { - Write-Error ".NET 2.0 or greater is not detected and could not be installed." -ErrorAction Stop - return $false - } - } - else { - Write-Error ".NET 3.5 is not detected and could not be installed." -ErrorAction Stop - return $false - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAADownloadIntegrity { - <# - .SYNOPSIS - Validates a downloaded file meets minimum size requirements. - .DESCRIPTION - Private helper that checks whether a downloaded installer file exists and - exceeds the specified minimum size threshold. If the file is below the - threshold, it is treated as corrupt or incomplete: a warning is emitted - and the file is removed. - The default threshold of 1234 KB matches the established convention for - MSI/EXE installer files. The Agent_Uninstall.exe uses a lower threshold - of 80 KB due to its smaller expected size. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param( - [Parameter(Mandatory = $True)] - [string]$FilePath, - [Parameter()] - [string]$FileName, - [Parameter()] - [int]$MinimumSizeKB = 1234 - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - if (-not $FileName) { - $FileName = Split-Path $FilePath -Leaf - } - } - Process { - if (-not (Test-Path $FilePath)) { - Write-Debug "$FileName not found at '$FilePath'." - return $false - } - $fileSizeKB = (Get-Item $FilePath -ErrorAction SilentlyContinue).Length / 1KB - if (-not ($fileSizeKB -gt $MinimumSizeKB)) { - Write-Warning "$FileName size is below normal ($([math]::Round($fileSizeKB, 1)) KB < $MinimumSizeKB KB). Removing suspected corrupt file." - Remove-Item $FilePath -ErrorAction SilentlyContinue -Force -Confirm:$False - return $false - } - Write-Debug "$FileName integrity check passed ($([math]::Round($fileSizeKB, 1)) KB >= $MinimumSizeKB KB)." - return $true - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAAServiceExists { - <# - .SYNOPSIS - Tests whether the Automate agent services are installed on the local computer. - .DESCRIPTION - Checks for the existence of the LTService and LTSvcMon services using the - centralized $Script:CWAAServiceNames constant. Returns $true if at least one - service is found, $false otherwise. - When -WriteErrorOnMissing is specified, writes a WhatIf-aware error message - if the services are not found. This consolidates the duplicated service existence - check pattern found in Start-CWAA, Stop-CWAA, Restart-CWAA, and Reset-CWAA. - .PARAMETER WriteErrorOnMissing - When specified, writes a Write-Error message if the services are not found. - The error message is WhatIf-aware (includes 'What If:' prefix when - $WhatIfPreference is $true in the caller's scope). - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - Param( - [switch]$WriteErrorOnMissing - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - $services = Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue - if ($services) { - return $true - } - if ($WriteErrorOnMissing) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } - } - return $false - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Wait-CWAACondition { - <# - .SYNOPSIS - Polls a condition script block until it returns $true or a timeout is reached. - .DESCRIPTION - Generic polling helper that evaluates a condition at regular intervals. Returns $true - if the condition was satisfied before the timeout, or $false if the timeout expired. - Used to replace duplicated stopwatch-based Do-Until polling loops throughout the module. - .PARAMETER Condition - A script block that is evaluated each interval. The loop exits when this returns $true. - .PARAMETER TimeoutSeconds - Maximum number of seconds to wait before giving up. Must be at least 1. - .PARAMETER IntervalSeconds - Number of seconds to sleep between condition evaluations. Defaults to 5. - .PARAMETER Activity - Optional description logged via Write-Verbose at start and finish for diagnostics. - .NOTES - Version: 1.0.0 - Author: Chris Taylor - Private function - not exported. - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - Param( - [Parameter(Mandatory = $True)] - [ScriptBlock]$Condition, - [Parameter(Mandatory = $True)] - [ValidateRange(1, [int]::MaxValue)] - [int]$TimeoutSeconds, - [Parameter()] - [ValidateRange(1, [int]::MaxValue)] - [int]$IntervalSeconds = 5, - [Parameter()] - [string]$Activity - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if ($Activity) { Write-Verbose "Waiting for: $Activity" } - $timeout = New-TimeSpan -Seconds $TimeoutSeconds - $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - Do { - Start-Sleep -Seconds $IntervalSeconds - $conditionMet = & $Condition - } Until ($stopwatch.Elapsed -gt $timeout -or $conditionMet) - $stopwatch.Stop() - $elapsedSeconds = [int]$stopwatch.Elapsed.TotalSeconds - if ($conditionMet) { - if ($Activity) { Write-Verbose "$Activity completed after $elapsedSeconds seconds." } - return $true - } - else { - if ($Activity) { Write-Verbose "$Activity timed out after $elapsedSeconds seconds." } - return $false - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Write-CWAAEventLog { - <# - .SYNOPSIS - Writes an entry to the Windows Event Log for ConnectWise Automate Agent operations. - .DESCRIPTION - Centralized event log writer for the ConnectWiseAutomateAgent module. Writes to the - Application event log under the source defined by $Script:CWAAEventLogSource. - On first call, registers the event source if it does not already exist (requires - administrator privileges for registration). If the source cannot be registered or - the write fails for any reason, the error is written to Write-Debug and the function - returns silently. This ensures event logging never disrupts the calling function. - Event ID ranges by category: - 1000-1039 Installation (Install, Uninstall, Redo, Update) - 2000-2029 Service Control (Start, Stop, Restart) - 3000-3069 Configuration (Reset, Backup, Proxy, LogLevel, AddRemove) - 4000-4039 Health/Monitoring (Repair, Register/Unregister task) - .NOTES - Version: 0.1.5.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param( - [Parameter(Mandatory = $True)] - [string]$Message, - [Parameter(Mandatory = $True)] - [ValidateSet('Information', 'Warning', 'Error')] - [string]$EntryType, - [Parameter(Mandatory = $True)] - [int]$EventId - ) - Try { - # Register the event source if it does not exist yet. - # This requires administrator privileges the first time. - if (-not [System.Diagnostics.EventLog]::SourceExists($Script:CWAAEventLogSource)) { - New-EventLog -LogName $Script:CWAAEventLogName -Source $Script:CWAAEventLogSource -ErrorAction Stop - Write-Debug "Write-CWAAEventLog: Registered event source '$($Script:CWAAEventLogSource)' in '$($Script:CWAAEventLogName)' log." - } - Write-EventLog -LogName $Script:CWAAEventLogName ` - -Source $Script:CWAAEventLogSource ` - -EventId $EventId ` - -EntryType $EntryType ` - -Message $Message ` - -ErrorAction Stop - Write-Debug "Write-CWAAEventLog: Wrote EventId $EventId ($EntryType) to '$($Script:CWAAEventLogName)' log." - } - Catch { - # Best-effort: never disrupt the calling function if event logging fails. - Write-Debug "Write-CWAAEventLog: Failed to write event log entry. Error: $($_.Exception.Message)" - } -} -function Get-CWAARedactedValue { - <# - .SYNOPSIS - Returns a SHA256-hashed redacted representation of a sensitive string. - .DESCRIPTION - Private helper that returns '[SHA256:a1b2c3d4]' for non-empty strings - and '[EMPTY]' for null/empty strings. Used to log that a credential value - is present without exposing the actual content. - .NOTES - Version: 0.1.5.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param( - [AllowNull()] - [AllowEmptyString()] - [string]$InputString - ) - if ([string]::IsNullOrEmpty($InputString)) { - return '[EMPTY]' - } - $sha256 = [System.Security.Cryptography.SHA256]::Create() - $hashBytes = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InputString)) - $hashHex = -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) - $sha256.Dispose() - return "[SHA256:$($hashHex.Substring(0, 8))]" -} -function Initialize-CWAA { - # Guard: PowerShell 1.0 lacks $PSVersionTable entirely - if (-not ($PSVersionTable)) { - Write-Warning 'PS1 Detected. PowerShell Version 2.0 or higher is required.' - return - } - if ($PSVersionTable.PSVersion.Major -lt 3) { - Write-Verbose 'PS2 Detected. PowerShell Version 3.0 or higher may be required for full functionality.' - } - # WOW64 relaunch: When running as 32-bit PowerShell on a 64-bit OS, many registry - # and file system operations target the wrong hive/path. Re-launch under native - # 64-bit PowerShell to ensure consistent behavior with the Automate agent services. - # Note: This relaunch works correctly in single-file mode (ConnectWiseAutomateAgent.ps1). - # In module mode (Import-Module), the .psm1 emits a warning instead since relaunch - # cannot re-invoke Import-Module from within a function. - if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { - Write-Warning '32-bit PowerShell session detected on 64-bit OS. Attempting to launch 64-Bit session to process commands.' - $pshell = "${env:WINDIR}\sysnative\windowspowershell\v1.0\powershell.exe" - if (!(Test-Path -Path $pshell)) { - # sysnative virtual folder is unavailable (e.g. older OS or non-interactive context). - # Fall back to the real System32 path after disabling WOW64 file system redirection - # so the 64-bit powershell.exe is accessible instead of the 32-bit redirected copy. - Write-Warning 'SYSNATIVE PATH REDIRECTION IS NOT AVAILABLE. Attempting to access 64-bit PowerShell directly.' - $pshell = "${env:WINDIR}\System32\WindowsPowershell\v1.0\powershell.exe" - $FSRedirection = $True - Add-Type -Debug:$False -Name Wow64 -Namespace 'Kernel32' -MemberDefinition @' - [DllImport("kernel32.dll", SetLastError=true)] - public static extern bool Wow64DisableWow64FsRedirection(ref IntPtr ptr); - [DllImport("kernel32.dll", SetLastError=true)] - public static extern bool Wow64RevertWow64FsRedirection(ref IntPtr ptr); -'@ - [ref]$ptr = New-Object System.IntPtr - $Null = [Kernel32.Wow64]::Wow64DisableWow64FsRedirection($ptr) - } - # Re-invoke the original command/script under the 64-bit host - if ($myInvocation.Line) { - &"$pshell" -NonInteractive -NoProfile $myInvocation.Line - } - elseif ($myInvocation.InvocationName) { - &"$pshell" -NonInteractive -NoProfile -File "$($myInvocation.InvocationName)" $args - } - else { - &"$pshell" -NonInteractive -NoProfile $myInvocation.MyCommand - } - $ExitResult = $LASTEXITCODE - # Restore file system redirection if it was disabled - if ($FSRedirection -eq $True) { - [ref]$defaultptr = New-Object System.IntPtr - $Null = [Kernel32.Wow64]::Wow64RevertWow64FsRedirection($defaultptr) - } - Write-Warning 'Exiting 64-bit session. Module will only remain loaded in native 64-bit PowerShell environment.' - Exit $ExitResult - } - # Module-level constants — centralized to avoid duplication across functions. - # These are cheap to create with no side effects, so they run at module load. - $Script:CWAARegistryRoot = 'HKLM:\SOFTWARE\LabTech\Service' - $Script:CWAARegistrySettings = 'HKLM:\SOFTWARE\LabTech\Service\Settings' - $Script:CWAAInstallPath = "${env:windir}\LTSVC" - $Script:CWAAInstallerTempPath = "${env:windir}\Temp\LabTech" - $Script:CWAAServiceNames = @('LTService', 'LTSvcMon') - # Server URL validation regex breakdown: - # ^(https?://)? — optional http:// or https:// scheme - # (([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2} — IPv4 address (0.0.0.0 - 299.299.299.299) - # | — OR - # [a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*) — hostname with optional subdomains - # $ — end of string, no trailing path/query - $Script:CWAAServerValidationRegex = '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$' - # Registry paths for Add/Remove Programs operations (shared by Hide, Show, Rename functions) - $Script:CWAAInstallerProductKeys = @( - 'HKLM:\SOFTWARE\Classes\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'HKLM:\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC' - ) - $Script:CWAAUninstallKeys = @( - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}' - ) - $Script:CWAARegistryBackup = 'HKLM:\SOFTWARE\LabTechBackup\Service' - # Installer artifact paths for cleanup (used by Clear-CWAAInstallerArtifacts) - $Script:CWAAInstallerArtifactPaths = @( - "${env:windir}\Temp\_LTUpdate", - "${env:windir}\Temp\Agent_Uninstall.exe", - "${env:windir}\Temp\RemoteAgent.msi", - "${env:windir}\Temp\Uninstall.exe", - "${env:windir}\Temp\Uninstall.exe.config" - ) - # Installer process names for cleanup (used by Clear-CWAAInstallerArtifacts) - $Script:CWAAInstallerProcessNames = @('Agent_Uninstall', 'Uninstall', 'LTUpdate') - # Windows Event Log settings (used by Write-CWAAEventLog) - $Script:CWAAEventLogSource = 'ConnectWiseAutomateAgent' - $Script:CWAAEventLogName = 'Application' - # Timeout and retry configuration — used by Wait-CWAACondition and Install-CWAA callers. - # Centralized here so they are tunable and self-documenting in one place. - $Script:CWAAInstallMaxAttempts = 3 - $Script:CWAAInstallRetryDelaySeconds = 30 - $Script:CWAAServiceStartTimeoutSec = 120 # 2 minutes — proxy startup wait - $Script:CWAARegistrationTimeoutSec = 900 # 15 minutes — agent registration wait - $Script:CWAATrayPortMin = 42000 - $Script:CWAATrayPortMax = 42009 - $Script:CWAATrayPortDefault = 42000 - $Script:CWAAUninstallWaitSeconds = 10 - $Script:CWAAServiceWaitTimeoutSec = 60 # 1 minute — Start/Stop/Restart/Reset service waits - $Script:CWAARedoSettleDelaySeconds = 20 # Redo-CWAA settling delay between uninstall and reinstall - # Server version thresholds — document breaking changes in the server's deployment API. - # Each threshold gates a different URL construction or installer format in Install-CWAA. - $Script:CWAAVersionZipInstaller = '240.331' # InstallerToken deployments return ZIP (MSI+MST) - $Script:CWAAVersionAnonymousChange = '110.374' # Anonymous MSI download URL changed (LT11 Patch 13) - $Script:CWAAVersionVulnerabilityFix = '200.197' # CVE fix: unauthenticated Deployment.aspx access - $Script:CWAAVersionUpdateMinimum = '105.001' # Minimum version with update support - # Agent process names — for forceful termination in Stop-CWAA after service stop timeout. - $Script:CWAAAgentProcessNames = @('LTTray', 'LTSVC', 'LTSvcMon') - # All service names including LabVNC — for full service cleanup in Uninstall-CWAA. - $Script:CWAAAllServiceNames = @('LTService', 'LTSvcMon', 'LabVNC') - # Service credential storage â€" populated on-demand by Get-CWAAProxy - $Script:LTServiceKeys = [PSCustomObject]@{ - ServerPasswordString = '' - PasswordString = '' - } - # Proxy configuration — populated on-demand by Initialize-CWAANetworking - $Script:LTProxy = [PSCustomObject]@{ - ProxyServerURL = '' - ProxyUsername = '' - ProxyPassword = '' - Enabled = $False - } - # Networking subsystem deferred flags. Initialize-CWAANetworking sets these to $True - # after registration/initialization. This keeps module import fast and avoids - # irreversible global session side effects until networking is actually needed. - $Script:CWAANetworkInitialized = $False - $Script:CWAACertCallbackRegistered = $False -} -function Initialize-CWAANetworking { - <# - .SYNOPSIS - Lazily initializes networking objects on first use rather than at module load. - .DESCRIPTION - Performs deferred initialization of SSL certificate validation, TLS protocol enablement, - WebProxy, WebClient, and proxy configuration. This function is idempotent -- - subsequent calls skip core initialization after the first successful run. - SSL certificate handling uses a smart callback with graduated trust: - - IP address targets: auto-bypass (IPs cannot have properly signed certificates) - - Hostname name mismatch: tolerated (cert is trusted but CN/SAN does not match) - - Chain/trust errors on hostnames: rejected (untrusted CA, self-signed) - - -SkipCertificateCheck: full bypass for all certificate errors - Called automatically by networking functions (Install-CWAA, Uninstall-CWAA, - Update-CWAA, Set-CWAAProxy) in their Begin blocks. Non-networking functions - never trigger these side effects, keeping module import fast and clean. - .PARAMETER SkipCertificateCheck - Disables all SSL certificate validation for the current PowerShell session. - Use this when connecting to servers with self-signed certificates on hostname URLs. - Note: This affects ALL HTTPS connections in the session, not just Automate operations. - .NOTES - Version: 0.1.5.0 - Author: Chris Taylor - Private function - not exported. - #> - [CmdletBinding()] - Param( - [switch]$SkipCertificateCheck - ) - Write-Debug "Starting $($MyInvocation.InvocationName)" - # Smart SSL certificate callback: Registered once per session. Uses graduated trust - # rather than blanket bypass. The callback handles three scenarios: - # 1. IP address targets: auto-bypass (IPs cannot have properly signed certs) - # 2. Name mismatch only: tolerate (cert is trusted but hostname differs from CN/SAN) - # 3. Chain/trust errors: reject unless SkipAll is set via -SkipCertificateCheck - # On .NET 6+ (PS 7+), ServicePointManager triggers SYSLIB0014 obsolescence warning. - # Conditionally wrap with pragma directives based on the runtime. - if (-not $Script:CWAACertCallbackRegistered) { - Try { - # Check if the type already exists in the AppDomain (survives module re-import - # because .NET types cannot be unloaded). Only call Add-Type if it's truly new. - if (-not ([System.Management.Automation.PSTypeName]'ServerCertificateValidationCallback').Type) { - $sslCallbackSource = @" -using System; -using System.Net; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -public class ServerCertificateValidationCallback -{ - public static bool SkipAll = false; - public static void Register() - { - if (ServicePointManager.ServerCertificateValidationCallback == null) - { - ServicePointManager.ServerCertificateValidationCallback += - delegate(Object obj, X509Certificate certificate, - X509Chain chain, SslPolicyErrors errors) - { - if (errors == SslPolicyErrors.None) return true; - if (SkipAll) return true; - var request = obj as HttpWebRequest; - if (request != null) - { - IPAddress ip; - if (IPAddress.TryParse(request.RequestUri.Host, out ip)) - return true; - } - if (errors == SslPolicyErrors.RemoteCertificateNameMismatch) - return true; - return false; - }; - } - } -} -"@ - if ($PSVersionTable.PSEdition -eq 'Core') { - $sslCallbackSource = "#pragma warning disable SYSLIB0014`n" + $sslCallbackSource + "`n#pragma warning restore SYSLIB0014" - } - Add-Type -Debug:$False $sslCallbackSource - } - [ServerCertificateValidationCallback]::Register() - $Script:CWAACertCallbackRegistered = $True - } - Catch { - Write-Debug "SSL certificate validation callback could not be registered: $_" - } - } - # Full bypass mode: sets the SkipAll flag on the C# class so the callback - # accepts all certificates regardless of error type. Useful for servers with - # self-signed certificates on hostname URLs. - if ($SkipCertificateCheck -and $Script:CWAACertCallbackRegistered) { - if (-not [ServerCertificateValidationCallback]::SkipAll) { - Write-Warning 'SSL certificate validation is disabled for this session. This affects all HTTPS connections in this PowerShell session.' - [ServerCertificateValidationCallback]::SkipAll = $True - } - } - # Idempotency guard: TLS, WebClient, and proxy only need to run once per session - if ($Script:CWAANetworkInitialized -eq $True) { - Write-Debug "Initialize-CWAANetworking: Core networking already initialized, skipping." - return - } - Write-Verbose 'Initializing networking subsystem (TLS, WebClient, Proxy).' - # TLS protocol enablement: Enable TLS 1.2 and 1.3 for secure communication. - # TLS 1.0 and 1.1 are deprecated (POODLE, BEAST vulnerabilities) and intentionally - # excluded. Each version is added via bitwise OR to preserve already-enabled protocols. - Try { - if ([Net.SecurityProtocolType]::Tls12) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } - if ([Net.SecurityProtocolType]::Tls13) { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls13 } - } - Catch { - Write-Debug "TLS protocol configuration skipped (may not apply to this .NET runtime): $_" - } - # WebClient and WebProxy are deprecated in .NET 6+ (SYSLIB0014) but still functional. - # They remain the only option compatible with PowerShell 3.0-5.1 (.NET Framework). - Try { - $Script:LTWebProxy = New-Object System.Net.WebProxy - $Script:LTServiceNetWebClient = New-Object System.Net.WebClient - $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy - } - Catch { - Write-Warning "Failed to initialize network objects (WebClient/WebProxy may be unavailable in this .NET runtime). $_" - } - # Discover proxy settings from the installed agent (if present). - # Errors are non-fatal: the module works without proxy on systems with no agent. - $Null = Get-CWAAProxy -ErrorAction Continue - $Script:CWAANetworkInitialized = $True - Write-Debug "Exiting $($MyInvocation.InvocationName)" -} -function ConvertFrom-CWAASecurity { - <# - .SYNOPSIS - Decodes a Base64-encoded string using TripleDES decryption. - .DESCRIPTION - This function decodes the provided string using the specified or default key. - It uses TripleDES with an MD5-derived key and a fixed initialization vector. - If decoding fails with the provided key and Force is enabled, alternate key - values are attempted automatically. - .PARAMETER InputString - The Base64-encoded string to be decoded. - .PARAMETER Key - The key used for decoding. If not provided, default values will be tried. - .PARAMETER Force - Forces the function to try alternate key values if decoding fails using - the provided key. Enabled by default. - .EXAMPLE - ConvertFrom-CWAASecurity -InputString 'EncodedValue' - Decodes the string using the default key. - .EXAMPLE - ConvertFrom-CWAASecurity -InputString 'EncodedValue' -Key 'MyCustomKey' - Decodes the string using a custom key. - .NOTES - Author: Chris Taylor - Alias: ConvertFrom-LTSecurity - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('ConvertFrom-LTSecurity')] - Param( - [parameter(Mandatory = $true, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, Position = 1)] - [string[]]$InputString, - [parameter(Mandatory = $false, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $True)] - [AllowNull()] - [string[]]$Key, - [parameter(Mandatory = $false, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] - [switch]$Force = $True - ) - Begin { - $DefaultKey = 'Thank you for using LabTech.' - $_initializationVector = [byte[]](240, 3, 45, 29, 0, 76, 173, 59) - $NoKeyPassed = $False - $DecodedString = $Null - $DecodeString = $Null - } - Process { - Write-Debug "Starting $($MyInvocation.InvocationName)" - if ($Null -eq $Key) { - $NoKeyPassed = $True - $Key = $DefaultKey - } - foreach ($testInput in $InputString) { - $DecodeString = $Null - foreach ($testKey in $Key) { - if ($Null -eq $DecodeString) { - if ($Null -eq $testKey) { - $NoKeyPassed = $True - $testKey = $DefaultKey - } - Write-Debug "Attempting Decode for '$($testInput)' with Key '$($testKey)'" - Try { - $inputBytes = [System.Convert]::FromBase64String($testInput) - $tripleDesProvider = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider - $tripleDesProvider.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($testKey)) - $tripleDesProvider.IV = $_initializationVector - $cryptoTransform = $tripleDesProvider.CreateDecryptor() - $DecodeString = [System.Text.Encoding]::UTF8.GetString($cryptoTransform.TransformFinalBlock($inputBytes, 0, ($inputBytes.Length))) - $DecodedString += @($DecodeString) - } - Catch { - Write-Debug "Decode failed for '$($testInput)' with Key '$($testKey)': $_" - } - Finally { - if ((Get-Variable -Name cryptoTransform -Scope 0 -EA 0)) { try { $cryptoTransform.Dispose() } catch { $cryptoTransform.Clear() } } - if ((Get-Variable -Name tripleDesProvider -Scope 0 -EA 0)) { try { $tripleDesProvider.Dispose() } catch { $tripleDesProvider.Clear() } } - } - } - } - if ($Null -eq $DecodeString) { - if ($Force) { - if ($NoKeyPassed) { - $DecodeString = ConvertFrom-CWAASecurity -InputString "$($testInput)" -Key '' -Force:$False - if (-not ($Null -eq $DecodeString)) { - $DecodedString += @($DecodeString) - } - } - else { - $DecodeString = ConvertFrom-CWAASecurity -InputString "$($testInput)" - if (-not ($Null -eq $DecodeString)) { - $DecodedString += @($DecodeString) - } - } - } - else { - Write-Debug "All decode attempts exhausted for '$($testInput)' with Force disabled." - } - } - } - } - End { - if ($Null -eq $DecodedString) { - Write-Debug "Failed to Decode string: '$($InputString)'" - return $Null - } - else { - return $DecodedString - } - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function ConvertTo-CWAASecurity { - <# - .SYNOPSIS - Encodes a string using TripleDES encryption compatible with Automate operations. - .DESCRIPTION - This function encodes the provided string using the specified or default key. - It uses TripleDES with an MD5-derived key and a fixed initialization vector, - returning a Base64-encoded result. - .PARAMETER InputString - The string to be encoded. - .PARAMETER Key - The key used for encoding. If not provided, a default value will be used. - .EXAMPLE - ConvertTo-CWAASecurity -InputString 'PlainTextValue' - Encodes the string using the default key. - .EXAMPLE - ConvertTo-CWAASecurity -InputString 'PlainTextValue' -Key 'MyCustomKey' - Encodes the string using a custom key. - .NOTES - Author: Chris Taylor - Alias: ConvertTo-LTSecurity - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('ConvertTo-LTSecurity')] - Param( - [parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false)] - [AllowNull()] - [AllowEmptyString()] - [AllowEmptyCollection()] - [string]$InputString, - [parameter(Mandatory = $false, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false)] - [AllowNull()] - [AllowEmptyString()] - [AllowEmptyCollection()] - $Key - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - $_initializationVector = [byte[]](240, 3, 45, 29, 0, 76, 173, 59) - $DefaultKey = 'Thank you for using LabTech.' - if ($Null -eq $Key) { - $Key = $DefaultKey - } - try { - $inputBytes = [System.Text.Encoding]::UTF8.GetBytes($InputString) - } - catch { - try { $inputBytes = [System.Text.Encoding]::ASCII.GetBytes($InputString) } catch { - Write-Debug "Failed to convert InputString to byte array: $_" - } - } - Write-Debug "Attempting Encode for '$($InputString)' with Key '$($Key)'" - $encodedString = '' - try { - $tripleDesProvider = New-Object System.Security.Cryptography.TripleDESCryptoServiceProvider - $tripleDesProvider.key = (New-Object Security.Cryptography.MD5CryptoServiceProvider).ComputeHash([Text.Encoding]::UTF8.GetBytes($Key)) - $tripleDesProvider.IV = $_initializationVector - $cryptoTransform = $tripleDesProvider.CreateEncryptor() - $encodedString = [System.Convert]::ToBase64String($cryptoTransform.TransformFinalBlock($inputBytes, 0, ($inputBytes.Length))) - } - catch { - Write-Debug "Failed to Encode string: '$($InputString)'. $_" - } - Finally { - if ($cryptoTransform) { try { $cryptoTransform.Dispose() } catch { $cryptoTransform.Clear() } } - if ($tripleDesProvider) { try { $tripleDesProvider.Dispose() } catch { $tripleDesProvider.Clear() } } - } - return $encodedString - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Invoke-CWAACommand { - <# - .SYNOPSIS - Sends a service command to the ConnectWise Automate agent. - .DESCRIPTION - Sends a control command to the LTService Windows service using sc.exe. - The agent supports a set of predefined commands (mapped to numeric IDs 128-145) - that trigger actions such as sending inventory, updating schedules, or killing processes. - .PARAMETER Command - One or more commands to send to the agent service. Valid values include - 'Update Schedule', 'Send Inventory', 'Send Drives', 'Send Processes', - 'Send Spyware List', 'Send Apps', 'Send Events', 'Send Printers', - 'Send Status', 'Send Screen', 'Send Services', 'Analyze Network', - 'Write Last Contact Date', 'Kill VNC', 'Kill Trays', 'Send Patch Reboot', - 'Run App Care Update', and 'Start App Care Daytime Patching'. - .EXAMPLE - Invoke-CWAACommand -Command 'Send Inventory' - Sends the 'Send Inventory' command to the agent service. - .EXAMPLE - 'Send Status', 'Send Apps' | Invoke-CWAACommand - Sends multiple commands to the agent service via pipeline. - .NOTES - Author: Chris Taylor - Alias: Invoke-LTServiceCommand - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Invoke-LTServiceCommand')] - Param( - [Parameter(Mandatory = $True, Position = 1, ValueFromPipeline = $True)] - [ValidateSet( - "Update Schedule", - "Send Inventory", - "Send Drives", - "Send Processes", - "Send Spyware List", - "Send Apps", - "Send Events", - "Send Printers", - "Send Status", - "Send Screen", - "Send Services", - "Analyze Network", - "Write Last Contact Date", - "Kill VNC", - "Kill Trays", - "Send Patch Reboot", - "Run App Care Update", - "Start App Care Daytime Patching" - )] - [string[]]$Command - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $Service = Get-Service 'LTService' -ErrorAction SilentlyContinue - } - Process { - if (-not $Service) { - Write-Warning "Service 'LTService' was not found. Cannot send service command." - return - } - if ($Service.Status -ne 'Running') { - Write-Warning "Service 'LTService' is not running. Cannot send service command." - return - } - foreach ($Cmd in $Command) { - $CommandID = $Null - Try { - switch ($Cmd) { - 'Update Schedule' { $CommandID = 128 } - 'Send Inventory' { $CommandID = 129 } - 'Send Drives' { $CommandID = 130 } - 'Send Processes' { $CommandID = 131 } - 'Send Spyware List' { $CommandID = 132 } - 'Send Apps' { $CommandID = 133 } - 'Send Events' { $CommandID = 134 } - 'Send Printers' { $CommandID = 135 } - 'Send Status' { $CommandID = 136 } - 'Send Screen' { $CommandID = 137 } - 'Send Services' { $CommandID = 138 } - 'Analyze Network' { $CommandID = 139 } - 'Write Last Contact Date' { $CommandID = 140 } - 'Kill VNC' { $CommandID = 141 } - 'Kill Trays' { $CommandID = 142 } - 'Send Patch Reboot' { $CommandID = 143 } - 'Run App Care Update' { $CommandID = 144 } - 'Start App Care Daytime Patching' { $CommandID = 145 } - default { Write-Debug "Unrecognized command: '$Cmd'" } - } - if ($PSCmdlet.ShouldProcess("LTService", "Send Service Command '$($Cmd)' ($($CommandID))")) { - if ($Null -ne $CommandID) { - Write-Debug "Sending service command '$($Cmd)' ($($CommandID)) to 'LTService'" - Try { - $Null = & "$env:windir\system32\sc.exe" control LTService $($CommandID) 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "sc.exe control returned exit code $LASTEXITCODE for command '$Cmd' ($CommandID)." - } - Write-Output "Sent Command '$($Cmd)' to 'LTService'" - } - Catch { - Write-Output "Error calling sc.exe. Failed to send command." - } - } - } - } - Catch { - Write-Warning "Failed to process command '$Cmd'. $($_.Exception.Message)" - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAAPort { - <# - .SYNOPSIS - Tests connectivity to TCP ports required by the ConnectWise Automate agent. - .DESCRIPTION - Verifies that the local LTTray port is available and tests connectivity to - the required TCP ports (70, 80, 443) on the Automate server, plus port 8002 - on the Automate mediator server. - If no server is provided, the function attempts to detect it from the installed - agent configuration or backup info. - .PARAMETER Server - The URL of the Automate server (e.g., https://automate.domain.com). - If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. - .PARAMETER TrayPort - The local port LTSvc.exe listens on for LTTray communication. - Defaults to 42000 if not provided or not found in agent configuration. - .PARAMETER Quiet - Returns a boolean connectivity result instead of verbose output. - .EXAMPLE - Test-CWAAPort -Server 'https://automate.domain.com' - Tests all required ports against the specified server. - .EXAMPLE - Test-CWAAPort -Quiet - Returns $True if the TrayPort is available, $False otherwise. - .EXAMPLE - Get-CWAAInfo | Test-CWAAPort - Pipes the installed agent's Server and TrayPort into Test-CWAAPort via pipeline. - .NOTES - Author: Chris Taylor - Alias: Test-LTPorts - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Test-LTPorts')] - Param( - [Parameter(ValueFromPipelineByPropertyName = $true, ValueFromPipeline = $True)] - [string[]]$Server, - [Parameter(ValueFromPipelineByPropertyName = $true)] - [int]$TrayPort, - [Parameter(ValueFromPipelineByPropertyName = $true)] - [switch]$Quiet - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $MediatorServer = 'mediator.labtechsoftware.com' - function Private:TestPort { - Param( - [parameter(Position = 0)] - [string]$ComputerName, - [parameter(Mandatory = $False)] - [System.Net.IPAddress]$IPAddress, - [parameter(Mandatory = $True, Position = 1)] - [int]$Port - ) - $RemoteServer = if ([string]::IsNullOrEmpty($ComputerName)) { $IPAddress } else { $ComputerName } - if ([string]::IsNullOrEmpty($RemoteServer)) { - Write-Error "No ComputerName or IPAddress was provided to test." - return - } - $tcpClient = New-Object System.Net.Sockets.TcpClient - Try { - Write-Output "Connecting to $($RemoteServer):$Port (TCP).." - $tcpClient.Connect($RemoteServer, $Port) - Write-Output 'Connection successful' - } - Catch { - Write-Output 'Connection failed' - } - Finally { - $tcpClient.Close() - } - } - } - Process { - if (-not ($Server) -and (-not ($TrayPort) -or -not ($Quiet))) { - Write-Verbose 'No Server Input - Checking for names.' - $Server = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'Server' -EA 0 - if (-not ($Server)) { - Write-Verbose 'No Server found in installed Service Info. Checking for Service Backup.' - $Server = Get-CWAAInfoBackup -EA 0 -Verbose:$False | Select-Object -Expand 'Server' -EA 0 - } - } - if (-not ($Quiet) -or (($TrayPort) -ge 1 -and ($TrayPort) -le 65530)) { - if (-not ($TrayPort) -or -not (($TrayPort) -ge 1 -and ($TrayPort) -le 65530)) { - # Discover TrayPort from agent configuration if not provided - $TrayPort = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand TrayPort -EA 0) - } - if (-not ($TrayPort) -or $TrayPort -notmatch '^\d+$') { $TrayPort = 42000 } - [array]$processes = @() - # Get all processes using the TrayPort (default 42000) - Try { - $netstatOutput = & "$env:windir\system32\netstat.exe" -a -o -n | Select-String -Pattern " .*[0-9\.]+:$($TrayPort).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 - } - Catch { - Write-Output 'Error calling netstat.exe.' - $netstatOutput = $null - } - foreach ($netstatLine in $netstatOutput) { - $processes += ($netstatLine -split ' {4,}')[-1] - } - $processes = $processes | Where-Object { $_ -gt 0 -and $_ -match '^\d+$' } | Sort-Object | Get-Unique - if (($processes)) { - if (-not ($Quiet)) { - foreach ($processId in $processes) { - if ((Get-Process -Id $processId -EA 0 | Select-Object -Expand ProcessName -EA 0) -eq 'LTSvc') { - Write-Output "TrayPort Port $TrayPort is being used by LTSvc." - } - else { - Write-Output "Error: TrayPort Port $TrayPort is being used by $(Get-Process -Id $processId | Select-Object -Expand ProcessName -EA 0)." - } - } - } - else { return $False } - } - elseif (($Quiet) -eq $True) { - return $True - } - else { - Write-Output "TrayPort Port $TrayPort is available." - } - } - foreach ($serverEntry in $Server) { - if ($Quiet) { - $cleanServerAddress = ($serverEntry -replace 'https?://', '' | ForEach-Object { $_.Trim() }) - Test-Connection $cleanServerAddress -Quiet - return - } - if ($serverEntry -match $Script:CWAAServerValidationRegex) { - Try { - $cleanServerAddress = ($serverEntry -replace 'https?://', '' | ForEach-Object { $_.Trim() }) - Write-Output 'Testing connectivity to required TCP ports:' - TestPort -ComputerName $cleanServerAddress -Port 70 - TestPort -ComputerName $cleanServerAddress -Port 80 - TestPort -ComputerName $cleanServerAddress -Port 443 - TestPort -ComputerName $MediatorServer -Port 8002 - } - Catch { - Write-Error "There was an error testing the ports for '$serverEntry'. $($_)" -ErrorAction Stop - } - } - else { - Write-Warning "Server address '$($serverEntry)' is not valid or not formatted correctly. Example: https://automate.domain.com" - } - } - } - End { - if (-not ($Quiet)) { - Write-Output 'Test-CWAAPort Finished' - } - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAAServerConnectivity { - <# - .SYNOPSIS - Tests connectivity to a ConnectWise Automate server's agent endpoint. - .DESCRIPTION - Verifies that an Automate server is online and responding by querying the - agent.aspx endpoint. Validates that the response matches the expected version - format (pipe-delimited string ending with a version number). - If no server is provided, the function attempts to discover it from the - installed agent configuration or backup settings. - Returns a result object per server with availability status and version info, - or a simple boolean in Quiet mode. - .PARAMETER Server - One or more ConnectWise Automate server URLs (e.g., https://automate.domain.com). - If not provided, the function uses Get-CWAAInfo or Get-CWAAInfoBackup to discover it. - .PARAMETER Quiet - Returns $True if all servers are reachable, $False otherwise. - .EXAMPLE - Test-CWAAServerConnectivity -Server 'https://automate.domain.com' - Tests connectivity and returns a result object with Server, Available, Version, and ErrorMessage. - .EXAMPLE - Test-CWAAServerConnectivity -Quiet - Returns $True if the discovered server is reachable, $False otherwise. - .EXAMPLE - Get-CWAAInfo | Test-CWAAServerConnectivity - Tests connectivity to the server configured on the installed agent via pipeline. - .NOTES - Author: Chris Taylor - Alias: Test-LTServerConnectivity - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Test-LTServerConnectivity')] - Param( - [Parameter(ValueFromPipelineByPropertyName = $True, ValueFromPipeline = $True)] - [string[]]$Server, - [switch]$Quiet - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - # Enable TLS 1.2 for the web request without full Initialize-CWAANetworking - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 - # Expected response pattern from agent.aspx: pipe-delimited string ending with version - $agentResponsePattern = '\|\|\|\|\|\|\d+\.\d+' - $versionExtractPattern = '(\d+\.\d+)\s*$' - $allAvailable = $True - } - Process { - if (-not $Server) { - Write-Verbose 'No Server provided - checking installed agent configuration.' - $Server = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | - Select-Object -Expand 'Server' -EA 0 - if (-not $Server) { - Write-Verbose 'No Server found in agent config. Checking backup settings.' - $Server = Get-CWAAInfoBackup -EA 0 -Verbose:$False | - Select-Object -Expand 'Server' -EA 0 - } - if (-not $Server) { - Write-Error "No server could be determined. Provide a -Server parameter or ensure the agent is installed." - return - } - } - foreach ($serverEntry in $Server) { - # Normalize: ensure the URL has a scheme - $serverUrl = $serverEntry.Trim() - if ($serverUrl -notmatch '^https?://') { - $serverUrl = "https://$serverUrl" - } - # Validate server address format - $cleanAddress = $serverUrl -replace 'https?://', '' - if ($cleanAddress -notmatch $Script:CWAAServerValidationRegex) { - Write-Warning "Server address '$serverEntry' is not valid or not formatted correctly. Example: https://automate.domain.com" - $allAvailable = $False - if (-not $Quiet) { - [PSCustomObject]@{ - Server = $serverEntry - Available = $False - Version = $Null - ErrorMessage = 'Invalid server address format' - } - } - continue - } - $endpointUrl = "$serverUrl/LabTech/agent.aspx" - $available = $False - $version = $Null - $errorMessage = $Null - Try { - Write-Verbose "Testing connectivity to $endpointUrl" - $response = Invoke-RestMethod -Uri $endpointUrl -TimeoutSec 10 -ErrorAction Stop - if ($response -match $agentResponsePattern) { - $available = $True - if ($response -match $versionExtractPattern) { - $version = $Matches[1] - } - Write-Verbose "Server '$serverEntry' is available (version $version)." - } - else { - $errorMessage = 'Server responded but with unexpected format' - Write-Verbose "Server '$serverEntry' responded but response did not match expected agent pattern." - } - } - Catch { - $errorMessage = $_.Exception.Message - Write-Verbose "Server '$serverEntry' is not available: $errorMessage" - } - if (-not $available) { - $allAvailable = $False - } - if (-not $Quiet) { - [PSCustomObject]@{ - Server = $serverEntry - Available = $available - Version = $version - ErrorMessage = $errorMessage - } - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - if ($Quiet) { - return $allAvailable - } - } -} -function Hide-CWAAAddRemove { - <# - .SYNOPSIS - Hides the Automate agent from the Add/Remove Programs list. - .DESCRIPTION - Sets the SystemComponent registry value to 1 on Automate agent uninstall keys, - which hides the agent from the Windows Add/Remove Programs (Programs and Features) list. - Also cleans up any leftover HiddenProductName registry values from older hiding methods. - .EXAMPLE - Hide-CWAAAddRemove - Hides the Automate agent entry from Add/Remove Programs. - .EXAMPLE - Hide-CWAAAddRemove -WhatIf - Shows what registry changes would be made without applying them. - .NOTES - Author: Chris Taylor - Alias: Hide-LTAddRemove - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Hide-LTAddRemove')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $RegRoots = $Script:CWAAInstallerProductKeys - $PublisherRegRoots = $Script:CWAAUninstallKeys - $RegEntriesFound = 0 - $RegEntriesChanged = 0 - } - Process { - Try { - foreach ($RegRoot in $RegRoots) { - if (Test-Path $RegRoot) { - if (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { - if (!(Get-ItemProperty $RegRoot -Name ProductName -ErrorAction SilentlyContinue)) { - Write-Verbose 'Automate agent found with HiddenProductName value.' - Try { - Rename-ItemProperty $RegRoot -Name HiddenProductName -NewName ProductName - } - Catch { - Write-Error "There was an error renaming the registry value. $($_)" -ErrorAction Stop - } - } - else { - Write-Verbose 'Automate agent found with unused HiddenProductName value.' - Try { - Remove-ItemProperty $RegRoot -Name HiddenProductName -EA 0 -Confirm:$False -WhatIf:$False -Force - } - Catch { - Write-Debug "Failed to remove unused HiddenProductName from '$RegRoot': $($_)" - } - } - } - } - } - foreach ($RegRoot in $PublisherRegRoots) { - if (Test-Path $RegRoot) { - $RegKey = Get-Item $RegRoot -ErrorAction SilentlyContinue - if ($RegKey) { - $RegEntriesFound++ - if ($PSCmdlet.ShouldProcess("$($RegRoot)", "Set Registry Values to Hide $($RegKey.GetValue('DisplayName'))")) { - $RegEntriesChanged++ - @('SystemComponent') | ForEach-Object { - if (($RegKey.GetValue("$($_)")) -ne 1) { - Write-Verbose "Setting $($RegRoot)\$($_)=1" - Set-ItemProperty $RegRoot -Name "$($_)" -Value 1 -Type DWord -WhatIf:$False -Confirm:$False -Verbose:$False - } - } - } - } - } - } - # Output success/warning at end of try block (replaces if($?) pattern in End block) - if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { - Write-Output 'Automate agent is hidden from Add/Remove Programs.' - Write-CWAAEventLog -EventId 3040 -EntryType Information -Message 'Agent hidden from Add/Remove Programs.' - } - elseif ($WhatIfPreference -ne $True) { - Write-Warning "Automate agent may not be hidden from Add/Remove Programs." - Write-CWAAEventLog -EventId 3041 -EntryType Warning -Message 'Agent may not be hidden from Add/Remove Programs.' - } - } - Catch { - Write-CWAAEventLog -EventId 3042 -EntryType Error -Message "Failed to hide agent from Add/Remove Programs. Error: $($_.Exception.Message)" - Write-Error "There was an error setting the registry values. $($_)" -ErrorAction Stop - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Rename-CWAAAddRemove { - <# - .SYNOPSIS - Renames the Automate agent entry in the Add/Remove Programs list. - .DESCRIPTION - Changes the DisplayName (and optionally Publisher) registry values for the Automate agent - uninstall keys, which controls how the agent appears in the Windows Add/Remove Programs - (Programs and Features) list. - .PARAMETER Name - The display name for the Automate agent as shown in the list of installed software. - .PARAMETER PublisherName - The publisher name for the Automate agent as shown in the list of installed software. - .EXAMPLE - Rename-CWAAAddRemove -Name 'My Remote Agent' - Renames the Automate agent display name to 'My Remote Agent'. - .EXAMPLE - Rename-CWAAAddRemove -Name 'My Remote Agent' -PublisherName 'My Company' - Renames both the display name and publisher name in Add/Remove Programs. - .NOTES - Author: Chris Taylor - Alias: Rename-LTAddRemove - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Rename-LTAddRemove')] - Param( - [Parameter(Mandatory = $True, ValueFromPipeline = $true)] - $Name, - [Parameter(Mandatory = $False)] - [AllowNull()] - [string]$PublisherName - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $RegRoots = @($Script:CWAAUninstallKeys[0], $Script:CWAAUninstallKeys[1]) + $Script:CWAAInstallerProductKeys - $PublisherRegRoots = $Script:CWAAUninstallKeys - $RegNameFound = 0 - $RegPublisherFound = 0 - } - Process { - Try { - foreach ($RegRoot in $RegRoots) { - if (Get-ItemProperty $RegRoot -Name DisplayName -ErrorAction SilentlyContinue) { - if ($PSCmdlet.ShouldProcess("$($RegRoot)\DisplayName=$($Name)", 'Set Registry Value')) { - Write-Verbose "Setting $($RegRoot)\DisplayName=$($Name)" - Set-ItemProperty $RegRoot -Name DisplayName -Value $Name -Confirm:$False - $RegNameFound++ - } - } - elseif (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { - if ($PSCmdlet.ShouldProcess("$($RegRoot)\HiddenProductName=$($Name)", 'Set Registry Value')) { - Write-Verbose "Setting $($RegRoot)\HiddenProductName=$($Name)" - Set-ItemProperty $RegRoot -Name HiddenProductName -Value $Name -Confirm:$False - $RegNameFound++ - } - } - } - } - Catch { - Write-CWAAEventLog -EventId 3062 -EntryType Error -Message "Failed to rename agent in Add/Remove Programs. Error: $($_.Exception.Message)" - Write-Error "There was an error setting the DisplayName registry value. $($_)" -ErrorAction Stop - } - if (($PublisherName)) { - Try { - foreach ($RegRoot in $PublisherRegRoots) { - if (Get-ItemProperty $RegRoot -Name Publisher -ErrorAction SilentlyContinue) { - if ($PSCmdlet.ShouldProcess("$($RegRoot)\Publisher=$($PublisherName)", 'Set Registry Value')) { - Write-Verbose "Setting $($RegRoot)\Publisher=$($PublisherName)" - Set-ItemProperty $RegRoot -Name Publisher -Value $PublisherName -Confirm:$False - $RegPublisherFound++ - } - } - } - } - Catch { - Write-CWAAEventLog -EventId 3062 -EntryType Error -Message "Failed to set agent publisher name. Error: $($_.Exception.Message)" - Write-Error "There was an error setting the Publisher registry value. $($_)" -ErrorAction Stop - } - } - # Output success/warning (replaces if($?) pattern formerly in End block). - # Guarded by $WhatIfPreference because SupportsShouldProcess is enabled and - # these messages would be misleading during a -WhatIf dry run. - if ($WhatIfPreference -ne $True) { - if ($RegNameFound -gt 0) { - Write-Output "Automate agent is now listed as $($Name) in Add/Remove Programs." - Write-CWAAEventLog -EventId 3060 -EntryType Information -Message "Agent display name changed to '$Name' in Add/Remove Programs." - } - else { - Write-Warning "Automate agent was not found in installed software and the Name was not changed." - Write-CWAAEventLog -EventId 3061 -EntryType Warning -Message "Agent not found in installed software. Display name not changed." - } - if (($PublisherName)) { - if ($RegPublisherFound -gt 0) { - Write-Output "The Publisher is now listed as $($PublisherName)." - Write-CWAAEventLog -EventId 3060 -EntryType Information -Message "Agent publisher changed to '$PublisherName' in Add/Remove Programs." - } - else { - Write-Warning "Automate agent was not found in installed software and the Publisher was not changed." - Write-CWAAEventLog -EventId 3061 -EntryType Warning -Message "Agent not found in installed software. Publisher name not changed." - } - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Show-CWAAAddRemove { - <# - .SYNOPSIS - Shows the Automate agent in the Add/Remove Programs list. - .DESCRIPTION - Sets the SystemComponent registry value to 0 on Automate agent uninstall keys, - which makes the agent visible in the Windows Add/Remove Programs (Programs and Features) list. - Also cleans up any leftover HiddenProductName registry values from older hiding methods. - .EXAMPLE - Show-CWAAAddRemove - Makes the Automate agent entry visible in Add/Remove Programs. - .EXAMPLE - Show-CWAAAddRemove -WhatIf - Shows what registry changes would be made without applying them. - .NOTES - Author: Chris Taylor - Alias: Show-LTAddRemove - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Show-LTAddRemove')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $RegRoots = $Script:CWAAInstallerProductKeys - $PublisherRegRoots = $Script:CWAAUninstallKeys - $RegEntriesFound = 0 - $RegEntriesChanged = 0 - } - Process { - Try { - foreach ($RegRoot in $RegRoots) { - if (Test-Path $RegRoot) { - if (Get-ItemProperty $RegRoot -Name HiddenProductName -ErrorAction SilentlyContinue) { - if (!(Get-ItemProperty $RegRoot -Name ProductName -ErrorAction SilentlyContinue)) { - Write-Verbose 'Automate agent found with HiddenProductName value.' - Try { - Rename-ItemProperty $RegRoot -Name HiddenProductName -NewName ProductName - } - Catch { - Write-Error "There was an error renaming the registry value. $($_)" -ErrorAction Stop - } - } - else { - Write-Verbose 'Automate agent found with unused HiddenProductName value.' - Try { - Remove-ItemProperty $RegRoot -Name HiddenProductName -EA 0 -Confirm:$False -WhatIf:$False -Force - } - Catch { - Write-Debug "Failed to remove unused HiddenProductName from '$RegRoot': $($_)" - } - } - } - } - } - foreach ($RegRoot in $PublisherRegRoots) { - if (Test-Path $RegRoot) { - $RegKey = Get-Item $RegRoot -ErrorAction SilentlyContinue - if ($RegKey) { - $RegEntriesFound++ - if ($PSCmdlet.ShouldProcess("$($RegRoot)", "Set Registry Values to Show $($RegKey.GetValue('DisplayName'))")) { - $RegEntriesChanged++ - @('SystemComponent') | ForEach-Object { - if (($RegKey.GetValue("$($_)")) -eq 1) { - Write-Verbose "Setting $($RegRoot)\$($_)=0" - Set-ItemProperty $RegRoot -Name "$($_)" -Value 0 -Type DWord -WhatIf:$False -Confirm:$False -Verbose:$False - } - } - } - } - } - } - # Output success/warning at end of try block (replaces if($?) pattern in End block) - if ($RegEntriesFound -gt 0 -and $RegEntriesChanged -eq $RegEntriesFound) { - Write-Output 'Automate agent is visible in Add/Remove Programs.' - Write-CWAAEventLog -EventId 3050 -EntryType Information -Message 'Agent shown in Add/Remove Programs.' - } - elseif ($WhatIfPreference -ne $True) { - Write-Warning "Automate agent may not be visible in Add/Remove Programs." - Write-CWAAEventLog -EventId 3051 -EntryType Warning -Message 'Agent may not be visible in Add/Remove Programs.' - } - } - Catch { - Write-CWAAEventLog -EventId 3052 -EntryType Error -Message "Failed to show agent in Add/Remove Programs. Error: $($_.Exception.Message)" - Write-Error "There was an error setting the registry values. $($_)" -ErrorAction Stop - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Install-CWAA { - <# - .SYNOPSIS - Installs the ConnectWise Automate Agent on the local computer. - .DESCRIPTION - Downloads and installs the ConnectWise Automate agent from the specified server URL. - Supports authentication via InstallerToken (preferred) or ServerPassword. The function handles - prerequisite checks for .NET Framework 3.5, MSI download with file integrity validation, - proxy configuration, TrayPort conflict resolution, and post-install agent registration verification. - If a previous installation is detected, the function will automatically call Uninstall-LTService - before proceeding. The -Force parameter allows installation even when services are already present - or when only .NET 4.0+ is available without 3.5. - .PARAMETER Server - One or more ConnectWise Automate server URLs to download the installer from. - Example: https://automate.domain.com - The function tries each server in order until a successful download occurs. - .PARAMETER ServerPassword - The server password that agents use to authenticate with the Automate server. - Used for legacy deployment method. InstallerToken is preferred. - .PARAMETER InstallerToken - An installer token for authenticated agent deployment. This is the preferred - authentication method over ServerPassword. - See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken - .PARAMETER LocationID - The LocationID of the location the agent will be assigned to. - .PARAMETER TrayPort - The local port LTSvc.exe listens on for communication with LTTray processes. - Defaults to 42000. If the port is in use, the function auto-selects the next available port. - .PARAMETER Rename - Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. - .PARAMETER Hide - Hides the agent entry from Add/Remove Programs after installation by calling Hide-CWAAAddRemove. - .PARAMETER SkipDotNet - Skips .NET Framework 3.5 and 2.0 prerequisite checks. Use when .NET 4.0+ is already installed. - .PARAMETER Force - Disables safety checks including existing service detection and .NET version requirements. - .PARAMETER NoWait - Skips the post-install health check that waits for agent registration. - The function exits immediately after the installer completes. - .PARAMETER Credential - A PSCredential object containing the server password for deployment authentication. - The password is extracted and used as the ServerPassword. This is the preferred - secure alternative to passing -ServerPassword as plain text. - .PARAMETER SkipCertificateCheck - Bypasses SSL/TLS certificate validation for server connections. - Use in lab or test environments with self-signed certificates. - .PARAMETER ShowProgress - Displays a Write-Progress bar showing installation progress. Off by default - to avoid interference with unattended execution (RMM tools, GPO scripts). - .EXAMPLE - Install-CWAA -Server https://automate.domain.com -InstallerToken 'GeneratedToken' -LocationID 42 - Installs the agent using an InstallerToken for authentication. - .EXAMPLE - Install-CWAA -Server https://automate.domain.com -ServerPassword 'encryptedpass' -LocationID 1 - Installs the agent using a legacy server password. - .EXAMPLE - Install-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 -NoWait - Installs the agent without waiting for registration to complete. - .EXAMPLE - Get-CWAAInfoBackup | Install-CWAA -InstallerToken 'GeneratedToken' - Reinstalls the agent using Server and LocationID from a previous backup via pipeline. - .NOTES - Author: Chris Taylor - Alias: Install-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True, DefaultParameterSetName = 'deployment')] - [Alias('Install-LTService')] - Param( - [Parameter(ParameterSetName = 'deployment')] - [Parameter(ParameterSetName = 'installertoken')] - [Parameter(ValueFromPipelineByPropertyName = $true, Mandatory = $True)] - [ValidateScript({ - if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } - else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } - })] - [string[]]$Server, - [Parameter(ParameterSetName = 'deployment')] - [Parameter(ValueFromPipelineByPropertyName = $True)] - [AllowNull()] - [Alias('Password')] - [string]$ServerPassword, - [Parameter(ParameterSetName = 'deployment')] - [System.Management.Automation.PSCredential] - [System.Management.Automation.Credential()] - $Credential, - [Parameter(ParameterSetName = 'installertoken')] - [ValidatePattern('(?s:^[0-9a-z]+$)')] - [string]$InstallerToken, - [Parameter(ValueFromPipelineByPropertyName = $True)] - [AllowNull()] - [int]$LocationID, - [Parameter(ValueFromPipelineByPropertyName = $True)] - [AllowNull()] - [int]$TrayPort, - [Parameter()] - [AllowNull()] - [string]$Rename, - [switch]$Hide, - [switch]$SkipDotNet, - [switch]$Force, - [switch]$NoWait, - [switch]$SkipCertificateCheck, - [switch]$ShowProgress - ) - Begin { - Write-Debug "Starting $($myInvocation.InvocationName)" - # Snapshot error count so we can detect new errors from this function only, - # rather than checking the global $Error collection which accumulates all session errors. - $errorCountAtStart = $Error.Count - # If a PSCredential was provided, extract the password for the deployment workflow. - # This is the preferred secure alternative to passing -ServerPassword as plain text. - if ($Credential) { - $ServerPassword = $Credential.GetNetworkCredential().Password - } - # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. - # Only runs once per session, skips immediately on subsequent calls. - $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck - $progressId = 1 - $progressActivity = 'Installing ConnectWise Automate Agent' - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Checking prerequisites' -PercentComplete 11 } - if (-not $Force) { - if (Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services are already installed." -ErrorAction Stop - } - else { - Write-Error "What if: Stopping: Services are already installed." -ErrorAction Stop - } - } - } - if (-not ([bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544'))) { - Throw 'Needs to be ran as Administrator' - } - $Null = Test-CWAADotNetPrerequisite -SkipDotNet:$SkipDotNet -Force:$Force - $InstallBase = $Script:CWAAInstallerTempPath - $logfile = 'LTAgentInstall' - $curlog = "$InstallBase\$logfile.log" - if (-not (Test-Path -PathType Container -Path "$InstallBase\Installer")) { - New-Item "$InstallBase\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null - } - if (Test-Path -PathType Leaf -Path $curlog) { - if ($PSCmdlet.ShouldProcess($curlog, 'Rotate existing log file')) { - Get-Item -LiteralPath $curlog -EA 0 | Where-Object { $_ } | ForEach-Object { - Rename-Item -Path ($_ | Select-Object -Expand FullName -EA 0) -NewName "$logfile-$(Get-Date ($_ | Select-Object -Expand LastWriteTime -EA 0) -Format 'yyyyMMddHHmmss').log" -Force -Confirm:$False -WhatIf:$False - Remove-Item -Path ($_ | Select-Object -Expand FullName -EA 0) -Force -EA 0 -Confirm:$False -WhatIf:$False - } - } - } - } - Process { - # Escape double quotes in ServerPassword for MSI argument safety. - # Placed in Process (not Begin) because ServerPassword may arrive via pipeline binding. - if ($ServerPassword -match '"') { $ServerPassword = $ServerPassword.Replace('"', '""') } - if (-not ($LocationID -or $PSCmdlet.ParameterSetName -eq 'installertoken')) { - $LocationID = '1' - } - if (-not ($TrayPort) -or -not ($TrayPort -ge 1 -and $TrayPort -le 65535)) { - $TrayPort = $Script:CWAATrayPortDefault - } - # Resolve the first reachable server and its advertised version - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 22 } - $serverResult = Resolve-CWAAServer -Server $Server - if ($serverResult) { - $serverUrl = $serverResult.ServerUrl - $serverVersion = $serverResult.ServerVersion - } - if ($serverResult) { - $InstallMSI = 'Agent_Install.msi' - # Server version detection and installer URL selection: - # The download URL and installer format vary by server version and auth method. - # - v240.331+: InstallerToken deployments use a ZIP containing MSI+MST (new format) - # - v110.374+: Anonymous MSI download changed; direct location targeting removed (LT11 Patch 13) - # - v200.197+: Fixed a critical API vulnerability (CVE, June 2020) that allowed - # unauthenticated access to Deployment.aspx. Servers below this version get a warning. - # - Pre-110.374: Legacy deployment URL with per-location MSI targeting - if ($PSCmdlet.ParameterSetName -eq 'installertoken') { - $installer = "$serverUrl/LabTech/Deployment.aspx?InstallerToken=$InstallerToken" - if ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { - Write-Debug "New MSI Installer Format Needed" - $InstallMSI = 'Agent_Install.zip' - } - } - Elseif ($ServerPassword) { - $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" - } - Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionAnonymousChange) { - $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=1" - } - else { - Write-Warning 'The server version is not supported. Please update your Automate server.' - $installer = "$serverUrl/LabTech/Deployment.aspx?Probe=1&installType=msi&MSILocations=$LocationID" - } - # Vulnerability test June 10, 2020: ConnectWise Automate API Vulnerability - # Servers below v200.197 may allow unauthenticated access to Deployment.aspx - if ([System.Version]$serverVersion -lt [System.Version]$Script:CWAAVersionVulnerabilityFix) { - Try { - $HTTP_Request = [System.Net.WebRequest]::Create("$serverUrl/LabTech/Deployment.aspx") - if ($HTTP_Request.GetResponse().StatusCode -eq 'OK') { - $Message = @('Your server is vulnerable!!') - $Message += 'https://docs.connectwise.com/ConnectWise_Automate/ConnectWise_Automate_Supportability_Statements/Supportability_Statement%3A_ConnectWise_Automate_Mitigation_Steps' - Write-Warning ($Message | Out-String) - } - } - Catch { - if (-not $ServerPassword) { - Write-Error 'Anonymous downloads are not allowed. ServerPassword or InstallerToken may be needed.' - } - } - } - if ($PSCmdlet.ShouldProcess($installer, 'DownloadFile')) { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading agent installer' -PercentComplete 33 } - Write-Debug "Downloading $InstallMSI from $installer" - $Script:LTServiceNetWebClient.DownloadFile($installer, "$InstallBase\Installer\$InstallMSI") - if (-not (Test-CWAADownloadIntegrity -FilePath "$InstallBase\Installer\$InstallMSI" -FileName $InstallMSI)) { - $serverResult = $null - } - } - if ($serverResult) { - if ($WhatIfPreference -eq $True) { - $GoodServer = $serverUrl - } - Elseif (Test-Path "$InstallBase\Installer\$InstallMSI") { - $GoodServer = $serverUrl - Write-Verbose "$InstallMSI downloaded successfully from server $serverUrl." - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { - Expand-Archive "$InstallBase\Installer\$InstallMSI" -DestinationPath "$InstallBase\Installer" -Force - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - $InstallMSI = 'Agent_Install.msi' - } - } - else { - Write-Warning "Error encountered downloading from $serverUrl. No installation file was received." - } - } - } - } - End { - try { - if ($GoodServer) { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Preparing installation environment' -PercentComplete 44 } - if ($WhatIfPreference -eq $True -and (Get-PSCallStack)[1].Command -in @('Redo-CWAA', 'Redo-LTService', 'Reinstall-CWAA', 'Reinstall-LTService')) { - Write-Debug "Skipping Preinstall Check: Called by Redo-CWAA with -WhatIf" - } - else { - if ((Test-Path $Script:CWAAInstallPath -EA 0) -or (Test-Path "${env:windir}\temp\_ltupdate" -EA 0) -or (Test-Path registry::HKLM\Software\LabTech\Service -EA 0) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service -EA 0)) { - Write-Warning "Previous installation detected. Calling Uninstall-CWAA" - Uninstall-CWAA -Server $GoodServer -Force - Start-Sleep $Script:CWAAUninstallWaitSeconds - } - } - if ($WhatIfPreference -ne $True) { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving TrayPort' -PercentComplete 55 } - # TrayPort conflict resolution: LTSvc.exe listens on a local TCP port (default 42000) - # for communication with LTTray.exe (system tray UI). The valid range is 42000-42009. - # If the requested port is occupied by another process, we scan sequentially through - # the range, wrapping from 42009 back to 42000, trying up to 10 alternatives. - $GoodTrayPort = $Null - $TestTrayPort = $TrayPort - For ($i = 0; $i -le 10; $i++) { - if (-not $GoodTrayPort) { - if (-not (Test-CWAAPort -TrayPort $TestTrayPort -Quiet)) { - $TestTrayPort++ - if ($TestTrayPort -gt $Script:CWAATrayPortMax) { $TestTrayPort = $Script:CWAATrayPortMin } - } - else { - $GoodTrayPort = $TestTrayPort - } - } - } - if ($GoodTrayPort -and $GoodTrayPort -ne $TrayPort -and $GoodTrayPort -ge 1 -and $GoodTrayPort -le 65535) { - Write-Verbose "TrayPort $TrayPort is in use. Changing TrayPort to $GoodTrayPort" - $TrayPort = $GoodTrayPort - } - Write-Output 'Starting Install.' - } - # Build parameter string - $installerArguments = ($( - "/i `"$InstallBase\Installer\$InstallMSI`"" - "SERVERADDRESS=$GoodServer" - if (($PSCmdlet.ParameterSetName -eq 'installertoken') -and [System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionZipInstaller) { "TRANSFORMS=`"Agent_Install.mst`"" } - if ($ServerPassword -and $ServerPassword -match '.') { "SERVERPASS=`"$ServerPassword`"" } - if ($LocationID -and $LocationID -match '^\d+$') { "LOCATION=$LocationID" } - if ($TrayPort -and $TrayPort -ne $Script:CWAATrayPortDefault) { "SERVICEPORT=$TrayPort" } - "/qn" - "/l `"$InstallBase\$logfile.log`"" - ) | Where-Object { $_ }) -join ' ' - Try { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI installer' -PercentComplete 66 } - $installSuccess = Invoke-CWAAMsiInstaller -InstallerArguments $installerArguments - if (-not $installSuccess) { Return } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for services to start' -PercentComplete 77 } - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Verbose 'Proxy Configuration Needed. Applying Proxy Settings to Agent Installation.' - if ($PSCmdlet.ShouldProcess($Script:LTProxy.ProxyServerURL, 'Configure Agent Proxy')) { - $serviceRunning = Wait-CWAACondition -Condition { - $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - $count -eq 1 - } -TimeoutSeconds $Script:CWAAServiceStartTimeoutSec -IntervalSeconds 2 -Activity 'LTService initial startup' - if ($serviceRunning) { - Write-Debug "LTService Initial Startup Successful." - } - else { - Write-Debug "LTService Initial Startup failed to complete within expected period." - } - Set-CWAAProxy -ProxyServerURL $Script:LTProxy.ProxyServerURL -ProxyUsername $Script:LTProxy.ProxyUsername -ProxyPassword $Script:LTProxy.ProxyPassword -Confirm:$False -WhatIf:$False - } - } - else { - Write-Verbose 'No Proxy Configuration has been specified - Continuing.' - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Waiting for agent registration' -PercentComplete 88 } - if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Monitor For Successful Agent Registration')) { - $Null = Wait-CWAACondition -Condition { - $agentId = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'ID' -EA 0 - $agentId -ge 1 - } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 5 -Activity 'Agent registration' - $Null = Get-CWAAProxy -ErrorAction Continue - } - if ($Hide) { Hide-CWAAAddRemove } - } - Catch { - Write-Error "There was an error during the install process. $_" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Error: $($_.Exception.Message)" - Return - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Completing installation' -PercentComplete 100 } - if ($WhatIfPreference -ne $True) { - # Cleanup install files - Remove-Item "$InstallBase\Installer\$InstallMSI" -ErrorAction SilentlyContinue -Force -Confirm:$False - Remove-Item "$InstallBase\Installer\Agent_Install.mst" -ErrorAction SilentlyContinue -Force -Confirm:$False - @($curlog, "$Script:CWAAInstallPath\Install.log") | ForEach-Object { - if (Test-Path -PathType Leaf -LiteralPath $_) { - $logcontents = Get-Content -Path $_ - $logcontents = $logcontents -replace '(?<=PreInstallPass:[^\r\n]+? (?:result|value)): [^\r\n]+', ': ' - if ($logcontents) { Set-Content -Path $_ -Value $logcontents -Force -Confirm:$False } - } - } - $tempServiceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($tempServiceInfo) { - if (($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) -ge 1) { - Write-Output "Automate agent has been installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0) LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - Write-CWAAEventLog -EventId 1000 -EntryType Information -Message "Agent installed successfully. Agent ID: $($tempServiceInfo | Select-Object -Expand 'ID' -EA 0), LocationID: $($tempServiceInfo | Select-Object -Expand 'LocationID' -EA 0)" - } - Elseif (-not $NoWait) { - Write-Error "Automate agent installation completed but agent failed to register within expected period." -ErrorAction Continue - Write-CWAAEventLog -EventId 1001 -EntryType Warning -Message "Agent installed but failed to register within expected period." - } - else { - Write-Warning "Automate agent installation completed but agent did not yet register." -WarningAction Continue - } - } - else { - if ($Error.Count -gt $errorCountAtStart -or (-not $NoWait)) { - Write-Error "There was an error installing Automate agent. Check the log, $InstallBase\$logfile.log" - Write-CWAAEventLog -EventId 1002 -EntryType Error -Message "Agent installation failed. Check log: $InstallBase\$logfile.log" - Return - } - else { - Write-Warning "Automate agent installation may not have succeeded." -WarningAction Continue - } - } - } - if ($Rename) { Rename-CWAAAddRemove -Name $Rename } - } - Elseif ($WhatIfPreference -ne $True) { - Write-Error "No valid server was reached to use for the install." - } - } - finally { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } - Write-Debug "Exiting $($myInvocation.InvocationName)" - } - } -} -function Redo-CWAA { - <# - .SYNOPSIS - Reinstalls the ConnectWise Automate Agent on the local computer. - .DESCRIPTION - Performs a complete reinstall of the ConnectWise Automate Agent by uninstalling and then - reinstalling the agent. The function attempts to retrieve current settings (server, location, - etc.) from the existing installation or from a backup. If settings cannot be determined - automatically, the function will prompt for the required parameters. - The reinstall process: - 1. Reads current agent settings from registry or backup - 2. Uninstalls the existing agent via Uninstall-CWAA - 3. Waits 20 seconds for the uninstall to settle - 4. Installs a fresh agent via Install-CWAA with the gathered settings - .PARAMETER Server - One or more ConnectWise Automate server URLs. - Example: https://automate.domain.com - If not provided, the function reads the server URL from the current agent configuration - or backup settings. If neither is available, prompts interactively. - .PARAMETER ServerPassword - The server password for agent authentication. InstallerToken is preferred. - .PARAMETER InstallerToken - An installer token for authenticated agent deployment. This is the preferred - authentication method over ServerPassword. - See: https://forums.mspgeek.org/topic/5882-contribution-generate-agent-installertoken - .PARAMETER LocationID - The LocationID of the location the agent will be assigned to. - If not provided, reads from the current agent configuration or prompts interactively. - .PARAMETER Backup - Creates a backup of the current agent installation before uninstalling by calling New-CWAABackup. - .PARAMETER Hide - Hides the agent entry from Add/Remove Programs after reinstallation. - .PARAMETER Rename - Renames the agent entry in Add/Remove Programs after reinstallation. - .PARAMETER SkipDotNet - Skips .NET Framework 3.5 and 2.0 prerequisite checks during reinstallation. - .PARAMETER Force - Forces reinstallation even when a probe agent is detected. - .EXAMPLE - Redo-CWAA - Reinstalls the agent using settings from the current installation registry. - .EXAMPLE - Redo-CWAA -Server https://automate.domain.com -InstallerToken 'token' -LocationID 42 - Reinstalls the agent with explicitly provided settings. - .EXAMPLE - Redo-CWAA -Backup -Force - Backs up settings, then forces reinstallation even if a probe agent is detected. - .EXAMPLE - Get-CWAAInfo | Redo-CWAA -InstallerToken 'token' - Reinstalls the agent using Server and LocationID from the current installation via pipeline. - .NOTES - Author: Chris Taylor - Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Reinstall-CWAA', 'Redo-LTService', 'Reinstall-LTService')] - Param( - [Parameter(ValueFromPipelineByPropertyName = $True, ValueFromPipeline = $True)] - [AllowNull()] - [string[]]$Server, - [Parameter(ParameterSetName = 'deployment')] - [Parameter(ValueFromPipelineByPropertyName = $True, ValueFromPipeline = $True)] - [Alias('Password')] - [string]$ServerPassword, - [Parameter(ParameterSetName = 'installertoken')] - [ValidatePattern('(?s:^[0-9a-z]+$)')] - [string]$InstallerToken, - [Parameter(ValueFromPipelineByPropertyName = $True)] - [AllowNull()] - [int]$LocationID, - [switch]$Backup, - [switch]$Hide, - [Parameter()] - [AllowNull()] - [string]$Rename, - [switch]$SkipDotNet, - [switch]$Force - ) - Begin { - Write-Debug "Starting $($myInvocation.InvocationName)" - # Gather install settings from registry or backed up settings - $Settings = $Null - Try { - $Settings = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False - } - Catch { - Write-Debug "Failed to retrieve current Agent Settings: $_" - } - Assert-CWAANotProbeAgent -ServiceInfo $Settings -ActionName 'Re-Install' -Force:$Force - if ($Null -eq $Settings) { - Write-Debug "Unable to retrieve current Agent Settings. Testing for Backup Settings." - Try { - $Settings = Get-CWAAInfoBackup -EA 0 - } - Catch { Write-Debug "Failed to retrieve backup Agent Settings: $_" } - } - $ServerList = @() - } - Process { - if (-not $Server) { - if ($Settings) { - $Server = $Settings | Select-Object -Expand 'Server' -EA 0 - } - if (-not $Server) { - $Server = Read-Host -Prompt 'Provide the URL to your Automate server (https://automate.domain.com):' - } - } - if (-not $LocationID) { - if ($Settings) { - $LocationID = $Settings | Select-Object -Expand LocationID -EA 0 - } - if (-not $LocationID) { - $LocationID = Read-Host -Prompt 'Provide the LocationID' - } - } - if (-not $LocationID) { - $LocationID = '1' - } - $ServerList += $Server - } - End { - if ($Backup) { - if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { - New-CWAABackup - } - } - $RenameArg = '' - if ($Rename) { - $RenameArg = "-Rename $Rename" - } - if ($PSCmdlet.ParameterSetName -eq 'installertoken') { - $PasswordPresent = "-InstallerToken 'REDACTED'" - } - Elseif ($ServerPassword) { - $PasswordPresent = "-Password 'REDACTED'" - } - Write-Output "Reinstalling Automate agent with the following information, -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID $RenameArg" - Write-Verbose "Starting: UnInstall-CWAA -Server $($ServerList -join ',')" - Try { - Uninstall-CWAA -Server $ServerList -ErrorAction Stop -Force - } - Catch { - Write-CWAAEventLog -EventId 1022 -EntryType Error -Message "Agent reinstall failed during uninstall phase. Error: $($_.Exception.Message)" - Write-Error "There was an error during the reinstall process while uninstalling. $_" -ErrorAction Stop - } - Finally { - if ($WhatIfPreference -ne $True) { - Write-Verbose 'Waiting 20 seconds for prior uninstall to settle before starting Install.' - Start-Sleep 20 - } - } - Write-Verbose "Starting: Install-CWAA -Server $($ServerList -join ',') $PasswordPresent -LocationID $LocationID -Hide:`$$Hide $RenameArg" - Try { - if ($PSCmdlet.ParameterSetName -ne 'installertoken') { - Install-CWAA -Server $ServerList -ServerPassword $ServerPassword -LocationID $LocationID -Hide:$Hide -Rename $Rename -SkipDotNet:$SkipDotNet -Force - } - else { - Install-CWAA -Server $ServerList -InstallerToken $InstallerToken -LocationID $LocationID -Hide:$Hide -Rename $Rename -SkipDotNet:$SkipDotNet -Force - } - } - Catch { - Write-CWAAEventLog -EventId 1022 -EntryType Error -Message "Agent reinstall failed during install phase. Error: $($_.Exception.Message)" - Write-Error "There was an error during the reinstall process while installing. $_" -ErrorAction Stop - } - Write-CWAAEventLog -EventId 1020 -EntryType Information -Message "Agent reinstalled successfully. Server: $($ServerList -join ','), LocationID: $LocationID" - Write-Debug "Exiting $($myInvocation.InvocationName)" - } -} -function Uninstall-CWAA { - <# - .SYNOPSIS - Completely uninstalls the ConnectWise Automate Agent from the local computer. - .DESCRIPTION - Performs a comprehensive removal of the ConnectWise Automate Agent from a Windows computer. - This function is more thorough than a standard MSI uninstall, as it also removes residual - files, registry keys, and services that may not be cleaned up by the normal uninstall process. - The uninstall process performs the following operations: - 1. Downloads official uninstaller files (Agent_Uninstall.msi and Agent_Uninstall.exe) from the server - 2. Optionally creates a backup of the current agent installation (if -Backup is specified) - 3. Stops all running agent services (LTService, LTSvcMon, LabVNC) - 4. Terminates any running agent processes - 5. Unregisters the wodVPN.dll component - 6. Runs the MSI uninstaller (Agent_Uninstall.msi) - 7. Runs the agent uninstaller executable (Agent_Uninstall.exe) - 8. Removes agent Windows services - 9. Removes all agent files from the installation directory - 10. Removes all agent-related registry keys (over 30 different registry locations) - 11. Verifies the uninstall was successful - Probe Agent Protection: By default, this function will refuse to uninstall probe agents to - prevent accidental removal of critical infrastructure. Use -Force to override this protection. - .PARAMETER Server - One or more ConnectWise Automate server URLs to download uninstaller files from. - If not specified, reads the server URL from the agent's current registry configuration. - If that fails, prompts interactively for a server URL. - Example: https://automate.domain.com - .PARAMETER Backup - Creates a complete backup of the agent installation before uninstalling by calling New-CWAABackup. - .PARAMETER Force - Forces uninstallation even when a probe agent is detected. Use with extreme caution, - as probe agents are typically critical infrastructure components. - .PARAMETER SkipCertificateCheck - Bypasses SSL/TLS certificate validation for server connections. - Use in lab or test environments with self-signed certificates. - .PARAMETER ShowProgress - Displays a Write-Progress bar showing uninstall progress. Off by default - to avoid interference with unattended execution (RMM tools, GPO scripts). - .EXAMPLE - Uninstall-CWAA - Uninstalls the agent using the server URL from the agent's registry settings. - .EXAMPLE - Uninstall-CWAA -Backup - Creates a backup of the agent installation before uninstalling. - .EXAMPLE - Uninstall-CWAA -Server "https://automate.company.com" - Uninstalls using the specified server URL to download uninstaller files. - .EXAMPLE - Uninstall-CWAA -Server "https://primary.company.com","https://backup.company.com" - Provides multiple server URLs with fallback. Tries each until uninstaller files download successfully. - .EXAMPLE - Uninstall-CWAA -Force - Forces uninstallation even if a probe agent is detected. - .EXAMPLE - Uninstall-CWAA -WhatIf - Simulates the uninstall process without making any actual changes. - .EXAMPLE - Get-CWAAInfo | Uninstall-CWAA - Pipes the installed agent's Server property into Uninstall-CWAA via pipeline. - .NOTES - Author: Chris Taylor - Alias: Uninstall-LTService - Requires: Administrator privileges - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Uninstall-LTService')] - Param( - [Parameter(ValueFromPipelineByPropertyName = $true)] - [AllowNull()] - [string[]]$Server, - [switch]$Backup, - [switch]$Force, - [switch]$SkipCertificateCheck, - [switch]$ShowProgress - ) - Begin { - Write-Debug "Starting $($myInvocation.InvocationName)" - # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. - # Only runs once per session, skips immediately on subsequent calls. - $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck - if (-not ([bool](([System.Security.Principal.WindowsIdentity]::GetCurrent() | Select-Object -Expand groups -EA 0) -match 'S-1-5-32-544'))) { - Throw "Needs to be ran as Administrator" - } - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'UnInstall' -Force:$Force - if ($Backup) { - if ($PSCmdlet.ShouldProcess('LTService', 'Backup Current Service Settings')) { - New-CWAABackup - } - } - $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 - if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } - New-PSDrive HKU Registry HKEY_USERS -ErrorAction SilentlyContinue -WhatIf:$False -Confirm:$False -Debug:$False | Out-Null - $regs = @( 'Registry::HKEY_LOCAL_MACHINE\Software\LabTechMSP', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\LabTech\Service', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\LabTech\LabVNC', - 'Registry::HKEY_LOCAL_MACHINE\Software\Wow6432Node\LabTech\Service', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Classes\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\Managed\\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\UserData\S-1-5-18\Products\D1003A85576B76D45A1AF09A0FC87FAC\InstallProperties', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{58A3001D-B675-4D67-A5A1-0FA9F08CF7CA}', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{3426921d-9ad5-4237-9145-f15dee7e3004}', - 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\Appmgmt\{40bf8c82-ed0d-4f66-b73e-58a3d7ab6582}', - 'Registry::HKEY_CLASSES_ROOT\Installer\Dependencies\{3426921d-9ad5-4237-9145-f15dee7e3004}', - 'Registry::HKEY_CLASSES_ROOT\Installer\Dependencies\{3F460D4C-D217-46B4-80B6-B5ED50BD7CF5}', - 'Registry::HKEY_CLASSES_ROOT\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'Registry::HKEY_CLASSES_ROOT\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{09DF1DCA-C076-498A-8370-AD6F878B6C6A}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{15DD3BF6-5A11-4407-8399-A19AC10C65D0}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{3C198C98-0E27-40E4-972C-FDC656EC30D7}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{459C65ED-AA9C-4CF1-9A24-7685505F919A}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{7BE3886B-0C12-4D87-AC0B-09A5CE4E6BD6}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{7E092B5C-795B-46BC-886A-DFFBBBC9A117}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{9D101D9C-18CC-4E78-8D78-389E48478FCA}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{B0B8CDD6-8AAA-4426-82E9-9455140124A1}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{B1B00A43-7A54-4A0F-B35D-B4334811FAA4}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{BBC521C8-2792-43FE-9C91-CCA7E8ACBCC9}', - 'Registry::HKEY_CLASSES_ROOT\CLSID\{C59A1D54-8CD7-4795-AEDD-F6F6E2DE1FE7}', - 'Registry::HKEY_CLASSES_ROOT\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'Registry::HKEY_CLASSES_ROOT\Installer\Products\D1003A85576B76D45A1AF09A0FC87FAC', - 'Registry::HKEY_CURRENT_USER\SOFTWARE\LabTech\Service', - 'Registry::HKEY_CURRENT_USER\SOFTWARE\LabTech\LabVNC', - 'Registry::HKEY_CURRENT_USER\Software\Microsoft\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F', - 'HKU:\*\Software\Microsoft\Installer\Products\C4D064F3712D4B64086B5BDE05DBC75F' - ) - if ($WhatIfPreference -ne $True) { - Remove-Item 'Uninstall.exe', 'Uninstall.exe.config' -ErrorAction SilentlyContinue -Force -Confirm:$False - New-Item "$Script:CWAAInstallerTempPath\Installer" -type directory -ErrorAction SilentlyContinue | Out-Null - } - $uninstallArguments = "/x ""$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi"" /qn" - } - Process { - if (-not $Server) { - $Server = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand 'Server' -EA 0 - } - if (-not $Server) { - $Server = Read-Host -Prompt 'Provide the URL to your Automate server (https://automate.domain.com):' - } - # Resolve the first reachable server and its advertised version - $progressId = 2 - $progressActivity = 'Uninstalling ConnectWise Automate Agent' - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 12 } - $serverResult = Resolve-CWAAServer -Server $Server - if (-not $serverResult) { return } - $serverUrl = $serverResult.ServerUrl - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading uninstaller files' -PercentComplete 25 } - Try { - # Download the uninstall MSI (same URL for all server versions) - $installer = "$serverUrl/LabTech/Service/LabTechRemoteAgent.msi" - $installerTest = [System.Net.WebRequest]::Create($installer) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." - $installerTest.Proxy = $Script:LTWebProxy - } - $installerTest.KeepAlive = $False - $installerTest.ProtocolVersion = '1.0' - $installerResult = $installerTest.GetResponse() - $installerTest.Abort() - if ($installerResult.StatusCode -ne 200) { - Write-Warning "Unable to download Agent_Uninstall.msi from server $serverUrl." - return - } - if ($PSCmdlet.ShouldProcess("$installer", 'DownloadFile')) { - Write-Debug "Downloading Agent_Uninstall.msi from $installer" - $Script:LTServiceNetWebClient.DownloadFile($installer, "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") - if (-not (Test-CWAADownloadIntegrity -FilePath "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi" -FileName 'Agent_Uninstall.msi')) { - return - } - $AlternateServer = $serverUrl - } - # Download the uninstall EXE (same URI for all versions) - $uninstaller = "$serverUrl/LabTech/Service/LabUninstall.exe" - $uninstallerTest = [System.Net.WebRequest]::Create($uninstaller) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." - $uninstallerTest.Proxy = $Script:LTWebProxy - } - $uninstallerTest.KeepAlive = $False - $uninstallerTest.ProtocolVersion = '1.0' - $uninstallerResult = $uninstallerTest.GetResponse() - $uninstallerTest.Abort() - if ($uninstallerResult.StatusCode -ne 200) { - Write-Warning "Unable to download Agent_Uninstall from server." - return - } - 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 - if (-not (Test-CWAADownloadIntegrity -FilePath "${env:windir}\temp\Agent_Uninstall.exe" -FileName 'Agent_Uninstall.exe' -MinimumSizeKB 80)) { - return - } - } - if ($WhatIfPreference -eq $True) { - $GoodServer = $serverUrl - } - Elseif ((Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") -and (Test-Path "${env:windir}\temp\Agent_Uninstall.exe")) { - $GoodServer = $serverUrl - Write-Verbose "Successfully downloaded files from $serverUrl." - } - else { - Write-Warning "Error encountered downloading from $serverUrl. Uninstall file(s) could not be received." - } - } - Catch { - Write-Warning "Error encountered downloading from $serverUrl." - } - } - End { - try { - if ($GoodServer -match 'https?://.+' -or $AlternateServer -match 'https?://.+') { - Try { - Write-Output 'Starting Uninstall.' - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services and processes' -PercentComplete 37 } - Try { Stop-CWAA -ErrorAction SilentlyContinue } Catch { Write-Debug "Stop-CWAA encountered an error: $_" } - # Kill all running processes from %ltsvcdir% - if (Test-Path $BasePath) { - $Executables = (Get-ChildItem $BasePath -Filter *.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -Expand FullName) - if ($Executables) { - Write-Verbose "Terminating Automate agent processes from $BasePath if found running: $(($Executables) -replace [Regex]::Escape($BasePath),'' -replace '^\\','')" - Get-Process | Where-Object { $Executables -contains $_.Path } | ForEach-Object { - Write-Debug "Terminating Process $($_.ProcessName)" - $_ | Stop-Process -Force -ErrorAction SilentlyContinue - } - Get-ChildItem $BasePath -Filter labvnc.exe -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction 0 - } - if ($PSCmdlet.ShouldProcess("$BasePath\wodVPN.dll", 'Unregister DLL')) { - Write-Debug "Executing Command ""regsvr32.exe /u $BasePath\wodVPN.dll /s""" - Try { & "$env:windir\system32\regsvr32.exe" /u "$BasePath\wodVPN.dll" /s 2>'' } - Catch { Write-Output 'Error calling regsvr32.exe.' } - } - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running MSI uninstaller' -PercentComplete 50 } - if ($PSCmdlet.ShouldProcess("msiexec.exe $uninstallArguments", 'Execute MSI Uninstall')) { - if (Test-Path "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi") { - Write-Verbose 'Launching MSI Uninstall.' - Write-Debug "Executing Command ""msiexec.exe $uninstallArguments""" - Start-Process -Wait -FilePath "$env:windir\system32\msiexec.exe" -ArgumentList $uninstallArguments -WorkingDirectory $env:TEMP - Start-Sleep -Seconds 5 - } - else { - Write-Verbose "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi was not found." - } - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Running agent uninstaller' -PercentComplete 62 } - if ($PSCmdlet.ShouldProcess("${env:windir}\temp\Agent_Uninstall.exe", 'Execute Agent Uninstall')) { - if (Test-Path "${env:windir}\temp\Agent_Uninstall.exe") { - # Remove previously extracted SFX files to prevent UnRAR overwrite prompts - Remove-Item "$env:TEMP\Uninstall.exe", "$env:TEMP\Uninstall.exe.config" -ErrorAction SilentlyContinue -Force -Confirm:$False - Write-Verbose 'Launching Agent Uninstaller' - Write-Debug "Executing Command ""${env:windir}\temp\Agent_Uninstall.exe""" - Start-Process -Wait -FilePath "${env:windir}\temp\Agent_Uninstall.exe" -WorkingDirectory $env:TEMP - Start-Sleep -Seconds 5 - } - else { - Write-Verbose "${env:windir}\temp\Agent_Uninstall.exe was not found." - } - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Removing services' -PercentComplete 75 } - Write-Verbose 'Removing Services if found.' - $Script:CWAAAllServiceNames | ForEach-Object { - if (Get-Service $_ -EA 0) { - if ($PSCmdlet.ShouldProcess($_, 'Remove Service')) { - Write-Debug "Removing Service: $_" - Try { - & "$env:windir\system32\sc.exe" delete "$_" 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "sc.exe delete returned exit code $LASTEXITCODE for service '$_'." - } - } - Catch { Write-Output 'Error calling sc.exe.' } - } - } - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning up files and registry' -PercentComplete 87 } - Write-Verbose 'Cleaning Files remaining if found.' - # Depth-first removal to get as much removed as possible if complete removal fails - @($BasePath, "${env:windir}\temp\_ltupdate") | ForEach-Object { - if (Test-Path $_ -EA 0) { - Remove-CWAAFolderRecursive -Path $_ - } - } - Write-Verbose 'Removing agent installation msi file.' - if ($PSCmdlet.ShouldProcess('Agent_Uninstall.msi', 'Remove File')) { - $MsiPath = "$Script:CWAAInstallerTempPath\Installer\Agent_Uninstall.msi" - $tries = 0 - Try { - Do { - $MsiExists = Test-Path $MsiPath - Start-Sleep -Seconds 10 - Remove-Item $MsiPath -ErrorAction SilentlyContinue - $tries++ - } - While ($MsiExists -and $tries -lt 4) - } - Catch { - Write-Verbose "Unable to remove Agent_Uninstall.msi: $($_.Exception.Message)" - } - } - Write-Verbose 'Cleaning Registry Keys if found.' - # Depth First Value Removal, then Key Removal - Foreach ($reg in $regs) { - if (Test-Path $reg -EA 0) { - Write-Debug "Found Registry Key: $reg" - if ($PSCmdlet.ShouldProcess($reg, 'Remove Registry Key')) { - Try { - Get-ChildItem -Path $reg -Recurse -Force -ErrorAction SilentlyContinue | Sort-Object { $_.name.length } -Descending | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - Remove-Item -Recurse -Force -Path $reg -ErrorAction SilentlyContinue -Confirm:$False -WhatIf:$False - } - Catch { Write-Debug "Error removing registry key '$reg': $($_.Exception.Message)" } - } - } - } - } - Catch { - Write-CWAAEventLog -EventId 1012 -EntryType Error -Message "Agent uninstall failed. Error: $($_.Exception.Message)" - Write-Error "There was an error during the uninstall process. $($_.Exception.Message)" -ErrorAction Stop - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Verifying uninstall' -PercentComplete 100 } - if ($WhatIfPreference -ne $True) { - # Post Uninstall Check - If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { - Start-Sleep -Seconds 10 - } - If ((Test-Path $Script:CWAAInstallPath) -or (Test-Path "${env:windir}\temp\_ltupdate") -or (Test-Path registry::HKLM\Software\LabTech\Service) -or (Test-Path registry::HKLM\Software\WOW6432Node\Labtech\Service)) { - Write-Error "Remnants of previous install still detected after uninstall attempt. Please reboot and try again." - Write-CWAAEventLog -EventId 1011 -EntryType Warning -Message 'Remnants of previous install detected after uninstall. Reboot recommended.' - } - else { - Write-Output 'Automate agent has been successfully uninstalled.' - Write-CWAAEventLog -EventId 1010 -EntryType Information -Message 'Agent uninstalled successfully.' - } - } - } - Elseif ($WhatIfPreference -ne $True) { - Write-Error "No valid server was reached to use for the uninstall." -ErrorAction Stop - } - } - finally { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } - Write-Debug "Exiting $($myInvocation.InvocationName)" - } - } -} -function Update-CWAA { - <# - .SYNOPSIS - Manually updates the ConnectWise Automate Agent to a specified version. - .DESCRIPTION - Downloads and applies an agent update from the ConnectWise Automate server. The function - reads the current server configuration from the agent's registry settings, downloads the - appropriate update package, extracts it, and runs the updater. - If no version is specified, the function uses the version advertised by the server. - The function validates that the requested version is higher than the currently installed - version and not higher than the server version before proceeding. - The update process: - 1. Reads current agent settings and server information - 2. Downloads the LabtechUpdate.exe for the target version - 3. Stops agent services - 4. Extracts and runs the update - 5. Restarts agent services - .PARAMETER Version - The target agent version to update to. - Example: 120.240 - If omitted, the version advertised by the server will be used. - .PARAMETER SkipCertificateCheck - Bypasses SSL/TLS certificate validation for server connections. - Use in lab or test environments with self-signed certificates. - .PARAMETER ShowProgress - Displays a Write-Progress bar showing update progress. Off by default - to avoid interference with unattended execution (RMM tools, GPO scripts). - .EXAMPLE - Update-CWAA -Version 120.240 - Updates the agent to the specific version requested. - .EXAMPLE - Update-CWAA - Updates the agent to the current version advertised by the server. - .NOTES - Author: Darren White - Alias: Update-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Update-LTService')] - Param( - [parameter(Position = 0)] - [AllowNull()] - [string]$Version, - [switch]$SkipCertificateCheck, - [switch]$ShowProgress - ) - Begin { - Write-Debug "Starting $($myInvocation.InvocationName)" - # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. - # Only runs once per session, skips immediately on subsequent calls. - $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck - $Settings = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False - $updaterPath = [System.Environment]::ExpandEnvironmentVariables('%windir%\temp\_LTUpdate') - $extractArguments = @("/o""$updaterPath""", '/y') - $updaterArguments = @("""$updaterPath\Update.ini""") - } - Process { - if (-not $Server) { - if ($Settings) { - $Server = $Settings | Select-Object -Expand 'Server' -EA 0 - } - } - # Resolve the first reachable server and its advertised version - $progressId = 3 - $progressActivity = 'Updating ConnectWise Automate Agent' - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Resolving server address' -PercentComplete 14 } - if (-not $Server) { return } - $serverResult = Resolve-CWAAServer -Server $Server - if ($serverResult) { - $GoodServer = $serverResult.ServerUrl - $serverVersion = $serverResult.ServerVersion - } - if ($GoodServer) { - # Determine the target version and build the update download URL - if ($Version -match '[1-9][0-9]{2}\.[0-9]{1,3}') { - $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" - } - Elseif ([System.Version]$serverVersion -ge [System.Version]$Script:CWAAVersionUpdateMinimum) { - $Version = $serverVersion - Write-Verbose "Using detected version ($Version) from server: $GoodServer." - $updater = "$GoodServer/Labtech/Updates/LabtechUpdate_$Version.zip" - } - # Kill all running processes from $updaterPath before cleanup - if (Test-Path $updaterPath) { - $Executables = (Get-ChildItem $updaterPath -Filter *.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -Expand FullName) - if ($Executables) { - Write-Verbose "Terminating Automate agent processes from $updaterPath if found running: $(($Executables) -replace [Regex]::Escape($updaterPath),'' -replace '^\\','')" - Get-Process | Where-Object { $Executables -contains $_.Path } | ForEach-Object { - Write-Debug "Terminating Process $($_.ProcessName)" - $_ | Stop-Process -Force -ErrorAction SilentlyContinue - } - } - } - # Remove stale updater directory using depth-first removal - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Cleaning previous update files' -PercentComplete 28 } - Remove-CWAAFolderRecursive -Path $updaterPath - Try { - if (-not (Test-Path -PathType Container -Path $updaterPath)) { - New-Item $updaterPath -type directory -ErrorAction SilentlyContinue | Out-Null - } - $updaterTest = [System.Net.WebRequest]::Create($updater) - if (($Script:LTProxy.Enabled) -eq $True) { - Write-Debug "Proxy Configuration Needed. Applying Proxy Settings to request." - $updaterTest.Proxy = $Script:LTWebProxy - } - $updaterTest.KeepAlive = $False - $updaterTest.ProtocolVersion = '1.0' - $updaterResult = $updaterTest.GetResponse() - $updaterTest.Abort() - if ($updaterResult.StatusCode -ne 200) { - Write-Warning "Unable to download LabtechUpdate.exe version $Version from server $GoodServer." - $GoodServer = $null - } - else { - if ($PSCmdlet.ShouldProcess($updater, 'DownloadFile')) { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Downloading update package' -PercentComplete 42 } - Write-Debug "Downloading LabtechUpdate.exe from $updater" - $Script:LTServiceNetWebClient.DownloadFile($updater, "$updaterPath\LabtechUpdate.exe") - if (-not (Test-CWAADownloadIntegrity -FilePath "$updaterPath\LabtechUpdate.exe" -FileName 'LabtechUpdate.exe')) { - $GoodServer = $null - } - } - if ($GoodServer) { - if ($WhatIfPreference -ne $True -and -not (Test-Path "$updaterPath\LabtechUpdate.exe")) { - Write-Warning "Error encountered downloading from $GoodServer. No update file was received." - $GoodServer = $null - } - else { - Write-Verbose "LabtechUpdate.exe downloaded successfully from server $GoodServer." - } - } - } - } - Catch { - Write-Warning "Error encountered downloading $updater." - $GoodServer = $null - } - } - } - End { - try { - $detectedVersion = $Settings | Select-Object -Expand 'Version' -EA 0 - if ($Null -eq $detectedVersion) { - Write-Error "No existing installation was found." -ErrorAction Stop - Return - } - if ([System.Version]$detectedVersion -ge [System.Version]$Version) { - Write-Warning "Installed version detected ($detectedVersion) is higher than or equal to the requested version ($Version)." - Return - } - if (-not $GoodServer) { - Write-Warning "No valid server was detected." - Return - } - if ([System.Version]$serverVersion -gt [System.Version]$Version) { - Write-Warning "Server version detected ($serverVersion) is higher than the requested version ($Version)." - Return - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Stopping services' -PercentComplete 57 } - Try { - Stop-CWAA - } - Catch { - Write-Error "There was an error stopping the services. $_" - Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update failed - unable to stop services. Error: $($_.Exception.Message)" - Return - } - Write-Output "Updating Agent with the following information: Server $GoodServer, Version $Version" - Try { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Extracting update' -PercentComplete 71 } - if ($PSCmdlet.ShouldProcess("LabtechUpdate.exe $extractArguments", 'Extracting update files')) { - if (Test-Path "$updaterPath\LabtechUpdate.exe") { - Write-Verbose 'Launching LabtechUpdate Self-Extractor.' - Write-Debug "Executing Command ""LabtechUpdate.exe $extractArguments""" - Try { - Push-Location $updaterPath - & "$updaterPath\LabtechUpdate.exe" $extractArguments 2>'' - Pop-Location - } - Catch { Write-Output 'Error calling LabtechUpdate.exe.' } - Start-Sleep -Seconds 5 - } - else { - Write-Verbose "$updaterPath\LabtechUpdate.exe was not found." - } - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Applying update' -PercentComplete 85 } - if ($PSCmdlet.ShouldProcess("Update.exe $updaterArguments", 'Launching Updater')) { - if (Test-Path "$updaterPath\Update.exe") { - Write-Verbose 'Launching Labtech Updater' - Write-Debug "Executing Command ""Update.exe $updaterArguments""" - Try { & "$updaterPath\Update.exe" $updaterArguments 2>'' } - Catch { Write-Output 'Error calling Update.exe.' } - Start-Sleep -Seconds 5 - } - else { - Write-Verbose "$updaterPath\Update.exe was not found." - } - } - } - Catch { - Write-Error "There was an error during the update process. $_" -ErrorAction Continue - Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update process failed. Error: $($_.Exception.Message)" - } - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Status 'Restarting services' -PercentComplete 100 } - Try { - Start-CWAA - } - Catch { - Write-Error "There was an error starting the services. $_" - Write-CWAAEventLog -EventId 1032 -EntryType Error -Message "Agent update completed but services failed to start. Error: $($_.Exception.Message)" - Return - } - Write-CWAAEventLog -EventId 1030 -EntryType Information -Message "Agent updated successfully to version $Version." - } - finally { - if ($ShowProgress) { Write-Progress -Id $progressId -Activity $progressActivity -Completed } - Write-Debug "Exiting $($myInvocation.InvocationName)" - } - } -} -function Get-CWAAError { - <# - .SYNOPSIS - Reads the ConnectWise Automate Agent error log into structured objects. - .DESCRIPTION - Parses the LTErrors.txt file from the agent install directory into objects with - ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, - and pipeline operations on agent error log entries. - The log file location is determined from Get-CWAAInfo; if unavailable, falls back - to the default install path at C:\Windows\LTSVC. - .EXAMPLE - Get-CWAAError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} - Returns all agent errors from the last 24 hours. - .EXAMPLE - Get-CWAAError | Out-GridView - Opens the error log in a sortable, searchable grid view window. - .NOTES - Author: Chris Taylor - Alias: Get-LTErrors - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTErrors')] - Param() - Begin { - $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 - if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } - } - Process { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $logFilePath = "$BasePath\LTErrors.txt" - if (-not (Test-Path -Path $logFilePath)) { - Write-Error "Unable to find agent error log at '$logFilePath'." - return - } - Try { - $errors = Get-Content $logFilePath - $errors = $errors -join ' ' -split '::: ' - foreach ($line in $errors) { - $items = $line -split "`t" -replace ' - ', '' - if ($items[1]) { - [PSCustomObject]@{ - ServiceVersion = $items[0] - Timestamp = $(Try { [datetime]::Parse($items[1]) } Catch { $null }) - Message = $items[2] - } - } - } - } - Catch { - Write-Error "Failed to read agent error log at '$logFilePath'. Error: $($_.Exception.Message)" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAALogLevel { - <# - .SYNOPSIS - Retrieves the current logging level for the ConnectWise Automate Agent. - .DESCRIPTION - Checks the agent's registry settings to determine the current logging verbosity level. - The ConnectWise Automate Agent supports two logging levels: Normal (value 1) for standard - operations, and Verbose (value 1000) for detailed diagnostic logging. - The logging level is stored in the registry at HKLM:\SOFTWARE\LabTech\Service\Settings - under the "Debuging" value. - .EXAMPLE - Get-CWAALogLevel - Returns the current logging level (Normal or Verbose). - .EXAMPLE - Get-CWAALogLevel - Set-CWAALogLevel -Level Verbose - Get-CWAALogLevel - Typical troubleshooting workflow: check level, enable verbose, verify the change. - .NOTES - Author: Chris Taylor - Alias: Get-LTLogging - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTLogging')] - Param () - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - Try { - # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. - $logLevel = Get-CWAASettings | Select-Object -Expand Debuging -EA 0 - if ($logLevel -eq 1000) { - Write-Output 'Current logging level: Verbose' - } - elseif ($Null -eq $logLevel -or $logLevel -eq 1) { - # Fresh installs may not have the Debuging value yet; treat as Normal - Write-Output 'Current logging level: Normal' - } - else { - Write-Error "Unknown logging level value '$logLevel' in registry." - } - } - Catch { - Write-Error "Failed to read logging level from registry. Error: $($_.Exception.Message)" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAAProbeError { - <# - .SYNOPSIS - Reads the ConnectWise Automate Agent probe error log into structured objects. - .DESCRIPTION - Parses the LTProbeErrors.txt file from the agent install directory into objects with - ServiceVersion, Timestamp, and Message properties. This enables filtering, sorting, - and pipeline operations on agent probe error log entries. - The log file location is determined from Get-CWAAInfo; if unavailable, falls back - to the default install path at C:\Windows\LTSVC. - .EXAMPLE - Get-CWAAProbeError | Where-Object {$_.Timestamp -gt (Get-Date).AddHours(-24)} - Returns all probe errors from the last 24 hours. - .EXAMPLE - Get-CWAAProbeError | Out-GridView - Opens the probe error log in a sortable, searchable grid view window. - .NOTES - Author: Chris Taylor - Alias: Get-LTProbeErrors - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTProbeErrors')] - Param() - Begin { - $BasePath = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0 - if (-not $BasePath) { $BasePath = $Script:CWAAInstallPath } - } - Process { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $logFilePath = "$BasePath\LTProbeErrors.txt" - if (-not (Test-Path -Path $logFilePath)) { - Write-Error "Unable to find probe error log at '$logFilePath'." - return - } - Try { - $errors = Get-Content $logFilePath - $errors = $errors -join ' ' -split '::: ' - foreach ($line in $errors) { - $items = $line -split "`t" -replace ' - ', '' - if ($items[1]) { - [PSCustomObject]@{ - ServiceVersion = $items[0] - Timestamp = $(Try { [datetime]::Parse($items[1]) } Catch { $null }) - Message = $items[2] - } - } - } - } - Catch { - Write-Error "Failed to read probe error log at '$logFilePath'. Error: $($_.Exception.Message)" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Set-CWAALogLevel { - <# - .SYNOPSIS - Sets the logging level for the ConnectWise Automate Agent. - .DESCRIPTION - Configures the agent's logging verbosity by updating the registry and restarting the - agent services. Supports Normal (standard) and Verbose (detailed diagnostic) levels. - The function stops the agent service, writes the new logging level to the registry at - HKLM:\SOFTWARE\LabTech\Service\Settings under the "Debuging" value, then restarts the - agent service. After applying the change, it outputs the current logging level. - .PARAMETER Level - The desired logging level. Valid values are 'Normal' (default) and 'Verbose'. - Normal sets registry value 1; Verbose sets registry value 1000. - .EXAMPLE - Set-CWAALogLevel -Level Verbose - Enables verbose diagnostic logging on the agent. - .EXAMPLE - Set-CWAALogLevel -Level Normal - Returns the agent to standard logging. - .EXAMPLE - Set-CWAALogLevel -Level Verbose -WhatIf - Shows what changes would be made without applying them. - .EXAMPLE - 'Verbose' | Set-CWAALogLevel - Sets the log level to Verbose via pipeline input. - .NOTES - Author: Chris Taylor - Alias: Set-LTLogging - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Set-LTLogging')] - Param ( - [Parameter(ValueFromPipeline = $True)] - [ValidateSet('Normal', 'Verbose')] - $Level = 'Normal' - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - Try { - # "Debuging" is the vendor's original spelling in the registry -- not a typo in this code. - $registryPath = "$Script:CWAARegistrySettings" - $registryName = 'Debuging' - if ($Level -eq 'Normal') { - $registryValue = 1 - } - else { - $registryValue = 1000 - } - if ($PSCmdlet.ShouldProcess("$registryPath\$registryName", "Set logging level to $Level (value: $registryValue)")) { - Stop-CWAA - Set-ItemProperty $registryPath -Name $registryName -Value $registryValue - Start-CWAA - } - Get-CWAALogLevel - Write-CWAAEventLog -EventId 3030 -EntryType Information -Message "Agent log level set to $Level." - } - Catch { - Write-CWAAEventLog -EventId 3032 -EntryType Error -Message "Failed to set agent log level to '$Level'. Error: $($_.Exception.Message)" - Write-Error "Failed to set logging level to '$Level'. Error: $($_.Exception.Message)" -ErrorAction Stop - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAAProxy { - <# - .SYNOPSIS - Retrieves the current agent proxy settings for module operations. - .DESCRIPTION - Reads the current Automate agent proxy settings from the installed agent (if present) - and stores them in the module-scoped $Script:LTProxy object. The proxy URL, - username, and password are decrypted using the agent's password string. The - discovered settings are used by all module communication operations for the - duration of the session, and returned as the function result. - .EXAMPLE - Get-CWAAProxy - Retrieves and returns the current proxy configuration. - .EXAMPLE - $proxy = Get-CWAAProxy - if ($proxy.Enabled) { Write-Host "Proxy: $($proxy.ProxyServerURL)" } - Checks whether a proxy is configured and displays the URL. - .NOTES - Author: Darren White - Alias: Get-LTProxy - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTProxy')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - Write-Verbose 'Discovering Proxy Settings used by the LT Agent.' - # Decrypt agent passwords from registry. The decrypted PasswordString is used - # below to decode proxy credentials. This logic was formerly in the private - # Initialize-CWAAKeys function — inlined here because Get-CWAAProxy is the only - # consumer, and key decryption is inherently the first step of proxy discovery. - # The $serviceInfo result is reused in Process to avoid a redundant registry read. - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - if ($serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { - Write-Debug "Decoding Server Password." - $Script:LTServiceKeys.ServerPasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.ServerPassword)" - if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'Password' })) { - Write-Debug "Decoding Agent Password." - $Script:LTServiceKeys.PasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.Password)" -Key "$($Script:LTServiceKeys.ServerPasswordString)" - } - else { - $Script:LTServiceKeys.PasswordString = '' - } - } - else { - $Script:LTServiceKeys.ServerPasswordString = '' - $Script:LTServiceKeys.PasswordString = '' - } - } - Process { - Try { - # Reuse $serviceInfo from Begin block — eliminates a redundant Get-CWAAInfo call. - if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { - $serviceSettings = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False - if ($Null -ne $serviceSettings) { - if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' }) -and ($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -Match 'https?://.+')) { - Write-Debug "Proxy Detected. Setting ProxyServerURL to $($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0)" - $Script:LTProxy.Enabled = $True - $Script:LTProxy.ProxyServerURL = "$($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0)" - } - else { - Write-Debug 'Setting ProxyServerURL to empty.' - $Script:LTProxy.Enabled = $False - $Script:LTProxy.ProxyServerURL = '' - } - if ($Script:LTProxy.Enabled -eq $True -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)) { - $Script:LTProxy.ProxyUsername = "$(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" - Write-Debug "Setting ProxyUsername to $(Get-CWAARedactedValue $Script:LTProxy.ProxyUsername)" - } - else { - Write-Debug 'Setting ProxyUsername to empty.' - $Script:LTProxy.ProxyUsername = '' - } - if ($Script:LTProxy.Enabled -eq $True -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)) { - $Script:LTProxy.ProxyPassword = "$(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))" - Write-Debug "Setting ProxyPassword to $(Get-CWAARedactedValue $Script:LTProxy.ProxyPassword)" - } - else { - Write-Debug 'Setting ProxyPassword to empty.' - $Script:LTProxy.ProxyPassword = '' - } - } - } - else { - Write-Verbose 'No Server password or settings exist. No Proxy information will be available.' - } - } - Catch { - Write-Error "There was a problem retrieving Proxy Information. $_" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - return $Script:LTProxy - } -} -function Set-CWAAProxy { - <# - .SYNOPSIS - Configures module proxy settings for all operations during the current session. - .DESCRIPTION - Sets or clears Proxy settings needed for module function and agent operations. - If an agent is already installed, this function will update the ProxyUsername, - ProxyPassword, and ProxyServerURL values in the agent registry settings. - Agent services will be restarted for changes (if found) to be applied. - .PARAMETER ProxyServerURL - The URL and optional port to assign as the proxy server for module operations - and for the installed agent (if present). - Example: Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' - May be used with ProxyUsername/ProxyPassword or EncodedProxyUsername/EncodedProxyPassword. - .PARAMETER ProxyUsername - Plain text username for proxy authentication. - Must be used with ProxyServerURL and ProxyPassword. - .PARAMETER ProxyPassword - Plain text password for proxy authentication. - Must be used with ProxyServerURL and ProxyUsername. - .PARAMETER EncodedProxyUsername - Encoded username for proxy authentication, encrypted with the agent password. - Will be decoded using the agent password. Must be used with ProxyServerURL - and EncodedProxyPassword. - .PARAMETER EncodedProxyPassword - Encoded password for proxy authentication, encrypted with the agent password. - Will be decoded using the agent password. Must be used with ProxyServerURL - and EncodedProxyUsername. - .PARAMETER DetectProxy - Automatically detect system proxy settings for module operations. - Discovered settings are applied to the installed agent (if present). - Cannot be used with other parameters. - .PARAMETER ProxyCredential - A PSCredential object containing the proxy username and password. - This is the preferred secure alternative to passing -ProxyUsername - and -ProxyPassword separately. Must be used with -ProxyServerURL. - .PARAMETER ResetProxy - Clears any currently defined proxy settings for module operations. - Changes are applied to the installed agent (if present). - Cannot be used with other parameters. - .PARAMETER SkipCertificateCheck - Bypasses SSL/TLS certificate validation for server connections. - Use in lab or test environments with self-signed certificates. - .EXAMPLE - Set-CWAAProxy -DetectProxy - Automatically detects and configures the system proxy. - .EXAMPLE - Set-CWAAProxy -ResetProxy - Clears all proxy settings. - .EXAMPLE - Set-CWAAProxy -ProxyServerURL 'proxyhostname.fqdn.com:8080' - Sets the proxy server URL without authentication. - .NOTES - Author: Darren White - Alias: Set-LTProxy - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Set-LTProxy')] - Param( - [parameter(Mandatory = $False, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, Position = 0)] - [string]$ProxyServerURL, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True, Position = 1)] - [string]$ProxyUsername, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True, Position = 2)] - [SecureString]$ProxyPassword, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] - [string]$EncodedProxyUsername, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] - [SecureString]$EncodedProxyPassword, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] - [System.Management.Automation.PSCredential] - [System.Management.Automation.Credential()] - $ProxyCredential, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] - [alias('Detect')] - [alias('AutoDetect')] - [switch]$DetectProxy, - [parameter(Mandatory = $False, ValueFromPipeline = $False, ValueFromPipelineByPropertyName = $True)] - [alias('Clear')] - [alias('Reset')] - [alias('ClearProxy')] - [switch]$ResetProxy, - [switch]$SkipCertificateCheck - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - # Lazy initialization of SSL/TLS, WebClient, and proxy configuration. - # Only runs once per session, skips immediately on subsequent calls. - $Null = Initialize-CWAANetworking -SkipCertificateCheck:$SkipCertificateCheck - try { - $serviceSettings = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False - } - catch { Write-Debug "Failed to retrieve service settings. $_" } - } - Process { - # If a PSCredential was provided, extract username and password. - # This is the preferred secure alternative to passing plain text proxy credentials. - if ($ProxyCredential) { - $ProxyUsername = $ProxyCredential.UserName - $ProxyPassword = $ProxyCredential.GetNetworkCredential().Password - } - if ( - (($ResetProxy -eq $True) -and (($DetectProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) -or - (($DetectProxy -eq $True) -and (($ResetProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) -or - ((($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword)) -and (($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) -or - ((($ProxyUsername) -or ($ProxyPassword)) -and (-not ($ProxyServerURL) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword) -or ($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) -or - ((($EncodedProxyUsername) -or ($EncodedProxyPassword)) -and (-not ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($ResetProxy -eq $True) -or ($DetectProxy -eq $True))) - ) { Write-Error "Set-CWAAProxy: Invalid parameter combination specified." -ErrorAction Stop } - if (-not (($ResetProxy -eq $True) -or ($DetectProxy -eq $True) -or ($ProxyServerURL) -or ($ProxyUsername) -or ($ProxyPassword) -or ($EncodedProxyUsername) -or ($EncodedProxyPassword))) { - if ($Args.Count -gt 0) { Write-Error "Set-CWAAProxy: Unknown parameter specified." -ErrorAction Stop } - else { Write-Error "Set-CWAAProxy: Required parameters missing." -ErrorAction Stop } - } - Try { - if ($($ResetProxy) -eq $True) { - Write-Verbose 'ResetProxy selected. Clearing Proxy Settings.' - if ( $PSCmdlet.ShouldProcess('LTProxy', 'Clear') ) { - $Script:LTProxy.Enabled = $False - $Script:LTProxy.ProxyServerURL = '' - $Script:LTProxy.ProxyUsername = '' - $Script:LTProxy.ProxyPassword = '' - $Script:LTWebProxy = New-Object System.Net.WebProxy - $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy - } - } - Elseif ($($DetectProxy) -eq $True) { - Write-Verbose 'DetectProxy selected. Attempting to Detect Proxy Settings.' - if ( $PSCmdlet.ShouldProcess('LTProxy', 'Detect') ) { - $Script:LTWebProxy = [System.Net.WebRequest]::GetSystemWebProxy() - $Script:LTProxy.Enabled = $False - $Script:LTProxy.ProxyServerURL = '' - $Servers = @($("$($serviceSettings | Select-Object -Expand 'ServerAddress' -EA 0)|www.connectwise.com").Split('|') | ForEach-Object { $_.Trim() }) - Foreach ($serverUrl In $Servers) { - if (-not ($Script:LTProxy.Enabled)) { - if ($serverUrl -match $Script:CWAAServerValidationRegex) { - $serverUrl = $serverUrl -replace 'https?://', '' - Try { - $Script:LTProxy.ProxyServerURL = $Script:LTWebProxy.GetProxy("http://$($serverUrl)").Authority - } - catch { Write-Debug "Failed to get proxy for server $serverUrl. $_" } - if (($Null -ne $Script:LTProxy.ProxyServerURL) -and ($Script:LTProxy.ProxyServerURL -ne '') -and ($Script:LTProxy.ProxyServerURL -notcontains "$($serverUrl)")) { - Write-Debug "Detected Proxy URL: $($Script:LTProxy.ProxyServerURL) on server $($serverUrl)" - $Script:LTProxy.Enabled = $True - } - } - } - } - if (-not ($Script:LTProxy.Enabled)) { - if (($Script:LTProxy.ProxyServerURL -eq '') -or ($Script:LTProxy.ProxyServerURL -contains '$serverUrl')) { - $Script:LTProxy.ProxyServerURL = netsh winhttp show proxy | Select-String -Pattern '(?i)(?<=Proxyserver.*http\=)([^;\r\n]*)' -EA 0 | ForEach-Object { $_.matches } | Select-Object -Expand value - } - if (($Null -eq $Script:LTProxy.ProxyServerURL) -or ($Script:LTProxy.ProxyServerURL -eq '')) { - $Script:LTProxy.ProxyServerURL = '' - $Script:LTProxy.Enabled = $False - } - else { - $Script:LTProxy.Enabled = $True - Write-Debug "Detected Proxy URL: $($Script:LTProxy.ProxyServerURL)" - } - } - $Script:LTProxy.ProxyUsername = '' - $Script:LTProxy.ProxyPassword = '' - $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy - } - } - Elseif (($ProxyServerURL)) { - if ( $PSCmdlet.ShouldProcess('LTProxy', 'Set') ) { - foreach ($ProxyURL in $ProxyServerURL) { - $Script:LTWebProxy = New-Object System.Net.WebProxy($ProxyURL, $true); - $Script:LTProxy.Enabled = $True - $Script:LTProxy.ProxyServerURL = $ProxyURL - } - Write-Verbose "Setting Proxy URL to: $($ProxyServerURL)" - if ((($ProxyUsername) -and ($ProxyPassword)) -or (($EncodedProxyUsername) -and ($EncodedProxyPassword))) { - if (($ProxyUsername)) { - foreach ($proxyUser in $ProxyUsername) { - $Script:LTProxy.ProxyUsername = $proxyUser - } - } - if (($EncodedProxyUsername)) { - foreach ($proxyUser in $EncodedProxyUsername) { - $Script:LTProxy.ProxyUsername = $(ConvertFrom-CWAASecurity -InputString "$($proxyUser)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) - } - } - if (($ProxyPassword)) { - foreach ($proxyPass in $ProxyPassword) { - $Script:LTProxy.ProxyPassword = $proxyPass - $passwd = ConvertTo-SecureString $proxyPass -AsPlainText -Force; - } - } - if (($EncodedProxyPassword)) { - foreach ($proxyPass in $EncodedProxyPassword) { - $Script:LTProxy.ProxyPassword = $(ConvertFrom-CWAASecurity -InputString "$($proxyPass)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) - $passwd = ConvertTo-SecureString $Script:LTProxy.ProxyPassword -AsPlainText -Force; - } - } - $Script:LTWebProxy.Credentials = New-Object System.Management.Automation.PSCredential ($Script:LTProxy.ProxyUsername, $passwd); - } - $Script:LTServiceNetWebClient.Proxy = $Script:LTWebProxy - } - } - # Apply settings to agent registry if changes detected - $settingsChanged = $False - if ($Null -ne ($serviceSettings)) { - if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyServerURL' })) { - if (($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne $Script:LTProxy.ProxyServerURL) -and (($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -eq '' -and $Script:LTProxy.Enabled -eq $True -and $Script:LTProxy.ProxyServerURL -match '.+\..+') -or ($($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) -replace 'https?://', '' -ne '' -and ($Script:LTProxy.ProxyServerURL -ne '' -or $Script:LTProxy.Enabled -eq $False)))) { - Write-Debug "ProxyServerURL Changed: Old Value: $($serviceSettings | Select-Object -Expand ProxyServerURL -EA 0) New Value: $($Script:LTProxy.ProxyServerURL)" - $settingsChanged = $True - } - if (($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyUsername' }) -and ($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)) { - if ($(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyUsername) { - Write-Debug "ProxyUsername Changed: Old Value: $(Get-CWAARedactedValue (ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyUsername -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))) New Value: $(Get-CWAARedactedValue $Script:LTProxy.ProxyUsername)" - $settingsChanged = $True - } - } - if ($Null -ne ($serviceSettings) -and ($serviceSettings | Get-Member | Where-Object { $_.Name -eq 'ProxyPassword' }) -and ($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)) { - if ($(ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)", '')) -ne $Script:LTProxy.ProxyPassword) { - Write-Debug "ProxyPassword Changed: Old Value: $(Get-CWAARedactedValue (ConvertFrom-CWAASecurity -InputString "$($serviceSettings | Select-Object -Expand ProxyPassword -EA 0)" -Key ("$($Script:LTServiceKeys.PasswordString)",''))) New Value: $(Get-CWAARedactedValue $Script:LTProxy.ProxyPassword)" - $settingsChanged = $True - } - } - } - Elseif ($Script:LTProxy.Enabled -eq $True -and $Script:LTProxy.ProxyServerURL -match '(https?://)?.+\..+') { - Write-Debug "ProxyServerURL Changed: Old Value: NOT SET New Value: $($Script:LTProxy.ProxyServerURL)" - $settingsChanged = $True - } - } - else { - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Running' } | Measure-Object | Select-Object -Expand Count - if (($runningServiceCount -gt 0) -and ($($Script:LTProxy.ProxyServerURL) -match '.+')) { - $settingsChanged = $True - } - } - if ($settingsChanged -eq $True) { - $serviceRestartNeeded = $False - if ((Get-Service $Script:CWAAServiceNames -ErrorAction SilentlyContinue | Where-Object { $_.Status -match 'Running' })) { - $serviceRestartNeeded = $True - try { Stop-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to stop services before proxy update. $_" } - } - Write-Verbose 'Updating Automate agent proxy configuration.' - if ( $PSCmdlet.ShouldProcess('LTService Registry', 'Update') ) { - $serverUrl = $($Script:LTProxy.ProxyServerURL); if (($serverUrl -ne '') -and ($serverUrl -notmatch 'https?://')) { $serverUrl = "http://$($serverUrl)" } - @{'ProxyServerURL' = $serverUrl; - 'ProxyUserName' = "$(ConvertTo-CWAASecurity -InputString "$($Script:LTProxy.ProxyUserName)" -Key "$($Script:LTServiceKeys.PasswordString)")"; - 'ProxyPassword' = "$(ConvertTo-CWAASecurity -InputString "$($Script:LTProxy.ProxyPassword)" -Key "$($Script:LTServiceKeys.PasswordString)")" - }.GetEnumerator() | ForEach-Object { - Write-Debug "Setting Registry value for $($_.Name) to `"$($_.Value)`"" - Set-ItemProperty -Path $Script:CWAARegistrySettings -Name $($_.Name) -Value $($_.Value) -EA 0 -Confirm:$False - } - } - if ($serviceRestartNeeded -eq $True) { - try { Start-CWAA -EA 0 -WA 0 } catch { Write-Debug "Failed to restart services after proxy update. $_" } - } - Write-CWAAEventLog -EventId 3020 -EntryType Information -Message "Proxy settings updated. Enabled: $($Script:LTProxy.Enabled), Server: $($Script:LTProxy.ProxyServerURL)" - } - } - Catch { - Write-CWAAEventLog -EventId 3022 -EntryType Error -Message "Proxy configuration failed. Error: $($_.Exception.Message)" - Write-Error "There was an error during the Proxy Configuration process. $_" -ErrorAction Stop - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Register-CWAAHealthCheckTask { - <# - .SYNOPSIS - Creates or updates a scheduled task for periodic ConnectWise Automate agent health checks. - .DESCRIPTION - Creates a Windows scheduled task that runs Repair-CWAA at a configurable interval - (default every 6 hours) to monitor agent health and automatically remediate issues. - The task runs as SYSTEM with highest privileges, includes a random delay equal to the - interval to stagger execution across multiple machines, and has a 1-hour execution timeout. - If the task already exists and the InstallerToken has changed, the task is recreated - with the new token. Use -Force to recreate unconditionally. - A backup of the current agent configuration is created before task registration - via New-CWAABackup. - .PARAMETER InstallerToken - The installer token for authenticated agent deployment. Embedded in the scheduled - task action for use by Repair-CWAA. - .PARAMETER Server - Optional server URL. When provided, the scheduled task passes this to Repair-CWAA - in Install mode (with Server, LocationID, and InstallerToken). - .PARAMETER LocationID - Optional location ID. Required when Server is provided. - .PARAMETER TaskName - Name of the scheduled task. Default: 'CWAAHealthCheck'. - .PARAMETER IntervalHours - Hours between health check runs. Default: 6. - .PARAMETER Force - Force recreation of the task even if it already exists with the same token. - .EXAMPLE - Register-CWAAHealthCheckTask -InstallerToken 'abc123def456' - Creates a task that runs Repair-CWAA in Checkup mode every 6 hours. - .EXAMPLE - Register-CWAAHealthCheckTask -InstallerToken 'token' -Server 'https://automate.domain.com' -LocationID 42 - Creates a task that runs Repair-CWAA in Install mode (can install fresh if agent is missing). - .EXAMPLE - Register-CWAAHealthCheckTask -InstallerToken 'token' -IntervalHours 12 -TaskName 'MyHealthCheck' - Creates a custom-named task running every 12 hours. - .EXAMPLE - Get-CWAAInfo | Register-CWAAHealthCheckTask -InstallerToken 'token' - Uses Server and LocationID from the installed agent via pipeline to register a health check task. - .NOTES - Author: Chris Taylor - Alias: Register-LTHealthCheckTask - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Register-LTHealthCheckTask')] - Param( - [Parameter(Mandatory = $True)] - [ValidatePattern('(?s:^[0-9a-z]+$)')] - [string]$InstallerToken, - [Parameter(ValueFromPipelineByPropertyName = $True)] - [ValidatePattern('^[a-zA-Z0-9\.\-\:\/]+$')] - [string[]]$Server, - [Parameter(ValueFromPipelineByPropertyName = $True)] - [int]$LocationID, - [ValidatePattern('^[\w\-\. ]+$')] - [string]$TaskName = 'CWAAHealthCheck', - [ValidateRange(1, 168)] - [int]$IntervalHours = 6, - [switch]$Force - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - $created = $False - $updated = $False - # Check if the task already exists - $existingTaskXml = $Null - Try { - [xml]$existingTaskXml = schtasks /QUERY /XML /TN $TaskName 2>$Null - } - Catch { Write-Debug "Task '$TaskName' not found or query failed: $($_.Exception.Message)" } - # If the task exists and the token hasn't changed, skip recreation unless -Force - if ($existingTaskXml -and -not $Force) { - if ($existingTaskXml.Task.Actions.Exec.Arguments -match [regex]::Escape($InstallerToken)) { - Write-Verbose "Scheduled task '$TaskName' already exists with the same InstallerToken. Use -Force to recreate." - [PSCustomObject]@{ - TaskName = $TaskName - Created = $False - Updated = $False - } - return - } - $updated = $True - } - if ($PSCmdlet.ShouldProcess("Scheduled Task '$TaskName'", 'Create health check task')) { - # Back up agent settings before creating/updating the task - Write-Verbose 'Backing up agent configuration.' - New-CWAABackup -ErrorAction SilentlyContinue - # Build the PowerShell command for the scheduled task action - # Use Install mode if Server and LocationID are provided, otherwise Checkup mode - if ($Server -and $LocationID) { - # Build a proper PowerShell array literal for the Server argument. - # Handles both single-server and multi-server arrays from Get-CWAAInfo pipeline. - $serverArgument = ($Server | ForEach-Object { "'$_'" }) -join ',' - $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -Server $serverArgument -LocationID $LocationID -InstallerToken '$InstallerToken'" - } - else { - $repairCommand = "Import-Module ConnectWiseAutomateAgent; Repair-CWAA -InstallerToken '$InstallerToken'" - } - # XML-escape special characters in the command for the task definition - $escapedCommand = $repairCommand -replace '&', '&' -replace '<', '<' -replace '>', '>' -replace '"', '"' -replace "'", ''' - # Delete existing task if present - Try { - $Null = schtasks /DELETE /TN $TaskName /F 2>&1 - } - Catch { Write-Debug "Failed to delete existing task '$TaskName': $($_.Exception.Message)" } - # Build the task XML definition - # Runs as SYSTEM (S-1-5-18) with highest privileges - # Repeats every $IntervalHours hours with randomized delay for staggering - $intervalIso = "PT${IntervalHours}H" - $startBoundary = Get-Date -Format 'yyyy-MM-ddTHH:mm:ssK' - [xml]$taskXml = @" - - - - ConnectWise Automate agent health check and automatic remediation. - \$TaskName - - - - S-1-5-18 - HighestAvailable - - - - false - false - IgnoreNew - - PT10M - PT1H - false - false - - PT1H - - - - $startBoundary - - $intervalIso - P7300D - true - - $intervalIso - - - - - C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe - -NoProfile -WindowStyle Hidden -Command "$escapedCommand" - - - -"@ - $taskFilePath = "$env:TEMP\CWAAHealthCheckTask.xml" - Try { - $taskXml.Save($taskFilePath) - $schtasksOutput = schtasks /CREATE /TN $TaskName /XML $taskFilePath /RU SYSTEM 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "schtasks returned exit code $LASTEXITCODE. Output: $schtasksOutput" - } - $created = -not $updated - $resultMessage = if ($updated) { "Scheduled task '$TaskName' updated." } else { "Scheduled task '$TaskName' created." } - Write-Output $resultMessage - Write-CWAAEventLog -EventId 4020 -EntryType Information -Message "$resultMessage Interval: every $IntervalHours hours." - } - Catch { - Write-Error "Failed to create scheduled task '$TaskName'. Error: $($_.Exception.Message)" - Write-CWAAEventLog -EventId 4022 -EntryType Error -Message "Failed to create scheduled task '$TaskName'. Error: $($_.Exception.Message)" - } - Finally { - # Clean up the temporary XML file - Remove-Item -Path $taskFilePath -Force -ErrorAction SilentlyContinue - } - } - [PSCustomObject]@{ - TaskName = $TaskName - Created = $created - Updated = $updated - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Repair-CWAA { - <# - .SYNOPSIS - Performs escalating remediation of the ConnectWise Automate agent. - .DESCRIPTION - 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 - 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 - 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 - or from backup settings. - All remediation actions are logged to the Windows Event Log (Application log, - source ConnectWiseAutomateAgent) for visibility in unattended scheduled task runs. - Designed to be called periodically via Register-CWAAHealthCheckTask or any - external scheduler. - .PARAMETER Server - The ConnectWise Automate server URL for fresh installs or server mismatch correction. - Required when using the Install parameter set. - .PARAMETER LocationID - The LocationID for fresh agent installs. Required with the Install parameter set. - .PARAMETER InstallerToken - An installer token for authenticated agent deployment. Required for both parameter sets. - .PARAMETER HoursRestart - Hours since last check-in before a service restart is attempted. Expressed as a - negative number (e.g., -2 means 2 hours ago). Default: -2. - .PARAMETER HoursReinstall - Hours since last check-in before a full reinstall is attempted. Expressed as a - negative number (e.g., -120 means 120 hours / 5 days ago). Default: -120. - .EXAMPLE - Repair-CWAA -InstallerToken 'abc123def456' - Checks the installed agent and repairs if needed (Checkup mode). - .EXAMPLE - Repair-CWAA -Server 'https://automate.domain.com' -LocationID 42 -InstallerToken 'token' - Checks agent health. If the agent is missing or pointed at the wrong server, - installs or reinstalls with the specified settings. - .EXAMPLE - Repair-CWAA -InstallerToken 'token' -HoursRestart -4 -HoursReinstall -240 - Uses custom thresholds: restart after 4 hours offline, reinstall after 10 days. - .NOTES - Author: Chris Taylor - Alias: Repair-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Repair-LTService')] - Param( - [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] - [ValidateScript({ - if ($_ -match '^(https?://)?(([12]?[0-9]{1,2}\.){3}[12]?[0-9]{1,2}|[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)*)$') { $true } - else { throw "Server address '$_' is not valid. Expected format: https://automate.domain.com" } - })] - [string]$Server, - [Parameter(ParameterSetName = 'Install', Mandatory = $True, ValueFromPipelineByPropertyName = $true)] - [ValidateRange(1, [int]::MaxValue)] - [int]$LocationID, - [Parameter(ParameterSetName = 'Install', Mandatory = $True)] - [Parameter(ParameterSetName = 'Checkup', Mandatory = $True)] - [ValidatePattern('(?s:^[0-9a-z]+$)')] - [string]$InstallerToken, - [int]$HoursRestart = -2, - [int]$HoursReinstall = -120 - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - # Kill duplicate Repair-CWAA processes to prevent overlapping remediation - # Uses CIM for reliable command-line matching (Get-Process cannot filter by arguments) - if ($PSCmdlet.ShouldProcess('Duplicate Repair-CWAA processes', 'Terminate')) { - Try { - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { - $_.Name -eq 'powershell.exe' -and - $_.CommandLine -match 'Repair-CWAA' -and - $_.ProcessId -ne $PID - } | ForEach-Object { - Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue - } - } - Catch { - Write-Debug "Unable to check for duplicate processes: $($_.Exception.Message)" - } - } - } - Process { - $actionTaken = 'None' - $success = $True - $resultMessage = '' - # Determine if the agent service is installed - $agentServiceExists = [bool](Get-Service 'LTService' -ErrorAction SilentlyContinue) - if ($agentServiceExists) { - #region Agent is installed — check health and remediate - # Verify we can read agent configuration - $agentInfo = $Null - Try { - $agentInfo = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False - } - Catch { - # 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)" - $backupSettings = Get-CWAAInfoBackup -EA 0 - Try { - Get-Process 'Agent_Uninstall' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - if ($PSCmdlet.ShouldProcess('LTService', 'Uninstall agent with unreadable config')) { - Uninstall-CWAA -Force -Server ($backupSettings.Server[0]) - } - } - Catch { - Write-Error "Failed to uninstall agent with unreadable config. Error: $($_.Exception.Message)" - Write-CWAAEventLog -EventId 4009 -EntryType Error -Message "Failed to uninstall agent with unreadable config. Error: $($_.Exception.Message)" - } - $resultMessage = 'Uninstalled agent with unreadable config. Restart machine and run again.' - $actionTaken = 'Uninstall' - $success = $False - [PSCustomObject]@{ - ActionTaken = $actionTaken - Success = $success - Message = $resultMessage - } - return - } - # If Server parameter was provided, check that it matches the installed agent - if ($Server) { - $currentServers = ($agentInfo | Select-Object -Expand 'Server' -EA 0) - $cleanExpectedServer = $Server -replace 'https?://', '' -replace '/$', '' - $serverMatches = $False - foreach ($currentServer in $currentServers) { - $cleanCurrent = $currentServer -replace 'https?://', '' -replace '/$', '' - if ($cleanCurrent -eq $cleanExpectedServer) { - $serverMatches = $True - break - } - } - if (-not $serverMatches) { - Write-Warning "Wrong install server ($($currentServers -join ', ')). Expected '$Server'. Reinstalling." - Write-CWAAEventLog -EventId 4004 -EntryType Warning -Message "Server mismatch detected. Installed: $($currentServers -join ', '). Expected: $Server. Reinstalling." - if ($PSCmdlet.ShouldProcess('LTService', "Reinstall agent for server mismatch (current: $($currentServers -join ', '), expected: $Server)")) { - Clear-CWAAInstallerArtifacts - Try { - Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken - $actionTaken = 'Reinstall' - $resultMessage = "Reinstalled agent to correct server: $Server" - Write-CWAAEventLog -EventId 4004 -EntryType Information -Message $resultMessage - } - Catch { - $actionTaken = 'Reinstall' - $success = $False - $resultMessage = "Failed to reinstall agent for server mismatch. Error: $($_.Exception.Message)" - Write-Error $resultMessage - Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage - } - } - [PSCustomObject]@{ - ActionTaken = $actionTaken - Success = $success - Message = $resultMessage - } - return - } - } - # Get last contact timestamp (try LastSuccessStatus, fall back to HeartbeatLastReceived) - $lastContact = $Null - Try { - [datetime]$lastContact = $agentInfo.LastSuccessStatus - } - Catch { - Try { - [datetime]$lastContact = $agentInfo.HeartbeatLastReceived - } - Catch { - # No valid contact timestamp — treat as very old - [datetime]$lastContact = (Get-Date).AddYears(-1) - } - } - # Get last heartbeat timestamp - $lastHeartbeat = $Null - Try { - [datetime]$lastHeartbeat = $agentInfo.HeartbeatLastSent - } - Catch { - [datetime]$lastHeartbeat = (Get-Date).AddYears(-1) - } - Write-Verbose "Last check-in: $lastContact" - Write-Verbose "Last heartbeat: $lastHeartbeat" - # Determine the server address for connectivity checks - $activeServer = $Null - if ($Server) { - $activeServer = $Server - } - else { - Try { $activeServer = ($agentInfo | Select-Object -Expand 'Server' -EA 0)[0] } - Catch { - Try { $activeServer = (Get-CWAAInfoBackup -EA 0).Server[0] } - Catch { Write-Debug "Unable to retrieve server from backup settings: $($_.Exception.Message)" } - } - } - # Check if the agent is offline beyond the restart threshold - $restartThreshold = (Get-Date).AddHours($HoursRestart) - $reinstallThreshold = (Get-Date).AddHours($HoursReinstall) - if ($lastContact -lt $restartThreshold -or $lastHeartbeat -lt $restartThreshold) { - Write-Verbose "Agent has NOT checked in within the last $([Math]::Abs($HoursRestart)) hour(s)." - Write-CWAAEventLog -EventId 4001 -EntryType Warning -Message "Agent offline. Last contact: $lastContact. Last heartbeat: $lastHeartbeat. Threshold: $([Math]::Abs($HoursRestart)) hours." - # Verify the server is reachable before attempting remediation - if ($activeServer) { - $serverAvailable = Test-CWAAServerConnectivity -Server $activeServer -Quiet - if (-not $serverAvailable) { - $resultMessage = "Server '$activeServer' is not reachable. Cannot remediate." - Write-Error $resultMessage - Write-CWAAEventLog -EventId 4008 -EntryType Error -Message $resultMessage - [PSCustomObject]@{ - ActionTaken = 'None' - Success = $False - Message = $resultMessage - } - return - } - } - # Step 1: Restart services - if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart services to recover agent check-in')) { - Write-Verbose 'Restarting Automate agent services.' - Restart-CWAA - # Wait up to 2 minutes for the agent to check in after restart - Write-Verbose 'Waiting for agent check-in after restart.' - $waitStart = Get-Date - while ($lastContact -lt $restartThreshold -and $waitStart.AddMinutes(2) -gt (Get-Date)) { - Start-Sleep -Seconds 2 - Try { - [datetime]$lastContact = (Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False).LastSuccessStatus - } - Catch { - Write-Debug "Unable to re-read LastSuccessStatus during wait loop: $($_.Exception.Message)" - } - } - } - # Did the restart fix it? - if ($lastContact -ge $restartThreshold) { - $actionTaken = 'Restart' - $resultMessage = "Services restarted. Agent recovered. Last contact: $lastContact" - Write-Verbose $resultMessage - Write-CWAAEventLog -EventId 4001 -EntryType Information -Message $resultMessage - } - # Step 2: Reinstall if still offline beyond reinstall threshold - elseif ($lastContact -lt $reinstallThreshold) { - Write-Verbose "Agent still not connecting after restart. Offline beyond $([Math]::Abs($HoursReinstall))-hour threshold. Reinstalling." - Write-CWAAEventLog -EventId 4002 -EntryType Warning -Message "Agent still offline after restart. Last contact: $lastContact. Attempting reinstall." - if ($PSCmdlet.ShouldProcess('LTService', 'Reinstall agent after failed restart recovery')) { - Clear-CWAAInstallerArtifacts - Try { - if ($InstallerToken -and $Server -and $LocationID) { - Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken -Hide - } - else { - Redo-CWAA -Hide -InstallerToken $InstallerToken - } - $actionTaken = 'Reinstall' - $resultMessage = 'Agent reinstalled after extended offline period.' - Write-CWAAEventLog -EventId 4002 -EntryType Information -Message $resultMessage - } - Catch { - $actionTaken = 'Reinstall' - $success = $False - $resultMessage = "Agent reinstall failed. Error: $($_.Exception.Message)" - Write-Error $resultMessage - Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage - } - } - } - else { - # Restart was attempted but agent hasn't recovered yet. Not yet at reinstall threshold. - $actionTaken = 'Restart' - $success = $True - $resultMessage = "Services restarted. Agent has not recovered yet but is within reinstall threshold ($([Math]::Abs($HoursReinstall)) hours)." - Write-Verbose $resultMessage - } - } - else { - # Agent is healthy - $resultMessage = "Agent is healthy. Last contact: $lastContact. Last heartbeat: $lastHeartbeat." - Write-Verbose $resultMessage - Write-CWAAEventLog -EventId 4000 -EntryType Information -Message $resultMessage - } - #endregion - } - else { - #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.' - Try { - if ($Server -and $LocationID -and $InstallerToken) { - # Full install parameters provided - if ($PSCmdlet.ShouldProcess('LTService', "Install agent (Server: $Server, LocationID: $LocationID)")) { - Write-Verbose "Installing agent with provided parameters (Server: $Server, LocationID: $LocationID)." - Clear-CWAAInstallerArtifacts - Redo-CWAA -Server $Server -LocationID $LocationID -InstallerToken $InstallerToken - $actionTaken = 'Install' - $resultMessage = "Fresh agent install completed (Server: $Server, LocationID: $LocationID)." - Write-CWAAEventLog -EventId 4003 -EntryType Information -Message $resultMessage - } - } - else { - # Try to recover from existing settings or backup - $settings = $Null - $hasBackup = $False - Try { - $settings = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False - $hasBackup = $True - } - Catch { - $settings = Get-CWAAInfoBackup -EA 0 - $hasBackup = $False - } - if ($settings) { - if ($hasBackup) { - Write-Verbose 'Backing up current settings before reinstall.' - New-CWAABackup -ErrorAction SilentlyContinue - } - $reinstallServer = ($settings | Select-Object -Expand 'Server' -EA 0)[0] - $reinstallLocationID = $settings | Select-Object -Expand 'LocationID' -EA 0 - if ($PSCmdlet.ShouldProcess('LTService', "Reinstall from backup settings (Server: $reinstallServer)")) { - Write-Verbose "Reinstalling agent from backup settings (Server: $reinstallServer)." - Clear-CWAAInstallerArtifacts - Redo-CWAA -Server $reinstallServer -LocationID $reinstallLocationID -Hide -InstallerToken $InstallerToken - $actionTaken = 'Install' - $resultMessage = "Agent reinstalled from backup settings (Server: $reinstallServer)." - Write-CWAAEventLog -EventId 4003 -EntryType Information -Message $resultMessage - } - } - else { - $success = $False - $resultMessage = 'Unable to find install settings. Provide -Server, -LocationID, and -InstallerToken parameters.' - Write-Error $resultMessage - Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage - } - } - } - Catch { - $actionTaken = 'Install' - $success = $False - $resultMessage = "Agent installation failed. Error: $($_.Exception.Message)" - Write-Error $resultMessage - Write-CWAAEventLog -EventId 4009 -EntryType Error -Message $resultMessage - } - #endregion - } - [PSCustomObject]@{ - ActionTaken = $actionTaken - Success = $success - Message = $resultMessage - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Restart-CWAA { - <# - .SYNOPSIS - Restarts the ConnectWise Automate agent services. - .DESCRIPTION - Verifies that the Automate agent services (LTService, LTSvcMon) are present, then - calls Stop-CWAA followed by Start-CWAA to perform a full service restart. - .EXAMPLE - Restart-CWAA - Restarts the ConnectWise Automate agent services. - .EXAMPLE - Restart-CWAA -WhatIf - Shows what would happen without actually restarting the services. - .NOTES - Author: Chris Taylor - Alias: Restart-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Restart-LTService')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } - if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Restart Service')) { - Try { - Stop-CWAA - } - Catch { - Write-Error "There was an error stopping the services. $_" - Write-CWAAEventLog -EventId 2022 -EntryType Error -Message "Agent restart failed during stop phase. Error: $($_.Exception.Message)" - return - } - Try { - Start-CWAA - } - Catch { - Write-Error "There was an error starting the services. $_" - Write-CWAAEventLog -EventId 2022 -EntryType Error -Message "Agent restart failed during start phase. Error: $($_.Exception.Message)" - return - } - Write-Output 'Services restarted successfully.' - Write-CWAAEventLog -EventId 2020 -EntryType Information -Message 'Agent services restarted successfully.' - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Start-CWAA { - <# - .SYNOPSIS - Starts the ConnectWise Automate agent services. - .DESCRIPTION - Verifies that the Automate agent services (LTService, LTSvcMon) are present. Checks - for any process using the LTTray port (default 42000) and kills it. If a - protected application holds the port, increments the TrayPort (wrapping from - 42009 back to 42000). Sets services to Automatic startup and starts them via - sc.exe. Waits up to one minute for LTService to reach the Running state, then - issues a Send Status command for immediate check-in. - .EXAMPLE - Start-CWAA - Starts the ConnectWise Automate agent services. - .EXAMPLE - Start-CWAA -WhatIf - Shows what would happen without actually starting the services. - .NOTES - Author: Chris Taylor - Alias: Start-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Start-LTService')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - # Identify processes that are using the tray port - [array]$processes = @() - $Port = (Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand TrayPort -EA 0) - if (-not ($Port)) { $Port = '42000' } - $startedSvcCount = 0 - } - Process { - if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } - Try { - if ((('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object | Select-Object -Expand Count) -gt 0) { - Try { $netstat = & "$env:windir\system32\netstat.exe" -a -o -n 2>'' | Select-String -Pattern " .*[0-9\.]+:$($Port).*[0-9\.]+:[0-9]+ .*?([0-9]+)" -EA 0 } - Catch { Write-Debug 'Failed to call netstat.exe.'; $netstat = $null } - Foreach ($line in $netstat) { - $processes += ($line -split ' {4,}')[-1] - } - $processes = $processes | Where-Object { $_ -gt 0 -and $_ -match '^\d+$' } | Sort-Object | Get-Unique - if ($processes) { - Foreach ($processId in $processes) { - Write-Output "Process ID:$processId is using port $Port. Killing process." - Try { Stop-Process -Id $processId -Force -Verbose -EA Stop } - Catch { - Write-Warning "There was an issue killing process: $processId" - Write-Warning "This generally means that a 'protected application' is using this port." - # TrayPort wraps within the 42000-42009 range. If a protected process holds - # the current port, increment and wrap back to 42000 after 42009. - $newPort = [int]$Port + 1 - if ($newPort -gt $Script:CWAATrayPortMax) { $newPort = $Script:CWAATrayPortMin } - Write-Warning "Setting tray port to $newPort." - New-ItemProperty -Path $Script:CWAARegistryRoot -Name TrayPort -PropertyType String -Value $newPort -Force -WhatIf:$False -Confirm:$False | Out-Null - } - } - } - } - if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Start Service')) { - $Script:CWAAServiceNames | ForEach-Object { - if (Get-Service $_ -EA 0) { - Set-Service $_ -StartupType Automatic -EA 0 -Confirm:$False -WhatIf:$False - $Null = & "$env:windir\system32\sc.exe" start "$($_)" 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "sc.exe start returned exit code $LASTEXITCODE for service '$_'." - } - $startedSvcCount++ - Write-Debug "Executed Start Service for $($_)" - } - } - # Wait for services if we issued start commands - $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - if ($stoppedServiceCount -gt 0 -and $startedSvcCount -eq 2) { - $Null = Wait-CWAACondition -Condition { - $count = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - $count -eq 0 - } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services starting' - $stoppedServiceCount = ('LTService') | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Running' } | Measure-Object | Select-Object -Expand Count - } - # Report final state - if ($stoppedServiceCount -eq 0) { - Write-Output 'Services started successfully.' - Write-CWAAEventLog -EventId 2000 -EntryType Information -Message 'Agent services started successfully.' - $Null = Invoke-CWAACommand 'Send Status' -EA 0 -Confirm:$False - } - elseif ($startedSvcCount -gt 0) { - Write-Output 'Service Start was issued but LTService has not reached Running state.' - Write-CWAAEventLog -EventId 2001 -EntryType Warning -Message 'Agent services failed to reach Running state after start.' - } - else { - Write-Output 'Service Start was not issued.' - } - } - } - Catch { - Write-Error "There was an error starting the Automate agent services. $_" - Write-CWAAEventLog -EventId 2002 -EntryType Error -Message "Agent service start failed. Error: $($_.Exception.Message)" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Stop-CWAA { - <# - .SYNOPSIS - Stops the ConnectWise Automate agent services. - .DESCRIPTION - Verifies that the Automate agent services (LTService, LTSvcMon) are present, then - attempts to stop them gracefully via sc.exe. Waits up to one minute for the - services to reach a Stopped state. If they do not stop in time, remaining - Automate agent processes (LTTray, LTSVC, LTSvcMon) are forcefully terminated. - .EXAMPLE - Stop-CWAA - Stops the ConnectWise Automate agent services. - .EXAMPLE - Stop-CWAA -WhatIf - Shows what would happen without actually stopping the services. - .NOTES - Author: Chris Taylor - Alias: Stop-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Stop-LTService')] - Param() - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } - if ($PSCmdlet.ShouldProcess('LTService, LTSvcMon', 'Stop-Service')) { - $Null = Invoke-CWAACommand ('Kill VNC', 'Kill Trays') -EA 0 -WhatIf:$False -Confirm:$False - Write-Verbose 'Stopping Automate agent services.' - Try { - $Script:CWAAServiceNames | ForEach-Object { - Try { - $Null = & "$env:windir\system32\sc.exe" stop "$($_)" 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "sc.exe stop returned exit code $LASTEXITCODE for service '$_'." - } - } - Catch { Write-Debug "Failed to call sc.exe stop for service $_." } - } - $servicesStopped = Wait-CWAACondition -Condition { - $count = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count - $count -eq 0 - } -TimeoutSeconds $Script:CWAAServiceWaitTimeoutSec -IntervalSeconds 2 -Activity 'Services stopping' - if (-not $servicesStopped) { - Write-Verbose 'Services did not stop in time. Terminating processes.' - } - Get-Process | Where-Object { $Script:CWAAAgentProcessNames -contains $_.ProcessName } | Stop-Process -Force -ErrorAction Stop -WhatIf:$False -Confirm:$False - # Verify final state and report - $remainingCount = $Script:CWAAServiceNames | Get-Service -EA 0 | Where-Object { $_.Status -ne 'Stopped' } | Measure-Object | Select-Object -Expand Count - if ($remainingCount -eq 0) { - Write-Output 'Services stopped successfully.' - Write-CWAAEventLog -EventId 2010 -EntryType Information -Message 'Agent services stopped successfully.' - } - else { - Write-Warning 'Services have not stopped completely.' - Write-CWAAEventLog -EventId 2011 -EntryType Warning -Message 'Agent services did not stop completely.' - } - } - Catch { - Write-Error "There was an error stopping the Automate agent processes. $_" - Write-CWAAEventLog -EventId 2012 -EntryType Error -Message "Agent service stop failed. Error: $($_.Exception.Message)" - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Test-CWAAHealth { - <# - .SYNOPSIS - Performs a read-only health assessment of the ConnectWise Automate agent. - .DESCRIPTION - Checks the overall health of the installed Automate agent without taking any - remediation action. Returns a status object with details about the agent's - installation state, service status, last check-in times, and server connectivity. - This function never modifies the agent, services, or registry. It is safe to call - at any time for monitoring or diagnostic purposes. - Health assessment criteria: - - Agent is installed (LTService exists) - - Services are running (LTService and LTSvcMon) - - Agent has checked in recently (LastSuccessStatus or HeartbeatLastSent within threshold) - - Server is reachable (optional, tested when Server param is provided or auto-discovered) - The Healthy property is True only when the agent is installed, services are running, - and LastContact is not null. - .PARAMETER Server - An Automate server URL to validate against the installed agent's configured server. - If provided, the ServerMatch property indicates whether the installed agent points - to this server. If omitted, ServerMatch is null. - .PARAMETER TestServerConnectivity - When specified, tests whether the agent's server is reachable via the agent.aspx - endpoint. Adds a brief network call. The ServerReachable property is null when - this switch is not used. - .EXAMPLE - Test-CWAAHealth - Returns a health status object for the installed agent. - .EXAMPLE - Test-CWAAHealth -Server 'https://automate.domain.com' -TestServerConnectivity - Checks agent health, validates the server address matches, and tests server connectivity. - .EXAMPLE - if ((Test-CWAAHealth).Healthy) { Write-Output 'Agent is healthy' } - Uses the Healthy boolean for conditional logic. - .EXAMPLE - Get-CWAAInfo | Test-CWAAHealth - Pipes the installed agent's Server property into Test-CWAAHealth via pipeline. - .NOTES - Author: Chris Taylor - Alias: Test-LTHealth - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Test-LTHealth')] - Param( - [Parameter(ValueFromPipelineByPropertyName = $True)] - [string[]]$Server, - [switch]$TestServerConnectivity - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - # Defaults — populated progressively as checks succeed - $agentInstalled = $False - $servicesRunning = $False - $lastContact = $Null - $lastHeartbeat = $Null - $serverAddress = $Null - $serverMatch = $Null - $serverReachable = $Null - $healthy = $False - # Check if the agent service exists - $ltService = Get-Service 'LTService' -ErrorAction SilentlyContinue - if ($ltService) { - $agentInstalled = $True - # Check if both services are running - $ltSvcMon = Get-Service 'LTSvcMon' -ErrorAction SilentlyContinue - $servicesRunning = ( - $ltService.Status -eq 'Running' -and - $ltSvcMon -and $ltSvcMon.Status -eq 'Running' - ) - # Read agent configuration from registry - $agentInfo = $Null - Try { - $agentInfo = Get-CWAAInfo -EA Stop -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - } - Catch { - Write-Verbose "Unable to read agent info from registry: $($_.Exception.Message)" - } - if ($agentInfo) { - # Extract server address - $serverAddress = ($agentInfo | Select-Object -Expand 'Server' -EA 0) -join '|' - # Parse last contact timestamp - Try { - [datetime]$lastContact = $agentInfo.LastSuccessStatus - } - Catch { - Write-Verbose 'LastSuccessStatus not available or not a valid datetime.' - } - # Parse last heartbeat timestamp - Try { - [datetime]$lastHeartbeat = $agentInfo.HeartbeatLastSent - } - Catch { - Write-Verbose 'HeartbeatLastSent not available or not a valid datetime.' - } - # If a Server was provided, check if any matches the installed configuration. - # Server is string[] to handle Get-CWAAInfo pipeline output (which returns Server as an array). - if ($Server) { - $installedServers = @($agentInfo | Select-Object -Expand 'Server' -EA 0) - $cleanProvided = @($Server | ForEach-Object { $_ -replace 'https?://', '' -replace '/$', '' }) - $serverMatch = $False - foreach ($installedServer in $installedServers) { - $cleanInstalled = $installedServer -replace 'https?://', '' -replace '/$', '' - if ($cleanProvided -contains $cleanInstalled) { - $serverMatch = $True - break - } - } - } - # Optionally test server connectivity - if ($TestServerConnectivity) { - $serversToTest = @($agentInfo | Select-Object -Expand 'Server' -EA 0) - if ($serversToTest) { - $serverReachable = Test-CWAAServerConnectivity -Server $serversToTest[0] -Quiet - } - else { - $serverReachable = $False - } - } - } - # Overall health: installed, running, and has a recent contact timestamp - $healthy = $agentInstalled -and $servicesRunning -and ($Null -ne $lastContact) - } - [PSCustomObject]@{ - AgentInstalled = $agentInstalled - ServicesRunning = $servicesRunning - LastContact = $lastContact - LastHeartbeat = $lastHeartbeat - ServerAddress = $serverAddress - ServerMatch = $serverMatch - ServerReachable = $serverReachable - Healthy = $healthy - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Unregister-CWAAHealthCheckTask { - <# - .SYNOPSIS - Removes the ConnectWise Automate agent health check scheduled task. - .DESCRIPTION - Deletes the Windows scheduled task created by Register-CWAAHealthCheckTask. - If the task does not exist, writes a warning and returns gracefully. - .PARAMETER TaskName - Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. - .EXAMPLE - Unregister-CWAAHealthCheckTask - Removes the default CWAAHealthCheck scheduled task. - .EXAMPLE - Unregister-CWAAHealthCheckTask -TaskName 'MyHealthCheck' - Removes a custom-named health check task. - .NOTES - Author: Chris Taylor - Alias: Unregister-LTHealthCheckTask - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Unregister-LTHealthCheckTask')] - Param( - [string]$TaskName = 'CWAAHealthCheck' - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - } - Process { - $removed = $False - # Check if the task exists - $taskExists = $False - Try { - $Null = schtasks /QUERY /TN $TaskName 2>$Null - if ($LASTEXITCODE -eq 0) { - $taskExists = $True - } - } - Catch { Write-Debug "Task '$TaskName' query failed: $($_.Exception.Message)" } - if (-not $taskExists) { - Write-Warning "Scheduled task '$TaskName' does not exist." - [PSCustomObject]@{ - TaskName = $TaskName - Removed = $False - } - return - } - if ($PSCmdlet.ShouldProcess("Scheduled Task '$TaskName'", 'Remove health check task')) { - Try { - $schtasksOutput = schtasks /DELETE /TN $TaskName /F 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "schtasks returned exit code $LASTEXITCODE. Output: $schtasksOutput" - } - $removed = $True - Write-Output "Scheduled task '$TaskName' has been removed." - Write-CWAAEventLog -EventId 4030 -EntryType Information -Message "Scheduled task '$TaskName' removed." - } - Catch { - Write-Error "Failed to remove scheduled task '$TaskName'. Error: $($_.Exception.Message)" - Write-CWAAEventLog -EventId 4032 -EntryType Error -Message "Failed to remove scheduled task '$TaskName'. Error: $($_.Exception.Message)" - } - } - [PSCustomObject]@{ - TaskName = $TaskName - Removed = $removed - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAAInfo { - <# - .SYNOPSIS - Retrieves ConnectWise Automate agent configuration from the registry. - .DESCRIPTION - Reads all agent configuration values from the Automate agent service registry key and - returns them as a single object. Resolves the BasePath from the service image path - if not present in the registry, expands environment variables in BasePath, and parses - the pipe-delimited Server Address into a clean Server array. - This function supports ShouldProcess because many internal callers pass - -WhatIf:$False -Confirm:$False to suppress prompts during automated operations. - .EXAMPLE - Get-CWAAInfo - Returns an object containing all agent registry properties including ID, Server, - LocationID, BasePath, and other configuration values. - .EXAMPLE - Get-CWAAInfo -WhatIf:$False -Confirm:$False - Retrieves agent info with ShouldProcess suppressed, as used by internal callers. - .NOTES - Author: Chris Taylor - Alias: Get-LTServiceInfo - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True, ConfirmImpact = 'Low')] - [Alias('Get-LTServiceInfo')] - Param () - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' - } - Process { - if (-not (Test-Path $Script:CWAARegistryRoot)) { - Write-Error "Unable to find information on LTSvc. Make sure the agent is installed." - return $Null - } - if ($PSCmdlet.ShouldProcess('LTService', 'Retrieving Service Registry Values')) { - Write-Verbose 'Checking for LT Service registry keys.' - Try { - $key = Get-ItemProperty $Script:CWAARegistryRoot -ErrorAction Stop | Select-Object * -Exclude $exclude - if ($Null -ne $key -and -not ($key | Get-Member -EA 0 | Where-Object { $_.Name -match 'BasePath' })) { - $BasePath = $Script:CWAAInstallPath - if (Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService') { - Try { - $BasePath = Get-Item $( - Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\LTService' -ErrorAction Stop | - Select-Object -Expand ImagePath | - Select-String -Pattern '^[^"][^ ]+|(?<=^")[^"]+' | - Select-Object -Expand Matches -First 1 | - Select-Object -Expand Value -EA 0 -First 1 - ) | Select-Object -Expand DirectoryName -EA 0 - } - Catch { - Write-Debug "Could not resolve BasePath from service ImagePath, using default: $_" - } - } - Add-Member -InputObject $key -MemberType NoteProperty -Name BasePath -Value $BasePath - } - $key.BasePath = [System.Environment]::ExpandEnvironmentVariables( - $($key | Select-Object -Expand BasePath -EA 0) - ) -replace '\\\\', '\' - if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'Server Address' })) { - $Servers = ($key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | - ForEach-Object { $_.Trim() -replace '~', '' } | - Where-Object { $_ -match '.+' } - Add-Member -InputObject $key -MemberType NoteProperty -Name 'Server' -Value $Servers -Force - } - return $key - } - Catch { - Write-Error "There was a problem reading the registry keys. $_" - } - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAAInfoBackup { - <# - .SYNOPSIS - Retrieves backed-up ConnectWise Automate agent configuration from the registry. - .DESCRIPTION - Reads all agent configuration values from the LabTechBackup registry key - and returns them as a single object. This backup is created by New-CWAABackup and - stores a snapshot of the agent configuration at the time of backup. - Expands environment variables in BasePath and parses the pipe-delimited Server - Address into a clean Server array, matching the behavior of Get-CWAAInfo. - .EXAMPLE - Get-CWAAInfoBackup - Returns an object containing all backed-up agent registry properties. - .EXAMPLE - Get-CWAAInfoBackup | Select-Object -ExpandProperty Server - Returns only the server addresses from the backup configuration. - .NOTES - Author: Chris Taylor - Alias: Get-LTServiceInfoBackup - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTServiceInfoBackup')] - Param () - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' - } - Process { - if (-not (Test-Path $Script:CWAARegistryBackup)) { - Write-Error "Unable to find backup information on LTSvc. Use New-CWAABackup to create a settings backup." - return - } - Try { - $key = Get-ItemProperty $Script:CWAARegistryBackup -ErrorAction Stop | Select-Object * -Exclude $exclude - if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'BasePath' })) { - $key.BasePath = [System.Environment]::ExpandEnvironmentVariables($key.BasePath) -replace '\\\\', '\' - } - if ($Null -ne $key -and ($key | Get-Member | Where-Object { $_.Name -match 'Server Address' })) { - $Servers = ($key | Select-Object -Expand 'Server Address' -EA 0).Split('|') | - ForEach-Object { $_.Trim() -replace '~', '' } | - Where-Object { $_ -match '.+' } - Add-Member -InputObject $key -MemberType NoteProperty -Name 'Server' -Value $Servers -Force - } - return $key - } - Catch { - Write-Error "There was a problem reading the backup registry keys. $_" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Get-CWAASettings { - <# - .SYNOPSIS - Retrieves ConnectWise Automate agent settings from the registry. - .DESCRIPTION - Reads agent settings from the Automate agent service Settings registry subkey - (HKLM:\SOFTWARE\LabTech\Service\Settings) and returns them as an object. - These settings are separate from the main agent configuration returned by - Get-CWAAInfo and include proxy configuration (ProxyServerURL, ProxyUsername, - ProxyPassword), logging level, and other operational parameters written by - the agent or Set-CWAAProxy. - .EXAMPLE - Get-CWAASettings - Returns an object containing all agent settings registry properties. - .EXAMPLE - (Get-CWAASettings).ProxyServerURL - Returns just the configured proxy URL, if any. - .NOTES - Author: Chris Taylor - Alias: Get-LTServiceSettings - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding()] - [Alias('Get-LTServiceSettings')] - Param () - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $exclude = 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider', 'PSPath' - } - Process { - if (-not (Test-Path $Script:CWAARegistrySettings)) { - Write-Error "Unable to find LTSvc settings. Make sure the agent is installed." - return - } - Try { - return Get-ItemProperty $Script:CWAARegistrySettings -ErrorAction Stop | Select-Object * -Exclude $exclude - } - Catch { - Write-Error "There was a problem reading the registry keys. $_" - } - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function New-CWAABackup { - <# - .SYNOPSIS - Creates a complete backup of the ConnectWise Automate agent installation. - .DESCRIPTION - Creates a comprehensive backup of the currently installed ConnectWise Automate agent - by copying all files from the agent installation directory and exporting all related - registry keys. This backup can be used to restore the agent configuration if needed, - or to preserve settings before performing maintenance operations. - The backup process performs the following operations: - 1. Locates the agent installation directory (typically C:\Windows\LTSVC) - 2. Creates a Backup subdirectory within the agent installation path - 3. Copies all files from the installation directory to the Backup folder - 4. Exports registry keys from HKLM\SOFTWARE\LabTech to a .reg file - 5. Modifies the exported registry data to use the LabTechBackup key name - 6. Imports the modified registry data to HKLM\SOFTWARE\LabTechBackup - .EXAMPLE - New-CWAABackup - Creates a complete backup of the agent installation files and registry settings. - .EXAMPLE - New-CWAABackup -WhatIf - Shows what the backup operation would do without actually creating the backup. - .NOTES - Author: Chris Taylor - Alias: New-LTServiceBackup - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('New-LTServiceBackup')] - Param () - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - $agentPath = "$(Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False | Select-Object -Expand BasePath -EA 0)" - if (-not $agentPath) { - Write-Error "Unable to find LTSvc folder path." -ErrorAction Stop - } - $BackupPath = Join-Path $agentPath 'Backup' - $Keys = 'HKLM\SOFTWARE\LabTech' - $RegPath = "$BackupPath\LTBackup.reg" - Write-Verbose 'Checking for registry keys.' - if (-not (Test-Path ($Keys -replace '^(H[^\\]*)', '$1:'))) { - Write-Error "Unable to find registry information on LTSvc. Make sure the agent is installed." -ErrorAction Stop - } - if (-not (Test-Path -Path $agentPath -PathType Container)) { - Write-Error "Unable to find LTSvc folder path $agentPath" -ErrorAction Stop - } - } - Process { - if ($PSCmdlet.ShouldProcess($BackupPath, 'Create backup directory')) { - New-Item $BackupPath -Type Directory -ErrorAction SilentlyContinue | Out-Null - if (-not (Test-Path -Path $BackupPath -PathType Container)) { - Write-Error "Unable to create backup folder path $BackupPath" -ErrorAction Stop - } - } - if ($PSCmdlet.ShouldProcess($agentPath, 'Copy agent files to backup')) { - Try { - # Copy each top-level item individually, excluding the Backup directory - # itself to prevent recursive copy loop (Backup is inside the agent path) - Get-ChildItem $agentPath -Exclude 'Backup' | Copy-Item -Destination $BackupPath -Recurse -Force - } - Catch { - Write-Error "There was a problem backing up the LTSvc folder. $_" - Write-CWAAEventLog -EventId 3012 -EntryType Error -Message "Agent backup failed (file copy). Error: $($_.Exception.Message)" - } - } - if ($PSCmdlet.ShouldProcess($Keys, 'Export and backup registry keys')) { - Try { - Write-Debug 'Exporting registry data' - $Null = & "$env:windir\system32\reg.exe" export "$Keys" "$RegPath" /y 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "reg.exe export returned exit code $LASTEXITCODE. Registry backup may be incomplete." - } - Write-Debug 'Loading and modifying registry key name' - $Reg = Get-Content $RegPath - $Reg = $Reg -replace [Regex]::Escape('[HKEY_LOCAL_MACHINE\SOFTWARE\LabTech'), '[HKEY_LOCAL_MACHINE\SOFTWARE\LabTechBackup' - Write-Debug 'Writing modified registry data' - $Reg | Out-File $RegPath - Write-Debug 'Importing registry data to backup path' - $Null = & "$env:windir\system32\reg.exe" import "$RegPath" 2>'' - if ($LASTEXITCODE -ne 0) { - Write-Warning "reg.exe import returned exit code $LASTEXITCODE. Registry backup restoration may have failed." - } - $True | Out-Null - } - Catch { - Write-Error "There was a problem backing up the LTSvc registry keys. $_" - Write-CWAAEventLog -EventId 3012 -EntryType Error -Message "Agent backup failed (registry export). Error: $($_.Exception.Message)" - } - } - Write-Output 'The Automate agent backup has been created.' - Write-CWAAEventLog -EventId 3010 -EntryType Information -Message "Agent backup created at $BackupPath." - } - End { - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -function Reset-CWAA { - <# - .SYNOPSIS - Removes local agent identity settings to force re-registration. - .DESCRIPTION - Removes some of the agent's local settings: ID, MAC, and/or LocationID. The function - stops the services, removes the specified registry values, then restarts the services. - Resetting all three values forces the agent to check in as a new agent. If MAC filtering - is enabled on the server, the agent should check back in with the same ID. - This function is useful for resolving duplicate agent entries. If no switches are - specified, all three values (ID, Location, MAC) are reset. - Probe agents are protected from reset unless the -Force switch is used. - .PARAMETER ID - Resets the AgentID of the computer. - .PARAMETER Location - Resets the LocationID of the computer. - .PARAMETER MAC - Resets the MAC address of the computer. - .PARAMETER Force - Forces the reset operation on an agent detected as a probe. - .PARAMETER NoWait - Skips the post-reset health check that waits for the agent to re-register. - .EXAMPLE - Reset-CWAA - Resets the ID, MAC, and LocationID on the agent, then waits for re-registration. - .EXAMPLE - Reset-CWAA -ID - Resets only the AgentID of the agent. - .EXAMPLE - Reset-CWAA -Force -NoWait - Resets all values on a probe agent without waiting for re-registration. - .NOTES - Author: Chris Taylor - Alias: Reset-LTService - .LINK - https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> - [CmdletBinding(SupportsShouldProcess = $True)] - [Alias('Reset-LTService')] - Param( - [switch]$ID, - [switch]$Location, - [switch]$MAC, - [switch]$Force, - [switch]$NoWait - ) - Begin { - Write-Debug "Starting $($MyInvocation.InvocationName)" - if (-not $PSBoundParameters.ContainsKey('ID') -and -not $PSBoundParameters.ContainsKey('Location') -and -not $PSBoundParameters.ContainsKey('MAC')) { - $ID = $True - $Location = $True - $MAC = $True - } - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Assert-CWAANotProbeAgent -ServiceInfo $serviceInfo -ActionName 'Reset' -Force:$Force - Write-Output "OLD ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" - } - Process { - if (-not (Test-CWAAServiceExists -WriteErrorOnMissing)) { return } - Try { - if ($ID -or $Location -or $MAC) { - Stop-CWAA - if ($ID) { - Write-Output '.Removing ID' - Remove-ItemProperty -Name ID -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue - } - if ($Location) { - Write-Output '.Removing LocationID' - Remove-ItemProperty -Name LocationID -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue - } - if ($MAC) { - Write-Output '.Removing MAC' - Remove-ItemProperty -Name MAC -Path $Script:CWAARegistryRoot -ErrorAction SilentlyContinue - } - Start-CWAA - } - } - Catch { - Write-CWAAEventLog -EventId 3002 -EntryType Error -Message "Agent reset failed. Error: $($_.Exception.Message)" - Write-Error "There was an error during the reset process. $_" -ErrorAction Stop - } - } - End { - if (-not $NoWait -and $PSCmdlet.ShouldProcess('LTService', 'Discover new settings after Service Start')) { - $Null = Wait-CWAACondition -Condition { - $svcInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - ($svcInfo | Select-Object -Expand ID -EA 0) -and - ($svcInfo | Select-Object -Expand LocationID -EA 0) -and - ($svcInfo | Select-Object -Expand MAC -EA 0) - } -TimeoutSeconds $Script:CWAARegistrationTimeoutSec -IntervalSeconds 2 -Activity 'Agent re-registration' - $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False - Write-Output "NEW ID: $($serviceInfo | Select-Object -Expand ID -EA 0) LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0) MAC: $($serviceInfo | Select-Object -Expand MAC -EA 0)" - Write-CWAAEventLog -EventId 3000 -EntryType Information -Message "Agent reset successfully. New ID: $($serviceInfo | Select-Object -Expand ID -EA 0), LocationID: $($serviceInfo | Select-Object -Expand LocationID -EA 0)" - } - Write-Debug "Exiting $($MyInvocation.InvocationName)" - } -} -Initialize-CWAA diff --git a/README.md b/README.md index 015b443..dda040f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
-[![CI / Publish](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci-publish.yml/badge.svg)](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci-publish.yml) +[![CI / Publish](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci.yml/badge.svg)](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/actions/workflows/ci.yml) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/8aa3633cda3d41d5baa5e9f595b8124f)](https://www.codacy.com/gh/christaylorcodes/ConnectWiseAutomateAgent/dashboard?utm_source=github.com&utm_medium=referral&utm_content=christaylorcodes/ConnectWiseAutomateAgent&utm_campaign=Badge_Grade) [![Gallery](https://img.shields.io/powershellgallery/v/ConnectWiseAutomateAgent?label=PS%20Gallery&logo=powershell&logoColor=white)](https://www.powershellgallery.com/packages/ConnectWiseAutomateAgent) [![Donate](https://img.shields.io/badge/$-donate-ff69b4.svg?maxAge=2592000&style=flat)](https://paypal.me/ChrisTaylorCodes) @@ -152,7 +152,7 @@ _Hand-written documentation covering architecture and concepts._ ### Function Reference (Auto-Generated) -_Generated from source code comment-based help via [PlatyPS](https://github.com/PowerShell/platyPS). Regenerate with `Build\Build-Documentation.ps1`._ +_Generated from source code comment-based help via [PlatyPS](https://github.com/PowerShell/platyPS). Regenerate with `./build.ps1 -Tasks build`._ | Resource | Description | | --- | --- | @@ -160,6 +160,17 @@ _Generated from source code comment-based help via [PlatyPS](https://github.com/ | [Individual Function Docs](Docs/Help/) | One file per function -- parameters, examples, and syntax | | `Get-Help ` | In-session help compiled from the same source (MAML XML) | +### Tutorials & Blog Posts + +In-depth tutorials on [christaylor.codes](https://christaylor.codes): + +| Tutorial | Description | +| --- | --- | +| [Introducing ConnectWiseAutomateAgent](https://christaylor.codes/powershell/rmm/2024/02/12/introducing-connectwise-automate-agent.html) | Module overview, key features, and getting started | +| [Mass Agent Deployment](https://christaylor.codes/powershell/deployment/2024/02/26/mass-agent-deployment-connectwise-automate.html) | Deploying agents across hundreds of endpoints with parallel execution | +| [Troubleshooting Automate Agents](https://christaylor.codes/powershell/troubleshooting/2024/03/04/troubleshooting-connectwise-automate-agents-powershell.html) | Diagnostic techniques and common issue resolution | +| [10 Real-World Use Cases](https://christaylor.codes/powershell/automation/2024/03/11/ten-use-cases-connectwise-automate-agent.html) | Practical automation scenarios for MSP operations | + ### Examples Ready-to-use scripts in the [Examples/](Examples/) directory: diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 new file mode 100644 index 0000000..13f936b --- /dev/null +++ b/RequiredModules.psd1 @@ -0,0 +1,22 @@ +@{ + PSDependOptions = @{ + AddToPath = $true + Target = 'output\RequiredModules' + Parameters = @{ + Repository = 'PSGallery' + } + } + + InvokeBuild = 'latest' + PSScriptAnalyzer = 'latest' + Pester = @{ + Version = '5.6.1' + Parameters = @{ + SkipPublisherCheck = $true + } + } + Sampler = 'latest' + ModuleBuilder = 'latest' + platyPS = 'latest' + 'powershell-yaml' = 'latest' +} diff --git a/Resolve-Dependency.ps1 b/Resolve-Dependency.ps1 new file mode 100644 index 0000000..f677fee --- /dev/null +++ b/Resolve-Dependency.ps1 @@ -0,0 +1,1075 @@ +<# + .DESCRIPTION + Bootstrap script for PSDepend. + + .PARAMETER DependencyFile + Specifies the configuration file for the this script. The default value is + 'RequiredModules.psd1' relative to this script's path. + + .PARAMETER PSDependTarget + Path for PSDepend to be bootstrapped and save other dependencies. + Can also be CurrentUser or AllUsers if you wish to install the modules in + such scope. The default value is 'output/RequiredModules' relative to + this script's path. + + .PARAMETER Proxy + Specifies the URI to use for Proxy when attempting to bootstrap + PackageProvider and PowerShellGet. + + .PARAMETER ProxyCredential + Specifies the credential to contact the Proxy when provided. + + .PARAMETER Scope + Specifies the scope to bootstrap the PackageProvider and PSGet if not available. + THe default value is 'CurrentUser'. + + .PARAMETER Gallery + Specifies the gallery to use when bootstrapping PackageProvider, PSGet and + when calling PSDepend (can be overridden in Dependency files). The default + value is 'PSGallery'. + + .PARAMETER GalleryCredential + Specifies the credentials to use with the Gallery specified above. + + .PARAMETER AllowOldPowerShellGetModule + Allow you to use a locally installed version of PowerShellGet older than + 1.6.0 (not recommended). Default it will install the latest PowerShellGet + if an older version than 2.0 is detected. + + .PARAMETER MinimumPSDependVersion + Allow you to specify a minimum version fo PSDepend, if you're after specific + features. + + .PARAMETER AllowPrerelease + Not yet written. + + .PARAMETER WithYAML + Not yet written. + + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER ModuleFastBleedingEdge + Specifies to use ModuleFast code that is in the ModuleFast's main branch + in its GitHub repository. The parameter UseModuleFast must also be set to + true. + + .PARAMETER UsePSResourceGet + Specifies to use the new PSResourceGet module instead of the (now legacy) PowerShellGet module. + + .PARAMETER PSResourceGetVersion + String specifying the module version for PSResourceGet if the `UsePSResourceGet` switch is utilized. + + .NOTES + Load defaults for parameters values from Resolve-Dependency.psd1 if not + provided as parameter. +#> +[CmdletBinding()] +param +( + [Parameter()] + [System.String] + $DependencyFile = 'RequiredModules.psd1', + + [Parameter()] + [System.String] + $PSDependTarget = (Join-Path -Path $PSScriptRoot -ChildPath 'output/RequiredModules'), + + [Parameter()] + [System.Uri] + $Proxy, + + [Parameter()] + [System.Management.Automation.PSCredential] + $ProxyCredential, + + [Parameter()] + [ValidateSet('CurrentUser', 'AllUsers')] + [System.String] + $Scope = 'CurrentUser', + + [Parameter()] + [System.String] + $Gallery = 'PSGallery', + + [Parameter()] + [System.Management.Automation.PSCredential] + $GalleryCredential, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowOldPowerShellGetModule, + + [Parameter()] + [System.String] + $MinimumPSDependVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AllowPrerelease, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $WithYAML, + + [Parameter()] + [System.Collections.Hashtable] + $RegisterGallery, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $ModuleFastBleedingEdge, + + [Parameter()] + [System.String] + $ModuleFastVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.String] + $PSResourceGetVersion, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule, + + [Parameter()] + [System.String] + $UsePowerShellGetCompatibilityModuleVersion +) + +try +{ + if ($PSVersionTable.PSVersion.Major -le 5) + { + if (-not (Get-Command -Name 'Import-PowerShellDataFile' -ErrorAction 'SilentlyContinue')) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion '3.1.0.0' + } + } + + Write-Verbose -Message 'Importing Bootstrap default parameters from ''$PSScriptRoot/Resolve-Dependency.psd1''.' + + $resolveDependencyConfigPath = Join-Path -Path $PSScriptRoot -ChildPath '.\Resolve-Dependency.psd1' -Resolve -ErrorAction 'Stop' + + $resolveDependencyDefaults = Import-PowerShellDataFile -Path $resolveDependencyConfigPath + + $parameterToDefault = $MyInvocation.MyCommand.ParameterSets.Where{ $_.Name -eq $PSCmdlet.ParameterSetName }.Parameters.Keys + + if ($parameterToDefault.Count -eq 0) + { + $parameterToDefault = $MyInvocation.MyCommand.Parameters.Keys + } + + # Set the parameters available in the Parameter Set, or it's not possible to choose yet, so all parameters are an option. + foreach ($parameterName in $parameterToDefault) + { + if (-not $PSBoundParameters.Keys.Contains($parameterName) -and $resolveDependencyDefaults.ContainsKey($parameterName)) + { + Write-Verbose -Message "Setting parameter '$parameterName' to value '$($resolveDependencyDefaults[$parameterName])'." + + try + { + $variableValue = $resolveDependencyDefaults[$parameterName] + + if ($variableValue -is [System.String]) + { + $variableValue = $ExecutionContext.InvokeCommand.ExpandString($variableValue) + } + + $PSBoundParameters.Add($parameterName, $variableValue) + + Set-Variable -Name $parameterName -Value $variableValue -Force -ErrorAction 'SilentlyContinue' + } + catch + { + Write-Verbose -Message "Error adding default for $parameterName : $($_.Exception.Message)." + } + } + } +} +catch +{ + Write-Warning -Message "Error attempting to import Bootstrap's default parameters from '$resolveDependencyConfigPath': $($_.Exception.Message)." +} + +# Handle when both ModuleFast and PSResourceGet is configured or/and passed as parameter. +if ($UseModuleFast -and $UsePSResourceGet) +{ + Write-Information -MessageData 'Both ModuleFast and PSResourceGet is configured or/and passed as parameter.' -InformationAction 'Continue' + + if ($PSVersionTable.PSVersion -ge '7.2') + { + $UsePSResourceGet = $false + + Write-Information -MessageData 'PowerShell 7.2 or higher being used, prefer ModuleFast over PSResourceGet.' -InformationAction 'Continue' + } + else + { + $UseModuleFast = $false + + Write-Information -MessageData 'Windows PowerShell or PowerShell <=7.1 is being used, prefer PSResourceGet since ModuleFast is not supported on this version of PowerShell.' -InformationAction 'Continue' + } +} + +# Only bootstrap ModuleFast if it is not already imported. +if ($UseModuleFast -and -not (Get-Module -Name 'ModuleFast')) +{ + try + { + $moduleFastBootstrapScriptBlockParameters = @{} + + if ($ModuleFastBleedingEdge) + { + Write-Information -MessageData 'ModuleFast is configured to use Bleeding Edge (directly from ModuleFast''s main branch).' -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.UseMain = $true + } + elseif ($ModuleFastVersion) + { + if ($ModuleFastVersion -notmatch 'v') + { + $ModuleFastVersion = 'v{0}' -f $ModuleFastVersion + } + + Write-Information -MessageData ('ModuleFast is configured to use version {0}.' -f $ModuleFastVersion) -InformationAction 'Continue' + + $moduleFastBootstrapScriptBlockParameters.Release = $ModuleFastVersion + } + else + { + Write-Information -MessageData 'ModuleFast is configured to use latest released version.' -InformationAction 'Continue' + } + + $moduleFastBootstrapUri = 'bit.ly/modulefast' # cSpell: disable-line + + Write-Debug -Message ('Using bootstrap script at {0}' -f $moduleFastBootstrapUri) + + $invokeWebRequestParameters = @{ + Uri = $moduleFastBootstrapUri + ErrorAction = 'Stop' + } + + $moduleFastBootstrapScript = Invoke-WebRequest @invokeWebRequestParameters + + $moduleFastBootstrapScriptBlock = [ScriptBlock]::Create($moduleFastBootstrapScript) + + & $moduleFastBootstrapScriptBlock @moduleFastBootstrapScriptBlockParameters + } + catch + { + Write-Warning -Message ('ModuleFast could not be bootstrapped. Reverting to PSResourceGet. Error: {0}' -f $_.Exception.Message) + + $UseModuleFast = $false + $UsePSResourceGet = $true + } +} + +if ($UsePSResourceGet) +{ + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' + + # If PSResourceGet was used prior it will be locked and we can't replace it. + if ((Test-Path -Path "$PSDependTarget/$psResourceGetModuleName" -PathType 'Container') -and (Get-Module -Name $psResourceGetModuleName)) + { + Write-Information -MessageData ('{0} is already bootstrapped and imported into the session. If there is a need to refresh the module, open a new session and resolve dependencies again.' -f $psResourceGetModuleName) -InformationAction 'Continue' + } + else + { + Write-Debug -Message ('{0} do not exist, saving the module to RequiredModules.' -f $psResourceGetModuleName) + + $psResourceGetDownloaded = $false + + try + { + if (-not $PSResourceGetVersion) + { + # Default to latest version if no version is passed in parameter or specified in configuration. + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName" + } + else + { + $psResourceGetUri = "https://www.powershellgallery.com/api/v2/package/$psResourceGetModuleName/$PSResourceGetVersion" + } + + $invokeWebRequestParameters = @{ + # TODO: Should support proxy parameters passed to the script. + Uri = $psResourceGetUri + OutFile = "$PSDependTarget/$psResourceGetModuleName.nupkg" # cSpell: ignore nupkg + ErrorAction = 'Stop' + } + + $previousProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + + # Bootstrapping Microsoft.PowerShell.PSResourceGet. + Invoke-WebRequest @invokeWebRequestParameters + + $ProgressPreference = $previousProgressPreference + + $psResourceGetDownloaded = $true + } + catch + { + Write-Warning -Message ('{0} could not be bootstrapped. Reverting to PowerShellGet. Error: {1}' -f $psResourceGetModuleName, $_.Exception.Message) + } + + $UsePSResourceGet = $false + + if ($psResourceGetDownloaded) + { + # On Windows PowerShell the command Expand-Archive do not like .nupkg as a zip archive extension. + $zipFileName = ((Split-Path -Path $invokeWebRequestParameters.OutFile -Leaf) -replace 'nupkg', 'zip') + + $renameItemParameters = @{ + Path = $invokeWebRequestParameters.OutFile + NewName = $zipFileName + Force = $true + } + + Rename-Item @renameItemParameters + + $psResourceGetZipArchivePath = Join-Path -Path (Split-Path -Path $invokeWebRequestParameters.OutFile -Parent) -ChildPath $zipFileName + + $expandArchiveParameters = @{ + Path = $psResourceGetZipArchivePath + DestinationPath = "$PSDependTarget/$psResourceGetModuleName" + Force = $true + } + + Microsoft.PowerShell.Archive\Expand-Archive @expandArchiveParameters + + Remove-Item -Path $psResourceGetZipArchivePath + + Import-Module -Name $expandArchiveParameters.DestinationPath -Force + + # Successfully bootstrapped PSResourceGet, so let's use it. + $UsePSResourceGet = $true + } + } + + if ($UsePSResourceGet) + { + $psResourceGetModule = Get-Module -Name $psResourceGetModuleName + + $psResourceGetModuleVersion = $psResourceGetModule.Version.ToString() + + if ($psResourceGetModule.PrivateData.PSData.Prerelease) + { + $psResourceGetModuleVersion += '-{0}' -f $psResourceGetModule.PrivateData.PSData.Prerelease + } + + Write-Information -MessageData ('Using {0} v{1}.' -f $psResourceGetModuleName, $psResourceGetModuleVersion) -InformationAction 'Continue' + + if ($UsePowerShellGetCompatibilityModule) + { + $savePowerShellGetParameters = @{ + Name = 'PowerShellGet' + Path = $PSDependTarget + Repository = 'PSGallery' + TrustRepository = $true + } + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $savePowerShellGetParameters.Version = $UsePowerShellGetCompatibilityModuleVersion + + # Check if the version is a prerelease. + if ($UsePowerShellGetCompatibilityModuleVersion -match '\d+\.\d+\.\d+-.*') + { + $savePowerShellGetParameters.Prerelease = $true + } + } + + Save-PSResource @savePowerShellGetParameters + + Import-Module -Name "$PSDependTarget/PowerShellGet" + } + } +} + +# Check if legacy PowerShellGet and PSDepend must be bootstrapped. +if (-not ($UseModuleFast -or $UsePSResourceGet)) +{ + if ($PSVersionTable.PSVersion.Major -le 5) + { + <# + Making sure the imported PackageManagement module is not from PS7 module + path. The VSCode PS extension is changing the $env:PSModulePath and + prioritize the PS7 path. This is an issue with PowerShellGet because + it loads an old version if available (or fail to load latest). + #> + Get-Module -ListAvailable PackageManagement | + Where-Object -Property 'ModuleBase' -NotMatch 'powershell.7' | + Select-Object -First 1 | + Import-Module -Force + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 0 -CurrentOperation 'NuGet Bootstrap' + + $importModuleParameters = @{ + Name = 'PowerShellGet' + MinimumVersion = '2.0' + MaximumVersion = '2.8.999' + ErrorAction = 'SilentlyContinue' + PassThru = $true + } + + if ($AllowOldPowerShellGetModule) + { + $importModuleParameters.Remove('MinimumVersion') + } + + $powerShellGetModule = Import-Module @importModuleParameters + + # Install the package provider if it is not available. + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable -ErrorAction 'SilentlyContinue' | + Select-Object -First 1 + + if (-not $powerShellGetModule -and -not $nuGetProvider) + { + $providerBootstrapParameters = @{ + Name = 'NuGet' + Force = $true + ForceBootstrap = $true + ErrorAction = 'Stop' + Scope = $Scope + } + + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $providerBootstrapParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $providerBootstrapParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'AllowPrerelease' + { + $providerBootstrapParameters.Add('AllowPrerelease', $AllowPrerelease) + } + } + + Write-Information -MessageData 'Bootstrap: Installing NuGet Package Provider from the web (Make sure Microsoft addresses/ranges are allowed).' + + $null = Install-PackageProvider @providerBootstrapParameters + + $nuGetProvider = Get-PackageProvider -Name 'NuGet' -ListAvailable | Select-Object -First 1 + + $nuGetProviderVersion = $nuGetProvider.Version.ToString() + + Write-Information -MessageData "Bootstrap: Importing NuGet Package Provider version $nuGetProviderVersion to current session." + + $Null = Import-PackageProvider -Name 'NuGet' -RequiredVersion $nuGetProviderVersion -Force + } + + if ($RegisterGallery) + { + if ($RegisterGallery.ContainsKey('Name') -and -not [System.String]::IsNullOrEmpty($RegisterGallery.Name)) + { + $Gallery = $RegisterGallery.Name + } + else + { + $RegisterGallery.Name = $Gallery + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 7 -CurrentOperation "Verifying private package repository '$Gallery'" -Completed + + $previousRegisteredRepository = Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue' + + if ($previousRegisteredRepository.SourceLocation -ne $RegisterGallery.SourceLocation) + { + if ($previousRegisteredRepository) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Re-registrering private package repository '$Gallery'" -Completed + + Unregister-PSRepository -Name $Gallery + + $unregisteredPreviousRepository = $true + } + else + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 9 -CurrentOperation "Registering private package repository '$Gallery'" -Completed + } + + Register-PSRepository @RegisterGallery + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 10 -CurrentOperation "Ensuring Gallery $Gallery is trusted" + + # Fail if the given PSGallery is not registered. + $previousGalleryInstallationPolicy = (Get-PSRepository -Name $Gallery -ErrorAction 'Stop').Trusted + + $updatedGalleryInstallationPolicy = $false + + if ($previousGalleryInstallationPolicy -ne $true) + { + $updatedGalleryInstallationPolicy = $true + + # Only change policy if the repository is not trusted + Set-PSRepository -Name $Gallery -InstallationPolicy 'Trusted' -ErrorAction 'Ignore' + } +} + +try +{ + # Check if legacy PowerShellGet and PSDepend must be used. + if (-not ($UseModuleFast -or $UsePSResourceGet)) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 25 -CurrentOperation 'Checking PowerShellGet' + + # Ensure the module is loaded and retrieve the version you have. + $powerShellGetVersion = (Import-Module -Name 'PowerShellGet' -PassThru -ErrorAction 'SilentlyContinue').Version + + Write-Verbose -Message "Bootstrap: The PowerShellGet version is $powerShellGetVersion" + + # Versions below 2.0 are considered old, unreliable & not recommended + if (-not $powerShellGetVersion -or ($powerShellGetVersion -lt [System.Version] '2.0' -and -not $AllowOldPowerShellGetModule)) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 40 -CurrentOperation 'Fetching newer version of PowerShellGet' + + # PowerShellGet module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "PowerShellGet module not found. Attempting to install from Gallery $Gallery." + + Write-Warning -Message "Installing PowerShellGet in $PSDependTarget Scope." + + $installPowerShellGetParameters = @{ + Name = 'PowerShellGet' + Force = $true + SkipPublisherCheck = $true + AllowClobber = $true + Scope = $Scope + Repository = $Gallery + MaximumVersion = '2.8.999' + } + + switch ($PSBoundParameters.Keys) + { + 'Proxy' + { + $installPowerShellGetParameters.Add('Proxy', $Proxy) + } + + 'ProxyCredential' + { + $installPowerShellGetParameters.Add('ProxyCredential', $ProxyCredential) + } + + 'GalleryCredential' + { + $installPowerShellGetParameters.Add('Credential', $GalleryCredential) + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation 'Installing newer version of PowerShellGet' + + Install-Module @installPowerShellGetParameters + } + else + { + Write-Debug -Message "PowerShellGet module not found. Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PowerShellGet' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + MaximumVersion = '2.8.999' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 60 -CurrentOperation "Saving PowerShellGet from $Gallery to $Scope" + + Save-Module @saveModuleParameters + } + + Write-Debug -Message 'Removing previous versions of PowerShellGet and PackageManagement from session' + + Get-Module -Name 'PowerShellGet' -All | Remove-Module -Force -ErrorAction 'SilentlyContinue' + Get-Module -Name 'PackageManagement' -All | Remove-Module -Force + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 65 -CurrentOperation 'Loading latest version of PowerShellGet' + + Write-Debug -Message 'Importing latest PowerShellGet and PackageManagement versions into session' + + if ($AllowOldPowerShellGetModule) + { + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -Force -PassThru + } + else + { + Import-Module -Name 'PackageManagement' -MinimumVersion '1.4.8.1' -Force + + $powerShellGetModule = Import-Module -Name 'PowerShellGet' -MinimumVersion '2.2.5' -Force -PassThru + } + + $powerShellGetVersion = $powerShellGetModule.Version.ToString() + + Write-Information -MessageData "Bootstrap: PowerShellGet version loaded is $powerShellGetVersion" + } + + # Try to import the PSDepend module from the available modules. + $getModuleParameters = @{ + Name = 'PSDepend' + ListAvailable = $true + } + + $psDependModule = Get-Module @getModuleParameters + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + try + { + $psDependModule = $psDependModule | Where-Object -FilterScript { $_.Version -ge $MinimumPSDependVersion } + } + catch + { + throw ('There was a problem finding the minimum version of PSDepend. Error: {0}' -f $_) + } + } + + if (-not $psDependModule) + { + Write-Debug -Message 'PSDepend module not found.' + + # PSDepend module not found, installing or saving it. + if ($PSDependTarget -in 'CurrentUser', 'AllUsers') + { + Write-Debug -Message "Attempting to install from Gallery '$Gallery'." + + Write-Warning -Message "Installing PSDepend in $PSDependTarget Scope." + + $installPSDependParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Force = $true + Scope = $PSDependTarget + SkipPublisherCheck = $true + AllowClobber = $true + } + + if ($MinimumPSDependVersion) + { + $installPSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Installing PSDepend from $Gallery" + + Install-Module @installPSDependParameters + } + else + { + Write-Debug -Message "Attempting to Save from Gallery $Gallery to $PSDependTarget" + + $saveModuleParameters = @{ + Name = 'PSDepend' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + if ($MinimumPSDependVersion) + { + $saveModuleParameters.add('MinimumVersion', $MinimumPSDependVersion) + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 75 -CurrentOperation "Saving PSDepend from $Gallery to $PSDependTarget" + + Save-Module @saveModuleParameters + } + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 80 -CurrentOperation 'Importing PSDepend' + + $importModulePSDependParameters = @{ + Name = 'PSDepend' + ErrorAction = 'Stop' + Force = $true + } + + if ($PSBoundParameters.ContainsKey('MinimumPSDependVersion')) + { + $importModulePSDependParameters.Add('MinimumVersion', $MinimumPSDependVersion) + } + + # We should have successfully bootstrapped PSDepend. Fail if not available. + $null = Import-Module @importModulePSDependParameters + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 81 -CurrentOperation 'Invoke PSDepend' + + if ($WithYAML) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 82 -CurrentOperation 'Verifying PowerShell module PowerShell-Yaml' + + if (-not (Get-Module -ListAvailable -Name 'PowerShell-Yaml')) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 85 -CurrentOperation 'Installing PowerShell module PowerShell-Yaml' + + Write-Verbose -Message "PowerShell-Yaml module not found. Attempting to Save from Gallery '$Gallery' to '$PSDependTarget'." + + $SaveModuleParam = @{ + Name = 'PowerShell-Yaml' + Repository = $Gallery + Path = $PSDependTarget + Force = $true + } + + Save-Module @SaveModuleParam + } + else + { + Write-Verbose -Message 'PowerShell-Yaml is already available' + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 88 -CurrentOperation 'Importing PowerShell module PowerShell-Yaml' + } + } + + if (Test-Path -Path $DependencyFile) + { + if ($UseModuleFast -or $UsePSResourceGet) + { + $requiredModules = Import-PowerShellDataFile -Path $DependencyFile + + $requiredModules = $requiredModules.GetEnumerator() | + Where-Object -FilterScript { $_.Name -ne 'PSDependOptions' } + + if ($UseModuleFast) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking ModuleFast' + + Write-Progress -Activity 'ModuleFast:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' + + $modulesToSave = @( + 'PSDepend' # Always include PSDepend for backward compatibility. + ) + + if ($WithYAML) + { + $modulesToSave += 'PowerShell-Yaml' + } + + if ($UsePowerShellGetCompatibilityModule) + { + Write-Debug -Message 'PowerShellGet compatibility module is configured to be used.' + + # This is needed to ensure that the PowerShellGet compatibility module works. + $psResourceGetModuleName = 'Microsoft.PowerShell.PSResourceGet' + + if ($PSResourceGetVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $psResourceGetModuleName, $PSResourceGetVersion) + } + else + { + $modulesToSave += $psResourceGetModuleName + } + + $powerShellGetCompatibilityModuleName = 'PowerShellGet' + + if ($UsePowerShellGetCompatibilityModuleVersion) + { + $modulesToSave += ('{0}:[{1}]' -f $powerShellGetCompatibilityModuleName, $UsePowerShellGetCompatibilityModuleVersion) + } + else + { + $modulesToSave += $powerShellGetCompatibilityModuleName + } + } + + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + if (-not $requiredModule.Value.Version) + { + $requiredModuleVersion = 'latest' + } + else + { + $requiredModuleVersion = $requiredModule.Value.Version + } + + if ($requiredModuleVersion -eq 'latest') + { + $moduleNameSuffix = '' + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + <# + Adding '!' to the module name indicate to ModuleFast + that is should also evaluate pre-releases. + #> + $moduleNameSuffix = '!' + } + + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $moduleNameSuffix) + } + else + { + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModuleVersion) + } + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += $requiredModule.Name + } + else + { + # Handle different nuget version operators already present. + if ($requiredModule.Value -match '[!|:|[|(|,|>|<|=]') + { + $modulesToSave += ('{0}{1}' -f $requiredModule.Name, $requiredModule.Value) + } + else + { + # Assuming the version is a fixed version. + $modulesToSave += ('{0}:[{1}]' -f $requiredModule.Name, $requiredModule.Value) + } + } + } + } + + Write-Debug -Message ("Required modules to retrieve plan for:`n{0}" -f ($modulesToSave | Out-String)) + + $installModuleFastParameters = @{ + Destination = $PSDependTarget + DestinationOnly = $true + NoPSModulePathUpdate = $true + NoProfileUpdate = $true + Update = $true + Confirm = $false + } + + $moduleFastPlan = Install-ModuleFast -Specification $modulesToSave -Plan @installModuleFastParameters + + Write-Debug -Message ("Missing modules that need to be saved:`n{0}" -f ($moduleFastPlan | Out-String)) + + if ($moduleFastPlan) + { + # Clear all modules in plan from the current session so they can be fetched again. + try + { + $moduleFastPlan.Name | Get-Module | Remove-Module -Force + $moduleFastPlan | Install-ModuleFast @installModuleFastParameters + } + catch + { + Write-Warning -Message 'ModuleFast could not save one or more dependencies. Retrying...' + try + { + $moduleFastPlan.Name | Get-Module | Remove-Module -Force + $moduleFastPlan | Install-ModuleFast @installModuleFastParameters + } + catch + { + Write-Error 'ModuleFast could not save one or more dependencies even after a retry.' + } + } + } + else + { + Write-Verbose -Message 'All required modules were already up to date' + } + + Write-Progress -Activity 'ModuleFast:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } + + if ($UsePSResourceGet) + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSResourceGet' + + $modulesToSave = @( + @{ + Name = 'PSDepend' # Always include PSDepend for backward compatibility. + } + ) + + if ($WithYAML) + { + $modulesToSave += @{ + Name = 'PowerShell-Yaml' + } + } + + # Prepare hashtable that can be concatenated to the Save-PSResource parameters. + foreach ($requiredModule in $requiredModules) + { + # If the RequiredModules.psd1 entry is an Hashtable then special handling is needed. + if ($requiredModule.Value -is [System.Collections.Hashtable]) + { + $saveModuleHashtable = @{ + Name = $requiredModule.Name + } + + if ($requiredModule.Value.Version -and $requiredModule.Value.Version -ne 'latest') + { + $saveModuleHashtable.Version = $requiredModule.Value.Version + } + + if ($requiredModule.Value.Parameters.AllowPrerelease -eq $true) + { + $saveModuleHashtable.Prerelease = $true + } + + $modulesToSave += $saveModuleHashtable + } + else + { + if ($requiredModule.Value -eq 'latest') + { + $modulesToSave += @{ + Name = $requiredModule.Name + } + } + else + { + $modulesToSave += @{ + Name = $requiredModule.Name + Version = $requiredModule.Value + } + } + } + } + + $percentagePerModule = [System.Math]::Floor(100 / $modulesToSave.Length) + + $progressPercentage = 0 + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' + + foreach ($currentModule in $modulesToSave) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Saving module {0}' -f $savePSResourceParameters.Name) + + $savePSResourceParameters = @{ + Path = $PSDependTarget + TrustRepository = $true + Confirm = $false + } + + # Concatenate the module parameters to the Save-PSResource parameters. + $savePSResourceParameters += $currentModule + + # Modules that Sampler depend on that cannot be refreshed without a new session. + $skipModule = @('PowerShell-Yaml') + + if ($savePSResourceParameters.Name -in $skipModule -and (Get-Module -Name $savePSResourceParameters.Name)) + { + Write-Progress -Activity 'PSResourceGet:' -PercentComplete $progressPercentage -CurrentOperation 'Restoring Build Dependencies' -Status ('Skipping module {0}' -f $savePSResourceParameters.Name) + + Write-Information -MessageData ('Skipping the module {0} since it cannot be refresh while loaded into the session. To refresh the module open a new session and resolve dependencies again.' -f $savePSResourceParameters.Name) -InformationAction 'Continue' + } + else + { + # Clear all module from the current session so any new version fetched will be re-imported. + Get-Module -Name $savePSResourceParameters.Name | Remove-Module -Force + + Save-PSResource @savePSResourceParameters -ErrorVariable 'savePSResourceError' + + if ($savePSResourceError) + { + Write-Warning -Message 'Save-PSResource could not save (replace) one or more dependencies. This can be due to the module is loaded into the session (and referencing assemblies). Close the current session and open a new session and try again.' + } + } + + $progressPercentage += $percentagePerModule + } + + Write-Progress -Activity 'PSResourceGet:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } + } + else + { + Write-Progress -Activity 'Bootstrap:' -PercentComplete 90 -CurrentOperation 'Invoking PSDepend' + + Write-Progress -Activity 'PSDepend:' -PercentComplete 0 -CurrentOperation 'Restoring Build Dependencies' + + $psDependParameters = @{ + Force = $true + Path = $DependencyFile + } + + # TODO: Handle when the Dependency file is in YAML, and -WithYAML is specified. + Invoke-PSDepend @psDependParameters + + Write-Progress -Activity 'PSDepend:' -PercentComplete 100 -CurrentOperation 'Dependencies restored' -Completed + } + } + else + { + Write-Warning -Message "The dependency file '$DependencyFile' could not be found." + } + + Write-Progress -Activity 'Bootstrap:' -PercentComplete 100 -CurrentOperation 'Bootstrap complete' -Completed +} +finally +{ + if ($RegisterGallery) + { + Write-Verbose -Message "Removing private package repository '$Gallery'." + Unregister-PSRepository -Name $Gallery + } + + if ($unregisteredPreviousRepository) + { + Write-Verbose -Message "Reverting private package repository '$Gallery' to previous location URI:s." + + $registerPSRepositoryParameters = @{ + Name = $previousRegisteredRepository.Name + InstallationPolicy = $previousRegisteredRepository.InstallationPolicy + } + + if ($previousRegisteredRepository.SourceLocation) + { + $registerPSRepositoryParameters.SourceLocation = $previousRegisteredRepository.SourceLocation + } + + if ($previousRegisteredRepository.PublishLocation) + { + $registerPSRepositoryParameters.PublishLocation = $previousRegisteredRepository.PublishLocation + } + + if ($previousRegisteredRepository.ScriptSourceLocation) + { + $registerPSRepositoryParameters.ScriptSourceLocation = $previousRegisteredRepository.ScriptSourceLocation + } + + if ($previousRegisteredRepository.ScriptPublishLocation) + { + $registerPSRepositoryParameters.ScriptPublishLocation = $previousRegisteredRepository.ScriptPublishLocation + } + + Register-PSRepository @registerPSRepositoryParameters + } + + if ($updatedGalleryInstallationPolicy -eq $true -and $previousGalleryInstallationPolicy -ne $true) + { + # Only try to revert installation policy if the repository exist + if ((Get-PSRepository -Name $Gallery -ErrorAction 'SilentlyContinue')) + { + # Reverting the Installation Policy for the given gallery if it was not already trusted + Set-PSRepository -Name $Gallery -InstallationPolicy 'Untrusted' + } + } + + Write-Verbose -Message 'Project Bootstrapped, returning to Invoke-Build.' +} diff --git a/Resolve-Dependency.psd1 b/Resolve-Dependency.psd1 new file mode 100644 index 0000000..2ae8c0d --- /dev/null +++ b/Resolve-Dependency.psd1 @@ -0,0 +1,5 @@ +@{ + Gallery = 'PSGallery' + AllowPrerelease = $false + WithYAML = $true +} diff --git a/Scripts/Invoke-QuickTest.ps1 b/Scripts/Invoke-QuickTest.ps1 index fa5ea48..285d0a6 100644 --- a/Scripts/Invoke-QuickTest.ps1 +++ b/Scripts/Invoke-QuickTest.ps1 @@ -66,7 +66,7 @@ param( $ErrorActionPreference = 'Stop' $ProjectRoot = Split-Path -Parent $PSScriptRoot $TestsPath = Join-Path $ProjectRoot 'Tests' -$SourcePath = Join-Path $ProjectRoot 'ConnectWiseAutomateAgent' +$SourcePath = Join-Path $ProjectRoot 'source' # --- Short name mapping for test files --- diff --git a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 index adde414..cd956b4 100644 --- a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 @@ -50,14 +50,14 @@ BeforeDiscovery { # Check if single-file build exists (needed for SingleFile contexts) $repoRoot = Split-Path -Parent $PSScriptRoot - $script:SingleFileExists = Test-Path (Join-Path $repoRoot 'ConnectWiseAutomateAgent.ps1') + $script:SingleFileExists = Test-Path (Join-Path $repoRoot 'output\ConnectWiseAutomateAgent.ps1') } BeforeAll { $ModuleName = 'ConnectWiseAutomateAgent' $ModuleRoot = Split-Path -Parent $PSScriptRoot - $script:ModulePsd1 = Join-Path $ModuleRoot "$ModuleName\$ModuleName.psd1" - $script:SingleFilePath = Join-Path $ModuleRoot "$ModuleName.ps1" + $script:ModulePsd1 = Join-Path $ModuleRoot "source\$ModuleName.psd1" + $script:SingleFilePath = Join-Path $ModuleRoot "output\$ModuleName.ps1" # Helper: Run a verification script in a child process and parse JSON output. # Defined inside BeforeAll so Pester 5 scoping makes it available to test blocks. @@ -457,6 +457,6 @@ Describe 'Version Coverage' { } It 'single-file build exists for SingleFile tests' { - $script:SingleFilePath | Should -Exist -Because 'Run Build\SingleFileBuild.ps1 to create the single-file distribution' + $script:SingleFilePath | Should -Exist -Because 'Run ./build.ps1 -Tasks build to create the single-file distribution' } } diff --git a/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 index 2f38625..5f26ea2 100644 --- a/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 @@ -2,11 +2,11 @@ <# .SYNOPSIS - Documentation structure and build script tests. + Documentation structure tests. .DESCRIPTION Tests the documentation folder layout, auto-generated function reference, - MAML help, and build script functionality including Extract-ChangelogEntry. + and MAML help. Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD. @@ -32,7 +32,6 @@ Describe 'Documentation Structure' { $ModuleRoot = Split-Path -Parent $PSScriptRoot $DocsRoot = Join-Path $ModuleRoot 'Docs' $DocsHelp = Join-Path $DocsRoot 'Help' - $BuildScript = Join-Path $ModuleRoot 'Build\Build-Documentation.ps1' # Use the known list of public functions for doc checks. In single-file mode, # ExportedFunctions includes private helpers that don't have docs in Docs/Help/. $PublicFunctions = @( @@ -101,100 +100,14 @@ Describe 'Documentation Structure' { Context 'MAML help' { It 'has a compiled MAML XML help file' { - $mamlPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\ConnectWiseAutomateAgent-help.xml' + $mamlPath = Join-Path $ModuleRoot 'source\en-US\ConnectWiseAutomateAgent-help.xml' $mamlPath | Should -Exist } It 'has an about help topic' { - $aboutPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\en-US\about_ConnectWiseAutomateAgent.help.txt' + $aboutPath = Join-Path $ModuleRoot 'source\en-US\about_ConnectWiseAutomateAgent.help.txt' $aboutPath | Should -Exist } } - Context 'Build script' { - It 'Build-Documentation.ps1 exists' { - $BuildScript | Should -Exist - } - - It 'Build-Documentation.ps1 defaults output to Docs/Help' { - $scriptContent = Get-Content $BuildScript -Raw - $scriptContent | Should -Match "Join-Path.*'Help'" -Because 'default output path should target Docs/Help' - } - - It 'Extract-ChangelogEntry.ps1 exists' { - $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' - $extractScript | Should -Exist - } - - It 'Extract-ChangelogEntry.ps1 has comment-based help' { - $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' - $scriptContent = Get-Content $extractScript -Raw - $scriptContent | Should -Match '\.SYNOPSIS' -Because 'build scripts should have comment-based help' - } - - It 'Extract-ChangelogEntry.ps1 requires -Version parameter' { - $extractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' - $scriptContent = Get-Content $extractScript -Raw - $scriptContent | Should -Match '\[Parameter\(Mandatory' -Because 'Version should be mandatory' - } - } -} - -# ============================================================================= -# Extract-ChangelogEntry Functional Tests -# ============================================================================= -Describe 'Extract-ChangelogEntry.ps1' { - - BeforeAll { - $ModuleRoot = Split-Path -Parent $PSScriptRoot - $ExtractScript = Join-Path $ModuleRoot 'Build\Extract-ChangelogEntry.ps1' - } - - Context 'Extracts known versions from CHANGELOG.md' { - It 'extracts the 1.0.0-alpha001 entry' { - $result = & $ExtractScript -Version '1.0.0-alpha001' - $joined = $result -join "`n" - $joined | Should -Match 'Health check and auto-remediation system' -Because 'the alpha001 entry contains health check content' - $joined | Should -Not -Match '## \[0\.1\.4\.0\]' -Because 'extraction should stop before the next version heading' - } - - It 'extracts the 0.1.4.0 entry' { - $result = & $ExtractScript -Version '0.1.4.0' - $joined = $result -join "`n" - $joined | Should -Match 'Initial public release' -Because 'the 0.1.4.0 entry describes the initial release' - $joined | Should -Not -Match '1\.0\.0-alpha001' -Because 'extraction should not include content from other versions' - } - - It 'preserves nested ### headings within the entry' { - $result = & $ExtractScript -Version '1.0.0-alpha001' - $joined = $result -join "`n" - $joined | Should -Match '### Added' -Because 'subsection headings should be preserved' - $joined | Should -Match '### Changed' -Because 'subsection headings should be preserved' - } - } - - Context 'Error handling' { - It 'fails for a nonexistent version' { - { & $ExtractScript -Version '9.9.9' -ErrorAction Stop } | Should -Throw - } - - It 'fails for a nonexistent changelog path' { - { & $ExtractScript -Version '1.0.0' -ChangelogPath 'C:\nonexistent\CHANGELOG.md' -ErrorAction Stop } | Should -Throw - } - } - - Context 'OutputPath parameter' { - It 'writes to a file when -OutputPath is specified' { - $tempFile = Join-Path ([System.IO.Path]::GetTempPath()) "changelog-test-$(Get-Random).md" - try { - & $ExtractScript -Version '1.0.0-alpha001' -OutputPath $tempFile - $tempFile | Should -Exist - $content = Get-Content $tempFile -Raw - $content | Should -Match 'Health check and auto-remediation system' - } - finally { - if (Test-Path $tempFile) { Remove-Item $tempFile -Force } - } - } - } } diff --git a/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 index 689778d..95bc05f 100644 --- a/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 @@ -43,7 +43,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { Context 'Module Manifest' -Skip:$script:IsSingleFileMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot - $ManifestPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\ConnectWiseAutomateAgent.psd1' + $ManifestPath = Join-Path $ModuleRoot 'source\ConnectWiseAutomateAgent.psd1' $Manifest = Test-ModuleManifest -Path $ManifestPath -ErrorAction Stop } @@ -270,7 +270,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { Context 'Function-to-File Mapping' -Skip:$script:IsSingleFileMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot - $PublicPath = Join-Path $ModuleRoot 'ConnectWiseAutomateAgent\Public' + $PublicPath = Join-Path $ModuleRoot 'source\Public' $PublicFiles = Get-ChildItem -Path $PublicPath -Filter '*.ps1' -Recurse | Select-Object -ExpandProperty BaseName } @@ -293,7 +293,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { Context 'Single-File Build Validation' -Skip:$script:IsModuleMode { BeforeAll { $RepoRoot = Split-Path -Parent $PSScriptRoot - $SingleFilePath = Join-Path $RepoRoot 'ConnectWiseAutomateAgent.ps1' + $SingleFilePath = Join-Path $RepoRoot 'output\ConnectWiseAutomateAgent.ps1' } It 'single-file build exists' { diff --git a/Tests/Invoke-AllTests.ps1 b/Tests/Invoke-AllTests.ps1 index 0355f05..354aa3b 100644 --- a/Tests/Invoke-AllTests.ps1 +++ b/Tests/Invoke-AllTests.ps1 @@ -103,14 +103,14 @@ $moduleExitCode = Invoke-PesterInProcess -Mode 'Module' -Label 'Mode 1/2: Module # Rebuild single-file before SingleFile mode # ============================================ if (-not $SkipBuild) { - Write-Host "`n Rebuilding single-file distribution..." -ForegroundColor Gray - $buildScript = Join-Path $RepoRoot 'Build\SingleFileBuild.ps1' + Write-Host "`n Rebuilding module (Sampler/ModuleBuilder)..." -ForegroundColor Gray + $buildScript = Join-Path $RepoRoot 'build.ps1' if (Test-Path $buildScript) { - & powershell -NoProfile -NonInteractive -File $buildScript + & $buildScript -Tasks build if ($LASTEXITCODE -ne 0) { - Write-Host " WARNING: Single-file build exited with code $LASTEXITCODE" -ForegroundColor Red + Write-Host " WARNING: Build exited with code $LASTEXITCODE" -ForegroundColor Red } else { - Write-Host " Single-file build completed." -ForegroundColor Green + Write-Host " Build completed." -ForegroundColor Green } } else { Write-Host " WARNING: Build script not found at '$buildScript'" -ForegroundColor Red diff --git a/Tests/TestBootstrap.ps1 b/Tests/TestBootstrap.ps1 index 0578158..d959f7a 100644 --- a/Tests/TestBootstrap.ps1 +++ b/Tests/TestBootstrap.ps1 @@ -32,8 +32,8 @@ param() $ModuleName = 'ConnectWiseAutomateAgent' $RepoRoot = Split-Path -Parent $PSScriptRoot -$ModulePsd1 = Join-Path $RepoRoot "$ModuleName\$ModuleName.psd1" -$SingleFilePath = Join-Path $RepoRoot "$ModuleName.ps1" +$ModulePsd1 = Join-Path $RepoRoot "source\$ModuleName.psd1" +$SingleFilePath = Join-Path $RepoRoot "output\$ModuleName.ps1" $LoadMethod = if ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') { 'SingleFile' } else { 'Module' } @@ -44,7 +44,7 @@ if ($LoadMethod -eq 'SingleFile') { # SingleFile mode: load concatenated .ps1 into a dynamic module. # This validates the build output while preserving InModuleScope compatibility. if (-not (Test-Path $SingleFilePath)) { - throw "Single-file build not found at '$SingleFilePath'. Run Build\SingleFileBuild.ps1 first." + throw "Single-file build not found at '$SingleFilePath'. Run './build.ps1 -Tasks build' first." } $singleFileContent = Get-Content $SingleFilePath -Raw -ErrorAction Stop diff --git a/Tests/test-local.ps1 b/Tests/test-local.ps1 index e925e5f..0740c62 100644 --- a/Tests/test-local.ps1 +++ b/Tests/test-local.ps1 @@ -47,11 +47,11 @@ $currentStep = 0 # 1. BUILD if (-not $SkipBuild) { $currentStep++ - Write-Host "[$currentStep/$stepCount] Building single-file distribution..." -ForegroundColor Yellow - $buildScript = Join-Path $ProjectRoot 'Build\SingleFileBuild.ps1' + Write-Host "[$currentStep/$stepCount] Building module (Sampler/ModuleBuilder)..." -ForegroundColor Yellow + $buildScript = Join-Path $ProjectRoot 'build.ps1' if (Test-Path $buildScript) { - & powershell -NoProfile -NonInteractive -File $buildScript + & $buildScript -Tasks build if ($LASTEXITCODE -ne 0) { Write-Host "BUILD FAILED" -ForegroundColor Red exit 1 @@ -69,7 +69,7 @@ if (-not $SkipAnalyze) { Write-Host "[$currentStep/$stepCount] Running PSScriptAnalyzer..." -ForegroundColor Yellow Import-Module PSScriptAnalyzer -ErrorAction Stop - $sourcePath = Join-Path $ProjectRoot 'ConnectWiseAutomateAgent' + $sourcePath = Join-Path $ProjectRoot 'source' $settingsFile = Join-Path $ProjectRoot '.PSScriptAnalyzerSettings.psd1' $analyzeParams = @{ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..f005a4e --- /dev/null +++ b/build.ps1 @@ -0,0 +1,542 @@ +<# + .DESCRIPTION + Bootstrap and build script for PowerShell module CI/CD pipeline. + + .PARAMETER Tasks + The task or tasks to run. The default value is '.' (runs the default task). + + .PARAMETER CodeCoverageThreshold + The code coverage target threshold to uphold. Set to 0 to disable. + The default value is '' (empty string). + + .PARAMETER BuildConfig + Path to a file with configuration. Supported extensions : psd1, yaml, yml, json, jsonc. + + .PARAMETER OutputDirectory + Specifies the folder to build the artefact into. The default value is 'output'. + + .PARAMETER BuiltModuleSubdirectory + Subdirectory name to build the module (under $OutputDirectory). The default + value is '' (empty string). + + .PARAMETER RequiredModulesDirectory + Can be a path (relative to $PSScriptRoot or absolute) to tell Resolve-Dependency + and PSDepend where to save the required modules. It is also possible to use + 'CurrentUser' or 'AllUsers' to install missing dependencies. You can override + the value for PSDepend in the Build.psd1 build manifest. The default value is + 'output/RequiredModules'. + + .PARAMETER PesterScript + One or more paths that will override the Pester configuration in build + configuration file when running the build task Invoke_Pester_Tests. + + If running Pester 5 test, use the alias PesterPath to be future-proof. + + .PARAMETER PesterTag + Filter which tags to run when invoking Pester tests. This is used in the + Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER PesterExcludeTag + Filter which tags to exclude when invoking Pester tests. This is used in + the Invoke-Pester.pester.build.ps1 tasks. + + .PARAMETER DscTestTag + Filter which tags to run when invoking DSC Resource tests. This is used + in the DscResource.Test.build.ps1 tasks. + + .PARAMETER DscTestExcludeTag + Filter which tags to exclude when invoking DSC Resource tests. This is + used in the DscResource.Test.build.ps1 tasks. + + .PARAMETER ResolveDependency + Resolve missing dependencies. + + .PARAMETER BuildInfo + The build info object from ModuleBuilder. Defaults to an empty hashtable. + + .PARAMETER AutoRestore + Specifies to restore the required modules by running build.ps1 with ResolveDependency switch and empty task `noop`. + + .PARAMETER UseModuleFast + Specifies to use ModuleFast instead of PowerShellGet to resolve dependencies + faster. + + .PARAMETER UsePSResourceGet + Specifies to use PSResourceGet instead of PowerShellGet to resolve dependencies + faster. This can also be configured in Resolve-Dependency.psd1. + + .PARAMETER UsePowerShellGetCompatibilityModule + Specifies to use the compatibility module PowerShellGet. This parameter + only works then the method of downloading dependencies is PSResourceGet. + This can also be configured in Resolve-Dependency.psd1. +#> +[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Suppressing this rule because how $PSDependTarget is assigned to splatting variable $resolveDependencyParams.')] +[CmdletBinding()] +param +( + [Parameter(Position = 0)] + [System.String[]] + $Tasks = '.', + + [Parameter()] + [System.String] + $CodeCoverageThreshold = '', + + [Parameter()] + [System.String] + [ValidateScript( + { Test-Path -Path $_ } + )] + $BuildConfig, + + [Parameter()] + [System.String] + $OutputDirectory = 'output', + + [Parameter()] + [System.String] + $BuiltModuleSubdirectory = '', + + [Parameter()] + [System.String] + $RequiredModulesDirectory = $(Join-Path 'output' 'RequiredModules'), + + [Parameter()] + # This alias is to prepare for the rename of this parameter to PesterPath when Pester 4 support is removed + [Alias('PesterPath')] + [System.Object[]] + $PesterScript, + + [Parameter()] + [System.String[]] + $PesterTag, + + [Parameter()] + [System.String[]] + $PesterExcludeTag, + + [Parameter()] + [System.String[]] + $DscTestTag, + + [Parameter()] + [System.String[]] + $DscTestExcludeTag, + + [Parameter()] + [Alias('bootstrap')] + [System.Management.Automation.SwitchParameter] + $ResolveDependency, + + [Parameter(DontShow)] + [AllowNull()] + [System.Collections.Hashtable] + $BuildInfo, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $AutoRestore, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UseModuleFast, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePSResourceGet, + + [Parameter()] + [System.Management.Automation.SwitchParameter] + $UsePowerShellGetCompatibilityModule +) + +<# + The BEGIN block (at the end of this file) handles the Bootstrap of the Environment + before Invoke-Build can run the tasks if the parameter ResolveDependency (or + parameter alias Bootstrap) is specified. +#> + +process +{ + if ($MyInvocation.ScriptName -notLike '*Invoke-Build.ps1') + { + # Only run the process block through InvokeBuild (look at the Begin block at the bottom of this script). + return + } + + # Execute the Build process from the .build.ps1 path. + Push-Location -Path $PSScriptRoot -StackName 'BeforeBuild' + + try + { + Write-Host -Object "[build] Parsing defined tasks" -ForeGroundColor Magenta + + # Load the default BuildInfo if the parameter BuildInfo is not set. + if (-not $PSBoundParameters.ContainsKey('BuildInfo')) + { + try + { + if (Test-Path -Path $BuildConfig) + { + $configFile = Get-Item -Path $BuildConfig + + Write-Host -Object "[build] Loading Configuration from $configFile" + + $BuildInfo = switch -Regex ($configFile.Extension) + { + # Native Support for PSD1 + '\.psd1' + { + if (-not (Get-Command -Name Import-PowerShellDataFile -ErrorAction SilentlyContinue)) + { + Import-Module -Name Microsoft.PowerShell.Utility -RequiredVersion 3.1.0.0 + } + + Import-PowerShellDataFile -Path $BuildConfig + } + + # Support for yaml when module PowerShell-Yaml is available + '\.[yaml|yml]' + { + Import-Module -Name 'powershell-yaml' -ErrorAction Stop + + ConvertFrom-Yaml -Yaml (Get-Content -Raw $configFile) + } + + # Support for JSON and JSONC (by Removing comments) when module PowerShell-Yaml is available + '\.[json|jsonc]' + { + $jsonFile = Get-Content -Raw -Path $configFile + + $jsonContent = $jsonFile -replace '(?m)\s*//.*?$' -replace '(?ms)/\*.*?\*/' + + # Yaml is superset of JSON. + ConvertFrom-Yaml -Yaml $jsonContent + } + + # Unknown extension, return empty hashtable. + default + { + Write-Error -Message "Extension '$_' not supported. using @{}" + + @{ } + } + } + } + else + { + Write-Host -Object "Configuration file '$($BuildConfig.FullName)' not found" -ForegroundColor Red + + # No config file was found, return empty hashtable. + $BuildInfo = @{ } + } + } + catch + { + $logMessage = "Error loading Config '$($BuildConfig.FullName)'.`r`nAre you missing dependencies?`r`nMake sure you run './build.ps1 -ResolveDependency -tasks noop' before running build to restore the required modules." + + Write-Host -Object $logMessage -ForegroundColor Yellow + + $BuildInfo = @{ } + + Write-Error -Message $_.Exception.Message + } + } + + # If the Invoke-Build Task Header is specified in the Build Info, set it. + if ($BuildInfo.TaskHeader) + { + Set-BuildHeader -Script ([scriptblock]::Create($BuildInfo.TaskHeader)) + } + + <# + Add BuildModuleOutput to PSModule Path environment variable. + Moved here (not in begin block) because build file can contains BuiltSubModuleDirectory value. + #> + if ($BuiltModuleSubdirectory) + { + if (-not (Split-Path -IsAbsolute -Path $BuiltModuleSubdirectory)) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuiltModuleSubdirectory + } + else + { + $BuildModuleOutput = $BuiltModuleSubdirectory + } + } # test if BuiltModuleSubDirectory set in build config file + elseif ($BuildInfo.ContainsKey('BuiltModuleSubDirectory')) + { + $BuildModuleOutput = Join-Path -Path $OutputDirectory -ChildPath $BuildInfo['BuiltModuleSubdirectory'] + } + else + { + $BuildModuleOutput = $OutputDirectory + } + + # Pre-pending $BuildModuleOutput folder to PSModulePath to resolve built module from this folder. + if ($powerShellModulePaths -notcontains $BuildModuleOutput) + { + Write-Host -Object "[build] Pre-pending '$BuildModuleOutput' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $BuildModuleOutput + [System.IO.Path]::PathSeparator + $env:PSModulePath + } + + <# + Import Tasks from modules via their exported aliases when defined in Build Manifest. + https://github.com/nightroman/Invoke-Build/tree/master/Tasks/Import#example-2-import-from-a-module-with-tasks + #> + if ($BuildInfo.ContainsKey('ModuleBuildTasks')) + { + foreach ($module in $BuildInfo['ModuleBuildTasks'].Keys) + { + try + { + Write-Host -Object "Importing tasks from module $module" -ForegroundColor DarkGray + + $loadedModule = Import-Module -Name $module -PassThru -ErrorAction Stop + + foreach ($TaskToExport in $BuildInfo['ModuleBuildTasks'].($module)) + { + $aliasTasks = $loadedModule.ExportedAliases.GetEnumerator().Where{ + # Using -like to support wildcard. + $_.Key -like $TaskToExport + } + + foreach ($aliasTask in $aliasTasks) + { + Write-Host -Object "`t Loading $($aliasTask.Key)..." -ForegroundColor DarkGray + + # Dot-sourcing the Tasks via their exported aliases. + . (Get-Alias $aliasTask.Key) + } + } + } + catch + { + Write-Host -Object "Could not load tasks for module $module." -ForegroundColor Red + + Write-Error -Message $_ + } + } + } + + # Loading Build Tasks defined in the .build/ folder (will override the ones imported above if same task name). + $taskFiles = Get-ChildItem -Path '.build/' -Recurse -Include '*.ps1' -ErrorAction Ignore + foreach ($taskFile in $taskFiles) + { + "Importing file $($taskFile.BaseName)" | Write-Verbose + + . $taskFile.FullName + } + + # Synopsis: Empty task, useful to test the bootstrap process. + task noop { } + + # Define default task sequence ("."), can be overridden in the $BuildInfo. + task . { + Write-Build -Object 'No sequence currently defined for the default task' -ForegroundColor Yellow + } + + Write-Host -Object 'Adding Workflow from configuration:' -ForegroundColor DarkGray + + # Load Invoke-Build task sequences/workflows from $BuildInfo. + foreach ($workflow in $BuildInfo.BuildWorkflow.keys) + { + Write-Verbose -Message "Creating Build Workflow '$Workflow' with tasks $($BuildInfo.BuildWorkflow.($Workflow) -join ', ')." + + $workflowItem = $BuildInfo.BuildWorkflow.($workflow) + + if ($workflowItem.Trim() -match '^\{(?[\w\W]*)\}$') + { + $workflowItem = [ScriptBlock]::Create($Matches['sb']) + } + + Write-Host -Object " +-> $workflow" -ForegroundColor DarkGray + + task $workflow $workflowItem + } + + Write-Host -Object "[build] Executing requested workflow: $($Tasks -join ', ')" -ForeGroundColor Magenta + } + finally + { + Pop-Location -StackName 'BeforeBuild' + } +} + +begin +{ + # Find build config if not specified. + if (-not $BuildConfig) + { + $config = Get-ChildItem -Path "$PSScriptRoot\*" -Include 'build.y*ml', 'build.psd1', 'build.json*' -ErrorAction Ignore + + if (-not $config -or ($config -is [System.Array] -and $config.Length -le 0)) + { + throw 'No build configuration found. Specify path via parameter BuildConfig.' + } + elseif ($config -is [System.Array]) + { + if ($config.Length -gt 1) + { + throw 'More than one build configuration found. Specify which path to use via parameter BuildConfig.' + } + + $BuildConfig = $config[0] + } + else + { + $BuildConfig = $config + } + } + + # Bootstrapping the environment before using Invoke-Build as task runner + + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') + { + Write-Host -Object "[pre-build] Starting Build Init" -ForegroundColor Green + + Push-Location $PSScriptRoot -StackName 'BuildModule' + } + + if ($RequiredModulesDirectory -in @('CurrentUser', 'AllUsers')) + { + # Installing modules instead of saving them. + Write-Host -Object "[pre-build] Required Modules will be installed to the PowerShell module path that is used for $RequiredModulesDirectory." -ForegroundColor Green + + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> + $PSDependTarget = $RequiredModulesDirectory + } + else + { + if (-not (Split-Path -IsAbsolute -Path $OutputDirectory)) + { + $OutputDirectory = Join-Path -Path $PSScriptRoot -ChildPath $OutputDirectory + } + + # Resolving the absolute path to save the required modules to. + if (-not (Split-Path -IsAbsolute -Path $RequiredModulesDirectory)) + { + $RequiredModulesDirectory = Join-Path -Path $PSScriptRoot -ChildPath $RequiredModulesDirectory + } + + # Create the output/modules folder if not exists, or resolve the Absolute path otherwise. + if (Resolve-Path -Path $RequiredModulesDirectory -ErrorAction SilentlyContinue) + { + Write-Debug -Message "[pre-build] Required Modules path already exist at $RequiredModulesDirectory" + + $requiredModulesPath = Convert-Path -Path $RequiredModulesDirectory + } + else + { + Write-Host -Object "[pre-build] Creating required modules directory $RequiredModulesDirectory." -ForegroundColor Green + + $requiredModulesPath = (New-Item -ItemType Directory -Force -Path $RequiredModulesDirectory).FullName + } + + $powerShellModulePaths = $env:PSModulePath -split [System.IO.Path]::PathSeparator + + # Pre-pending $requiredModulesPath folder to PSModulePath to resolve from this folder FIRST. + if ($RequiredModulesDirectory -notin @('CurrentUser', 'AllUsers') -and + ($powerShellModulePaths -notcontains $RequiredModulesDirectory)) + { + Write-Host -Object "[pre-build] Pre-pending '$RequiredModulesDirectory' folder to PSModulePath" -ForegroundColor Green + + $env:PSModulePath = $RequiredModulesDirectory + [System.IO.Path]::PathSeparator + $env:PSModulePath + } + + $powerShellYamlModule = Get-Module -Name 'powershell-yaml' -ListAvailable + $invokeBuildModule = Get-Module -Name 'InvokeBuild' -ListAvailable + $psDependModule = Get-Module -Name 'PSDepend' -ListAvailable + + # Checking if the user should -ResolveDependency. + if (-not ($powerShellYamlModule -and $invokeBuildModule -and $psDependModule) -and -not $ResolveDependency) + { + if ($AutoRestore -or -not $PSBoundParameters.ContainsKey('Tasks') -or $Tasks -contains 'build') + { + Write-Host -Object "[pre-build] Dependency missing, running './build.ps1 -ResolveDependency -Tasks noop' for you `r`n" -ForegroundColor Yellow + + $ResolveDependency = $true + } + else + { + Write-Warning -Message "Some required Modules are missing, make sure you first run with the '-ResolveDependency' parameter. Running 'build.ps1 -ResolveDependency -Tasks noop' will pull required modules without running the build task." + } + } + + <# + The variable $PSDependTarget will be used below when building the splatting + variable before calling Resolve-Dependency.ps1, unless overridden in the + file Resolve-Dependency.psd1. + #> + $PSDependTarget = $requiredModulesPath + } + + if ($ResolveDependency) + { + Write-Host -Object "[pre-build] Resolving dependencies using preferred method." -ForegroundColor Green + + $resolveDependencyParams = @{ } + + # If BuildConfig is a Yaml file, bootstrap powershell-yaml via ResolveDependency. + if ($BuildConfig -match '\.[yaml|yml]$') + { + $resolveDependencyParams.Add('WithYaml', $true) + } + + $resolveDependencyAvailableParams = (Get-Command -Name '.\Resolve-Dependency.ps1').Parameters.Keys + + foreach ($cmdParameter in $resolveDependencyAvailableParams) + { + # The parameter has been explicitly used for calling the .build.ps1 + if ($MyInvocation.BoundParameters.ContainsKey($cmdParameter)) + { + $paramValue = $MyInvocation.BoundParameters.Item($cmdParameter) + + Write-Debug -Message " adding $cmdParameter :: $paramValue [from user-provided parameters to Build.ps1]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) + } + # Use defaults parameter value from Build.ps1, if any + else + { + $paramValue = Get-Variable -Name $cmdParameter -ValueOnly -ErrorAction Ignore + + if ($paramValue) + { + Write-Debug -Message " adding $cmdParameter :: $paramValue [from default Build.ps1 variable]" + + $resolveDependencyParams.Add($cmdParameter, $paramValue) + } + } + } + + Write-Host -Object "[pre-build] Starting bootstrap process." -ForegroundColor Green + + .\Resolve-Dependency.ps1 @resolveDependencyParams + } + + if ($MyInvocation.ScriptName -notlike '*Invoke-Build.ps1') + { + Write-Verbose -Message "Bootstrap completed. Handing back to InvokeBuild." + + if ($PSBoundParameters.ContainsKey('ResolveDependency')) + { + Write-Verbose -Message "Dependency already resolved. Removing task." + + $null = $PSBoundParameters.Remove('ResolveDependency') + } + + Write-Host -Object "[build] Starting build with InvokeBuild." -ForegroundColor Green + + Invoke-Build @PSBoundParameters -Task $Tasks -File $MyInvocation.MyCommand.Path + + Pop-Location -StackName 'BuildModule' + + return + } +} diff --git a/build.yaml b/build.yaml new file mode 100644 index 0000000..8b308eb --- /dev/null +++ b/build.yaml @@ -0,0 +1,81 @@ +--- +#################################################### +# ModuleBuilder Configuration # +#################################################### + +SourcePath: source +CopyPaths: + - en-US +Encoding: UTF8 +VersionedOutputDirectory: true +Prefix: prefix.ps1 +Suffix: suffix.ps1 + +#################################################### +# Pipeline Configuration # +#################################################### + +BuildWorkflow: + '.': + - build + - test + + build: + - Clean + - Build_ModuleOutput_ModuleBuilder + - Build_SingleFile_Distribution + + test: + - Pester_Tests_Stop_On_Fail + + publish: + - publish_module_to_gallery + +#################################################### +# Sampler Pipeline Configuration # +#################################################### + +Sampler: + DefaultOutputDirectory: 'output' + +ModuleBuildTasks: + Sampler: + - '*.build.Sampler.ib.tasks' + ModuleBuilder: + - '*.build.ModuleBuilder.ib.tasks' + +TaskHeader: | + param($Path) + "" + "=" * 79 + Write-Build Cyan "`t`t`t$($Task.Name.replace("_"," ").ToUpper())" + Write-Build DarkGray "$(Get-BuildSynopsis $Task)" + "-" * 79 + Write-Build DarkGray " $Path" + Write-Build DarkGray " $($Task.InvocationInfo.ScriptName):$($Task.InvocationInfo.ScriptLineNumber)" + "" + +#################################################### +# PESTER Configuration # +#################################################### + +Pester: + Script: + - Tests + ExcludeTag: + - Live + - Integration + Tag: + OutputFile: output/testResults/testResults.xml + OutputFormat: NUnitXml + PassThru: true + +#################################################### +# PSScriptAnalyzer Configuration # +#################################################### + +PSScriptAnalyzer: + Script: source + Settings: .PSScriptAnalyzerSettings.psd1 + ExcludeRule: + IncludeRule: diff --git a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psd1 b/source/ConnectWiseAutomateAgent.psd1 similarity index 52% rename from ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psd1 rename to source/ConnectWiseAutomateAgent.psd1 index 1603e1a..e4d8333 100644 --- a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psd1 +++ b/source/ConnectWiseAutomateAgent.psd1 @@ -25,38 +25,8 @@ PowerShellVersion = '3.0' # Functions to export from this module - FunctionsToExport = @( - 'Hide-CWAAAddRemove', - 'Rename-CWAAAddRemove', - 'Show-CWAAAddRemove', - 'Install-CWAA', - 'Redo-CWAA', - 'Uninstall-CWAA', - 'Update-CWAA', - 'Get-CWAAError', - 'Get-CWAALogLevel', - 'Get-CWAAProbeError', - 'Set-CWAALogLevel', - 'Get-CWAAProxy', - 'Set-CWAAProxy', - 'Restart-CWAA', - 'Start-CWAA', - 'Stop-CWAA', - 'Get-CWAAInfo', - 'Get-CWAAInfoBackup', - 'Get-CWAASettings', - 'New-CWAABackup', - 'Reset-CWAA', - 'ConvertFrom-CWAASecurity', - 'ConvertTo-CWAASecurity', - 'Invoke-CWAACommand', - 'Test-CWAAPort', - 'Test-CWAAServerConnectivity', - 'Test-CWAAHealth', - 'Repair-CWAA', - 'Register-CWAAHealthCheckTask', - 'Unregister-CWAAHealthCheckTask' - ) + # Wildcard for development; ModuleBuilder writes explicit list at build time + FunctionsToExport = @('*') # Cmdlets to export from this module CmdletsToExport = @() @@ -65,40 +35,8 @@ VariablesToExport = @() # Aliases to export from this module - AliasesToExport = @( - 'Hide-LTAddRemove', - 'Rename-LTAddRemove', - 'Show-LTAddRemove', - 'Install-LTService', - 'Redo-LTService', - 'Reinstall-CWAA', - 'Reinstall-LTService', - 'Uninstall-LTService', - 'Update-LTService', - 'Get-LTErrors', - 'Get-LTLogging', - 'Get-LTProbeErrors', - 'Set-LTLogging', - 'Get-LTProxy', - 'Set-LTProxy', - 'Restart-LTService', - 'Start-LTService', - 'Stop-LTService', - 'Get-LTServiceInfo', - 'Get-LTServiceInfoBackup', - 'Get-LTServiceSettings', - 'New-LTServiceBackup', - 'Reset-LTService', - 'ConvertFrom-LTSecurity', - 'ConvertTo-LTSecurity', - 'Invoke-LTServiceCommand', - 'Test-LTPorts', - 'Test-LTServerConnectivity', - 'Test-LTHealth', - 'Repair-LTService', - 'Register-LTHealthCheckTask', - 'Unregister-LTHealthCheckTask' - ) + # Wildcard for development; ModuleBuilder discovers [Alias()] attributes at build time + AliasesToExport = @('*') # Private data to pass to the module specified in RootModule/ModuleToProcess PrivateData = @{ @@ -126,6 +64,6 @@ } # HelpInfo URI of this module - HelpInfoURI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml' + HelpInfoURI = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/source/en-US/ConnectWiseAutomateAgent-help.xml' } diff --git a/ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 b/source/ConnectWiseAutomateAgent.psm1 similarity index 100% rename from ConnectWiseAutomateAgent/ConnectWiseAutomateAgent.psm1 rename to source/ConnectWiseAutomateAgent.psm1 diff --git a/ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 b/source/Private/Assert-CWAANotProbeAgent.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Assert-CWAANotProbeAgent.ps1 rename to source/Private/Assert-CWAANotProbeAgent.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 b/source/Private/Clear-CWAAInstallerArtifacts.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Clear-CWAAInstallerArtifacts.ps1 rename to source/Private/Clear-CWAAInstallerArtifacts.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 b/source/Private/Initialize/Initialize-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAA.ps1 rename to source/Private/Initialize/Initialize-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 b/source/Private/Initialize/Initialize-CWAANetworking.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Initialize/Initialize-CWAANetworking.ps1 rename to source/Private/Initialize/Initialize-CWAANetworking.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 b/source/Private/Invoke-CWAAMsiInstaller.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Invoke-CWAAMsiInstaller.ps1 rename to source/Private/Invoke-CWAAMsiInstaller.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 b/source/Private/Remove-CWAAFolderRecursive.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Remove-CWAAFolderRecursive.ps1 rename to source/Private/Remove-CWAAFolderRecursive.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 b/source/Private/Resolve-CWAAServer.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Resolve-CWAAServer.ps1 rename to source/Private/Resolve-CWAAServer.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 b/source/Private/Test-CWAADotNetPrerequisite.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Test-CWAADotNetPrerequisite.ps1 rename to source/Private/Test-CWAADotNetPrerequisite.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 b/source/Private/Test-CWAADownloadIntegrity.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Test-CWAADownloadIntegrity.ps1 rename to source/Private/Test-CWAADownloadIntegrity.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 b/source/Private/Test-CWAAServiceExists.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Test-CWAAServiceExists.ps1 rename to source/Private/Test-CWAAServiceExists.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 b/source/Private/Wait-CWAACondition.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Wait-CWAACondition.ps1 rename to source/Private/Wait-CWAACondition.ps1 diff --git a/ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 b/source/Private/Write-CWAAEventLog.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Private/Write-CWAAEventLog.ps1 rename to source/Private/Write-CWAAEventLog.ps1 diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 rename to source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 rename to source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 diff --git a/ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 rename to source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 diff --git a/ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 b/source/Public/ConvertFrom-CWAASecurity.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/ConvertFrom-CWAASecurity.ps1 rename to source/Public/ConvertFrom-CWAASecurity.ps1 diff --git a/ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 b/source/Public/ConvertTo-CWAASecurity.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/ConvertTo-CWAASecurity.ps1 rename to source/Public/ConvertTo-CWAASecurity.ps1 diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 b/source/Public/InstallUninstall/Install-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/InstallUninstall/Install-CWAA.ps1 rename to source/Public/InstallUninstall/Install-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 b/source/Public/InstallUninstall/Redo-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/InstallUninstall/Redo-CWAA.ps1 rename to source/Public/InstallUninstall/Redo-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 b/source/Public/InstallUninstall/Uninstall-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/InstallUninstall/Uninstall-CWAA.ps1 rename to source/Public/InstallUninstall/Uninstall-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 b/source/Public/InstallUninstall/Update-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/InstallUninstall/Update-CWAA.ps1 rename to source/Public/InstallUninstall/Update-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 b/source/Public/Invoke-CWAACommand.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Invoke-CWAACommand.ps1 rename to source/Public/Invoke-CWAACommand.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 b/source/Public/Logging/Get-CWAAError.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Logging/Get-CWAAError.ps1 rename to source/Public/Logging/Get-CWAAError.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 b/source/Public/Logging/Get-CWAALogLevel.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Logging/Get-CWAALogLevel.ps1 rename to source/Public/Logging/Get-CWAALogLevel.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 b/source/Public/Logging/Get-CWAAProbeError.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Logging/Get-CWAAProbeError.ps1 rename to source/Public/Logging/Get-CWAAProbeError.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 b/source/Public/Logging/Set-CWAALogLevel.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Logging/Set-CWAALogLevel.ps1 rename to source/Public/Logging/Set-CWAALogLevel.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 b/source/Public/Proxy/Get-CWAAProxy.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Proxy/Get-CWAAProxy.ps1 rename to source/Public/Proxy/Get-CWAAProxy.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 b/source/Public/Proxy/Set-CWAAProxy.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Proxy/Set-CWAAProxy.ps1 rename to source/Public/Proxy/Set-CWAAProxy.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 b/source/Public/Service/Register-CWAAHealthCheckTask.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Register-CWAAHealthCheckTask.ps1 rename to source/Public/Service/Register-CWAAHealthCheckTask.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 b/source/Public/Service/Repair-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Repair-CWAA.ps1 rename to source/Public/Service/Repair-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 b/source/Public/Service/Restart-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Restart-CWAA.ps1 rename to source/Public/Service/Restart-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 b/source/Public/Service/Start-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Start-CWAA.ps1 rename to source/Public/Service/Start-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 b/source/Public/Service/Stop-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Stop-CWAA.ps1 rename to source/Public/Service/Stop-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 b/source/Public/Service/Test-CWAAHealth.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Test-CWAAHealth.ps1 rename to source/Public/Service/Test-CWAAHealth.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 b/source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Service/Unregister-CWAAHealthCheckTask.ps1 rename to source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 b/source/Public/Settings/Get-CWAAInfo.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfo.ps1 rename to source/Public/Settings/Get-CWAAInfo.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 b/source/Public/Settings/Get-CWAAInfoBackup.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Settings/Get-CWAAInfoBackup.ps1 rename to source/Public/Settings/Get-CWAAInfoBackup.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 b/source/Public/Settings/Get-CWAASettings.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Settings/Get-CWAASettings.ps1 rename to source/Public/Settings/Get-CWAASettings.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 b/source/Public/Settings/New-CWAABackup.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Settings/New-CWAABackup.ps1 rename to source/Public/Settings/New-CWAABackup.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 b/source/Public/Settings/Reset-CWAA.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Settings/Reset-CWAA.ps1 rename to source/Public/Settings/Reset-CWAA.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 b/source/Public/Test-CWAAPort.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Test-CWAAPort.ps1 rename to source/Public/Test-CWAAPort.ps1 diff --git a/ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 b/source/Public/Test-CWAAServerConnectivity.ps1 similarity index 100% rename from ConnectWiseAutomateAgent/Public/Test-CWAAServerConnectivity.ps1 rename to source/Public/Test-CWAAServerConnectivity.ps1 diff --git a/ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml b/source/en-US/ConnectWiseAutomateAgent-help.xml similarity index 100% rename from ConnectWiseAutomateAgent/en-US/ConnectWiseAutomateAgent-help.xml rename to source/en-US/ConnectWiseAutomateAgent-help.xml diff --git a/ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt b/source/en-US/about_ConnectWiseAutomateAgent.help.txt similarity index 100% rename from ConnectWiseAutomateAgent/en-US/about_ConnectWiseAutomateAgent.help.txt rename to source/en-US/about_ConnectWiseAutomateAgent.help.txt diff --git a/source/prefix.ps1 b/source/prefix.ps1 new file mode 100644 index 0000000..4d27f17 --- /dev/null +++ b/source/prefix.ps1 @@ -0,0 +1,6 @@ +# Module mode 32-bit warning: WOW64 relaunch in Initialize-CWAA works correctly for +# single-file mode (ConnectWiseAutomateAgent.ps1) but cannot relaunch Import-Module. +# Warn users so they know to use the native 64-bit PowerShell host. +if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { + Write-Warning 'ConnectWiseAutomateAgent: Module imported from 32-bit PowerShell on a 64-bit OS. Registry and file operations may target incorrect locations. Please use 64-bit PowerShell for reliable operation.' +} diff --git a/source/suffix.ps1 b/source/suffix.ps1 new file mode 100644 index 0000000..2b1db80 --- /dev/null +++ b/source/suffix.ps1 @@ -0,0 +1 @@ +Initialize-CWAA From 6bed037b5d7c988001cf5097e8a05fb0eec7447d Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 3 Feb 2026 12:01:38 -0700 Subject: [PATCH 5/5] Prepare v1.0.0 stable release - Promote CHANGELOG entry from [1.0.0-alpha001] to [1.0.0] - Add GitVersion.yml for CI versioning (was untracked) - Fix LICENSE copyright year to 2021-2026 - Fill PlatyPS placeholders in module overview doc (Download Help Link, Help Version) - Add .PARAMETER ProgressAction to all 30 public function source files - Replace ProgressAction placeholder in 30 function docs and 62 MAML XML entries - Includes all pending changes from Sampler/ModuleBuilder migration, CI/CD pipeline, test refactoring, documentation updates, and example scripts All 496 tests pass (8 skipped, 0 failed). PSScriptAnalyzer clean (warnings only). Co-Authored-By: Claude Opus 4.5 --- .../Build_SingleFile_Distribution.build.ps1 | 51 -- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/ci.yml | 201 +++-- .gitignore | 1 - AGENTS.md | 10 +- CHANGELOG.md | 10 +- Docs/Architecture.md | 2 +- Docs/FAQ.md | 16 +- Docs/Help/ConnectWiseAutomateAgent.md | 4 +- Docs/Help/ConvertFrom-CWAASecurity.md | 20 +- Docs/Help/ConvertTo-CWAASecurity.md | 20 +- Docs/Help/Get-CWAAError.md | 19 +- Docs/Help/Get-CWAAInfo.md | 19 +- Docs/Help/Get-CWAAInfoBackup.md | 19 +- Docs/Help/Get-CWAALogLevel.md | 19 +- Docs/Help/Get-CWAAProbeError.md | 19 +- Docs/Help/Get-CWAAProxy.md | 19 +- Docs/Help/Get-CWAASettings.md | 19 +- Docs/Help/Hide-CWAAAddRemove.md | 19 +- Docs/Help/Install-CWAA.md | 21 +- Docs/Help/Invoke-CWAACommand.md | 20 +- Docs/Help/New-CWAABackup.md | 19 +- Docs/Help/Private/Initialize-CWAA.md | 4 +- Docs/Help/Redo-CWAA.md | 23 +- Docs/Help/Register-CWAAHealthCheckTask.md | 22 +- Docs/Help/Rename-CWAAAddRemove.md | 20 +- Docs/Help/Repair-CWAA.md | 23 +- Docs/Help/Reset-CWAA.md | 20 +- Docs/Help/Restart-CWAA.md | 19 +- Docs/Help/Set-CWAALogLevel.md | 18 +- Docs/Help/Set-CWAAProxy.md | 20 +- Docs/Help/Show-CWAAAddRemove.md | 19 +- Docs/Help/Start-CWAA.md | 19 +- Docs/Help/Stop-CWAA.md | 19 +- Docs/Help/Test-CWAAHealth.md | 18 +- Docs/Help/Test-CWAAPort.md | 20 +- Docs/Help/Test-CWAAServerConnectivity.md | 20 +- Docs/Help/Uninstall-CWAA.md | 19 +- Docs/Help/Unregister-CWAAHealthCheckTask.md | 26 +- Docs/Help/Update-CWAA.md | 21 +- Docs/Security.md | 4 +- Docs/Troubleshooting.md | 2 +- Examples/AgentInstall.ps1 | 2 +- Examples/AgentInstallWithHealthCheck.ps1 | 6 +- Examples/GPOScheduledTaskDeployment.ps1 | 8 +- Examples/HealthCheck-Monitoring.ps1 | 4 +- Examples/PipelineUsage.ps1 | 2 +- Examples/ProxyConfiguration.ps1 | 2 +- Examples/Troubleshooting-QuickDiagnostic.ps1 | 2 +- GitVersion.yml | 38 + LICENSE | 2 +- README.md | 16 +- RequiredModules.psd1 | 2 + Scripts/Invoke-QuickTest.ps1 | 2 +- ...ctWiseAutomateAgent.CrossVersion.Tests.ps1 | 83 +- ...tWiseAutomateAgent.Documentation.Tests.ps1 | 2 +- ...utomateAgent.Mocked.Installation.Tests.ps1 | 4 +- .../ConnectWiseAutomateAgent.Module.Tests.ps1 | 83 +- Tests/Invoke-AllTests.ps1 | 32 +- Tests/TestBootstrap.ps1 | 66 +- Tests/test-local.ps1 | 2 +- build.yaml | 6 +- source/ConnectWiseAutomateAgent.psd1 | 11 +- source/ConnectWiseAutomateAgent.psm1 | 2 +- source/Private/Assert-CWAANotProbeAgent.ps1 | 8 +- source/Private/Initialize/Initialize-CWAA.ps1 | 8 +- source/Private/Invoke-CWAAMsiInstaller.ps1 | 13 +- source/Private/Resolve-CWAAServer.ps1 | 2 +- source/Private/Test-CWAAServiceExists.ps1 | 8 +- .../AddRemovePrograms/Hide-CWAAAddRemove.ps1 | 4 +- .../Rename-CWAAAddRemove.ps1 | 4 +- .../AddRemovePrograms/Show-CWAAAddRemove.ps1 | 4 +- source/Public/ConvertFrom-CWAASecurity.ps1 | 8 +- source/Public/ConvertTo-CWAASecurity.ps1 | 4 +- .../Public/InstallUninstall/Install-CWAA.ps1 | 4 +- source/Public/InstallUninstall/Redo-CWAA.ps1 | 6 +- .../InstallUninstall/Uninstall-CWAA.ps1 | 8 +- .../Public/InstallUninstall/Update-CWAA.ps1 | 4 +- source/Public/Invoke-CWAACommand.ps1 | 4 +- source/Public/Logging/Get-CWAAError.ps1 | 4 +- source/Public/Logging/Get-CWAALogLevel.ps1 | 4 +- source/Public/Logging/Get-CWAAProbeError.ps1 | 4 +- source/Public/Logging/Set-CWAALogLevel.ps1 | 4 +- source/Public/Proxy/Get-CWAAProxy.ps1 | 12 +- source/Public/Proxy/Set-CWAAProxy.ps1 | 4 +- .../Service/Register-CWAAHealthCheckTask.ps1 | 8 +- source/Public/Service/Repair-CWAA.ps1 | 26 +- source/Public/Service/Restart-CWAA.ps1 | 4 +- source/Public/Service/Start-CWAA.ps1 | 4 +- source/Public/Service/Stop-CWAA.ps1 | 4 +- source/Public/Service/Test-CWAAHealth.ps1 | 8 +- .../Unregister-CWAAHealthCheckTask.ps1 | 8 +- source/Public/Settings/Get-CWAAInfo.ps1 | 4 +- source/Public/Settings/Get-CWAAInfoBackup.ps1 | 4 +- source/Public/Settings/Get-CWAASettings.ps1 | 4 +- source/Public/Settings/New-CWAABackup.ps1 | 4 +- source/Public/Settings/Reset-CWAA.ps1 | 4 +- source/Public/Test-CWAAPort.ps1 | 4 +- source/Public/Test-CWAAServerConnectivity.ps1 | 4 +- .../en-US/ConnectWiseAutomateAgent-help.xml | 782 +++++++++++++++++- source/prefix.ps1 | 2 +- 101 files changed, 1827 insertions(+), 450 deletions(-) delete mode 100644 .build/Build_SingleFile_Distribution.build.ps1 create mode 100644 GitVersion.yml diff --git a/.build/Build_SingleFile_Distribution.build.ps1 b/.build/Build_SingleFile_Distribution.build.ps1 deleted file mode 100644 index 0c3e7d7..0000000 --- a/.build/Build_SingleFile_Distribution.build.ps1 +++ /dev/null @@ -1,51 +0,0 @@ -# Synopsis: Build the standalone single-file .ps1 distribution for GitHub Releases. -# This task takes the compiled .psm1 from ModuleBuilder output and produces a -# standalone ConnectWiseAutomateAgent.ps1 that can be executed directly. - -task Build_SingleFile_Distribution { - $moduleName = 'ConnectWiseAutomateAgent' - $outputBase = Join-Path $OutputDirectory $moduleName - - # Find the built module (latest version directory) - $builtManifest = Get-ChildItem -Path "$outputBase\*\$moduleName.psd1" -ErrorAction SilentlyContinue | - Sort-Object { [version](Split-Path (Split-Path $_.FullName -Parent) -Leaf) } -Descending | - Select-Object -First 1 - - if (-not $builtManifest) { - throw "Built module manifest not found in $outputBase. Run Build_ModuleOutput_ModuleBuilder first." - } - - $versionDir = Split-Path $builtManifest.FullName -Parent - $builtPsm1 = Join-Path $versionDir "$moduleName.psm1" - - if (-not (Test-Path $builtPsm1)) { - throw "Built .psm1 not found at $builtPsm1" - } - - # Read version from built manifest - $manifest = Import-PowerShellDataFile $builtManifest.FullName - $version = $manifest.ModuleVersion - $prerelease = $manifest.PrivateData.PSData.Prerelease - $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } - - # Build header - $header = @" -# $moduleName $fullVersion -# Single-file distribution - built $(Get-Date -Format 'yyyy-MM-dd') -# https://github.com/christaylorcodes/ConnectWiseAutomateAgent - -"@ - - # Write single-file output. - # Strip Export-ModuleMember because the .ps1 is dot-sourced or IEX'd outside - # a module context where that cmdlet is not valid. - $singleFilePath = Join-Path $OutputDirectory "$moduleName.ps1" - $header | Out-File $singleFilePath -Force -Encoding UTF8 - Get-Content $builtPsm1 | - Where-Object { $_ -notmatch '^\s*Export-ModuleMember\b' } | - Out-File $singleFilePath -Append -Encoding UTF8 - - $lineCount = (Get-Content $singleFilePath | Measure-Object).Count - $size = (Get-Item $singleFilePath).Length - Write-Build Green "Single-file built: $singleFilePath ($lineCount lines, $([math]::Round($size / 1KB, 1)) KB)" -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 718d927..f577982 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -32,7 +32,7 @@ assignees: '' - **OS**: [e.g., Windows 11, Windows Server 2022] - **Module Version**: [e.g., 1.0.0] - **PowerShell Version**: [e.g., 5.1, 7.4.0] -- **Loading Method**: [Gallery (Install-Module) or SingleFile (Invoke-Expression)] +- **Loading Method**: [Gallery (Install-Module) or Direct Download (Invoke-Expression)] - **Automate Server Version** (if applicable): [e.g., v200.197] ## Additional Context diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe77ca9..98311ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,19 @@ # CI / Publish workflow for ConnectWiseAutomateAgent # -# Uses Sampler/ModuleBuilder/InvokeBuild pipeline. -# Build process runs on PowerShell 7+; compiled output targets PowerShell 3.0+. +# Uses Sampler/ModuleBuilder/InvokeBuild pipeline with GitVersion for automatic +# 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) # +# Versioning: +# GitVersion calculates the version from git history and branch. +# develop → prerelease (e.g., 1.0.0-alpha0001) +# main → stable (e.g., 1.0.0) +# Sampler reads GitVersion output automatically during Build_ModuleOutput_ModuleBuilder. +# # Required secrets: # PSGALLERY_API_KEY — NuGet API key for the PowerShell Gallery # @@ -35,6 +41,13 @@ jobs: is_prerelease: ${{ steps.version.outputs.is_prerelease }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v1 + with: + versionSpec: '5.x' - name: Resolve dependencies shell: pwsh @@ -44,11 +57,13 @@ jobs: shell: pwsh run: ./build.ps1 -Tasks build - - name: Read version from manifest + - name: Read version from built manifest id: version shell: pwsh run: | - $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + $manifest = Import-PowerShellDataFile (Join-Path $modulePath.FullName "${{ env.MODULE_NAME }}.psd1") $version = $manifest.ModuleVersion $prerelease = $manifest.PrivateData.PSData.Prerelease $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } @@ -65,12 +80,13 @@ jobs: path: output/${{ env.MODULE_NAME }} retention-days: 7 - - name: Upload single-file artifact + - name: Upload release notes uses: actions/upload-artifact@v4 with: - name: single-file - path: output/ConnectWiseAutomateAgent.ps1 + name: release-notes + path: output/ReleaseNotes.md retention-days: 7 + if-no-files-found: ignore - name: Upload required modules uses: actions/upload-artifact@v4 @@ -104,6 +120,17 @@ jobs: $env:PSModulePath = "$(Resolve-Path output/RequiredModules)" + [IO.Path]::PathSeparator + $env:PSModulePath ./build.ps1 -Tasks test + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + output/testResults/testResults.xml + output/testResults/JaCoCo_coverage.xml + retention-days: 7 + if-no-files-found: ignore + analyze: name: PSScriptAnalyzer needs: build @@ -137,26 +164,46 @@ jobs: runs-on: windows-latest environment: PSGallery steps: - - uses: actions/checkout@v4 + - name: Download built module + uses: actions/download-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} - name: Verify prerelease tag is set shell: pwsh run: | - $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + $manifest = Import-PowerShellDataFile (Join-Path $modulePath.FullName "${{ env.MODULE_NAME }}.psd1") $prerelease = $manifest.PrivateData.PSData.Prerelease if (-not $prerelease) { - Write-Error "Prerelease tag is not set in the manifest. Skipping prerelease publish." + Write-Error "Prerelease tag is not set in the built manifest. GitVersion should produce a prerelease on develop." exit 1 } Write-Host "Prerelease tag: $prerelease" - - name: Download built module - uses: actions/download-artifact@v4 - with: - name: module-build - path: output/${{ env.MODULE_NAME }} + - name: Check if version already exists on PSGallery + id: gallery-check + shell: pwsh + run: | + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + $manifest = Import-PowerShellDataFile (Join-Path $modulePath.FullName "${{ env.MODULE_NAME }}.psd1") + $version = $manifest.ModuleVersion + $prerelease = $manifest.PrivateData.PSData.Prerelease + $fullVersion = if ($prerelease) { "$version-$prerelease" } else { $version } + $existing = Find-Module -Name ${{ env.MODULE_NAME }} -RequiredVersion $fullVersion -AllowPrerelease -ErrorAction SilentlyContinue + if ($existing) { + Write-Host "Version $fullVersion already exists on PSGallery. Skipping publish." + "skip=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host "Version $fullVersion not found on PSGallery. Proceeding with publish." + "skip=false" >> $env:GITHUB_OUTPUT + } - name: Publish to PSGallery (prerelease) + if: steps.gallery-check.outputs.skip != 'true' shell: pwsh env: NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -172,26 +219,44 @@ jobs: runs-on: windows-latest environment: PSGallery steps: - - uses: actions/checkout@v4 + - name: Download built module + uses: actions/download-artifact@v4 + with: + name: module-build + path: output/${{ env.MODULE_NAME }} - name: Verify no prerelease tag shell: pwsh run: | - $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + $manifest = Import-PowerShellDataFile (Join-Path $modulePath.FullName "${{ env.MODULE_NAME }}.psd1") $prerelease = $manifest.PrivateData.PSData.Prerelease if ($prerelease) { - Write-Error "Prerelease tag '$prerelease' is still set. Remove it before publishing stable." + Write-Error "Prerelease tag '$prerelease' is still set in the built manifest. Expected GitVersion to produce a stable version on main." exit 1 } Write-Host "Version: $($manifest.ModuleVersion) (stable)" - - name: Download built module - uses: actions/download-artifact@v4 - with: - name: module-build - path: output/${{ env.MODULE_NAME }} + - name: Check if version already exists on PSGallery + id: gallery-check + shell: pwsh + run: | + $modulePath = Get-ChildItem -Path "output/${{ env.MODULE_NAME }}" -Directory | + Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1 + $manifest = Import-PowerShellDataFile (Join-Path $modulePath.FullName "${{ env.MODULE_NAME }}.psd1") + $version = $manifest.ModuleVersion + $existing = Find-Module -Name ${{ env.MODULE_NAME }} -RequiredVersion $version -ErrorAction SilentlyContinue + if ($existing) { + Write-Host "Version $version already exists on PSGallery. Skipping publish." + "skip=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host "Version $version not found on PSGallery. Proceeding with publish." + "skip=false" >> $env:GITHUB_OUTPUT + } - name: Publish to PSGallery (stable) + if: steps.gallery-check.outputs.skip != 'true' shell: pwsh env: NUGET_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} @@ -205,7 +270,7 @@ jobs: release-prerelease: name: GitHub Release (Prerelease) if: github.ref == 'refs/heads/develop' && github.event_name == 'push' && !failure() && !cancelled() - needs: publish-prerelease + needs: [publish-prerelease, build] runs-on: windows-latest permissions: contents: write @@ -214,16 +279,13 @@ jobs: with: ref: ${{ github.sha }} - - name: Read version from manifest + - name: Set version from build outputs id: version shell: pwsh run: | - $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 - $v = $manifest.ModuleVersion - $pre = $manifest.PrivateData.PSData.Prerelease - $full = if ($pre) { "$v-$pre" } else { $v } - "full_version=$full" >> $env:GITHUB_OUTPUT - "tag=v$full" >> $env:GITHUB_OUTPUT + $fullVersion = '${{ needs.build.outputs.full_version }}' + "full_version=$fullVersion" >> $env:GITHUB_OUTPUT + "tag=v$fullVersion" >> $env:GITHUB_OUTPUT - name: Check if release already exists id: check @@ -240,32 +302,32 @@ jobs: "skip=false" >> $env:GITHUB_OUTPUT } - - name: Extract release notes from CHANGELOG + - name: Download release notes + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: release-notes + path: . + continue-on-error: true + + - name: Prepare release notes if: steps.check.outputs.skip != 'true' shell: pwsh run: | - $version = '${{ steps.version.outputs.full_version }}' - $lines = Get-Content CHANGELOG.md - $pattern = "^## \[$([regex]::Escape($version))\]" - $start = -1 - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match $pattern) { $start = $i; break } - } - if ($start -eq -1) { - "No changelog entry found for $version" | Out-File release-notes.md - } else { - $end = $lines.Count - for ($i = $start + 1; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match '^## \[') { $end = $i; break } - } - ($lines[($start+1)..($end-1)] -join "`n").Trim() | Out-File release-notes.md -Encoding UTF8 + if (Test-Path 'ReleaseNotes.md') { + $content = (Get-Content 'ReleaseNotes.md' -Raw).Trim() + if ($content) { + $content | Out-File release-notes.md -Encoding UTF8 + return + } } + "Prerelease ${{ steps.version.outputs.full_version }}" | Out-File release-notes.md -Encoding UTF8 - - name: Download single-file artifact + - name: Download built module if: steps.check.outputs.skip != 'true' uses: actions/download-artifact@v4 with: - name: single-file + name: module-build path: artifact - name: Create git tag @@ -281,8 +343,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + $psm1 = Get-ChildItem -Path 'artifact/*/ConnectWiseAutomateAgent.psm1' | Select-Object -First 1 gh release create '${{ steps.version.outputs.tag }}' ` - artifact/ConnectWiseAutomateAgent.ps1 ` + $psm1.FullName ` --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` --notes-file release-notes.md ` --prerelease ` @@ -291,7 +354,7 @@ jobs: release-stable: name: GitHub Release (Stable) if: github.ref == 'refs/heads/main' && github.event_name == 'push' && !failure() && !cancelled() - needs: publish-stable + needs: [publish-stable, build] runs-on: windows-latest permissions: contents: write @@ -300,15 +363,13 @@ jobs: with: ref: ${{ github.sha }} - - name: Read version from manifest + - name: Set version from build outputs id: version shell: pwsh run: | - $manifest = Import-PowerShellDataFile source/ConnectWiseAutomateAgent.psd1 - $v = $manifest.ModuleVersion - $full = $v - "full_version=$full" >> $env:GITHUB_OUTPUT - "tag=v$full" >> $env:GITHUB_OUTPUT + $fullVersion = '${{ needs.build.outputs.full_version }}' + "full_version=$fullVersion" >> $env:GITHUB_OUTPUT + "tag=v$fullVersion" >> $env:GITHUB_OUTPUT - name: Check if release already exists id: check @@ -325,10 +386,25 @@ jobs: "skip=false" >> $env:GITHUB_OUTPUT } - - name: Extract release notes from CHANGELOG + - name: Download release notes + if: steps.check.outputs.skip != 'true' + uses: actions/download-artifact@v4 + with: + name: release-notes + path: . + continue-on-error: true + + - name: Prepare release notes if: steps.check.outputs.skip != 'true' shell: pwsh run: | + if (Test-Path 'ReleaseNotes.md') { + $content = (Get-Content 'ReleaseNotes.md' -Raw).Trim() + if ($content) { + $content | Out-File release-notes.md -Encoding UTF8 + return + } + } $version = '${{ steps.version.outputs.full_version }}' $lines = Get-Content CHANGELOG.md $pattern = "^## \[$([regex]::Escape($version))\]" @@ -337,7 +413,7 @@ jobs: if ($lines[$i] -match $pattern) { $start = $i; break } } if ($start -eq -1) { - "No changelog entry found for $version" | Out-File release-notes.md + "Release $version" | Out-File release-notes.md -Encoding UTF8 } else { $end = $lines.Count for ($i = $start + 1; $i -lt $lines.Count; $i++) { @@ -346,11 +422,11 @@ jobs: ($lines[($start+1)..($end-1)] -join "`n").Trim() | Out-File release-notes.md -Encoding UTF8 } - - name: Download single-file artifact + - name: Download built module if: steps.check.outputs.skip != 'true' uses: actions/download-artifact@v4 with: - name: single-file + name: module-build path: artifact - name: Create git tag @@ -366,8 +442,9 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + $psm1 = Get-ChildItem -Path 'artifact/*/ConnectWiseAutomateAgent.psm1' | Select-Object -First 1 gh release create '${{ steps.version.outputs.tag }}' ` - artifact/ConnectWiseAutomateAgent.ps1 ` + $psm1.FullName ` --title 'ConnectWiseAutomateAgent ${{ steps.version.outputs.full_version }}' ` --notes-file release-notes.md ` --verify-tag diff --git a/.gitignore b/.gitignore index ecfa9c9..f9ab12a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ Scratch\.ps1 # Build output output/ -/ConnectWiseAutomateAgent.ps1 # Build artifacts *.log diff --git a/AGENTS.md b/AGENTS.md index 45bd38f..215c738 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ ConnectWiseAutomateAgent/ ### Module Loading (Two-Phase) -**Phase 1 (module import -- fast, no side effects):** `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively (in source mode; ModuleBuilder merges them during build), emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (single-file mode only). No network objects are created and no registry reads occur. +**Phase 1 (module import -- fast, no side effects):** `ConnectWiseAutomateAgent.psm1` dot-sources every `.ps1` from `Public/` and `Private/` recursively (in source mode; ModuleBuilder merges them during build), emits a 32-bit warning if running under WOW64 in module mode, then calls `Initialize-CWAA`. This creates centralized constants (`$Script:CWAA*`), empty state objects (`$Script:LTServiceKeys`, `$Script:LTProxy`), the PS version guard, and the WOW64 32-to-64-bit relaunch (direct download mode only). No network objects are created and no registry reads occur. **Phase 2 (on-demand -- first networking call):** `Initialize-CWAANetworking` (private) is called in the `Begin` block of networking functions (`Install-CWAA`, `Uninstall-CWAA`, `Update-CWAA`, `Set-CWAAProxy`). On first call it performs SSL certificate validation bypass, TLS protocol enablement, creates `$Script:LTWebProxy` and `$Script:LTServiceNetWebClient`, and runs `Get-CWAAProxy` to discover proxy settings from the installed agent. The `$Script:CWAANetworkInitialized` flag ensures this runs only once per session. @@ -105,12 +105,16 @@ CI is intentionally lightweight (smoke test, build, publish). Full testing is lo ### Common Patterns -**WOW64 Handling.** The module detects 32-bit PowerShell on 64-bit OS. In single-file mode, it auto-relaunches under 64-bit PowerShell. In module mode, it emits a warning (cannot relaunch `Import-Module`). Registry and file operations must account for WOW64 redirection. +**WOW64 Handling.** The module detects 32-bit PowerShell on 64-bit OS. In direct download mode (.psm1 via IEX), it auto-relaunches under 64-bit PowerShell. In module mode, it emits a warning (cannot relaunch `Import-Module`). Registry and file operations must account for WOW64 redirection. **SSL Callback Persistence.** The SSL certificate validation callback is compiled via `Add-Type` in `Initialize-CWAANetworking`. Because compiled .NET types cannot be unloaded from an AppDomain, the callback persists for the lifetime of the PowerShell process -- even across module re-imports. **Dual-Mode Testing.** The module ships as both a PSGallery module and a single `.ps1` file. Both loading methods must be tested. See `Get-Help .\Tests\TestBootstrap.ps1` for details on load methods. +**.psm1 Module Scoping.** PowerShell treats `.psm1` files differently from `.ps1` when dot-sourced. Dot-sourcing a `.psm1` applies module-scoping rules that make functions invisible to `Get-Command` and the `Function:` drive -- even in the global scope. This means `. .\Module.psm1` does not make functions callable the way `. .\Script.ps1` does. To execute `.psm1` content without module scoping, read the file as a string and execute via `Invoke-Expression` or `[scriptblock]::Create()`. The cross-version tests use the scriptblock approach for this reason. + +**System-Installed Module Interference.** If an older version of the module is installed system-wide (e.g., in `C:\Program Files\WindowsPowerShell\Modules\`), `Get-Command` may return functions from the installed version via auto-discovery instead of functions defined in the current session. This can cause tests to report incorrect function counts matching the old version rather than the current source. Always verify which module version `Get-Command` is resolving from by checking the `.Source` or `.ModuleName` property on returned commands. + ## Code Conventions For the full contributing guide covering development setup, coding standards, and PR workflow, see [CONTRIBUTING.md](CONTRIBUTING.md). @@ -164,7 +168,7 @@ Parse the JSON `success` field. If `false`, read `failedTests` and `analyzerErro ```powershell ./Tests/test-local.ps1 # Full: build + analyze + test -./Tests/test-local.ps1 -DualMode # Also test SingleFile loading +./Tests/test-local.ps1 -DualMode # Also test BuiltModule loading ``` See `Get-Help .\Tests\test-local.ps1` for flags: `-SkipBuild`, `-SkipTests`, `-SkipAnalyze`, `-Quick`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a138a8f..67ca52a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-alpha001] - 2026-01-31 +## [Unreleased] + +### Added + +### Changed + +### Fixed + +## [1.0.0] - 2026-02-03 ### Added diff --git a/Docs/Architecture.md b/Docs/Architecture.md index da4cdc4..eb43973 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -11,7 +11,7 @@ flowchart TD A[Import-Module ConnectWiseAutomateAgent] --> B[PSM1: Dot-source all .ps1 files
from Public/ and Private/] B --> C{Running 32-bit PS
on 64-bit OS?} C -->|Yes, module mode| D[Emit WOW64 warning] - C -->|Yes, single-file mode| E[Relaunch under 64-bit PowerShell] + C -->|Yes, direct download mode| E[Relaunch under 64-bit PowerShell] C -->|No| F[Initialize-CWAA] D --> F F --> G[Create Script constants
CWAARegistryRoot, CWAAInstallPath,
CWAAServiceNames, etc.] diff --git a/Docs/FAQ.md b/Docs/FAQ.md index ab9286b..d566f3d 100644 --- a/Docs/FAQ.md +++ b/Docs/FAQ.md @@ -28,11 +28,11 @@ Yes. Any tool that can execute a PowerShell script with administrator privileges Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 ``` -For environments without PowerShell Gallery access, use the version-locked single-file from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases): +For environments without PowerShell Gallery access, use the version-locked `.psm1` from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases): ```powershell # Pin to a specific version — replace v1.0.0 with your tested version -Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.psm1' | Invoke-Expression Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationID 1 ``` @@ -40,7 +40,7 @@ Install-CWAA -Server 'automate.example.com' -InstallerToken 'MyToken' -LocationI | Version | Support Level | | --- | --- | -| PowerShell 2.0 | Limited (no module manifest support, use single-file mode) | +| PowerShell 2.0 | Limited (no module manifest support, use direct download .psm1) | | PowerShell 3.0 - 5.1 | Full support | | PowerShell 7+ | Full support | @@ -80,7 +80,7 @@ This creates a task that checks agent health every 60 minutes and applies escala Yes. The Automate agent is a 32-bit application, but its registry keys are accessible from both 32-bit and 64-bit PowerShell (with different paths due to WOW64 redirection). The module handles this automatically: - **Module mode:** Emits a warning if imported in 32-bit PowerShell on a 64-bit OS. Re-import in 64-bit PowerShell. -- **Single-file mode:** Automatically relaunches under 64-bit PowerShell. +- **Direct download mode (.psm1):** Automatically relaunches under 64-bit PowerShell. Always use 64-bit PowerShell when possible. See [Troubleshooting](Troubleshooting.md#wow64--32-bit-vs-64-bit-mismatches) for details. @@ -140,12 +140,12 @@ Other useful commands: `'Send Inventory'`, `'Send Drives'`, `'Send Procs'`. See ## Module Usage -### What does the single-file version do differently? +### What does the direct download (.psm1) version do differently? Functionally identical — all the same functions are available. The difference is packaging: - **Module (Install-Module):** Installed to the module path, imported with `Import-Module`, supports `Get-Help`, auto-updates via `Update-Module`. -- **Single-file (.ps1):** All functions concatenated into one file, loaded via `Invoke-Expression`. Used when the PowerShell Gallery is unavailable. `Initialize-CWAA` is appended at the end of the file. +- **Direct download (.psm1):** The compiled module file loaded via `Invoke-Expression`. Used when the PowerShell Gallery is unavailable. Available from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases). Prefer `Install-Module` whenever possible. @@ -180,8 +180,8 @@ Note that most functions require administrator privileges and a Windows target. Install-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' Import-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' -# Single-file — use a version-locked GitHub Release URL -Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +# Direct download — use a version-locked GitHub Release URL +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.psm1' | Invoke-Expression ``` Update the version number deliberately after validating new releases in a test environment. All example scripts in this repository use version-locked patterns by default. diff --git a/Docs/Help/ConnectWiseAutomateAgent.md b/Docs/Help/ConnectWiseAutomateAgent.md index 0656ae9..a55eba4 100644 --- a/Docs/Help/ConnectWiseAutomateAgent.md +++ b/Docs/Help/ConnectWiseAutomateAgent.md @@ -1,8 +1,8 @@ --- Module Name: ConnectWiseAutomateAgent Module Guid: 37424fc5-48d4-4d15-8b19-e1c2bf4bab67 -Download Help Link: {{ Update Download Link }} -Help Version: {{ Please enter version of help manually (X.X.X.X) format }} +Download Help Link: https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/source/en-US/ConnectWiseAutomateAgent-help.xml +Help Version: 1.0.0.0 Locale: en-US --- diff --git a/Docs/Help/ConvertFrom-CWAASecurity.md b/Docs/Help/ConvertFrom-CWAASecurity.md index efab167..0f9ad24 100644 --- a/Docs/Help/ConvertFrom-CWAASecurity.md +++ b/Docs/Help/ConvertFrom-CWAASecurity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Decodes a Base64-encoded string using TripleDES decryption. ## SYNTAX ``` -ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] [] +ConvertFrom-CWAASecurity [-InputString] [-Key ] [-Force] + [-ProgressAction ] [] ``` ## DESCRIPTION @@ -88,6 +89,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/ConvertTo-CWAASecurity.md b/Docs/Help/ConvertTo-CWAASecurity.md index 02acbef..f2201e4 100644 --- a/Docs/Help/ConvertTo-CWAASecurity.md +++ b/Docs/Help/ConvertTo-CWAASecurity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Encodes a string using TripleDES encryption compatible with Automate operations. ## SYNTAX ``` -ConvertTo-CWAASecurity [-InputString] [[-Key] ] [] +ConvertTo-CWAASecurity [-InputString] [[-Key] ] [-ProgressAction ] + [] ``` ## DESCRIPTION @@ -70,6 +71,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAError.md b/Docs/Help/Get-CWAAError.md index 7a2aa37..0e7d608 100644 --- a/Docs/Help/Get-CWAAError.md +++ b/Docs/Help/Get-CWAAError.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Reads the ConnectWise Automate Agent error log into structured objects. ## SYNTAX ``` -Get-CWAAError [] +Get-CWAAError [-ProgressAction ] [] ``` ## DESCRIPTION @@ -43,6 +43,21 @@ Opens the error log in a sortable, searchable grid view window. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAInfo.md b/Docs/Help/Get-CWAAInfo.md index 94e6cdf..ad6e8a1 100644 --- a/Docs/Help/Get-CWAAInfo.md +++ b/Docs/Help/Get-CWAAInfo.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves ConnectWise Automate agent configuration from the registry. ## SYNTAX ``` -Get-CWAAInfo [-WhatIf] [-Confirm] [] +Get-CWAAInfo [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -45,6 +45,21 @@ Retrieves agent info with ShouldProcess suppressed, as used by internal callers. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Get-CWAAInfoBackup.md b/Docs/Help/Get-CWAAInfoBackup.md index 2316d1b..ea1fca7 100644 --- a/Docs/Help/Get-CWAAInfoBackup.md +++ b/Docs/Help/Get-CWAAInfoBackup.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves backed-up ConnectWise Automate agent configuration from the registry. ## SYNTAX ``` -Get-CWAAInfoBackup [] +Get-CWAAInfoBackup [-ProgressAction ] [] ``` ## DESCRIPTION @@ -43,6 +43,21 @@ Returns only the server addresses from the backup configuration. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAALogLevel.md b/Docs/Help/Get-CWAALogLevel.md index ef18873..eb9369f 100644 --- a/Docs/Help/Get-CWAALogLevel.md +++ b/Docs/Help/Get-CWAALogLevel.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves the current logging level for the ConnectWise Automate Agent. ## SYNTAX ``` -Get-CWAALogLevel [] +Get-CWAALogLevel [-ProgressAction ] [] ``` ## DESCRIPTION @@ -44,6 +44,21 @@ Typical troubleshooting workflow: check level, enable verbose, verify the change ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAProbeError.md b/Docs/Help/Get-CWAAProbeError.md index d763057..237a14c 100644 --- a/Docs/Help/Get-CWAAProbeError.md +++ b/Docs/Help/Get-CWAAProbeError.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Reads the ConnectWise Automate Agent probe error log into structured objects. ## SYNTAX ``` -Get-CWAAProbeError [] +Get-CWAAProbeError [-ProgressAction ] [] ``` ## DESCRIPTION @@ -43,6 +43,21 @@ Opens the probe error log in a sortable, searchable grid view window. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAAProxy.md b/Docs/Help/Get-CWAAProxy.md index 291dbc2..71d39c4 100644 --- a/Docs/Help/Get-CWAAProxy.md +++ b/Docs/Help/Get-CWAAProxy.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves the current agent proxy settings for module operations. ## SYNTAX ``` -Get-CWAAProxy [] +Get-CWAAProxy [-ProgressAction ] [] ``` ## DESCRIPTION @@ -44,6 +44,21 @@ Checks whether a proxy is configured and displays the URL. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Get-CWAASettings.md b/Docs/Help/Get-CWAASettings.md index 07f0bd7..f9c932e 100644 --- a/Docs/Help/Get-CWAASettings.md +++ b/Docs/Help/Get-CWAASettings.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Retrieves ConnectWise Automate agent settings from the registry. ## SYNTAX ``` -Get-CWAASettings [] +Get-CWAASettings [-ProgressAction ] [] ``` ## DESCRIPTION @@ -42,6 +42,21 @@ Returns just the configured proxy URL, if any. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### CommonParameters This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). diff --git a/Docs/Help/Hide-CWAAAddRemove.md b/Docs/Help/Hide-CWAAAddRemove.md index 0c4efe7..5565ccc 100644 --- a/Docs/Help/Hide-CWAAAddRemove.md +++ b/Docs/Help/Hide-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Hides the Automate agent from the Add/Remove Programs list. ## SYNTAX ``` -Hide-CWAAAddRemove [-WhatIf] [-Confirm] [] +Hide-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -39,6 +39,21 @@ Shows what registry changes would be made without applying them. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Install-CWAA.md b/Docs/Help/Install-CWAA.md index 0da73f8..a342979 100644 --- a/Docs/Help/Install-CWAA.md +++ b/Docs/Help/Install-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -16,14 +16,14 @@ Installs the ConnectWise Automate Agent on the local computer. ``` Install-CWAA [-Server ] [-ServerPassword ] [-Credential ] [-LocationID ] [-TrayPort ] [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] - [-ShowProgress] [-WhatIf] [-Confirm] [] + [-ShowProgress] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ### installertoken ``` Install-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] [-TrayPort ] [-Rename ] [-Hide] [-SkipDotNet] [-Force] [-NoWait] [-SkipCertificateCheck] - [-ShowProgress] [-WhatIf] [-Confirm] [] + [-ShowProgress] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -160,6 +160,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Rename Renames the agent entry in Add/Remove Programs after installation by calling Rename-CWAAAddRemove. diff --git a/Docs/Help/Invoke-CWAACommand.md b/Docs/Help/Invoke-CWAACommand.md index 688316c..36ca20e 100644 --- a/Docs/Help/Invoke-CWAACommand.md +++ b/Docs/Help/Invoke-CWAACommand.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Sends a service command to the ConnectWise Automate agent. ## SYNTAX ``` -Invoke-CWAACommand [-Command] [-WhatIf] [-Confirm] [] +Invoke-CWAACommand [-Command] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -60,6 +61,21 @@ Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/New-CWAABackup.md b/Docs/Help/New-CWAABackup.md index b48328b..342a999 100644 --- a/Docs/Help/New-CWAABackup.md +++ b/Docs/Help/New-CWAABackup.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Creates a complete backup of the ConnectWise Automate agent installation. ## SYNTAX ``` -New-CWAABackup [-WhatIf] [-Confirm] [] +New-CWAABackup [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -55,6 +55,21 @@ Shows what the backup operation would do without actually creating the backup. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Private/Initialize-CWAA.md b/Docs/Help/Private/Initialize-CWAA.md index db17868..331108b 100644 --- a/Docs/Help/Private/Initialize-CWAA.md +++ b/Docs/Help/Private/Initialize-CWAA.md @@ -19,7 +19,7 @@ Initialize-CWAA [] ## DESCRIPTION Initialize-CWAA is the Phase 1 initialization function called once at module import time by ConnectWiseAutomateAgent.psm1. It creates all `$Script:CWAA*` constants (registry paths, file paths, service names, validation patterns), initializes empty state objects for credential storage (`$Script:LTServiceKeys`) and proxy configuration (`$Script:LTProxy`), and sets the deferred networking flags to `$False`. -This function also handles the WOW64 relaunch guard: when running as 32-bit PowerShell on a 64-bit OS, it re-launches the script under the native 64-bit PowerShell host. This is critical because the Automate agent's registry keys and file paths differ between 32-bit and 64-bit views. The relaunch works in single-file mode (`ConnectWiseAutomateAgent.ps1`); in module mode the `.psm1` emits a warning instead. +This function also handles the WOW64 relaunch guard: when running as 32-bit PowerShell on a 64-bit OS, it re-launches the script under the native 64-bit PowerShell host. This is critical because the Automate agent's registry keys and file paths differ between 32-bit and 64-bit views. The relaunch works in direct download mode (`.psm1` via `Invoke-Expression`); in module mode the `.psm1` emits a warning instead. Phase 2 initialization (networking, SSL, proxy) is handled separately by `Initialize-CWAANetworking`, which runs on-demand at first networking call. @@ -30,7 +30,7 @@ Phase 2 initialization (networking, SSL, proxy) is handled separately by `Initia ### Example 1 ```powershell # Called automatically by the module — not typically invoked directly. -# In single-file mode, Initialize-CWAA is appended at the end of ConnectWiseAutomateAgent.ps1. +# In direct download mode, Initialize-CWAA is called at the end of the compiled .psm1. Initialize-CWAA ``` diff --git a/Docs/Help/Redo-CWAA.md b/Docs/Help/Redo-CWAA.md index c895b5a..de14838 100644 --- a/Docs/Help/Redo-CWAA.md +++ b/Docs/Help/Redo-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -15,13 +15,15 @@ Reinstalls the ConnectWise Automate Agent on the local computer. ### deployment ``` Redo-CWAA [-Server ] [-ServerPassword ] [-LocationID ] [-Backup] [-Hide] - [-Rename ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] [] + [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ### installertoken ``` Redo-CWAA [-Server ] [-ServerPassword ] [-InstallerToken ] [-LocationID ] - [-Backup] [-Hide] [-Rename ] [-SkipDotNet] [-Force] [-WhatIf] [-Confirm] [] + [-Backup] [-Hide] [-Rename ] [-SkipDotNet] [-Force] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION @@ -146,6 +148,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Rename Renames the agent entry in Add/Remove Programs after reinstallation. diff --git a/Docs/Help/Register-CWAAHealthCheckTask.md b/Docs/Help/Register-CWAAHealthCheckTask.md index 1990a12..5076cf4 100644 --- a/Docs/Help/Register-CWAAHealthCheckTask.md +++ b/Docs/Help/Register-CWAAHealthCheckTask.md @@ -14,7 +14,8 @@ Creates or updates a scheduled task for periodic ConnectWise Automate agent heal ``` Register-CWAAHealthCheckTask [-InstallerToken] [[-Server] ] [[-LocationID] ] - [[-TaskName] ] [[-IntervalHours] ] [-Force] [-WhatIf] [-Confirm] [] + [[-TaskName] ] [[-IntervalHours] ] [-Force] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION @@ -120,6 +121,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Server Optional server URL. When provided, the scheduled task passes this to Repair-CWAA @@ -139,7 +155,7 @@ Accept wildcard characters: False ### -TaskName Name of the scheduled task. -Default: 'CWAAHealthCheck'. +Default: 'AAutomate'. ```yaml Type: String @@ -148,7 +164,7 @@ Aliases: Required: False Position: 4 -Default value: CWAAHealthCheck +Default value: AAutomate Accept pipeline input: False Accept wildcard characters: False ``` diff --git a/Docs/Help/Rename-CWAAAddRemove.md b/Docs/Help/Rename-CWAAAddRemove.md index 5107979..e3fca2d 100644 --- a/Docs/Help/Rename-CWAAAddRemove.md +++ b/Docs/Help/Rename-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Renames the Automate agent entry in the Add/Remove Programs list. ## SYNTAX ``` -Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-WhatIf] [-Confirm] [] +Rename-CWAAAddRemove [-Name] [[-PublisherName] ] [-ProgressAction ] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -54,6 +55,21 @@ Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -PublisherName The publisher name for the Automate agent as shown in the list of installed software. diff --git a/Docs/Help/Repair-CWAA.md b/Docs/Help/Repair-CWAA.md index 1c38e45..dc42e0f 100644 --- a/Docs/Help/Repair-CWAA.md +++ b/Docs/Help/Repair-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -15,13 +15,13 @@ Performs escalating remediation of the ConnectWise Automate agent. ### Install ``` Repair-CWAA -Server -LocationID -InstallerToken [-HoursRestart ] - [-HoursReinstall ] [-WhatIf] [-Confirm] [] + [-HoursReinstall ] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ### Checkup ``` -Repair-CWAA -InstallerToken [-HoursRestart ] [-HoursReinstall ] [-WhatIf] [-Confirm] - [] +Repair-CWAA -InstallerToken [-HoursRestart ] [-HoursReinstall ] + [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -145,6 +145,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Server The ConnectWise Automate server URL for fresh installs or server mismatch correction. Required when using the Install parameter set. diff --git a/Docs/Help/Reset-CWAA.md b/Docs/Help/Reset-CWAA.md index 3893756..1a514c1 100644 --- a/Docs/Help/Reset-CWAA.md +++ b/Docs/Help/Reset-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Removes local agent identity settings to force re-registration. ## SYNTAX ``` -Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-WhatIf] [-Confirm] [] +Reset-CWAA [-ID] [-Location] [-MAC] [-Force] [-NoWait] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ## DESCRIPTION @@ -130,6 +131,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Restart-CWAA.md b/Docs/Help/Restart-CWAA.md index 10b7ac8..c2ce699 100644 --- a/Docs/Help/Restart-CWAA.md +++ b/Docs/Help/Restart-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Restarts the ConnectWise Automate agent services. ## SYNTAX ``` -Restart-CWAA [-WhatIf] [-Confirm] [] +Restart-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -38,6 +38,21 @@ Shows what would happen without actually restarting the services. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Set-CWAALogLevel.md b/Docs/Help/Set-CWAALogLevel.md index 68e4ea1..55fc0b2 100644 --- a/Docs/Help/Set-CWAALogLevel.md +++ b/Docs/Help/Set-CWAALogLevel.md @@ -13,7 +13,8 @@ Sets the logging level for the ConnectWise Automate Agent. ## SYNTAX ``` -Set-CWAALogLevel [[-Level] ] [-WhatIf] [-Confirm] [] +Set-CWAALogLevel [[-Level] ] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -68,6 +69,21 @@ Accept pipeline input: True (ByValue) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Set-CWAAProxy.md b/Docs/Help/Set-CWAAProxy.md index a6005ec..216b32e 100644 --- a/Docs/Help/Set-CWAAProxy.md +++ b/Docs/Help/Set-CWAAProxy.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -15,7 +15,8 @@ Configures module proxy settings for all operations during the current session. ``` Set-CWAAProxy [[-ProxyServerURL] ] [[-ProxyUsername] ] [[-ProxyPassword] ] [-EncodedProxyUsername ] [-EncodedProxyPassword ] [-ProxyCredential ] - [-DetectProxy] [-ResetProxy] [-SkipCertificateCheck] [-WhatIf] [-Confirm] [] + [-DetectProxy] [-ResetProxy] [-SkipCertificateCheck] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -102,6 +103,21 @@ Accept pipeline input: True (ByPropertyName) Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProxyCredential A PSCredential object containing the proxy username and password. This is the preferred secure alternative to passing -ProxyUsername diff --git a/Docs/Help/Show-CWAAAddRemove.md b/Docs/Help/Show-CWAAAddRemove.md index 7d35662..3398738 100644 --- a/Docs/Help/Show-CWAAAddRemove.md +++ b/Docs/Help/Show-CWAAAddRemove.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Shows the Automate agent in the Add/Remove Programs list. ## SYNTAX ``` -Show-CWAAAddRemove [-WhatIf] [-Confirm] [] +Show-CWAAAddRemove [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -39,6 +39,21 @@ Shows what registry changes would be made without applying them. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Start-CWAA.md b/Docs/Help/Start-CWAA.md index 4578a8e..97d2cbd 100644 --- a/Docs/Help/Start-CWAA.md +++ b/Docs/Help/Start-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Starts the ConnectWise Automate agent services. ## SYNTAX ``` -Start-CWAA [-WhatIf] [-Confirm] [] +Start-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -46,6 +46,21 @@ Shows what would happen without actually starting the services. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Stop-CWAA.md b/Docs/Help/Stop-CWAA.md index 421679d..7ce24f0 100644 --- a/Docs/Help/Stop-CWAA.md +++ b/Docs/Help/Stop-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,7 @@ Stops the ConnectWise Automate agent services. ## SYNTAX ``` -Stop-CWAA [-WhatIf] [-Confirm] [] +Stop-CWAA [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -42,6 +42,21 @@ Shows what would happen without actually stopping the services. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Confirm Prompts you for confirmation before running the cmdlet. diff --git a/Docs/Help/Test-CWAAHealth.md b/Docs/Help/Test-CWAAHealth.md index ff47aa8..c2dd445 100644 --- a/Docs/Help/Test-CWAAHealth.md +++ b/Docs/Help/Test-CWAAHealth.md @@ -13,7 +13,8 @@ Performs a read-only health assessment of the ConnectWise Automate agent. ## SYNTAX ``` -Test-CWAAHealth [[-Server] ] [-TestServerConnectivity] [] +Test-CWAAHealth [[-Server] ] [-TestServerConnectivity] [-ProgressAction ] + [] ``` ## DESCRIPTION @@ -60,6 +61,21 @@ Uses the Healthy boolean for conditional logic. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Server An Automate server URL to validate against the installed agent's configured server. If provided, the ServerMatch property indicates whether the installed agent points diff --git a/Docs/Help/Test-CWAAPort.md b/Docs/Help/Test-CWAAPort.md index 8224b9f..3764dd3 100644 --- a/Docs/Help/Test-CWAAPort.md +++ b/Docs/Help/Test-CWAAPort.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Tests connectivity to TCP ports required by the ConnectWise Automate agent. ## SYNTAX ``` -Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [] +Test-CWAAPort [[-Server] ] [[-TrayPort] ] [-Quiet] [-ProgressAction ] + [] ``` ## DESCRIPTION @@ -41,6 +42,21 @@ Returns $True if the TrayPort is available, $False otherwise. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Quiet Returns a boolean connectivity result instead of verbose output. diff --git a/Docs/Help/Test-CWAAServerConnectivity.md b/Docs/Help/Test-CWAAServerConnectivity.md index 8ca55fd..9d63afd 100644 --- a/Docs/Help/Test-CWAAServerConnectivity.md +++ b/Docs/Help/Test-CWAAServerConnectivity.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Tests connectivity to a ConnectWise Automate server's agent endpoint. ## SYNTAX ``` -Test-CWAAServerConnectivity [[-Server] ] [-Quiet] [] +Test-CWAAServerConnectivity [[-Server] ] [-Quiet] [-ProgressAction ] + [] ``` ## DESCRIPTION @@ -53,6 +54,21 @@ Tests connectivity to the server configured on the installed agent via pipeline. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Quiet Returns $True if all servers are reachable, $False otherwise. diff --git a/Docs/Help/Uninstall-CWAA.md b/Docs/Help/Uninstall-CWAA.md index ce051ba..540564f 100644 --- a/Docs/Help/Uninstall-CWAA.md +++ b/Docs/Help/Uninstall-CWAA.md @@ -13,8 +13,8 @@ Completely uninstalls the ConnectWise Automate Agent from the local computer. ## SYNTAX ``` -Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-SkipCertificateCheck] [-ShowProgress] [-WhatIf] - [-Confirm] [] +Uninstall-CWAA [[-Server] ] [-Backup] [-Force] [-SkipCertificateCheck] [-ShowProgress] + [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -129,6 +129,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Server One or more ConnectWise Automate server URLs to download uninstaller files from. If not specified, reads the server URL from the agent's current registry configuration. diff --git a/Docs/Help/Unregister-CWAAHealthCheckTask.md b/Docs/Help/Unregister-CWAAHealthCheckTask.md index 0cf25d8..167d1c8 100644 --- a/Docs/Help/Unregister-CWAAHealthCheckTask.md +++ b/Docs/Help/Unregister-CWAAHealthCheckTask.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,7 +13,8 @@ Removes the ConnectWise Automate agent health check scheduled task. ## SYNTAX ``` -Unregister-CWAAHealthCheckTask [[-TaskName] ] [-WhatIf] [-Confirm] [] +Unregister-CWAAHealthCheckTask [[-TaskName] ] [-ProgressAction ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -27,7 +28,7 @@ If the task does not exist, writes a warning and returns gracefully. Unregister-CWAAHealthCheckTask ``` -Removes the default CWAAHealthCheck scheduled task. +Removes the default AAutomate scheduled task. ### EXAMPLE 2 ``` @@ -38,9 +39,24 @@ Removes a custom-named health check task. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -TaskName Name of the scheduled task to remove. -Default: 'CWAAHealthCheck'. +Default: 'AAutomate'. ```yaml Type: String @@ -49,7 +65,7 @@ Aliases: Required: False Position: 1 -Default value: CWAAHealthCheck +Default value: AAutomate Accept pipeline input: False Accept wildcard characters: False ``` diff --git a/Docs/Help/Update-CWAA.md b/Docs/Help/Update-CWAA.md index 0e4fb39..9effe5f 100644 --- a/Docs/Help/Update-CWAA.md +++ b/Docs/Help/Update-CWAA.md @@ -1,4 +1,4 @@ ---- +--- external help file: ConnectWiseAutomateAgent-help.xml Module Name: ConnectWiseAutomateAgent online version: https://github.com/christaylorcodes/ConnectWiseAutomateAgent @@ -13,8 +13,8 @@ Manually updates the ConnectWise Automate Agent to a specified version. ## SYNTAX ``` -Update-CWAA [[-Version] ] [-SkipCertificateCheck] [-ShowProgress] [-WhatIf] [-Confirm] - [] +Update-CWAA [[-Version] ] [-SkipCertificateCheck] [-ShowProgress] [-ProgressAction ] + [-WhatIf] [-Confirm] [] ``` ## DESCRIPTION @@ -57,6 +57,21 @@ Updates the agent to the current version advertised by the server. ## PARAMETERS +### -ProgressAction +Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ShowProgress Displays a Write-Progress bar showing update progress. Off by default diff --git a/Docs/Security.md b/Docs/Security.md index 7f52157..bc8ad58 100644 --- a/Docs/Security.md +++ b/Docs/Security.md @@ -157,12 +157,12 @@ Install-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' -Force -Scope A Import-Module ConnectWiseAutomateAgent -RequiredVersion '1.0.0' ``` -**Single-file (restricted networks):** +**Direct download (restricted networks):** ```powershell # Download from a version-locked GitHub Release — the URL is immutable after publication $ModuleVersion = '1.0.0' -$URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" +$URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" Invoke-RestMethod $URI | Invoke-Expression ``` diff --git a/Docs/Troubleshooting.md b/Docs/Troubleshooting.md index a10c305..ccbfa0c 100644 --- a/Docs/Troubleshooting.md +++ b/Docs/Troubleshooting.md @@ -268,7 +268,7 @@ $env:PROCESSOR_ARCHITEW6432 # Non-empty = running 32-bit on 64-bit OS ### Module Behavior - **Module mode:** If imported in 32-bit PowerShell on a 64-bit OS, the module emits a warning. Re-import in 64-bit PowerShell. -- **Single-file mode:** The script automatically relaunches under the native 64-bit PowerShell host via `Initialize-CWAA`. +- **Direct download mode (.psm1):** The script automatically relaunches under the native 64-bit PowerShell host via `Initialize-CWAA`. ### Resolution diff --git a/Examples/AgentInstall.ps1 b/Examples/AgentInstall.ps1 index edd4a48..e463288 100644 --- a/Examples/AgentInstall.ps1 +++ b/Examples/AgentInstall.ps1 @@ -37,7 +37,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/AgentInstallWithHealthCheck.ps1 b/Examples/AgentInstallWithHealthCheck.ps1 index fd13ef0..491d43b 100644 --- a/Examples/AgentInstallWithHealthCheck.ps1 +++ b/Examples/AgentInstallWithHealthCheck.ps1 @@ -20,7 +20,7 @@ # - Windows PowerShell 3.0 or later # - Administrator privileges (for agent install and scheduled task creation) # - Internet access to the Automate server and PowerShell Gallery (or use -# the single-file fallback) +# the direct download fallback) # ============================================================================== # --- Configuration ----------------------------------------------------------- @@ -33,7 +33,7 @@ $InstallParameters = @{ # ^^ This info is sensitive -- take precautions to secure it ^^ $HealthCheckIntervalHours = 6 # How often the health check runs (default: 6) -$TaskName = 'CWAAHealthCheck' # Scheduled task name (default: CWAAHealthCheck) +$TaskName = 'AAutomate' # Scheduled task name (default: AAutomate) # --- Module Loading ---------------------------------------------------------- @@ -67,7 +67,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/GPOScheduledTaskDeployment.ps1 b/Examples/GPOScheduledTaskDeployment.ps1 index 56cd3c4..c74b9f5 100644 --- a/Examples/GPOScheduledTaskDeployment.ps1 +++ b/Examples/GPOScheduledTaskDeployment.ps1 @@ -54,7 +54,7 @@ # - Windows PowerShell 3.0 or later # - SYSTEM context (GPO scheduled tasks run as SYSTEM) # - Network access to the Automate server and PowerShell Gallery (or the -# single-file fallback URL) +# direct download fallback URL) # # Security considerations: # - Store this script on a secured NETLOGON or SYSVOL share with restricted @@ -80,7 +80,7 @@ Param( [int]$HealthCheckIntervalHours = 6, - [string]$TaskName = 'CWAAHealthCheck' + [string]$TaskName = 'AAutomate' ) $ErrorActionPreference = 'Stop' @@ -141,11 +141,11 @@ try { Write-Log "Module '$Module' v$ModuleVersion loaded." } catch { - Write-Log 'PowerShell Gallery unavailable. Falling back to version-locked single-file download.' + Write-Log 'PowerShell Gallery unavailable. Falling back to version-locked direct download.' # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/HealthCheck-Monitoring.ps1 b/Examples/HealthCheck-Monitoring.ps1 index 2585ab7..1ea078d 100644 --- a/Examples/HealthCheck-Monitoring.ps1 +++ b/Examples/HealthCheck-Monitoring.ps1 @@ -37,7 +37,7 @@ $InstallerToken = 'YourGeneratedInstallerToken' $Server = 'automate.example.com' # Only needed for Install mode $LocationID = 1 # Only needed for Install mode -$TaskName = 'CWAAHealthCheck' # Scheduled task name +$TaskName = 'AAutomate' # Scheduled task name $HealthCheckInterval = 6 # Hours between health checks # ^^ Fill in the InstallerToken at minimum. Server and LocationID are needed @@ -75,7 +75,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/PipelineUsage.ps1 b/Examples/PipelineUsage.ps1 index f719b59..af042be 100644 --- a/Examples/PipelineUsage.ps1 +++ b/Examples/PipelineUsage.ps1 @@ -49,7 +49,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/ProxyConfiguration.ps1 b/Examples/ProxyConfiguration.ps1 index d7cbb4e..efc3ffb 100644 --- a/Examples/ProxyConfiguration.ps1 +++ b/Examples/ProxyConfiguration.ps1 @@ -62,7 +62,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/Examples/Troubleshooting-QuickDiagnostic.ps1 b/Examples/Troubleshooting-QuickDiagnostic.ps1 index 3ad3379..d1326c0 100644 --- a/Examples/Troubleshooting-QuickDiagnostic.ps1 +++ b/Examples/Troubleshooting-QuickDiagnostic.ps1 @@ -56,7 +56,7 @@ catch { # WARNING: Invoke-Expression executes downloaded code. See security note above. # This fallback is ONLY for systems where the PowerShell Gallery is unavailable. # The URL is pinned to a specific release tag — it will not change after publication. - $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.ps1" + $URI = "https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v$ModuleVersion/ConnectWiseAutomateAgent.psm1" (New-Object Net.WebClient).DownloadString($URI) | Invoke-Expression } diff --git a/GitVersion.yml b/GitVersion.yml new file mode 100644 index 0000000..5f05d68 --- /dev/null +++ b/GitVersion.yml @@ -0,0 +1,38 @@ +mode: ContinuousDelivery +next-version: 1.0.0 + +branches: + main: + regex: ^main$ + tag: '' + increment: Patch + prevent-increment-of-merged-branch-version: true + is-release-branch: true + develop: + regex: ^develop$ + tag: alpha + increment: Minor + prevent-increment-of-merged-branch-version: false + is-release-branch: false + feature: + regex: ^feature(s)?[\/-] + tag: useBranchName + increment: Minor + source-branches: ['develop'] + hotfix: + regex: ^(hot)?fix(es)?[\/-] + tag: fix + increment: Patch + source-branches: ['main'] + pull-request: + tag: PR + increment: Inherit + +major-version-bump-message: '(breaking\schange|breaking|major)\b' +minor-version-bump-message: '(adds?|features?|minor)\b' +patch-version-bump-message: '\s?(fix|patch)' +no-bump-message: '\+semver:\s?(none|skip)' + +ignore: + sha: [] +merge-message-formats: {} diff --git a/LICENSE b/LICENSE index 8c0302b..eb8b008 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 Chris Taylor +Copyright (c) 2021-2026 Chris Taylor Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index dda040f..dbd26c0 100644 --- a/README.md +++ b/README.md @@ -93,26 +93,20 @@ This prevents untested updates from rolling out to production machines. Update t > **Why version lock?** Scripts that always pull the latest version are vulnerable to supply-chain risk -- a compromised update or a breaking change could affect every endpoint at once. Pinning to a tested version gives you control over when updates roll out. See [Security Model — Version Locking](Docs/Security.md#version-locking) for details. -### Single-File Usage +### Direct Download Usage -For older machines or environments without PowerShell Gallery access, a standalone `.ps1` file is available. This is a fallback -- prefer `Install-Module` above whenever possible. +For environments without PowerShell Gallery access, the compiled `.psm1` module file is attached to each [GitHub Release](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases). This is a fallback -- prefer `Install-Module` above whenever possible. -**Version-locked** (from [GitHub Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases) -- recommended for production scripts): +**Version-locked** (recommended for production scripts): ```powershell # Pin to a specific version for reproducible deployments -Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.ps1' | Invoke-Expression -``` - -**Latest** (tracks `main` branch -- use only for interactive testing, not production): - -```powershell -Invoke-RestMethod 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/ConnectWiseAutomateAgent.ps1' | Invoke-Expression +Invoke-RestMethod 'https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases/download/v1.0.0/ConnectWiseAutomateAgent.psm1' | Invoke-Expression ``` > **Tip:** Browse all versions on the [Releases](https://github.com/christaylorcodes/ConnectWiseAutomateAgent/releases) page. Replace `v1.0.0` with your desired version tag. > -> **Note:** Both methods download and execute code at runtime. Use `Install-Module` when the Gallery is available. Version-locked URLs are strongly preferred for production because they are immutable after release. +> **Note:** This downloads and executes code at runtime. Use `Install-Module` when the Gallery is available. Version-locked URLs are strongly preferred for production because they are immutable after release. ## Getting Started diff --git a/RequiredModules.psd1 b/RequiredModules.psd1 index 13f936b..efc75b0 100644 --- a/RequiredModules.psd1 +++ b/RequiredModules.psd1 @@ -19,4 +19,6 @@ ModuleBuilder = 'latest' platyPS = 'latest' 'powershell-yaml' = 'latest' + ChangelogManagement = 'latest' + Configuration = 'latest' } diff --git a/Scripts/Invoke-QuickTest.ps1 b/Scripts/Invoke-QuickTest.ps1 index 285d0a6..5a89d66 100644 --- a/Scripts/Invoke-QuickTest.ps1 +++ b/Scripts/Invoke-QuickTest.ps1 @@ -7,7 +7,7 @@ without the overhead of the full build pipeline. Designed for rapid write-test-fix-retest cycles. - Does NOT build the single-file distribution or compute code coverage. + Does NOT build the compiled module output or compute code coverage. For full validation, use Tests/test-local.ps1. .PARAMETER FunctionName diff --git a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 index cd956b4..bb06613 100644 --- a/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.CrossVersion.Tests.ps1 @@ -11,8 +11,10 @@ Tests three loading methods per PowerShell version: - Module Import: Import-Module from .psd1 manifest (PSGallery install path) - - SingleFile Dot-Source: . .\ConnectWiseAutomateAgent.ps1 (local file execution) - - SingleFile Invoke-Expression: Get-Content | IEX (web download path — the primary + - Built Module Dot-Source: Content loaded as scriptblock (local file execution). + Uses [scriptblock]::Create() because PowerShell applies module-scoping to .psm1 + files when dot-sourced directly, making functions invisible to Get-Command. + - Built Module Invoke-Expression: Get-Content | IEX (web download path — the primary method for systems without gallery access: Invoke-RestMethod | IEX) The module targets PowerShell 3.0+ but these tests exercise whichever @@ -48,16 +50,26 @@ BeforeDiscovery { $script:PSVersions += @{ Name = 'PowerShell 7 preview'; Exe = $ps7preview } } - # Check if single-file build exists (needed for SingleFile contexts) + # Check if built module exists (needed for Built Module contexts) $repoRoot = Split-Path -Parent $PSScriptRoot - $script:SingleFileExists = Test-Path (Join-Path $repoRoot 'output\ConnectWiseAutomateAgent.ps1') + $script:BuiltModuleExists = $null -ne ( + Get-ChildItem -Path (Join-Path $repoRoot 'output\ConnectWiseAutomateAgent\*\ConnectWiseAutomateAgent.psm1') -ErrorAction SilentlyContinue | + Select-Object -First 1 + ) } BeforeAll { $ModuleName = 'ConnectWiseAutomateAgent' $ModuleRoot = Split-Path -Parent $PSScriptRoot $script:ModulePsd1 = Join-Path $ModuleRoot "source\$ModuleName.psd1" - $script:SingleFilePath = Join-Path $ModuleRoot "output\$ModuleName.ps1" + $script:BuiltModulePsm1 = Get-ChildItem -Path (Join-Path $ModuleRoot "output\$ModuleName\*\$ModuleName.psm1") -ErrorAction SilentlyContinue | + Sort-Object { [version](Split-Path (Split-Path $_.FullName -Parent) -Leaf) } -Descending | + Select-Object -First 1 -ExpandProperty FullName + + $PublicPath = Join-Path $ModuleRoot 'source\Public' + $script:ExpectedFunctionCount = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -Recurse -File).Count + # Alias count: 1 per function + 2 extra for Redo-CWAA (Reinstall-CWAA, Reinstall-LTService) + $script:ExpectedAliasCount = $script:ExpectedFunctionCount + 2 # Helper: Run a verification script in a child process and parse JSON output. # Defined inside BeforeAll so Pester 5 scoping makes it available to test blocks. @@ -174,14 +186,14 @@ Catch { $script:Result.ModuleVersion | Should -Be $expectedVersion } - It 'exports all 30 functions' { + It 'exports all expected functions' { if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } - $script:Result.FunctionCount | Should -Be 30 + $script:Result.FunctionCount | Should -Be $script:ExpectedFunctionCount } - It 'exports all 32 aliases' { + It 'exports all expected aliases' { if (-not $script:Result.ModuleLoaded) { Set-ItResult -Skipped -Because 'module failed to import' } - $script:Result.AliasCount | Should -Be 32 + $script:Result.AliasCount | Should -Be $script:ExpectedAliasCount } It 'exports the ConvertTo-CWAASecurity function' { @@ -223,18 +235,22 @@ Catch { } # ============================================================================= -# Cross-Version SingleFile Dot-Source (local file execution) +# Cross-Version Built Module Dot-Source (local file execution) # ============================================================================= -Describe 'Cross-Version Compatibility - SingleFile Dot-Source' -Skip:(-not $script:SingleFileExists) { +Describe 'Cross-Version Compatibility - Built Module Dot-Source' -Skip:(-not $script:BuiltModuleExists) { Context ' - Dot-Source' -ForEach $script:PSVersions { BeforeAll { $currentExe = $Exe - $singleFile = $script:SingleFilePath - - # Dot-source the single file in a child process — this is how users run - # the file locally when they don't have gallery access. + $builtPsm1 = $script:BuiltModulePsm1 + + # Load the compiled .psm1 content as a scriptblock in a child process. + # This simulates how users run the file locally without gallery access. + # We use [scriptblock]::Create() instead of direct ". file.psm1" because + # PowerShell applies module-scoping rules to .psm1 files that make + # functions invisible to Get-Command. The scriptblock approach matches + # the real-world behavior when content is loaded as text. $verifyScript = @" `$ErrorActionPreference = 'Stop' `$results = [ordered]@{ @@ -253,7 +269,8 @@ Describe 'Cross-Version Compatibility - SingleFile Dot-Source' -Skip:(-not $scri if (-not `$results.PSEdition) { `$results.PSEdition = 'Desktop' } Try { - . '$($singleFile -replace "'","''")' + `$content = Get-Content '$($builtPsm1 -replace "'","''")' -Raw + . ([scriptblock]::Create(`$content)) `$results.Success = `$true # In dot-source mode, functions are in the session scope — find CWAA functions @@ -296,33 +313,33 @@ Catch { $script:Result.PSVersion | Should -Not -BeNullOrEmpty } - It 'loads the single file without errors' { + It 'loads the built module without errors' { $script:Result.Success | Should -BeTrue -Because $script:Result.ImportError } - It 'makes all 30 public functions available' { - if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } - $script:Result.FunctionCount | Should -BeGreaterOrEqual 30 + It 'makes all public functions available' { + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'built module failed to load' } + $script:Result.FunctionCount | Should -BeGreaterOrEqual $script:ExpectedFunctionCount } It 'has the ConvertTo-CWAASecurity function' { - if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'built module failed to load' } $script:Result.Functions | Should -Contain 'ConvertTo-CWAASecurity' } It 'has the Install-CWAA function' { - if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'built module failed to load' } $script:Result.Functions | Should -Contain 'Install-CWAA' } It 'has legacy aliases available' { - if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'built module failed to load' } $script:Result.Aliases | Should -Contain 'Install-LTService' $script:Result.Aliases | Should -Contain 'ConvertTo-LTSecurity' } It 'encrypt/decrypt round-trip succeeds' { - if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'single file failed to load' } + if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'built module failed to load' } $errorDetail = if ($script:Result.TestErrors) { $script:Result.TestErrors -join '; ' } else { 'no errors' } $script:Result.EncryptDecrypt | Should -BeTrue -Because $errorDetail } @@ -330,17 +347,17 @@ Catch { } # ============================================================================= -# Cross-Version SingleFile Invoke-Expression (web download path) +# Cross-Version Built Module Invoke-Expression (web download path) # This is the primary method for systems without gallery access: -# Invoke-RestMethod 'https://.../ConnectWiseAutomateAgent.ps1' | Invoke-Expression +# Invoke-RestMethod 'https://.../ConnectWiseAutomateAgent.psm1' | Invoke-Expression # ============================================================================= -Describe 'Cross-Version Compatibility - SingleFile Invoke-Expression' -Skip:(-not $script:SingleFileExists) { +Describe 'Cross-Version Compatibility - Built Module Invoke-Expression' -Skip:(-not $script:BuiltModuleExists) { Context ' - Invoke-Expression' -ForEach $script:PSVersions { BeforeAll { $currentExe = $Exe - $singleFile = $script:SingleFilePath + $builtPsm1 = $script:BuiltModulePsm1 # Simulate the IEX web-download path: Get-Content | Invoke-Expression # This is different from dot-sourcing — $MyInvocation has no file context, @@ -364,7 +381,7 @@ if (-not `$results.PSEdition) { `$results.PSEdition = 'Desktop' } Try { # Read file content as string and execute via IEX — simulates web download - `$scriptContent = Get-Content '$($singleFile -replace "'","''")' -Raw + `$scriptContent = Get-Content '$($builtPsm1 -replace "'","''")' -Raw Invoke-Expression `$scriptContent `$results.Success = `$true @@ -412,9 +429,9 @@ Catch { $script:Result.Success | Should -BeTrue -Because $script:Result.ImportError } - It 'makes all 30 public functions available' { + It 'makes all public functions available' { if (-not $script:Result.Success) { Set-ItResult -Skipped -Because 'IEX execution failed' } - $script:Result.FunctionCount | Should -BeGreaterOrEqual 30 + $script:Result.FunctionCount | Should -BeGreaterOrEqual $script:ExpectedFunctionCount } It 'has the ConvertTo-CWAASecurity function' { @@ -456,7 +473,7 @@ Describe 'Version Coverage' { $ps7 | Should -Not -BeNullOrEmpty -Because 'PowerShell 7 should be installed for cross-version testing' } - It 'single-file build exists for SingleFile tests' { - $script:SingleFilePath | Should -Exist -Because 'Run ./build.ps1 -Tasks build to create the single-file distribution' + It 'built module .psm1 exists for Built Module tests' { + $script:BuiltModulePsm1 | Should -Exist -Because 'Run ./build.ps1 -Tasks build to create the compiled module' } } diff --git a/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 index 5f26ea2..fcdf746 100644 --- a/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Documentation.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'Documentation Structure' { $ModuleRoot = Split-Path -Parent $PSScriptRoot $DocsRoot = Join-Path $ModuleRoot 'Docs' $DocsHelp = Join-Path $DocsRoot 'Help' - # Use the known list of public functions for doc checks. In single-file mode, + # Use the known list of public functions for doc checks. In built module mode, # ExportedFunctions includes private helpers that don't have docs in Docs/Help/. $PublicFunctions = @( 'Hide-CWAAAddRemove', 'Rename-CWAAAddRemove', 'Show-CWAAAddRemove', diff --git a/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 index de3d5f5..29de007 100644 --- a/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Mocked.Installation.Tests.ps1 @@ -573,7 +573,7 @@ Describe 'Register-CWAAHealthCheckTask' { } $result.Created | Should -BeTrue $result.Updated | Should -BeFalse - $result.TaskName | Should -Be 'CWAAHealthCheck' + $result.TaskName | Should -Be 'AAutomate' } } @@ -649,7 +649,7 @@ Describe 'Unregister-CWAAHealthCheckTask' { Unregister-CWAAHealthCheckTask -Confirm:$false } $result.Removed | Should -BeTrue - $result.TaskName | Should -Be 'CWAAHealthCheck' + $result.TaskName | Should -Be 'AAutomate' } } diff --git a/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 index 95bc05f..3a9831c 100644 --- a/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 +++ b/Tests/ConnectWiseAutomateAgent.Module.Tests.ps1 @@ -6,13 +6,13 @@ .DESCRIPTION Tests module manifest, import, exports, aliases, function structure, - parameter validation, and single-file build validation. + parameter validation, and built module validation. Supports dual-mode testing via $env:CWAA_TEST_LOAD_METHOD: - - 'Module' (default): Import-Module from .psd1 manifest - - 'SingleFile': Load concatenated .ps1 via dynamic module + - 'Module' (default): Import-Module from source .psd1 manifest + - 'BuiltModule': Import-Module from compiled ModuleBuilder output - Module-only tests are automatically skipped in SingleFile mode. + Source-only tests (file mapping) are skipped in BuiltModule mode. .NOTES Run with: @@ -20,14 +20,14 @@ #> BeforeDiscovery { - $script:IsSingleFileMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') - $script:IsModuleMode = -not $script:IsSingleFileMode + $script:IsBuiltModuleMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'BuiltModule') + $script:IsModuleMode = -not $script:IsBuiltModuleMode } BeforeAll { $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" - $script:IsSingleFileMode = ($script:BootstrapResult.LoadMethod -eq 'SingleFile') - $script:IsModuleMode = -not $script:IsSingleFileMode + $script:IsBuiltModuleMode = ($script:BootstrapResult.LoadMethod -eq 'BuiltModule') + $script:IsModuleMode = -not $script:IsBuiltModuleMode $ModuleName = $script:BootstrapResult.ModuleName } @@ -40,7 +40,7 @@ AfterAll { # ============================================================================= Describe 'Module: ConnectWiseAutomateAgent' { - Context 'Module Manifest' -Skip:$script:IsSingleFileMode { + Context 'Module Manifest' -Skip:$script:IsBuiltModuleMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot $ManifestPath = Join-Path $ModuleRoot 'source\ConnectWiseAutomateAgent.psd1' @@ -159,7 +159,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { $cmd | Should -Not -BeNullOrEmpty } - It 'does not export Initialize-CWAANetworking as a public function' -Skip:$script:IsSingleFileMode { + It 'does not export Initialize-CWAANetworking as a public function' -Skip:$script:IsBuiltModuleMode { $exported = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys $exported | Should -Not -Contain 'Initialize-CWAANetworking' } @@ -202,12 +202,12 @@ Describe 'Module: ConnectWiseAutomateAgent' { $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys } - It 'exports exactly 30 functions' -Skip:$script:IsSingleFileMode { - $ExportedFunctions | Should -HaveCount 30 + It 'exports the expected number of functions' -Skip:$script:IsBuiltModuleMode { + $ExportedFunctions | Should -HaveCount $ExpectedFunctions.Count } - It 'exports at least 30 functions (includes private in single-file mode)' -Skip:$script:IsModuleMode { - $ExportedFunctions.Count | Should -BeGreaterOrEqual 30 + It 'exports at least the expected number of functions (includes private in built module mode)' -Skip:$script:IsModuleMode { + $ExportedFunctions.Count | Should -BeGreaterOrEqual $ExpectedFunctions.Count } It 'exports <_>' -ForEach $ExpectedFunctions { @@ -254,12 +254,12 @@ Describe 'Module: ConnectWiseAutomateAgent' { $ExportedAliases = (Get-Module 'ConnectWiseAutomateAgent').ExportedAliases.Keys } - It 'exports exactly 32 aliases' -Skip:$script:IsSingleFileMode { - $ExportedAliases | Should -HaveCount 32 + It 'exports the expected number of aliases' -Skip:$script:IsBuiltModuleMode { + $ExportedAliases | Should -HaveCount $ExpectedAliases.Count } - It 'exports at least 32 aliases' -Skip:$script:IsModuleMode { - $ExportedAliases.Count | Should -BeGreaterOrEqual 32 + It 'exports at least the expected number of aliases' -Skip:$script:IsModuleMode { + $ExportedAliases.Count | Should -BeGreaterOrEqual $ExpectedAliases.Count } It 'exports alias <_>' -ForEach $ExpectedAliases { @@ -267,7 +267,7 @@ Describe 'Module: ConnectWiseAutomateAgent' { } } - Context 'Function-to-File Mapping' -Skip:$script:IsSingleFileMode { + Context 'Function-to-File Mapping' -Skip:$script:IsBuiltModuleMode { BeforeAll { $ModuleRoot = Split-Path -Parent $PSScriptRoot $PublicPath = Join-Path $ModuleRoot 'source\Public' @@ -290,25 +290,48 @@ Describe 'Module: ConnectWiseAutomateAgent' { } } - Context 'Single-File Build Validation' -Skip:$script:IsModuleMode { + Context 'Built Module Validation' -Skip:$script:IsModuleMode { BeforeAll { $RepoRoot = Split-Path -Parent $PSScriptRoot - $SingleFilePath = Join-Path $RepoRoot 'output\ConnectWiseAutomateAgent.ps1' + $BuiltPsm1 = Get-ChildItem -Path (Join-Path $RepoRoot 'output\ConnectWiseAutomateAgent\*\ConnectWiseAutomateAgent.psm1') -ErrorAction SilentlyContinue | + Select-Object -First 1 + $BuiltPsd1 = Get-ChildItem -Path (Join-Path $RepoRoot 'output\ConnectWiseAutomateAgent\*\ConnectWiseAutomateAgent.psd1') -ErrorAction SilentlyContinue | + Select-Object -First 1 + $PublicPath = Join-Path $RepoRoot 'source\Public' + $ExpectedFunctionCount = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -Recurse -File).Count + # Alias count: 1 per function + 2 extra for Redo-CWAA (Reinstall-CWAA, Reinstall-LTService) + $ExpectedAliasCount = $ExpectedFunctionCount + 2 } - It 'single-file build exists' { - $SingleFilePath | Should -Exist + It 'compiled .psm1 exists in output' { + $BuiltPsm1 | Should -Not -BeNullOrEmpty + $BuiltPsm1.FullName | Should -Exist } - It 'single-file ends with Initialize-CWAA call' { - $lastLines = Get-Content $SingleFilePath -Tail 5 - ($lastLines -join "`n") | Should -Match 'Initialize-CWAA' -Because 'single-file must call initialization at the end' + It 'compiled .psd1 exists in output' { + $BuiltPsd1 | Should -Not -BeNullOrEmpty + $BuiltPsd1.FullName | Should -Exist } - It 'private helper functions are available' { - $exported = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys - $exported | Should -Contain 'Initialize-CWAA' - $exported | Should -Contain 'Initialize-CWAANetworking' + It 'compiled .psm1 ends with Initialize-CWAA call' { + $lastLines = Get-Content $BuiltPsm1.FullName -Tail 5 + ($lastLines -join "`n") | Should -Match 'Initialize-CWAA' -Because 'compiled module must call initialization at the end' + } + + It 'built manifest has explicit FunctionsToExport (not wildcards)' { + $manifest = Import-PowerShellDataFile $BuiltPsd1.FullName + $manifest.FunctionsToExport | Should -Not -Contain '*' + $manifest.FunctionsToExport.Count | Should -Be $ExpectedFunctionCount + } + + It 'exports the expected number of functions' { + $ExportedFunctions = (Get-Module 'ConnectWiseAutomateAgent').ExportedFunctions.Keys + $ExportedFunctions | Should -HaveCount $ExpectedFunctionCount + } + + It 'exports the expected number of aliases' { + $ExportedAliases = (Get-Module 'ConnectWiseAutomateAgent').ExportedAliases.Keys + $ExportedAliases | Should -HaveCount $ExpectedAliasCount } } } diff --git a/Tests/Invoke-AllTests.ps1 b/Tests/Invoke-AllTests.ps1 index 354aa3b..ecaeac6 100644 --- a/Tests/Invoke-AllTests.ps1 +++ b/Tests/Invoke-AllTests.ps1 @@ -4,11 +4,11 @@ .DESCRIPTION Executes the entire Pester test suite twice — once in Module mode (standard - Import-Module from manifest) and once in SingleFile mode (dynamic module from - concatenated build output). Each mode runs in its own pwsh process to prevent - .NET state leakage (SSL callbacks, compiled types, etc.). + Import-Module from source manifest) and once in BuiltModule mode (Import-Module + from the compiled ModuleBuilder output). Each mode runs in its own pwsh process + to prevent .NET state leakage (SSL callbacks, compiled types, etc.). - The single-file build is regenerated automatically before the SingleFile run. + The module is rebuilt automatically before the BuiltModule run. .PARAMETER ExcludeTag Pester tags to exclude. Defaults to 'Live'. @@ -17,7 +17,7 @@ Path to test files. Defaults to the Tests directory containing this script. .PARAMETER SkipBuild - Skip the single-file rebuild before the SingleFile mode run. + Skip the module rebuild before the BuiltModule mode run. .EXAMPLE .\Tests\Invoke-AllTests.ps1 @@ -29,7 +29,7 @@ .EXAMPLE .\Tests\Invoke-AllTests.ps1 -SkipBuild - Runs both modes without rebuilding the single-file first. + Runs both modes without rebuilding the module first. .NOTES Exit code is non-zero if any test fails in either mode. @@ -95,12 +95,12 @@ Invoke-Pester -Configuration `$config } # ============================================ -# Mode 1: Module (standard Import-Module) +# Mode 1: Module (standard Import-Module from source) # ============================================ -$moduleExitCode = Invoke-PesterInProcess -Mode 'Module' -Label 'Mode 1/2: Module Import' +$moduleExitCode = Invoke-PesterInProcess -Mode 'Module' -Label 'Mode 1/2: Module Import (Source)' # ============================================ -# Rebuild single-file before SingleFile mode +# Rebuild module before BuiltModule mode # ============================================ if (-not $SkipBuild) { Write-Host "`n Rebuilding module (Sampler/ModuleBuilder)..." -ForegroundColor Gray @@ -118,9 +118,9 @@ if (-not $SkipBuild) { } # ============================================ -# Mode 2: SingleFile (dynamic module from .ps1) +# Mode 2: BuiltModule (compiled ModuleBuilder output) # ============================================ -$singleFileExitCode = Invoke-PesterInProcess -Mode 'SingleFile' -Label 'Mode 2/2: SingleFile (Dynamic Module)' +$builtModuleExitCode = Invoke-PesterInProcess -Mode 'BuiltModule' -Label 'Mode 2/2: Built Module (Compiled Output)' # ============================================ # Summary @@ -130,16 +130,16 @@ Write-Host " Summary" -ForegroundColor Cyan Write-Host "========================================" -ForegroundColor Cyan $moduleStatus = if ($moduleExitCode -eq 0) { 'PASS' } else { 'FAIL' } -$singleFileStatus = if ($singleFileExitCode -eq 0) { 'PASS' } else { 'FAIL' } +$builtModuleStatus = if ($builtModuleExitCode -eq 0) { 'PASS' } else { 'FAIL' } $moduleColor = if ($moduleExitCode -eq 0) { 'Green' } else { 'Red' } -$singleFileColor = if ($singleFileExitCode -eq 0) { 'Green' } else { 'Red' } +$builtModuleColor = if ($builtModuleExitCode -eq 0) { 'Green' } else { 'Red' } -Write-Host " Module mode: " -NoNewline; Write-Host $moduleStatus -ForegroundColor $moduleColor -Write-Host " SingleFile mode: " -NoNewline; Write-Host $singleFileStatus -ForegroundColor $singleFileColor +Write-Host " Module mode: " -NoNewline; Write-Host $moduleStatus -ForegroundColor $moduleColor +Write-Host " BuiltModule mode: " -NoNewline; Write-Host $builtModuleStatus -ForegroundColor $builtModuleColor Write-Host "" # Exit with non-zero if either mode failed -$finalExitCode = if ($moduleExitCode -ne 0 -or $singleFileExitCode -ne 0) { 1 } else { 0 } +$finalExitCode = if ($moduleExitCode -ne 0 -or $builtModuleExitCode -ne 0) { 1 } else { 0 } if ($finalExitCode -eq 0) { Write-Host " All tests passed in both modes." -ForegroundColor Green diff --git a/Tests/TestBootstrap.ps1 b/Tests/TestBootstrap.ps1 index d959f7a..2751798 100644 --- a/Tests/TestBootstrap.ps1 +++ b/Tests/TestBootstrap.ps1 @@ -6,26 +6,25 @@ Loads the module using one of two methods based on the CWAA_TEST_LOAD_METHOD environment variable: - - Module (default): Import-Module ConnectWiseAutomateAgent.psd1 - Standard PSGallery loading path. + - Module (default): Import-Module from source/ConnectWiseAutomateAgent.psd1 + Development source — dot-sources individual .ps1 files at import time. - - SingleFile: Load ConnectWiseAutomateAgent.ps1 into a dynamic module via New-Module. - Tests the concatenated single-file build used by systems without gallery access. - The dynamic module wrapper preserves InModuleScope and Get-Module compatibility. + - BuiltModule: Import-Module from the compiled output built by ModuleBuilder. + Tests the artifact that ships to PSGallery and GitHub Releases. Call this from BeforeAll in each test file. For discovery-time flags (Context -Skip), check $env:CWAA_TEST_LOAD_METHOD directly in BeforeDiscovery instead. .EXAMPLE BeforeDiscovery { - $script:IsSingleFileMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') + $script:IsBuiltModuleMode = ($env:CWAA_TEST_LOAD_METHOD -eq 'BuiltModule') } BeforeAll { $script:BootstrapResult = & "$PSScriptRoot\TestBootstrap.ps1" } .OUTPUTS - Hashtable with keys: LoadMethod, ModuleName, ModulePath, SingleFilePath, IsLoaded + Hashtable with keys: LoadMethod, ModuleName, ModulePath, BuiltModulePath, IsLoaded #> [CmdletBinding()] param() @@ -33,41 +32,38 @@ param() $ModuleName = 'ConnectWiseAutomateAgent' $RepoRoot = Split-Path -Parent $PSScriptRoot $ModulePsd1 = Join-Path $RepoRoot "source\$ModuleName.psd1" -$SingleFilePath = Join-Path $RepoRoot "output\$ModuleName.ps1" -$LoadMethod = if ($env:CWAA_TEST_LOAD_METHOD -eq 'SingleFile') { 'SingleFile' } else { 'Module' } +# Find the built module manifest (latest version directory under output/) +$BuiltModulePsd1 = Get-ChildItem -Path (Join-Path $RepoRoot "output\$ModuleName\*\$ModuleName.psd1") -ErrorAction SilentlyContinue | + Sort-Object { [version](Split-Path (Split-Path $_.FullName -Parent) -Leaf) } -Descending | + Select-Object -First 1 -# Remove any existing module (standard or dynamic) +$LoadMethod = if ($env:CWAA_TEST_LOAD_METHOD -eq 'BuiltModule') { 'BuiltModule' } else { 'Module' } + +# Remove any existing module Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force -if ($LoadMethod -eq 'SingleFile') { - # SingleFile mode: load concatenated .ps1 into a dynamic module. - # This validates the build output while preserving InModuleScope compatibility. - if (-not (Test-Path $SingleFilePath)) { - throw "Single-file build not found at '$SingleFilePath'. Run './build.ps1 -Tasks build' first." +if ($LoadMethod -eq 'BuiltModule') { + # BuiltModule mode: Import-Module from the compiled output. + # This validates the ModuleBuilder-compiled .psm1 and manifest with explicit exports. + if (-not $BuiltModulePsd1) { + throw "Built module not found in 'output/$ModuleName/'. Run './build.ps1 -Tasks build' first." } - $singleFileContent = Get-Content $SingleFilePath -Raw -ErrorAction Stop - - # Append Export-ModuleMember so [Alias()] attributes on functions are exported. - # Without this, dynamic modules don't export aliases from function attributes. - $singleFileContent += "`nExport-ModuleMember -Function * -Alias *" - - New-Module -Name $ModuleName -ScriptBlock ([ScriptBlock]::Create($singleFileContent)) | - Import-Module -Force -ErrorAction Stop + Import-Module $BuiltModulePsd1.FullName -Force -ErrorAction Stop - Write-Verbose "TestBootstrap: Loaded single-file into dynamic module '$ModuleName'" + Write-Verbose "TestBootstrap: Imported built module from '$($BuiltModulePsd1.FullName)'" return @{ - LoadMethod = 'SingleFile' - ModuleName = $ModuleName - ModulePath = $SingleFilePath - SingleFilePath = $SingleFilePath - IsLoaded = $true + LoadMethod = 'BuiltModule' + ModuleName = $ModuleName + ModulePath = $BuiltModulePsd1.FullName + BuiltModulePath = $BuiltModulePsd1.FullName + IsLoaded = $true } } else { - # Module mode: standard Import-Module from manifest + # Module mode: standard Import-Module from source manifest if (-not (Test-Path $ModulePsd1)) { throw "Module manifest not found at '$ModulePsd1'." } @@ -77,10 +73,10 @@ else { Write-Verbose "TestBootstrap: Imported module '$ModuleName' from manifest" return @{ - LoadMethod = 'Module' - ModuleName = $ModuleName - ModulePath = $ModulePsd1 - SingleFilePath = $SingleFilePath - IsLoaded = $true + LoadMethod = 'Module' + ModuleName = $ModuleName + ModulePath = $ModulePsd1 + BuiltModulePath = if ($BuiltModulePsd1) { $BuiltModulePsd1.FullName } else { $null } + IsLoaded = $true } } diff --git a/Tests/test-local.ps1 b/Tests/test-local.ps1 index 0740c62..f83133e 100644 --- a/Tests/test-local.ps1 +++ b/Tests/test-local.ps1 @@ -31,7 +31,7 @@ if ($Quick) { # Dual mode: delegate to Invoke-AllTests.ps1 if ($DualMode) { - Write-Host "`n[DUAL MODE] Running Module + SingleFile test modes..." -ForegroundColor Yellow + Write-Host "`n[DUAL MODE] Running Module + BuiltModule test modes..." -ForegroundColor Yellow $allTestsScript = Join-Path $PSScriptRoot 'Invoke-AllTests.ps1' & $allTestsScript exit $LASTEXITCODE diff --git a/build.yaml b/build.yaml index 8b308eb..dd07165 100644 --- a/build.yaml +++ b/build.yaml @@ -23,10 +23,11 @@ BuildWorkflow: build: - Clean - Build_ModuleOutput_ModuleBuilder - - Build_SingleFile_Distribution + - Create_changelog_release_output test: - Pester_Tests_Stop_On_Fail + - Pester_if_Code_Coverage_Under_Threshold publish: - publish_module_to_gallery @@ -69,6 +70,9 @@ Pester: OutputFile: output/testResults/testResults.xml OutputFormat: NUnitXml PassThru: true + CodeCoverageThreshold: 0 + CodeCoverageOutputFile: output/testResults/JaCoCo_coverage.xml + CodeCoverageOutputFileEncoding: ascii #################################################### # PSScriptAnalyzer Configuration # diff --git a/source/ConnectWiseAutomateAgent.psd1 b/source/ConnectWiseAutomateAgent.psd1 index e4d8333..854d81d 100644 --- a/source/ConnectWiseAutomateAgent.psd1 +++ b/source/ConnectWiseAutomateAgent.psd1 @@ -4,7 +4,7 @@ RootModule = 'ConnectWiseAutomateAgent.psm1' # Version number of this module. - ModuleVersion = '1.0.0' + ModuleVersion = '0.0.1' # ID used to uniquely identify this module GUID = '37424fc5-48d4-4d15-8b19-e1c2bf4bab67' @@ -16,7 +16,7 @@ CompanyName = 'Chris Taylor' # Copyright statement for this module - Copyright = '(c) 2025 Chris Taylor. All rights reserved.' + Copyright = '(c) 2026 Chris Taylor. All rights reserved.' # Description of the functionality provided by this module Description = 'PowerShell module for working with the ConnectWise Automate Agent.' @@ -55,9 +55,12 @@ # A URL to an icon representing this module IconUri = 'https://raw.githubusercontent.com/christaylorcodes/ConnectWiseAutomateAgent/main/Media/connectwise-automate.png' + # ReleaseNotes populated at build time from CHANGELOG.md + ReleaseNotes = '' + # Prerelease tag for PSGallery (ASCII alphanumeric only, no dots/hyphens) - # Remove or set to '' for stable releases - Prerelease = 'alpha001' + # Controlled by GitVersion at build time; leave empty in source + Prerelease = '' } diff --git a/source/ConnectWiseAutomateAgent.psm1 b/source/ConnectWiseAutomateAgent.psm1 index bf638f9..e934b06 100644 --- a/source/ConnectWiseAutomateAgent.psm1 +++ b/source/ConnectWiseAutomateAgent.psm1 @@ -7,7 +7,7 @@ Foreach ($import in @($Public + $Private)) { } # Module mode 32-bit warning: WOW64 relaunch in Initialize-CWAA works correctly for -# single-file mode (ConnectWiseAutomateAgent.ps1) but cannot relaunch Import-Module. +# direct download mode (.psm1 via Invoke-Expression) but cannot relaunch Import-Module. # Warn users so they know to use the native 64-bit PowerShell host. if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { Write-Warning 'ConnectWiseAutomateAgent: Module imported from 32-bit PowerShell on a 64-bit OS. Registry and file operations may target incorrect locations. Please use 64-bit PowerShell for reliable operation.' diff --git a/source/Private/Assert-CWAANotProbeAgent.ps1 b/source/Private/Assert-CWAANotProbeAgent.ps1 index 23638c2..1666737 100644 --- a/source/Private/Assert-CWAANotProbeAgent.ps1 +++ b/source/Private/Assert-CWAANotProbeAgent.ps1 @@ -51,12 +51,8 @@ function Assert-CWAANotProbeAgent { Write-Output "Probe Agent Detected. $ActionName Forced." } else { - if ($WhatIfPreference -ne $True) { - Write-Error -Exception ([System.OperationCanceledException]"Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop - } - else { - Write-Error -Exception ([System.OperationCanceledException]"What If: Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop - } + $prefix = if ($WhatIfPreference) { 'What If: ' } else { '' } + Write-Error -Exception ([System.OperationCanceledException]"${prefix}Probe Agent Detected. $ActionName Denied.") -ErrorAction Stop } } } diff --git a/source/Private/Initialize/Initialize-CWAA.ps1 b/source/Private/Initialize/Initialize-CWAA.ps1 index 69e878b..a9a17f9 100644 --- a/source/Private/Initialize/Initialize-CWAA.ps1 +++ b/source/Private/Initialize/Initialize-CWAA.ps1 @@ -40,7 +40,7 @@ function Initialize-CWAA { # WOW64 relaunch: When running as 32-bit PowerShell on a 64-bit OS, many registry # and file system operations target the wrong hive/path. Re-launch under native # 64-bit PowerShell to ensure consistent behavior with the Automate agent services. - # Note: This relaunch works correctly in single-file mode (ConnectWiseAutomateAgent.ps1). + # Note: This relaunch works correctly in direct download mode (.psm1 via Invoke-Expression). # In module mode (Import-Module), the .psm1 emits a warning instead since relaunch # cannot re-invoke Import-Module from within a function. if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { @@ -85,7 +85,7 @@ function Initialize-CWAA { Exit $ExitResult } - # Module-level constants — centralized to avoid duplication across functions. + # Module-level constants -- centralized to avoid duplication across functions. # These are cheap to create with no side effects, so they run at module load. $Script:CWAARegistryRoot = 'HKLM:\SOFTWARE\LabTech\Service' $Script:CWAARegistrySettings = 'HKLM:\SOFTWARE\LabTech\Service\Settings' @@ -155,13 +155,13 @@ function Initialize-CWAA { # All service names including LabVNC — for full service cleanup in Uninstall-CWAA. $Script:CWAAAllServiceNames = @('LTService', 'LTSvcMon', 'LabVNC') - # Service credential storage â€" populated on-demand by Get-CWAAProxy + # Service credential storage -- populated on-demand by Get-CWAAProxy $Script:LTServiceKeys = [PSCustomObject]@{ ServerPasswordString = '' PasswordString = '' } - # Proxy configuration — populated on-demand by Initialize-CWAANetworking + # Proxy configuration -- populated on-demand by Initialize-CWAANetworking $Script:LTProxy = [PSCustomObject]@{ ProxyServerURL = '' ProxyUsername = '' diff --git a/source/Private/Invoke-CWAAMsiInstaller.ps1 b/source/Private/Invoke-CWAAMsiInstaller.ps1 index 88a4451..6b79710 100644 --- a/source/Private/Invoke-CWAAMsiInstaller.ps1 +++ b/source/Private/Invoke-CWAAMsiInstaller.ps1 @@ -42,28 +42,27 @@ function Invoke-CWAAMsiInstaller { } $installAttempt = 0 + $serviceInstalled = $False Do { if ($installAttempt -gt 0) { Write-Warning "Service Failed to Install. Retrying in $RetryDelaySeconds seconds." -WarningAction 'Continue' $Null = Wait-CWAACondition -Condition { - $serviceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - $serviceCount -eq 1 + [bool](Get-Service 'LTService' -EA 0) } -TimeoutSeconds $RetryDelaySeconds -IntervalSeconds 5 -Activity 'Waiting for service availability before retry' } $installAttempt++ - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - if ($runningServiceCount -eq 0) { + if (-not (Get-Service 'LTService' -EA 0)) { $redactedArguments = $InstallerArguments -replace 'SERVERPASS="[^"]*"', 'SERVERPASS="REDACTED"' Write-Verbose "Launching Installation Process: msiexec.exe $redactedArguments" Start-Process -Wait -FilePath "${env:windir}\system32\msiexec.exe" -ArgumentList $InstallerArguments -WorkingDirectory $env:TEMP Start-Sleep 5 } - $runningServiceCount = ('LTService') | Get-Service -EA 0 | Measure-Object | Select-Object -Expand Count - } Until ($installAttempt -ge $MaxAttempts -or $runningServiceCount -eq 1) + $serviceInstalled = [bool](Get-Service 'LTService' -EA 0) + } Until ($installAttempt -ge $MaxAttempts -or $serviceInstalled) - if ($runningServiceCount -eq 0) { + if (-not $serviceInstalled) { Write-Error "LTService was not installed. Installation failed after $MaxAttempts attempts." return $false } diff --git a/source/Private/Resolve-CWAAServer.ps1 b/source/Private/Resolve-CWAAServer.ps1 index 7bf2a14..bed5b0a 100644 --- a/source/Private/Resolve-CWAAServer.ps1 +++ b/source/Private/Resolve-CWAAServer.ps1 @@ -33,7 +33,7 @@ function Resolve-CWAAServer { # Normalize: prepend https:// to bare hostnames/IPs so the loop has consistent URLs $normalizedServers = ForEach ($serverUrl in $Server) { if ($serverUrl -notmatch 'https?://.+') { "https://$serverUrl" } - $serverUrl + else { $serverUrl } } ForEach ($serverUrl in $normalizedServers) { diff --git a/source/Private/Test-CWAAServiceExists.ps1 b/source/Private/Test-CWAAServiceExists.ps1 index 16913fc..b287f9d 100644 --- a/source/Private/Test-CWAAServiceExists.ps1 +++ b/source/Private/Test-CWAAServiceExists.ps1 @@ -37,12 +37,8 @@ function Test-CWAAServiceExists { } if ($WriteErrorOnMissing) { - if ($WhatIfPreference -ne $True) { - Write-Error "Services NOT Found." - } - else { - Write-Error "What If: Services NOT Found." - } + $prefix = if ($WhatIfPreference) { 'What If: ' } else { '' } + Write-Error "${prefix}Services NOT Found." } return $false } diff --git a/source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 index c6639ea..152c335 100644 --- a/source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 +++ b/source/Public/AddRemovePrograms/Hide-CWAAAddRemove.ps1 @@ -17,7 +17,9 @@ function Hide-CWAAAddRemove { Alias: Hide-LTAddRemove .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Hide-LTAddRemove')] Param() diff --git a/source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 index 1ed1cb8..80e26f7 100644 --- a/source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 +++ b/source/Public/AddRemovePrograms/Rename-CWAAAddRemove.ps1 @@ -21,7 +21,9 @@ function Rename-CWAAAddRemove { Alias: Rename-LTAddRemove .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Rename-LTAddRemove')] Param( diff --git a/source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 b/source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 index a1eac5f..256e601 100644 --- a/source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 +++ b/source/Public/AddRemovePrograms/Show-CWAAAddRemove.ps1 @@ -17,7 +17,9 @@ function Show-CWAAAddRemove { Alias: Show-LTAddRemove .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Show-LTAddRemove')] Param() diff --git a/source/Public/ConvertFrom-CWAASecurity.ps1 b/source/Public/ConvertFrom-CWAASecurity.ps1 index e68ee94..2ac3315 100644 --- a/source/Public/ConvertFrom-CWAASecurity.ps1 +++ b/source/Public/ConvertFrom-CWAASecurity.ps1 @@ -25,7 +25,9 @@ function ConvertFrom-CWAASecurity { Alias: ConvertFrom-LTSecurity .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('ConvertFrom-LTSecurity')] Param( @@ -106,9 +108,7 @@ function ConvertFrom-CWAASecurity { Write-Debug "Failed to Decode string: '$($InputString)'" return $Null } - else { - return $DecodedString - } Write-Debug "Exiting $($MyInvocation.InvocationName)" + return $DecodedString } } diff --git a/source/Public/ConvertTo-CWAASecurity.ps1 b/source/Public/ConvertTo-CWAASecurity.ps1 index 3d52e36..a05ca4c 100644 --- a/source/Public/ConvertTo-CWAASecurity.ps1 +++ b/source/Public/ConvertTo-CWAASecurity.ps1 @@ -21,7 +21,9 @@ function ConvertTo-CWAASecurity { Alias: ConvertTo-LTSecurity .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('ConvertTo-LTSecurity')] Param( diff --git a/source/Public/InstallUninstall/Install-CWAA.ps1 b/source/Public/InstallUninstall/Install-CWAA.ps1 index 1d03c04..d3cee53 100644 --- a/source/Public/InstallUninstall/Install-CWAA.ps1 +++ b/source/Public/InstallUninstall/Install-CWAA.ps1 @@ -65,7 +65,9 @@ function Install-CWAA { Alias: Install-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True, DefaultParameterSetName = 'deployment')] [Alias('Install-LTService')] Param( diff --git a/source/Public/InstallUninstall/Redo-CWAA.ps1 b/source/Public/InstallUninstall/Redo-CWAA.ps1 index 1dc9d8c..402ee67 100644 --- a/source/Public/InstallUninstall/Redo-CWAA.ps1 +++ b/source/Public/InstallUninstall/Redo-CWAA.ps1 @@ -1,4 +1,4 @@ -function Redo-CWAA { +function Redo-CWAA { <# .SYNOPSIS Reinstalls the ConnectWise Automate Agent on the local computer. @@ -54,7 +54,9 @@ Alias: Reinstall-CWAA, Redo-LTService, Reinstall-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Reinstall-CWAA', 'Redo-LTService', 'Reinstall-LTService')] Param( diff --git a/source/Public/InstallUninstall/Uninstall-CWAA.ps1 b/source/Public/InstallUninstall/Uninstall-CWAA.ps1 index 2bdda3e..20e978e 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. @@ -65,7 +65,9 @@ Requires: Administrator privileges .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Uninstall-LTService')] Param( @@ -206,7 +208,7 @@ 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/InstallUninstall/Update-CWAA.ps1 b/source/Public/InstallUninstall/Update-CWAA.ps1 index dc50195..69ce7df 100644 --- a/source/Public/InstallUninstall/Update-CWAA.ps1 +++ b/source/Public/InstallUninstall/Update-CWAA.ps1 @@ -38,7 +38,9 @@ function Update-CWAA { Alias: Update-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Update-LTService')] Param( diff --git a/source/Public/Invoke-CWAACommand.ps1 b/source/Public/Invoke-CWAACommand.ps1 index 0bd257c..5924232 100644 --- a/source/Public/Invoke-CWAACommand.ps1 +++ b/source/Public/Invoke-CWAACommand.ps1 @@ -24,7 +24,9 @@ function Invoke-CWAACommand { Alias: Invoke-LTServiceCommand .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Invoke-LTServiceCommand')] Param( diff --git a/source/Public/Logging/Get-CWAAError.ps1 b/source/Public/Logging/Get-CWAAError.ps1 index 09fd30b..5810bc9 100644 --- a/source/Public/Logging/Get-CWAAError.ps1 +++ b/source/Public/Logging/Get-CWAAError.ps1 @@ -20,7 +20,9 @@ function Get-CWAAError { Alias: Get-LTErrors .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTErrors')] Param() diff --git a/source/Public/Logging/Get-CWAALogLevel.ps1 b/source/Public/Logging/Get-CWAALogLevel.ps1 index e25f4f8..d961bc0 100644 --- a/source/Public/Logging/Get-CWAALogLevel.ps1 +++ b/source/Public/Logging/Get-CWAALogLevel.ps1 @@ -22,7 +22,9 @@ function Get-CWAALogLevel { Alias: Get-LTLogging .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTLogging')] Param () diff --git a/source/Public/Logging/Get-CWAAProbeError.ps1 b/source/Public/Logging/Get-CWAAProbeError.ps1 index 2464f54..1782e1a 100644 --- a/source/Public/Logging/Get-CWAAProbeError.ps1 +++ b/source/Public/Logging/Get-CWAAProbeError.ps1 @@ -20,7 +20,9 @@ function Get-CWAAProbeError { Alias: Get-LTProbeErrors .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTProbeErrors')] Param() diff --git a/source/Public/Logging/Set-CWAALogLevel.ps1 b/source/Public/Logging/Set-CWAALogLevel.ps1 index c3b1201..e21046f 100644 --- a/source/Public/Logging/Set-CWAALogLevel.ps1 +++ b/source/Public/Logging/Set-CWAALogLevel.ps1 @@ -29,7 +29,9 @@ function Set-CWAALogLevel { Alias: Set-LTLogging .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTLogging')] Param ( diff --git a/source/Public/Proxy/Get-CWAAProxy.ps1 b/source/Public/Proxy/Get-CWAAProxy.ps1 index ddaf7e6..7d4489c 100644 --- a/source/Public/Proxy/Get-CWAAProxy.ps1 +++ b/source/Public/Proxy/Get-CWAAProxy.ps1 @@ -1,4 +1,4 @@ -function Get-CWAAProxy { +function Get-CWAAProxy { <# .SYNOPSIS Retrieves the current agent proxy settings for module operations. @@ -20,7 +20,9 @@ Alias: Get-LTProxy .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTProxy')] Param() @@ -31,14 +33,14 @@ # Decrypt agent passwords from registry. The decrypted PasswordString is used # below to decode proxy credentials. This logic was formerly in the private - # Initialize-CWAAKeys function — inlined here because Get-CWAAProxy is the only + # Initialize-CWAAKeys function -- inlined here because Get-CWAAProxy is the only # consumer, and key decryption is inherently the first step of proxy discovery. # The $serviceInfo result is reused in Process to avoid a redundant registry read. $serviceInfo = Get-CWAAInfo -EA 0 -Verbose:$False -WhatIf:$False -Confirm:$False -Debug:$False if ($serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { Write-Debug "Decoding Server Password." $Script:LTServiceKeys.ServerPasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.ServerPassword)" - if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'Password' })) { + if ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'Password' }) { Write-Debug "Decoding Agent Password." $Script:LTServiceKeys.PasswordString = ConvertFrom-CWAASecurity -InputString "$($serviceInfo.Password)" -Key "$($Script:LTServiceKeys.ServerPasswordString)" } @@ -54,7 +56,7 @@ Process { Try { - # Reuse $serviceInfo from Begin block — eliminates a redundant Get-CWAAInfo call. + # Reuse $serviceInfo from Begin block -- eliminates a redundant Get-CWAAInfo call. if ($Null -ne $serviceInfo -and ($serviceInfo | Get-Member | Where-Object { $_.Name -eq 'ServerPassword' })) { $serviceSettings = Get-CWAASettings -EA 0 -Verbose:$False -WA 0 -Debug:$False if ($Null -ne $serviceSettings) { diff --git a/source/Public/Proxy/Set-CWAAProxy.ps1 b/source/Public/Proxy/Set-CWAAProxy.ps1 index 6b5ae0b..c297d2e 100644 --- a/source/Public/Proxy/Set-CWAAProxy.ps1 +++ b/source/Public/Proxy/Set-CWAAProxy.ps1 @@ -55,7 +55,9 @@ function Set-CWAAProxy { Alias: Set-LTProxy .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Set-LTProxy')] Param( diff --git a/source/Public/Service/Register-CWAAHealthCheckTask.ps1 b/source/Public/Service/Register-CWAAHealthCheckTask.ps1 index 779d4a4..9711531 100644 --- a/source/Public/Service/Register-CWAAHealthCheckTask.ps1 +++ b/source/Public/Service/Register-CWAAHealthCheckTask.ps1 @@ -23,7 +23,7 @@ function Register-CWAAHealthCheckTask { .PARAMETER LocationID Optional location ID. Required when Server is provided. .PARAMETER TaskName - Name of the scheduled task. Default: 'CWAAHealthCheck'. + Name of the scheduled task. Default: 'AAutomate'. .PARAMETER IntervalHours Hours between health check runs. Default: 6. .PARAMETER Force @@ -45,7 +45,9 @@ function Register-CWAAHealthCheckTask { Alias: Register-LTHealthCheckTask .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Register-LTHealthCheckTask')] Param( @@ -61,7 +63,7 @@ function Register-CWAAHealthCheckTask { [int]$LocationID, [ValidatePattern('^[\w\-\. ]+$')] - [string]$TaskName = 'CWAAHealthCheck', + [string]$TaskName = 'AAutomate', [ValidateRange(1, 168)] [int]$IntervalHours = 6, diff --git a/source/Public/Service/Repair-CWAA.ps1 b/source/Public/Service/Repair-CWAA.ps1 index 372a662..e762d88 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 @@ 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, @@ -49,7 +49,9 @@ Alias: Repair-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Repair-LTService')] Param( @@ -104,7 +106,7 @@ $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 @@ -112,7 +114,7 @@ $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)" @@ -192,7 +194,7 @@ [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) } } @@ -317,7 +319,7 @@ #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/Restart-CWAA.ps1 b/source/Public/Service/Restart-CWAA.ps1 index a83db09..6efd82c 100644 --- a/source/Public/Service/Restart-CWAA.ps1 +++ b/source/Public/Service/Restart-CWAA.ps1 @@ -16,7 +16,9 @@ function Restart-CWAA { Alias: Restart-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Restart-LTService')] Param() diff --git a/source/Public/Service/Start-CWAA.ps1 b/source/Public/Service/Start-CWAA.ps1 index 60c4856..a183f0d 100644 --- a/source/Public/Service/Start-CWAA.ps1 +++ b/source/Public/Service/Start-CWAA.ps1 @@ -20,7 +20,9 @@ function Start-CWAA { Alias: Start-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Start-LTService')] Param() diff --git a/source/Public/Service/Stop-CWAA.ps1 b/source/Public/Service/Stop-CWAA.ps1 index abb1b04..c5c96cd 100644 --- a/source/Public/Service/Stop-CWAA.ps1 +++ b/source/Public/Service/Stop-CWAA.ps1 @@ -18,7 +18,9 @@ function Stop-CWAA { Alias: Stop-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Stop-LTService')] Param() diff --git a/source/Public/Service/Test-CWAAHealth.ps1 b/source/Public/Service/Test-CWAAHealth.ps1 index 4e7a853..a7d8cba 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. @@ -43,7 +43,9 @@ Alias: Test-LTHealth .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Test-LTHealth')] Param( @@ -58,7 +60,7 @@ } Process { - # Defaults — populated progressively as checks succeed + # Defaults populated progressively as checks succeed $agentInstalled = $False $servicesRunning = $False $lastContact = $Null diff --git a/source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 b/source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 index 0495789..013044b 100644 --- a/source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 +++ b/source/Public/Service/Unregister-CWAAHealthCheckTask.ps1 @@ -6,7 +6,7 @@ function Unregister-CWAAHealthCheckTask { Deletes the Windows scheduled task created by Register-CWAAHealthCheckTask. If the task does not exist, writes a warning and returns gracefully. .PARAMETER TaskName - Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. + Name of the scheduled task to remove. Default: 'AAutomate'. .EXAMPLE Unregister-CWAAHealthCheckTask Removes the default CWAAHealthCheck scheduled task. @@ -18,11 +18,13 @@ function Unregister-CWAAHealthCheckTask { Alias: Unregister-LTHealthCheckTask .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Unregister-LTHealthCheckTask')] Param( - [string]$TaskName = 'CWAAHealthCheck' + [string]$TaskName = 'AAutomate' ) Begin { diff --git a/source/Public/Settings/Get-CWAAInfo.ps1 b/source/Public/Settings/Get-CWAAInfo.ps1 index 998ec45..4671a32 100644 --- a/source/Public/Settings/Get-CWAAInfo.ps1 +++ b/source/Public/Settings/Get-CWAAInfo.ps1 @@ -22,7 +22,9 @@ function Get-CWAAInfo { Alias: Get-LTServiceInfo .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True, ConfirmImpact = 'Low')] [Alias('Get-LTServiceInfo')] Param () diff --git a/source/Public/Settings/Get-CWAAInfoBackup.ps1 b/source/Public/Settings/Get-CWAAInfoBackup.ps1 index 5ea5b54..b01cf85 100644 --- a/source/Public/Settings/Get-CWAAInfoBackup.ps1 +++ b/source/Public/Settings/Get-CWAAInfoBackup.ps1 @@ -20,7 +20,9 @@ function Get-CWAAInfoBackup { Alias: Get-LTServiceInfoBackup .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTServiceInfoBackup')] Param () diff --git a/source/Public/Settings/Get-CWAASettings.ps1 b/source/Public/Settings/Get-CWAASettings.ps1 index 073066c..631c24d 100644 --- a/source/Public/Settings/Get-CWAASettings.ps1 +++ b/source/Public/Settings/Get-CWAASettings.ps1 @@ -20,7 +20,9 @@ function Get-CWAASettings { Alias: Get-LTServiceSettings .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Get-LTServiceSettings')] Param () diff --git a/source/Public/Settings/New-CWAABackup.ps1 b/source/Public/Settings/New-CWAABackup.ps1 index 75708ab..abd6dcb 100644 --- a/source/Public/Settings/New-CWAABackup.ps1 +++ b/source/Public/Settings/New-CWAABackup.ps1 @@ -26,7 +26,9 @@ function New-CWAABackup { Alias: New-LTServiceBackup .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('New-LTServiceBackup')] Param () diff --git a/source/Public/Settings/Reset-CWAA.ps1 b/source/Public/Settings/Reset-CWAA.ps1 index 7302b2d..eb2ff07 100644 --- a/source/Public/Settings/Reset-CWAA.ps1 +++ b/source/Public/Settings/Reset-CWAA.ps1 @@ -36,7 +36,9 @@ function Reset-CWAA { Alias: Reset-LTService .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding(SupportsShouldProcess = $True)] [Alias('Reset-LTService')] Param( diff --git a/source/Public/Test-CWAAPort.ps1 b/source/Public/Test-CWAAPort.ps1 index b4c1d28..d211102 100644 --- a/source/Public/Test-CWAAPort.ps1 +++ b/source/Public/Test-CWAAPort.ps1 @@ -30,7 +30,9 @@ function Test-CWAAPort { Alias: Test-LTPorts .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Test-LTPorts')] Param( diff --git a/source/Public/Test-CWAAServerConnectivity.ps1 b/source/Public/Test-CWAAServerConnectivity.ps1 index 3aa3432..50ea72e 100644 --- a/source/Public/Test-CWAAServerConnectivity.ps1 +++ b/source/Public/Test-CWAAServerConnectivity.ps1 @@ -31,7 +31,9 @@ function Test-CWAAServerConnectivity { Alias: Test-LTServerConnectivity .LINK https://github.com/christaylorcodes/ConnectWiseAutomateAgent - #> + .PARAMETER ProgressAction + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + #> [CmdletBinding()] [Alias('Test-LTServerConnectivity')] Param( diff --git a/source/en-US/ConnectWiseAutomateAgent-help.xml b/source/en-US/ConnectWiseAutomateAgent-help.xml index 515f244..a16320b 100644 --- a/source/en-US/ConnectWiseAutomateAgent-help.xml +++ b/source/en-US/ConnectWiseAutomateAgent-help.xml @@ -1,4 +1,4 @@ - + @@ -50,6 +50,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + @@ -89,6 +101,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + @@ -159,6 +183,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + @@ -186,6 +222,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + @@ -233,9 +281,34 @@ Get-CWAAError + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -282,6 +355,18 @@ Get-CWAAInfo + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -307,6 +392,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -378,9 +475,34 @@ Get-CWAAInfoBackup + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -427,9 +549,34 @@ Get-CWAALogLevel + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -476,9 +623,34 @@ Get-CWAAProbeError + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -524,9 +696,34 @@ Get-CWAAProxy + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -572,9 +769,34 @@ Get-CWAASettings + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + - + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + + @@ -620,6 +842,18 @@ Hide-CWAAAddRemove + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -645,6 +879,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -773,6 +1019,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -936,6 +1194,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -1114,6 +1384,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -1287,6 +1569,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -1324,6 +1618,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -1395,6 +1701,18 @@ New-CWAABackup + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -1420,6 +1738,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -1548,6 +1878,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -1665,6 +2007,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -1797,6 +2151,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Rename @@ -1964,14 +2330,14 @@ TaskName - Name of the scheduled task. Default: 'CWAAHealthCheck'. + Name of the scheduled task. Default: 'AAutomate'. String String - CWAAHealthCheck + AAutomate IntervalHours @@ -1996,6 +2362,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2069,6 +2447,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Server @@ -2084,14 +2474,14 @@ TaskName - Name of the scheduled task. Default: 'CWAAHealthCheck'. + Name of the scheduled task. Default: 'AAutomate'. String String - CWAAHealthCheck + AAutomate Confirm @@ -2194,6 +2584,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2231,6 +2633,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + PublisherName @@ -2364,6 +2778,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Server @@ -2449,6 +2875,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Server @@ -2595,6 +3033,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2680,6 +3130,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2757,6 +3219,18 @@ Restart-CWAA + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2782,6 +3256,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2865,6 +3351,18 @@ Normal + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -2902,6 +3400,18 @@ Normal + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3050,6 +3560,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + ProxyCredential @@ -3145,6 +3667,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + ProxyCredential @@ -3294,6 +3828,18 @@ Show-CWAAAddRemove + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3319,6 +3865,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3389,6 +3947,18 @@ Start-CWAA + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3414,6 +3984,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3484,6 +4066,18 @@ Stop-CWAA + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3509,6 +4103,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Confirm @@ -3598,6 +4204,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + TestServerConnectivity @@ -3612,6 +4230,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Server @@ -3713,6 +4343,18 @@ 0 + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Quiet @@ -3727,6 +4369,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Quiet @@ -3823,6 +4477,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Quiet @@ -3837,6 +4503,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Quiet @@ -3950,6 +4628,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + ShowProgress @@ -4021,6 +4711,18 @@ False + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + Server @@ -4158,14 +4860,26 @@ TaskName - Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. + Name of the scheduled task to remove. Default: 'AAutomate'. String String - CWAAHealthCheck + AAutomate + + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None Confirm @@ -4192,17 +4906,29 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + TaskName - Name of the scheduled task to remove. Default: 'CWAAHealthCheck'. + Name of the scheduled task to remove. Default: 'AAutomate'. String String - CWAAHealthCheck + AAutomate Confirm @@ -4241,7 +4967,7 @@ -------------------------- EXAMPLE 1 -------------------------- Unregister-CWAAHealthCheckTask - Removes the default CWAAHealthCheck scheduled task. + Removes the default AAutomate scheduled task. @@ -4288,6 +5014,18 @@ None + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + ShowProgress @@ -4335,6 +5073,18 @@ + + ProgressAction + + Determines how PowerShell responds to progress updates generated by a script, cmdlet, or provider. + + ActionPreference + + ActionPreference + + + None + ShowProgress diff --git a/source/prefix.ps1 b/source/prefix.ps1 index 4d27f17..cbf0137 100644 --- a/source/prefix.ps1 +++ b/source/prefix.ps1 @@ -1,5 +1,5 @@ # Module mode 32-bit warning: WOW64 relaunch in Initialize-CWAA works correctly for -# single-file mode (ConnectWiseAutomateAgent.ps1) but cannot relaunch Import-Module. +# direct download mode (.psm1 via Invoke-Expression) but cannot relaunch Import-Module. # Warn users so they know to use the native 64-bit PowerShell host. if ($env:PROCESSOR_ARCHITEW6432 -match '64' -and [IntPtr]::Size -ne 8) { Write-Warning 'ConnectWiseAutomateAgent: Module imported from 32-bit PowerShell on a 64-bit OS. Registry and file operations may target incorrect locations. Please use 64-bit PowerShell for reliable operation.'