Files
SAMY/samy.ps1
2025-12-05 17:41:45 -05:00

2877 lines
105 KiB
PowerShell

<#
Last changes made should fix the issues we had wen running thi in Windows 11 25H2
change this line for the message
<!-- Big red line under tagline -->
<div id="samyHint" class="samy-hint">Please use samy.svstools.ca</div>
Invoke-installprinters you can uncomment whatif in the function
# SAMY asset config (change branch or base once and it updates everything)
$Script:SamyBranch = 'beta' # or 'main'
.SYNOPSIS
Script Automation Monkey (SAMY) is a unified MSP assistant that automates onboarding, headless offboarding,
Datto RMM deployments, and toolkit management through a local UI, HTTP endpoints, or direct PowerShell switches.
.DESCRIPTION
Install-DattoRMM is the core helper that handles credential retrieval (webhook or direct), OAuth token management,
site list fetching/persistence, registry variable pushes, agent downloads/installs, and optional installer archiving.
The UI exposes all tasks as checkboxes with select-all helpers, and each action is wrapped in detailed logging plus
HTTP responders so callers can see success or failure.$jsContent = Get-RemoteText -Url $Script:SamyJsUrl
Key features:
- Credential retrieval - securely fetches ApiUrl, ApiKey, and ApiSecretKey from a webhook when requested.
- OAuth management - automatically acquires and refreshes bearer tokens over TLS to talk to Datto.
- Site list fetching - returns the list of RMM sites and validates OutputFile extensions (.csv/.json).
- Site list persistence - saves fetched site lists to the requester's desktop as CSV or JSON.
- Registry variable push - writes site-specific variables under HKLM:\Software\SVS\Deployment.
- Agent download & install - pulls the Datto RMM agent, launches it, and optionally saves a copy to C:\Temp.
- HTTP endpoints - /getpw and /installDattoRMM handle UI or API-triggered workflows with failure trapping.
- Idempotent & WhatIf support - uses ShouldProcess/SupportsShouldProcess to protect installs.
- Headless Offboard - runs every offboarding task serially just like "Select All" in the UI.
Throughout, secrets are never written to logs or console, and all operations produce clear success/failure messages via Write-LogHybrid.
.PARAMETER UseWebhook
Switch that forces credential retrieval from the webhook at WebhookUrl using WebhookPassword.
When omitted, you must supply ApiUrl, ApiKey, and ApiSecretKey directly.
.PARAMETER WebhookPassword
Password to authenticate to the credentials-fetch webhook. Mandatory when -UseWebhook is set.
.PARAMETER WebhookUrl
URL of the credentials webhook endpoint. Defaults to $Global:DattoWebhookUrl.
.PARAMETER ApiUrl
Direct Datto RMM API base URL (used if not fetching from webhook).
.PARAMETER ApiKey
Direct Datto RMM API key (used if not fetching from webhook).
.PARAMETER ApiSecretKey
Direct Datto RMM secret (used if not fetching from webhook).
.PARAMETER FetchSites
Switch to fetch the list of RMM sites and skip all install or variable-push actions.
.PARAMETER SaveSitesList
Switch to save the fetched site list to the desktop as a file named by OutputFile.
Must be used together with -FetchSites.
.PARAMETER OutputFile
Name of the file to write the site list to (must end in “.csv” or “.json”).
Defaults to 'datto_sites.csv'.
.PARAMETER PushSiteVars
Switch to fetch site-specific variables and write them under HKLM:\Software\SVS\Deployment.
.PARAMETER InstallRMM
Switch to download and launch the Datto RMM agent installer for the specified site.
.PARAMETER SaveCopy
Switch to save a copy of the downloaded Datto RMM installer into C:\Temp.
.PARAMETER Offboard
Switch that runs every off-boarding task sequentially (same behavior as checking "Select All" on the Off-Boarding tab) without launching the web UI.
.PARAMETER SiteUID
The unique identifier of the Datto RMM site. Mandatory when performing install or variable-push.
.PARAMETER SiteName
The friendly name of the Datto RMM site (used in logging). Mandatory when performing install or variable-push.
.NOTES
Default EventLog : SVSMSP Events
Default Source : SAMY
.EXAMPLE
& ([ScriptBlock]::Create( (iwr 'https://samy.svstools.com' -UseBasicParsing).Content )) `
-UseWebhook
-WebhookPassword 'pwd'
-SiteUID 'site-123'
-SiteName 'Acme Corp'
-PushSiteVars
-InstallRMM
# Headlessly installs the Datto RMM agent on “Acme Corp” and writes site variables to the registry.
.EXAMPLE
& ([ScriptBlock]::Create( (iwr 'https://samy.svstools.com' -UseBasicParsing).Content )) `
-ApiUrl 'https://api.example.com' `
-ApiKey 'YourApiKey' `
-ApiSecretKey 'YourSecretKey' `
-SiteUID 'site-123' `
-SiteName 'Acme Corp' `
-PushSiteVars `
-InstallRMM
# Headlessly installs the Datto RMM agent on “Acme Corp” and writes site variables to the registry.
.EXAMPLE
& ([ScriptBlock]::Create( (iwr 'https://samy.svstools.com' -UseBasicParsing).Content )) `
-UseWebhook `
-WebhookPassword 'pwd' `
-FetchSites `
-SaveSitesList `
-OutputFile 'sites.json'
# Fetches the full site list via webhook and saves it as JSON to your Desktop.
.EXAMPLE
& ([ScriptBlock]::Create( (iwr 'https://samy.svstools.com' -UseBasicParsing).Content )) `
-ApiUrl 'https://api.example.com' `
-ApiKey 'YourApiKey' `
-ApiSecretKey 'YourSecretKey' `
-SiteUID 'site-123' `
-SiteName 'Acme Corp' `
-SaveCopy
# Downloads the RMM installer for “Acme Corp” and saves a copy under C:\Temp without running it.
.EXAMPLE
& ([ScriptBlock]::Create( (iwr 'https://samy.svstools.com' -UseBasicParsing).Content )) `
-ApiUrl 'https://api.example.com' `
-ApiKey 'YourApiKey' `
-ApiSecretKey 'YourSecretKey' `
-SiteUID 'site-123' `
-SiteName 'Acme Corp' `
-InstallRMM `
-WhatIf
# Shows what would happen when installing the RMM agent, without making any changes.
.EXAMPLE
& ([ScriptBlock]::Create((iwr 'samy.svstools.ca').Content )) -SilentInstall
.EXAMPLE
& ([ScriptBlock]::Create((iwr 'samy.svstools.com').Content)) -Cleanup
.EXAMPLE
& ([ScriptBlock]::Create((iwr 'samy.svstools.ca').Content)) -Offboard
# Runs the off-boarding tasks in sequence without launching the UI.
#>
#region Safely bypass Restricted Execution Policy
# ─── Safely bypass Restricted Execution Policy ───
if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage' -or
(Get-ExecutionPolicy) -eq 'Restricted') {
Write-Host "[Info] Relaunching with ExecutionPolicy Bypass..." -ForegroundColor Yellow
if ($PSCommandPath) {
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "`"$PSCommandPath`""
} else {
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& { iwr 'https://samy.svstools.com' -UseBasicParsing | iex }"
}
exit
}
# ─── TLS and silent install defaults ───
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue'
$ConfirmPreference = 'None'
#endregion Safely bypass Restricted Execution Policy
function Invoke-ScriptAutomationMonkey {
# ─────────────────────────────────────────────────────────────────────────
# PARAMETERS + GLOBAL VARIABLES
# ─────────────────────────────────────────────────────────────────────────
[CmdletBinding(
DefaultParameterSetName='UI',
SupportsShouldProcess=$true,
ConfirmImpact= 'Medium'
)]
#region Parameter Definitions
param(
# ─────────────────────────────────────────────────────────
# Toolkit-only mode
[Parameter(Mandatory,ParameterSetName='Toolkit')][switch]$SilentInstall,
# ─────────────────────────────────────────────────────────
# remove Toolkit
[Parameter(Mandatory,ParameterSetName='Cleanup')][switch]$Cleanup,
# headless offboarding
[Parameter(Mandatory,ParameterSetName='Offboard')][switch]$Offboard,
# ─────────────────────────────────────────────────────────
# Datto headless mode
# ─── DattoFetch & DattoInstall share the webhook creds ─────────────
[Parameter(Mandatory,ParameterSetName='DattoFetch')]
[Parameter(Mandatory,ParameterSetName='DattoInstall')]
[switch]$UseWebhook,
[Parameter(Mandatory,ParameterSetName='DattoFetch')]
[Parameter(Mandatory,ParameterSetName='DattoInstall')]
[String]$WebhookPassword,
[string]$WebhookUrl = $Global:DattoWebhookUrl,
# ─── only DattoFetch uses these ────────────────────────────────────
[Parameter(ParameterSetName='DattoFetch')][switch]$FetchSites,
[Parameter(ParameterSetName='DattoFetch')][switch] $SaveSitesList,
[Parameter(ParameterSetName='DattoFetch')][ValidatePattern('\.csv$|\.json$')][string] $OutputFile = 'datto_sites.csv',
# ─── only DattoInstall uses these ─────────────────────────────────
[Parameter(Mandatory,ParameterSetName='DattoInstall')][string] $SiteUID,
[Parameter(Mandatory,ParameterSetName='DattoInstall')][string] $SiteName,
[Parameter(ParameterSetName='DattoInstall')][switch] $PushSiteVars,
[Parameter(ParameterSetName='DattoInstall')][switch] $InstallRMM,
[Parameter(ParameterSetName='DattoInstall')][switch] $SaveCopy
)
#endregion Parameter Definitions
#region global variables
# Listening port for HTTP UI
$Port = 8082
# Configurable endpoints
$Global:DattoWebhookUrl = 'https://automate.svstools.ca/webhook/svsmspkit'
# SAMY asset config (change branch or base once and it updates everything)
$Script:SamyBranch = 'beta' # or 'main'
$Script:SamyRepoBase = 'https://git.svstools.ca/SVS_Public_Repo/SAMY/raw/branch'
# Top-left corner logo (SVS)
$Script:SamyTopLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_logo.svg"
# Background SAMY image used in CSS
$Script:SamyBgLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SAMY.png"
$Script:SamyFaviconUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_Favicon.ico"
$Script:SamyCssUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.css?raw=1"
$Script:SamyJsUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.js?raw=1"
# Initialize a global in-memory log cache
if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) {
$Global:LogCache = [System.Collections.ArrayList]::new()
}
#endregion global variables
#region SVS Module
function Initialize-NuGetProvider {
[CmdletBinding()]
param()
#region — guarantee NuGet provider is present without prompting
# ─── Silent defaults ───
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue'
$ConfirmPreference = 'None'
# ─── Pre-create folder if running as SYSTEM (avoids NuGet install bug) ───
$provPath = "$env:ProgramData\PackageManagement\ProviderAssemblies"
if (-not (Test-Path $provPath)) {
try {
New-Item -Path $provPath -ItemType Directory -Force -ErrorAction Stop | Out-Null
Write-LogHybrid "Created missing provider folder: $provPath" Info Bootstrap -LogToEvent
} catch {
Write-LogHybrid "Failed to create provider folder: $($_.Exception.Message)" Warning Bootstrap -LogToEvent
}
}
# ─── Ensure PowerShellGet is available ───
if (-not (Get-Command Install-PackageProvider -ErrorAction SilentlyContinue)) {
try {
Install-Module PowerShellGet -Force -AllowClobber -Confirm:$false -ErrorAction Stop
Write-LogHybrid "Installed PowerShellGet module" Info Bootstrap -LogToEvent
} catch {
Write-LogHybrid "PowerShellGet install failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent
}
}
# ─── Ensure PackageManagement is up-to-date ───
$pkgMgmtVersion = (Get-Module PackageManagement -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version
if ($pkgMgmtVersion -lt [Version]"1.3.1") {
try {
Install-Module PackageManagement -Force -AllowClobber -Confirm:$false -ErrorAction Stop
Write-LogHybrid "Updated PackageManagement to latest version" Info Bootstrap -LogToEvent
} catch {
Write-LogHybrid "PackageManagement update failed: $($_.Exception.Message)" Warning Bootstrap -LogToEvent
}
}
# ─── Import modules silently ───
Import-Module PackageManagement -Force -ErrorAction SilentlyContinue | Out-Null
Import-Module PowerShellGet -Force -ErrorAction SilentlyContinue | Out-Null
# ─── Trust PSGallery if not already ───
$gallery = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
if ($gallery -and $gallery.InstallationPolicy -ne 'Trusted') {
try {
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop
Write-LogHybrid "PSGallery marked as Trusted" Info Bootstrap -LogToEvent
} catch {
Write-LogHybrid "Failed to trust PSGallery: $($_.Exception.Message)" Warning Bootstrap -LogToEvent
}
}
# ─── Ensure NuGet is installed silently ───
$nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue
if (-not $nuget) {
try {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Confirm:$false -ErrorAction Stop
$nuget = Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue
Write-LogHybrid "Installed NuGet provider v$($nuget.Version)" Info Bootstrap -LogToEvent
} catch {
Write-LogHybrid "NuGet install failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent
}
} else {
Write-LogHybrid "NuGet provider already present (v$($nuget.Version))" Info Bootstrap -LogToEvent
}
# ─── Final import check ───
try {
Import-PackageProvider -Name NuGet -Force -ErrorAction Stop | Out-Null
} catch {
Write-LogHybrid "NuGet provider import failed: $($_.Exception.Message)" Error Bootstrap -LogToEvent
}
#endregion — guarantee NuGet provider is present without prompting
}
function Install-SVSMSP {
param (
[switch] $Cleanup,
[switch] $InstallToolkit,
[Parameter(Mandatory = $false)][array] $AllModules = @(@{ ModuleName = "SVS_Toolkit" }, @{ ModuleName = "SVSMSP" }),
[Parameter(Mandatory = $false)][array] $AllRepositories = @(@{ RepoName = "SVS_Repo" }, @{ RepoName = "SVS_Toolkit" }),
[Parameter(Mandatory = $false)][string] $NewModuleName = "SVSMSP",
[Parameter(Mandatory = $false)][string] $NewRepositoryName = "SVS_Repo",
[Parameter(Mandatory = $false)][string] $NewRepositoryURL = "http://proget.svstools.ca:8083/nuget/SVS_Repo/"
)
function Start-Cleanup {
Write-LogHybrid "Cleanup mode enabled. Starting cleanup..." "Info" "SVSModule"
# Attempt to uninstall all versions of SVSMSP
try {
Uninstall-Module -Name SVSMSP -AllVersions -Force -ErrorAction Stop
Write-LogHybrid "SVSMSP module uninstalled from system." "Success" "SVSModule" -LogToEvent
}
catch {
# If no module was found, just warn and continue
if ($_.Exception.Message -match 'No match was found') {
Write-LogHybrid "No existing SVSMSP module found to uninstall." "Warning" "SVSModule" -LogToEvent
}
else {
Write-LogHybrid "Failed to uninstall SVSMSP: $($_.Exception.Message)" "Error" "SVSModule" -LogToEvent
}
}
# Remove the custom repository if registered
if (Get-PSRepository -Name SVS_Repo -ErrorAction SilentlyContinue) {
try {
Unregister-PSRepository -Name SVS_Repo -ErrorAction Stop
Write-LogHybrid "SVS_Repo repository unregistered." "Success" "SVSModule" -LogToEvent
}
catch {
Write-LogHybrid "Failed to unregister SVS_Repo: $($_.Exception.Message)" "Error" "SVSModule" -LogToEvent
}
}
# Finally, remove it from the current session if loaded
if (Get-Module -Name SVSMSP) {
try {
Remove-Module SVSMSP -Force -ErrorAction Stop
Write-LogHybrid "SVSMSP module removed from current session." "Success" "SVSModule" -LogToEvent
}
catch {
Write-LogHybrid "Failed to remove SVSMSP from session: $($_.Exception.Message)" "Error" "SVSModule" -LogToEvent
}
}
# CSCE cleanup
$cscePath = 'C:\CSCE'
if (Test-Path $cscePath) {
try {
Remove-Item -Path $cscePath -Recurse -Force
Write-LogHybrid "Deleted '$cscePath' contents." "Success" "SVSModule" -LogToEvent
} catch {
Write-LogHybrid "Failed to delete '$cscePath': $($_.Exception.Message)" "Warning" "SVSModule" -LogToEvent
}
}
}
function Remove-SVSDeploymentRegKey {
$regKey = 'HKLM:\Software\SVS'
try {
if (Test-Path $regKey) {
Remove-Item -Path $regKey -Recurse -Force
Write-LogHybrid "Registry key '$regKey' deleted successfully." "Success" "SVSModule" -LogToEvent
}
else {
Write-LogHybrid "Registry key '$regKey' not found; nothing to delete." "Info" "SVSModule" -LogToEvent
}
}
catch {
Write-LogHybrid "Failed to delete registry key '$regKey': $($_.Exception.Message)" "Error" "SVSModule" -LogToEvent
}
}
function Repair-SVSMspEventLogBinding {
param(
[string]$EventSource = "SVSMSP_Module",
[string]$TargetLog = "SVSMSP Events"
)
Write-LogHybrid "Checking Event Log binding for source '$EventSource'..." Info SVSModule -LogToEvent
# 1) Make sure the source exists
try {
if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) {
Write-LogHybrid "Event source '$EventSource' not found. Nothing to repair." Info SVSModule -LogToEvent
return
}
$currentLog = [System.Diagnostics.EventLog]::LogNameFromSourceName($EventSource, '.')
}
catch {
Write-LogHybrid "Failed to query Event Log binding for '$EventSource': $($_.Exception.Message)" Warning SVSModule -LogToEvent
return
}
if (-not $currentLog) {
Write-LogHybrid "Could not determine current log for event source '$EventSource'. Skipping repair." Warning SVSModule -LogToEvent
return
}
# 2) If it's already correct, bail out
if ($currentLog -eq $TargetLog) {
Write-LogHybrid "Event source '$EventSource' already bound to '$TargetLog'." Info SVSModule -LogToEvent
return
}
Write-LogHybrid "Rebinding event source '$EventSource' from '$currentLog' to '$TargetLog'..." Warning SVSModule -LogToEvent
# 3) Delete and recreate the source bound to the desired log
try {
[System.Diagnostics.EventLog]::DeleteEventSource($EventSource)
if (-not [System.Diagnostics.EventLog]::Exists($TargetLog)) {
New-EventLog -LogName $TargetLog -Source $EventSource -ErrorAction Stop
}
else {
New-EventLog -LogName $TargetLog -Source $EventSource -ErrorAction Stop
}
Write-LogHybrid "Event source '$EventSource' rebound to '$TargetLog'." Success SVSModule -LogToEvent
}
catch {
Write-LogHybrid "Failed to rebind event source '$EventSource' to log '$TargetLog': $($_.Exception.Message)" Error SVSModule -LogToEvent
}
}
function Start-ToolkitInstallation {
Initialize-NuGetProvider
Start-Cleanup
Write-LogHybrid "Registering repo $NewRepositoryName" "Info" "SVSModule" -LogToEvent
if (-not (Get-PSRepository -Name $NewRepositoryName -ErrorAction SilentlyContinue)) {
Register-PSRepository -Name $NewRepositoryName -SourceLocation $NewRepositoryURL -InstallationPolicy Trusted
}
Write-LogHybrid "Installing module $NewModuleName" "Info" "SVSModule" -LogToEvent
Install-Module -Name $NewModuleName -Repository $NewRepositoryName -Scope AllUsers -Force
# After module install, repair Event Log binding for legacy systems
Repair-SVSMspEventLogBinding -EventSource "SVSMSP_Module" -TargetLog "SVSMSP Events"
Write-LogHybrid "Toolkit installation completed." "Success" "SVSModule" -LogToEvent
}
Write-LogHybrid "Install-SVSMSP called" "Info" "SVSModule" -LogToEvent
if ($Cleanup) {
Start-Cleanup
Remove-SVSDeploymentRegKey
return
}
if ($InstallToolkit) {
Start-ToolkitInstallation; return
}
# default if no switch passed:
Start-ToolkitInstallation
}
#endregion SVS Module
#region Write-Log
# Fallback logger used when the SVSMSP module (and its Write-Log) is not available.
# Mirrors the behaviour of the toolkit Write-Log (v1.5), including:
# - Default EventLog: "SVSMSP Events" (out of Application log)
# - Default EventSource: "SVSMSP_Module"
# - Level-based Event IDs and console colors
# - Global in-memory log cache
# - One-time Event Log/source initialization with optional auto-elevation
function Write-LogHelper {
<#
.SYNOPSIS
Standardized logging utility with console/file output and Windows Event Log support,
including one-time event source initialization and optional auto-elevated creation
of a custom log/source. (Fallback implementation for ScriptAutomationMonkey.)
.DESCRIPTION
Mirrors the SVSMSP toolkit Write-Log so that Write-LogHybrid can safely fall back
when the module isn't loaded.
.NOTES
Default EventLog : SVSMSP Events
Default Source : SVSMSP_Module
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$Message,
[ValidateSet("Info", "Warning", "Error", "Success", "General")]
[string]$Level = "Info",
[string]$TaskCategory = "GeneralTask",
[switch]$LogToEvent = $false,
[string]$EventSource = "SAMY",
# Custom log name so you get your own node under "Applications and Services Logs"
[string]$EventLog = "SVSMSP Events",
[int]$CustomEventID,
[string]$LogFile,
[switch]$PassThru
)
# ---------- Event ID / console color ----------
$EventID = if ($CustomEventID) { $CustomEventID } else {
switch ($Level) {
"Info" { 1000 }
"Warning" { 2000 }
"Error" { 3000 }
"Success" { 4000 }
default { 1000 }
}
}
$Color = switch ($Level) {
"Info" { "Cyan" }
"Warning" { "Yellow" }
"Error" { "Red" }
"Success" { "Green" }
default { "White" }
}
$FormattedMessage = "[$Level] [$TaskCategory] $Message (Event ID: $EventID)"
Write-Host $FormattedMessage -ForegroundColor $Color
# ---------- In-memory cache ----------
if (-not $Global:LogCache -or -not ($Global:LogCache -is [System.Collections.ArrayList])) {
$Global:LogCache = [System.Collections.ArrayList]::new()
}
$logEntry = [PSCustomObject]@{
Timestamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
Level = $Level
Message = $FormattedMessage
}
[void]$Global:LogCache.Add($logEntry)
# ---------- Optional file output ----------
if ($LogFile) {
try {
"$($logEntry.Timestamp) $FormattedMessage" |
Out-File -FilePath $LogFile -Append -Encoding UTF8
}
catch {
Write-Host "[Warning] Failed to write to log file: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# ---------- Windows Event Log handling with one-time init + optional auto-elevate ----------
if ($LogToEvent) {
# Per-run cache for (LogName|Source) init state
if (-not $Global:EventSourceInitState) {
$Global:EventSourceInitState = @{}
}
$EntryType = switch ($Level) {
"Info" { "Information" }
"Warning" { "Warning" }
"Error" { "Error" }
"Success" { "Information" } # treat success as info in Event Log
default { "Information" }
}
$sourceKey = "$EventLog|$EventSource"
if (-not $Global:EventSourceInitState.ContainsKey($sourceKey) -or
-not $Global:EventSourceInitState[$sourceKey]) {
try {
# Only bother if the source doesn't already exist
if (-not [System.Diagnostics.EventLog]::SourceExists($EventSource)) {
# Check if current token is admin
$isAdmin = $false
try {
$current = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($current)
$isAdmin = $principal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
}
catch {
$isAdmin = $false
}
if ($isAdmin) {
# Elevated already: create log/source directly
New-EventLog -LogName $EventLog -Source $EventSource -ErrorAction Stop
}
else {
# Not elevated: run a one-off helper as admin to create log/source
$helperScript = @"
if (-not [System.Diagnostics.EventLog]::SourceExists('$EventSource')) {
New-EventLog -LogName '$EventLog' -Source '$EventSource'
}
"@
$tempPath = [System.IO.Path]::Combine(
$env:TEMP,
"Init_${EventLog}_$EventSource.ps1".Replace(' ', '_')
)
$helperScript | Set-Content -Path $tempPath -Encoding UTF8
try {
# This will trigger UAC prompt in interactive sessions
$null = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-ExecutionPolicy Bypass -File `"$tempPath`"" `
-Verb RunAs -Wait -PassThru
}
catch {
Write-Host "[Warning] Auto-elevation to create Event Log '$EventLog' / source '$EventSource' failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
finally {
Remove-Item -Path $tempPath -ErrorAction SilentlyContinue
}
}
}
# Re-check after creation attempt
if ([System.Diagnostics.EventLog]::SourceExists($EventSource)) {
$Global:EventSourceInitState[$sourceKey] = $true
}
else {
$Global:EventSourceInitState[$sourceKey] = $false
Write-Host "[Warning] Event source '$EventSource' does not exist and could not be created. Skipping Event Log write." -ForegroundColor Yellow
}
}
catch {
Write-Host "[Warning] Failed to initialize Event Log '$EventLog' / source '$EventSource': $($_.Exception.Message)" -ForegroundColor Yellow
$Global:EventSourceInitState[$sourceKey] = $false
}
}
# Only write if initialization succeeded
if ($Global:EventSourceInitState[$sourceKey]) {
try {
$EventMessage = "TaskCategory: $TaskCategory | Message: $Message"
Write-EventLog -LogName $EventLog -Source $EventSource -EntryType $EntryType -EventId $EventID -Message $EventMessage
}
catch {
Write-Host "[Warning] Failed to write to Event Log: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
}
# ------------------------------------------------------------------------------------------
if ($PassThru) {
return $logEntry
}
}
# ─────────────────────────────────────────────────────────────────────────
# WRITE-LOG HYBRID
# Uses module Write-Log if present; otherwise falls back to Write-LogHelper.
# Defaults aligned with toolkit:
# EventSource = "SVSMSP_Module"
# EventLog = "SVSMSP Events"
# ─────────────────────────────────────────────────────────────────────────
function Write-LogHybrid {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[ValidateSet("Info", "Warning", "Error", "Success", "General")]
[string]$Level = "Info",
[string]$TaskCategory = "GeneralTask",
[switch]$LogToEvent,
[string]$EventSource = "SVSMSP_Module",
[string]$EventLog = "SVSMSP Events",
[int]$CustomEventID,
[string]$LogFile,
[switch]$PassThru,
[ValidateSet("Black","DarkGray","Gray","White","Red","Green","Blue","Yellow","Magenta","Cyan")]
[string]$ForegroundColorOverride
)
$formatted = "[$Level] [$TaskCategory] $Message"
# Build the common parameter set for forwarding into Write-Log / Write-LogHelper
$invokeParams = @{
Message = $Message
Level = $Level
TaskCategory = $TaskCategory
LogToEvent = $LogToEvent
EventSource = $EventSource
EventLog = $EventLog
}
if ($PSBoundParameters.ContainsKey('CustomEventID')) {
$invokeParams.CustomEventID = $CustomEventID
}
if ($PSBoundParameters.ContainsKey('LogFile')) {
$invokeParams.LogFile = $LogFile
}
if ($PassThru) {
$invokeParams.PassThru = $true
}
if ($PSBoundParameters.ContainsKey('ForegroundColorOverride')) {
# 1) print to console with the override color
Write-Host $formatted -ForegroundColor $ForegroundColorOverride
# 2) then forward the call (sans the override) to Write-Log or Write-LogHelper
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
Write-Log @invokeParams
}
else {
Write-LogHelper @invokeParams
}
}
else {
# No override: let Write-Log / Write-LogHelper handle everything (including console color)
if (Get-Command Write-Log -ErrorAction SilentlyContinue) {
Write-Log @invokeParams
}
else {
Write-LogHelper @invokeParams
}
}
}
#endregion Write-Log
#region Computer rename helpers
function Test-ComputerName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
if ($Name.Length -gt 15) { return $false }
if ($Name -notmatch '^[A-Za-z0-9-]+$') { return $false }
return $true
}
#endregion Computer rename helpers
#region building the Menus
# Define every task once here:
# Id → checkbox HTML `id`
# Name → URL path (`/Name`)
# Label → user-visible text
# HandlerFn → the PowerShell function to invoke
# Page → which tab/page it appears on
$Global:SamyTasks = @(
# On-Boarding, left column
@{ Id='setSVSPowerplan'; Name='setSVSPowerplan'; Label='Set SVS Powerplan'; HandlerFn='Invoke-setSVSPowerPlan'; Page='onboard'; Column='left' },
@{ Id='installSVSMSPModule'; Name='installSVSMSPModule'; Label='Install SVSMSP Module'; HandlerFn='Invoke-InstallSVSMSP'; Page='onboard'; Column='left' },
@{ Id='installCyberQP'; Name='installCyberQP'; Label='Install CyberQP'; HandlerFn='Invoke-InstallCyberQP'; Page='onboard'; Column='left' },
@{ Id='installHelpDesk'; Name='installHelpDesk'; Label='Install HelpDesk'; HandlerFn='Invoke-InstallHelpDesk'; Page='onboard'; Column='left' },
@{ Id='installThreatLocker'; Name='installThreatLocker'; Label='Install ThreatLocker'; HandlerFn='Invoke-InstallThreatLocker'; Page='onboard'; Column='left' },
@{ Id='installRocketCyber'; Name='installRocketCyber'; Label='Install RocketCyber'; HandlerFn='Invoke-InstallRocketCyber'; Page='onboard'; Column='left' },
@{ Id='installDattoRMM'; Name='installDattoRMM'; Label='Install DattoRMM'; HandlerFn='Invoke-InstallDattoRMM'; Page='onboard'; Column='left';
SubOptions= @(
@{ Value='inputVar'; Label='Copy Site Variables' },
@{ Value='rmm'; Label='Install RMM Agent' },
@{ Value='exe'; Label='Download Executable' }
)
},
# On-Boarding, right column (optional bits)
@{ Id='enableBitLocker'; Name='EnableBitLocker'; Label='Enable BitLocker'; HandlerFn='Set-SVSBitLocker'; Page='onboard'; Column='right' },
@{ Id='setEdgeDefaultSearch';Name='setedgedefaultsearch';Label='Set Edge Default Search'; Tooltip='Will configure Edge to use Google as default search provider'; HandlerFn='Invoke-SetEdgeDefaultSearchEngine';Page='onboard'; Column='right' },
# Off-Boarding
@{ Id='offUninstallCyberQP'; Name='offUninstallCyberQP'; Label='Uninstall CyberQP'; HandlerFn='Invoke-UninstallCyberQP'; Page='offboard' },
@{ Id='offUninstallHelpDesk'; Name='offUninstallHelpDesk'; Label='Uninstall HelpDesk'; HandlerFn='Invoke-UninstallHelpDesk'; Page='offboard' },
@{ Id='offUninstallThreatLocker'; Name='offUninstallThreatLocker'; Label='Uninstall ThreatLocker'; HandlerFn='Invoke-UninstallThreatLocker'; Page='offboard' },
@{ Id='offUninstallRocketCyber'; Name='offUninstallRocketCyber'; Label='Uninstall RocketCyber'; HandlerFn='Invoke-UninstallRocketCyber'; Page='offboard' },
@{ Id='offCleanupSVSMSPModule'; Name='offCleanupSVSMSPModule'; Label='Cleanup SVSMSP Toolkit'; HandlerFn='Invoke-CleanupSVSMSP'; Page='offboard' },
# Tweaks
@{ Id='disableAnimations'; Name='disableAnimations'; Label='Disable Animations'; HandlerFn='Disable-Animations'; Page='tweaks' },
# SVS Apps
@{ Id='wingetLastpass'; Name='wingetLastpass'; Label='LastPass Desktop App'; HandlerFn='Install-WingetLastPass'; Page='SVSApps' },
@{ Id='wingetChrome'; Name='wingetChrome'; Label='Google Chrome'; HandlerFn='Invoke-InstallChrome'; Page='SVSApps' },
@{ Id='wingetAcrobat'; Name='wingetAcrobat'; Label='Adobe Acrobat Reader (64-bit)'; HandlerFn='Invoke-InstallAcrobat'; Page='SVSApps' }
)
Write-LogHybrid "Tasks by page: onboard=$(
($Global:SamyTasks | Where-Object Page -eq 'onboard').Count
) offboard=$(
($Global:SamyTasks | Where-Object Page -eq 'offboard').Count
) tweaks=$(
($Global:SamyTasks | Where-Object Page -eq 'tweaks').Count
) apps=$(
($Global:SamyTasks | Where-Object Page -eq 'SVSApps').Count
)" Info UI -LogToEvent
#endregion building the Menus
#region Publish-Checkboxes
function Publish-Checkboxes {
param(
[Parameter(Mandatory)][string]$Page,
[string]$Column
)
# Start with all tasks on the given page
$tasks = $Global:SamyTasks | Where-Object Page -EQ $Page
# Only filter by Column when it actually matters (onboard left/right)
if (-not [string]::IsNullOrEmpty($Column)) {
$tasks = $tasks | Where-Object Column -EQ $Column
}
(
$tasks |
ForEach-Object {
$taskId = $_.Id
$tooltip = if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip) {
" title='$($_.Tooltip)'"
} else { '' }
$html = "<label$tooltip><input type='checkbox' id='$taskId' name='$($_.Name)' data-column='$Column'> $($_.Label)</label>"
if ($_.SubOptions) {
$subHtml = (
$_.SubOptions |
ForEach-Object {
"<label style='margin-left:20px; display:block;'>
<input type='checkbox' class='sub-option-$taskId' name='$($_.Value)' value='$($_.Value)'> $($_.Label)
</label>"
}
) -join "`n"
$html += @"
<div id='${taskId}OptionsContainer' style='display:none; margin-top:4px;'>
$subHtml
</div>
"@
}
$html
}
) -join "`n"
} # end function Publish-Checkboxes
#endregion Publish-Checkboxes
#region Get-ModuleVersionHtml
### Get SVSMSP module version to display in the UI
function Get-ModuleVersionHtml {
$mod = Get-Module -ListAvailable -Name SVSMSP | Sort-Object Version -Descending | Select-Object -First 1
# Friendly branch label based on $Script:SamyBranch
$branchDisplay = switch ($Script:SamyBranch.ToLower()) {
'main' { 'Main / Stable' }
'beta' { 'Beta' }
default { $Script:SamyBranch }
}
if ($mod) {
return "<div style='color:#bbb; font-size:0.9em; margin-top:1em;'>
Module Version: $($mod.Version)<br>
UI Branch: $branchDisplay
</div>"
}
return "<div style='color:#f66;'>SVSMSP_Module not found</div>"
}
#endregion Get-ModuleVersionHtml
#region Strat-Server
function Get-NextFreePort {
param([int]$Start = $Port)
for ($p = [Math]::Max(1024,$Start); $p -lt 65535; $p++) {
$l = [System.Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $p)
try { $l.Start(); $l.Stop(); return $p } catch {}
}
throw "No free TCP port available."
}
# Starts the HTTP listener loop
function Start-Server {
$Global:Listener = [System.Net.HttpListener]::new()
$primaryPrefix = "http://localhost:$Port/"
$wildcardPrefix = "http://+:$Port/"
try {
$Global:Listener.Prefixes.Add($primaryPrefix)
$Global:Listener.Start()
Write-LogHybrid "Listening on $primaryPrefix" Info Server -LogToEvent
}
catch [System.Net.HttpListenerException] {
if ($_.Exception.ErrorCode -eq 5) {
Write-LogHybrid "Access denied on $primaryPrefix. Attempting URL ACL…" Warning Server -LogToEvent
try {
$user = "$env:USERDOMAIN\$env:USERNAME"
if (-not $user.Trim()) { $user = $env:USERNAME }
Start-Process -FilePath "netsh" -ArgumentList "http add urlacl url=$wildcardPrefix user=`"$user`" listen=yes" -Verb RunAs -WindowStyle Hidden -Wait
$Global:Listener = [System.Net.HttpListener]::new()
$Global:Listener.Prefixes.Add($wildcardPrefix)
$Global:Listener.Start()
Write-LogHybrid "Listening on $wildcardPrefix (URL ACL added for $user)" Success Server -LogToEvent
} catch {
Write-LogHybrid "URL ACL registration failed: $($_.Exception.Message)" Error Server -LogToEvent
return
}
}
elseif ($_.Exception.NativeErrorCode -in 32,183) {
$old = $Port
$Port = Get-NextFreePort -Start ($Port + 1)
$Global:Listener = [System.Net.HttpListener]::new()
$primaryPrefix = "http://localhost:$Port/"
$Global:Listener.Prefixes.Add($primaryPrefix)
$Global:Listener.Start()
Write-LogHybrid "Port $old busy. Listening on $primaryPrefix" Warning Server -LogToEvent
}
else {
Write-LogHybrid "HttpListener start failed: $($_.Exception.Message)" Error Server -LogToEvent
return
}
}
try {
while ($Global:Listener.IsListening) {
$ctx = $Global:Listener.GetContext()
try {
Dispatch-Request $ctx
} catch {
Write-LogHybrid "Dispatch error: $($_.Exception.Message)" Error Server -LogToEvent
}
}
}
finally {
$Global:Listener.Close()
Write-LogHybrid "Listener closed." Info Server -LogToEvent
}
}
#endregion Strat-Server
#region UIHtml
function Get-RemoteText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Url
)
try {
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop
return $resp.Content
}
catch {
Write-LogHybrid "Get-RemoteText failed for ${Url}: $($_.Exception.Message)" Warning UI -LogToEvent
return ""
}
}
function Get-UIHtml {
param([string]$Page = 'onboard')
if (-not $Page) { $Page = 'onboard' }
#
# 1) Build checkbox HTML per page/column
#
$onboardLeft = Publish-Checkboxes -Page 'onboard' -Column 'left'
$onboardRight = Publish-Checkboxes -Page 'onboard' -Column 'right'
$offboard = Publish-Checkboxes -Page 'offboard' -Column ''
$tweaks = Publish-Checkboxes -Page 'tweaks' -Column ''
$apps = Publish-Checkboxes -Page 'SVSApps' -Column ''
#
# 2) Build the JS tasks array once (this is the only dynamic JS piece)
#
$tasksJsAll = (
$Global:SamyTasks | ForEach-Object {
" { id: '$($_.Id)', handler: '/$($_.Name)', label: '$($_.Label)' }"
}
) -join ",`n"
# Human friendly branch label for UI
$branchDisplay = switch ($Script:SamyBranch.ToLower()) {
'main' { 'Main / Stable' }
'beta' { 'Beta' }
default { $Script:SamyBranch }
}
#
# 3) Pull CSS/JS from Gitea and inline them
#
$cssContent = Get-RemoteText -Url $Script:SamyCssUrl
$jsContent = Get-RemoteText -Url $Script:SamyJsUrl
# Make the CSS background-image follow $Script:SamyBgLogoUrl
if ($cssContent) {
$pattern = 'background-image:\s*url\("SAMY\.png"\);?' # matches with or without extra spaces/semicolon
$replacement = "background-image: url('$Script:SamyBgLogoUrl');"
$cssContent = [regex]::Replace($cssContent, $pattern, $replacement)
}
#
# 4) HTML template - **no external link/script src** anymore, all inlined
#
$htmlTemplate = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Script Automation Monkey</title>
<link rel="icon" href="$Script:SamyFaviconUrl">
<style>
$cssContent
</style>
</head>
<body>
<div class="logo-container">
<!-- SVS Logo (left) -->
<div class="logo-left">
<img src="$Script:SamyTopLogoUrl" alt="SVS Logo">
{{moduleVersion}}
</div>
<!-- Centered rotating tagline -->
<div id="tagline" class="tagline">
Script Automation Monkey (Yeah!)
</div>
<!-- Big red line under tagline -->
<div id="samyHint" class="samy-hint"></div>
</div>
<div class="container">
<div class="sidebar">
<button class="tab-button" data-tab="onboardTab">On-Boarding</button>
<button class="tab-button" data-tab="offboardTab">Off-Boarding</button>
<button class="tab-button" data-tab="tweaksTab">Tweaks</button>
<button class="tab-button" data-tab="SVSAppsTab">SVS APPs</button>
<button class="tab-button" data-tab="devicesTab">Devices</button>
<div id="status-box" style="margin-top: 1em; font-family: monospace;"></div>
</div>
<div class="content">
<div id="onboardTab" class="tab-content">
<h2>On-Boarding</h2>
<h3 class="subtitle">This new deployment method ensures everything is successfully deployed with greater ease!</h3>
<div class="columns-container">
<div class="checkbox-group column">
<h3>SVSMSP Stack</h3>
<label><input type="checkbox" id="selectAllLeftCheckbox" onclick="toggleColumn('left')"> Select All</label>
{{onboardLeftColumn}}
</div>
<div class="checkbox-group column">
<h3>Optional</h3>
<label><input type="checkbox" id="selectAllRightCheckbox" onclick="toggleColumn('right')"> Select All</label>
{{onboardRightColumn}}
<!-- Rename Computer Option -->
<div class="option">
<label>
<input type="checkbox" id="chkRenameComputer" data-column="right">
Rename Computer
</label>
</div>
<div id="renameComputerBlock" style="display:none; margin-left: 24px; margin-top: 6px;">
<label for="txtNewComputerName">New computer name:</label>
<input type="text" id="txtNewComputerName" placeholder="e.g. CORP-LAP-123" />
<small style="display:block; margin-top:4px;">
(Max 15 chars; letters, numbers, and hyphens only.)
</small>
</div>
</div>
</div>
<div id="PasswordContainer" style="display:none; margin-bottom:1em;">
<label for="Password">Enter Password:</label>
<div style="display:flex; gap:5px;">
<input type="password" id="Password" placeholder="Enter Password" style="flex:1;" />
<button onclick="fetchSites()" class="go-button">GO!</button>
</div>
</div>
<div id="dattoRmmContainer" style="display:none; margin-bottom:1em;">
<label for="dattoDropdown">Select a Datto RMM site:</label>
<select id="dattoDropdown" style="width:100%;">
<option disabled selected>Fetching sites...</option>
</select>
</div>
</div> <!-- end onboardTab -->
<div id="offboardTab" class="tab-content">
<h2>Off-Boarding</h2>
<div class="columns-container">
<div class="checkbox-group column">
<h3>Remove Stack</h3>
<label>
<input type="checkbox" id="offboardSelectAll" onclick="toggleOffboardAll()">
Select All
</label>
{{offboardCheckboxes}}
</div>
</div>
</div>
<div id="tweaksTab" class="tab-content">
<h2>Tweaks</h2>
<div class="columns-container">
<div class="checkbox-group column">
<h3>Tweaks</h3>
{{tweaksCheckboxes}}
</div>
</div>
</div>
<div id="SVSAppsTab" class="tab-content">
<h2>SVS APPs</h2>
<div class="columns-container">
<div class="checkbox-group column">
<h3>Applications</h3>
{{appsCheckboxes}}
</div>
</div>
</div>
<!-- NEW: Devices tab for printers and future stuff -->
<div id="devicesTab" class="tab-content">
<h2>Devices</h2>
<h3 class="subtitle">Manage printers and other client devices.</h3>
<!-- Printer password + fetch -->
<div id="printerPasswordContainer" style="margin-bottom:1em;">
<label for="PrinterPassword">Enter Printer Password:</label>
<div style="display:flex; gap:5px;">
<input type="password"
id="PrinterPassword"
placeholder="Enter printer password"
style="flex:1;" />
<button onclick="fetchPrinters()" class="go-button">Get Printers</button>
</div>
</div>
<!-- Client code dropdown -->
<div id="printerClientContainer" style="display:none; margin-bottom:1em;">
<label for="printerClientDropdown">Select Client:</label>
<select id="printerClientDropdown" style="width:100%;">
<option disabled selected>Fetch printers first...</option>
</select>
</div>
<!-- Printer checkbox list -->
<div id="printerListContainer" style="display:none; margin-bottom:1em;">
<label>Printers for selected client:</label>
<small style="display:block; margin-bottom:4px; opacity:0.8;">
Check the printers to install, and mark one as "Make default" (optional).
</small>
<div id="printerCheckboxContainer"
style="max-height:200px; overflow-y:auto; border:1px solid #444; padding:6px; border-radius:4px;">
<!-- Populated by JS -->
</div>
<button id="installPrintersButton"
class="go-button"
style="margin-top:8px;"
onclick="installSelectedPrinters()">
Install Selected Printers
</button>
</div>
</div>
</div>
</div>
<!-- Tiny inline bridge: pass dynamic data, then inline JS from Gitea -->
<script>
window.SAMY_TASKS = [
{{tasksJsAll}}
];
window.SAMY_DEFAULT_PAGE = "{{defaultPage}}";
</script>
<script>
$jsContent
</script>
<!-- Floating button group -->
<div class="fixed-buttons">
<button class="exit-button" onclick="endSession()">Exit</button>
<button class="run-button" onclick="triggerInstall()">Run Selected</button>
</div>
</body>
</html>
"@
#
# 5) Replace placeholders (unchanged vs your version)
#
$html = $htmlTemplate
$html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml))
$html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft)
$html = $html.Replace('{{onboardRightColumn}}', $onboardRight)
$html = $html.Replace('{{offboardCheckboxes}}', $offboard)
$html = $html.Replace('{{tweaksCheckboxes}}', $tweaks)
$html = $html.Replace('{{appsCheckboxes}}', $apps)
$html = $html.Replace('{{tasksJsAll}}', $tasksJsAll)
$html = $html.Replace('{{defaultPage}}', $Page)
return $html
}
#endregion UIHtml
#region Handler Stubs
#region HTTP responder helpers
function Send-Text {
param($Context, $Text)
if (-not $Context -or -not $Context.Response) {
return
}
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
$Context.Response.ContentType = 'text/plain'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes,0,$bytes.Length)
$Context.Response.OutputStream.Close()
}
function Send-HTML {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][object] $Context,
[Parameter(Mandatory = $true)][string] $Html
)
if (-not $Context -or -not $Context.Response) {
return
}
$bytes = [Text.Encoding]::UTF8.GetBytes($Html)
$Context.Response.ContentType = 'text/html'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Context.Response.OutputStream.Close()
}
function Send-JSON {
[CmdletBinding()]
param(
$Context,
$Object
)
if (-not $Context -or -not $Context.Response) {
return
}
try {
# 🔹 Normalize $Object so we never feed $null to GetBytes
if ($null -eq $Object) {
Write-LogHybrid "Send-JSON called with `$null object; returning empty JSON array." Warning Printers -LogToEvent
$json = '[]'
}
else {
# If ConvertTo-Json fails, force an empty array string instead of bubbling $null
try {
$json = $Object | ConvertTo-Json -Depth 5 -ErrorAction Stop
}
catch {
Write-LogHybrid "Send-JSON serialization failed: $($_.Exception.Message); returning empty JSON array." Error Printers -LogToEvent
$json = '[]'
}
}
# 🔹 Final safety: ensure we always pass a *string* to GetBytes
$json = [string]$json
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$Context.Response.ContentType = 'application/json'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Context.Response.OutputStream.Close()
}
catch {
# Last-resort error handling - don't let the whole request crash
Write-LogHybrid "Send-JSON fatal error: $($_.Exception.Message)" Error Printers -LogToEvent
try {
$fallback = '[]'
$bytes = [Text.Encoding]::UTF8.GetBytes($fallback)
$Context.Response.ContentType = 'application/json'
$Context.Response.ContentLength64 = $bytes.Length
$Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$Context.Response.OutputStream.Close()
}
catch {
# If even this fails, just give up silently - we've already logged it.
}
}
}
#endregion HTTP responder helpers
function Invoke-TasksCompleted {
param($Context)
Write-LogHybrid "All UI-selected tasks processed" Info UI -LogToEvent
Send-Text $Context "Tasks completion acknowledged."
}
#region Datto handlers
function Invoke-FetchSites {
param($Context)
try {
# 1) Read the incoming JSON payload (contains only the webhook password)
$raw = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
$pw = (ConvertFrom-Json $raw).password
# ★ Store it globally for the next call ★
$Global:WebhookPassword = $pw
# 2) Delegate to your unified function
$sites = Install-DattoRMM `
-UseWebhook `
-WebhookPassword $pw `
-FetchSites `
# -SaveSitesList:$SaveSitesList `
# -OutputFile $OutputFile
# 3) Return JSON array of sites
Send-JSON $Context $sites
}
catch {
# Log the exception and return HTTP 500
Write-LogHybrid "Invoke-FetchSites error: $($_.Exception.Message)" Error DattoRMM -LogToEvent
$Context.Response.StatusCode = 500
Send-Text $Context "Internal server error fetching sites."
}
}
#endregion Datto handlers
#region Onboarding handlers
# On-boarding handlers
function Invoke-SetSVSPowerPlan {
param($Context)
# 1) call into your module
Set-SVSPowerPlan
# 2) log & write back a simple text response
Write-LogHybrid "PowerPlan set" "Success" "OnBoard"
Send-Text $Context "PowerPlan applied"
}
function Invoke-InstallSVSMSP {
param($Context)
Write-LogHybrid "HTTP trigger: Invoke-InstallSVSMSP" "Info" "OnBoard"
try {
Install-SVSMSP -InstallToolkit
Send-Text $Context "SVSMSP Module installed/updated."
} catch {
Write-LogHybrid "Error in Install-SVSMSP: $_" "Error" "OnBoard"
Send-Text $Context "ERROR: $_"
}
}
function Invoke-InstallCyberQP {
param($Context)
# 1) call into your module
Install-CyberQP
# 2) log & write back a simple text response
Write-LogHybrid "CyberQP installed" "Success" "OnBoard"
Send-Text $Context "CyberQP installed"
}
function Invoke-InstallThreatLocker {
param($Context)
# 1) call into your module
Install-ThreatLocker
# 2) log & write back a simple text response
Write-LogHybrid "ThreatLocker installed" "Success" "OnBoard"
Send-Text $Context "ThreatLocker installed"
}
function Invoke-InstallRocketCyber {
param($Context)
# 1) call into your module
Install-RocketCyber
# 2) log & write back a simple text response
Write-LogHybrid "RocketCyber installed" "Success" "OnBoard"
Send-Text $Context "RocketCyber installed"
}
function Invoke-InstallHelpDesk {
param($Context)
# 1) call into your module
Install-svsHelpDesk
# 2) log & write back a simple text response
Write-LogHybrid "SVS HelpDesk installed" "Success" "OnBoard"
Send-Text $Context "SVS HelpDesk installed"
}
function Invoke-SetEdgeDefaultSearchEngine {
param($Context)
try {
Write-LogHybrid "Configuring Edge default search provider" Info OnBoard
set-EdgeDefaultSearchEngine
Write-LogHybrid "Edge default search set to Google" Success OnBoard
Send-Text $Context "Edge default search provider configured."
} catch {
Write-LogHybrid "Failed to set Edge default search: $($_.Exception.Message)" Error OnBoard
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-RenameComputer {
param($Context)
try {
if ($Context.Request.HttpMethod -ne 'POST') {
$Context.Response.StatusCode = 405
Send-Text $Context 'Use POST'
return
}
# Read raw JSON body
$rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
if (-not $rawBody) {
$Context.Response.StatusCode = 400
Send-Text $Context 'Missing request body.'
return
}
try {
$body = $rawBody | ConvertFrom-Json
} catch {
$Context.Response.StatusCode = 400
Send-Text $Context 'Invalid JSON body.'
return
}
$newName = $body.newName
if (-not (Test-ComputerName -Name $newName)) {
Write-LogHybrid "RenameComputer: invalid computer name '$newName'." Error OnBoard -LogToEvent
$Context.Response.StatusCode = 400
Send-JSON $Context @{
Success = $false
Error = "Invalid computer name. Must be 1-15 characters and use only letters, numbers, and hyphens."
}
return
}
Write-LogHybrid "RenameComputer: renaming computer to '$newName'." Info OnBoard -LogToEvent
try {
Rename-Computer -NewName $newName -Force -ErrorAction Stop
} catch {
Write-LogHybrid "RenameComputer: rename failed: $($_.Exception.Message)" Error OnBoard -LogToEvent
$Context.Response.StatusCode = 500
Send-JSON $Context @{
Success = $false
Error = $_.Exception.Message
}
return
}
Write-LogHybrid "RenameComputer: rename complete, reboot required for new name to apply." Success OnBoard -LogToEvent
Send-JSON $Context @{
Success = $true
NewName = $newName
Note = "Rename successful. A reboot is required for the new name to take effect."
}
} catch {
Write-LogHybrid "Invoke-RenameComputer fatal error: $($_.Exception.Message)" Error OnBoard -LogToEvent
$Context.Response.StatusCode = 500
Send-Text $Context "Internal error during computer rename."
}
}
#endregion Onboarding handlers
function Invoke-InstallDattoRMM {
param($Context)
try {
if ($Context.Request.HttpMethod -ne 'POST') {
$Context.Response.StatusCode = 405
Send-Text $Context 'Use POST'
return
}
# 1) Read and parse the JSON body
$body = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
$data = ConvertFrom-Json $body
# 2) Delegate to your unified function for the install
Install-DattoRMM `
-UseWebhook `
-WebhookPassword $Global:WebhookPassword `
-SiteUID $data.UID `
-SiteName $data.Name `
-PushSiteVars:($data.checkedValues -contains 'inputVar') `
-InstallRMM: ($data.checkedValues -contains 'rmm') `
-SaveCopy: ($data.checkedValues -contains 'exe')
# 3) Acknowledge to the client
Send-Text $Context "Triggered DattoRMM for $($data.Name)"
}
catch {
# Log the exception and return HTTP 500
Write-LogHybrid "Invoke-InstallDattoRMM error: $($_.Exception.Message)" Error DattoRMM -LogToEvent
$Context.Response.StatusCode = 500
Send-Text $Context "Internal server error during DattoRMM install."
}
}
#endregion Datto handlers
#region App handlers
function Invoke-InstallChrome { param($Context)
try {
winget install --id=Google.Chrome --silent --accept-package-agreements --accept-source-agreements
Write-LogHybrid "Installed Google Chrome via winget" Success SVSApps -LogToEvent
Send-Text $Context "Chrome installed"
} catch {
Write-LogHybrid "Chrome install failed: $($_.Exception.Message)" Error SVSApps -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-InstallAcrobat { param($Context)
try {
winget install --id=Adobe.Acrobat.Reader.64-bit --silent --accept-package-agreements --accept-source-agreements
Write-LogHybrid "Installed Adobe Acrobat Reader (64-bit) via winget" Success SVSApps -LogToEvent
Send-Text $Context "Acrobat Reader installed"
} catch {
Write-LogHybrid "Acrobat install failed: $($_.Exception.Message)" Error SVSApps -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
#endregion App handlers
#region Offboarding handlers
function Invoke-UninstallCyberQP {
param($Context)
try {
if (Get-Command Uninstall-CyberQP -ErrorAction Stop) {
Uninstall-CyberQP
Write-LogHybrid "CyberQP uninstalled" Success OffBoard -LogToEvent
Send-Text $Context "CyberQP uninstalled."
} else {
throw "Uninstall-CyberQP cmdlet not found in SVSMSP toolkit."
}
}
catch {
Write-LogHybrid "Error uninstalling CyberQP: $($_.Exception.Message)" Error OffBoard -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-UninstallHelpDesk {
param($Context)
try {
if (Get-Command Uninstall-HelpDesk -ErrorAction Stop) {
Uninstall-HelpDesk
Write-LogHybrid "SVS HelpDesk uninstalled" Success OffBoard -LogToEvent
Send-Text $Context "SVS HelpDesk uninstalled."
} else {
throw "Uninstall-HelpDesk cmdlet not found in SVSMSP toolkit."
}
}
catch {
Write-LogHybrid "Error uninstalling SVS HelpDesk: $($_.Exception.Message)" Error OffBoard -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-UninstallThreatLocker {
param($Context)
try {
if (Get-Command Uninstall-ThreatLocker -ErrorAction Stop) {
Uninstall-ThreatLocker
Write-LogHybrid "ThreatLocker uninstalled" Success OffBoard -LogToEvent
Send-Text $Context "ThreatLocker uninstalled."
} else {
throw "Uninstall-ThreatLocker cmdlet not found in SVSMSP toolkit."
}
}
catch {
Write-LogHybrid "Error uninstalling ThreatLocker: $($_.Exception.Message)" Error OffBoard -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-UninstallRocketCyber {
param($Context)
try {
if (Get-Command Uninstall-RocketCyber -ErrorAction Stop) {
Uninstall-RocketCyber
Write-LogHybrid "RocketCyber uninstalled" Success OffBoard -LogToEvent
Send-Text $Context "RocketCyber uninstalled."
} else {
throw "Uninstall-RocketCyber cmdlet not found in SVSMSP toolkit."
}
}
catch {
Write-LogHybrid "Error uninstalling RocketCyber: $($_.Exception.Message)" Error OffBoard -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
function Invoke-CleanupSVSMSP {
param($Context)
try {
if (Get-Command Install-SVSMSP -ErrorAction Stop) {
# This will:
# - Uninstall SVSMSP
# - Unregister SVS_Repo
# - Remove SVSMSP from the session
# - Delete HKLM:\Software\SVS\Deployment (via Remove-SVSDeploymentRegKey)
Install-SVSMSP -Cleanup
Write-LogHybrid "SVSMSP toolkit cleanup completed (module, repo, registry)." Success OffBoard -LogToEvent
Send-Text $Context "SVSMSP toolkit cleanup completed."
} else {
throw "Install-SVSMSP function not found in current session."
}
}
catch {
Write-LogHybrid "Error cleaning up SVSMSP toolkit: $($_.Exception.Message)" Error OffBoard -LogToEvent
Send-Text $Context "ERROR: $($_.Exception.Message)"
}
}
#endregion Offboarding handlers
#region Printer handlers
function Get-SamyDriverRootFolder {
[CmdletBinding()]
param()
$root = Join-Path $env:ProgramData 'SVS\Samy\Drivers'
if (-not (Test-Path $root)) {
try {
New-Item -Path $root -ItemType Directory -Force | Out-Null
Write-LogHybrid "Created driver root folder '$root'." Info Printers -LogToEvent
} catch {
Write-LogHybrid "Failed to create driver root folder '$root': $($_.Exception.Message)" Warning Printers -LogToEvent
}
}
return $root
}
function Get-SamyDriverFolderForProfile {
[CmdletBinding()]
param(
[Parameter(Mandatory)][pscustomobject]$Profile
)
$root = Get-SamyDriverRootFolder
# Optional override if you ever add DriverFolderName to the profile
if ($Profile.PSObject.Properties.Name -contains 'DriverFolderName' -and $Profile.DriverFolderName) {
$folderName = $Profile.DriverFolderName
} else {
$folderName = "$($Profile.ClientCode)_$($Profile.ProfileName)"
}
$dest = Join-Path $root $folderName
if (-not (Test-Path $dest)) {
try {
New-Item -Path $dest -ItemType Directory -Force | Out-Null
Write-LogHybrid "Created driver folder '$dest'." Info Printers -LogToEvent
} catch {
Write-LogHybrid "Failed to create driver folder '$dest': $($_.Exception.Message)" Warning Printers -LogToEvent
}
}
return $dest
}
function Get-SamyDriverPackageUrl {
[CmdletBinding()]
param(
[Parameter(Mandatory)][pscustomobject]$Profile
)
# If profile explicitly provides a full URL, prefer that
if ($Profile.PSObject.Properties.Name -contains 'DriverPackageUrl' -and $Profile.DriverPackageUrl) {
return $Profile.DriverPackageUrl
}
# Otherwise build it from SamyRepoBase / SamyBranch and DriverPackagePath
if ($Profile.PSObject.Properties.Name -contains 'DriverPackagePath' -and $Profile.DriverPackagePath) {
# Example: https://git.../SAMY/raw/branch/beta/Drivers/.../package.zip?raw=1
return "$Script:SamyRepoBase/$Script:SamyBranch/$($Profile.DriverPackagePath)?raw=1"
}
return $null
}
function Get-SamyDriverInfFromRepo {
[CmdletBinding()]
param(
[Parameter(Mandatory)][pscustomobject]$Profile
)
$url = Get-SamyDriverPackageUrl -Profile $Profile
$targetDir = Get-SamyDriverFolderForProfile -Profile $Profile
if (-not $url) {
throw "No DriverPackageUrl or DriverPackagePath defined for profile '$($Profile.ProfileName)'."
}
$zipPath = Join-Path $targetDir 'driver.zip'
Write-LogHybrid "Downloading driver package for profile '$($Profile.ProfileName)' from $url" Info Printers -LogToEvent
try {
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -ErrorAction Stop
} catch {
throw "Failed to download driver package from ${url}: $($_.Exception.Message)"
}
# Extract zip
try {
Expand-Archive -Path $zipPath -DestinationPath $targetDir -Force
} catch {
throw "Failed to extract driver package '$zipPath': $($_.Exception.Message)"
} finally {
Remove-Item -Path $zipPath -ErrorAction SilentlyContinue
}
# Decide which INF to use
$infPath = $null
if ($Profile.PSObject.Properties.Name -contains 'DriverInfName' -and $Profile.DriverInfName) {
$candidate = Join-Path $targetDir $Profile.DriverInfName
if (Test-Path $candidate) {
$infPath = $candidate
} else {
Write-LogHybrid "DriverInfName '$($Profile.DriverInfName)' not found under '$targetDir'." Warning Printers -LogToEvent
}
}
if (-not $infPath) {
$inf = Get-ChildItem -Path $targetDir -Recurse -Filter '*.inf' -ErrorAction SilentlyContinue | Select-Object -First 1
if ($inf) {
$infPath = $inf.FullName
}
}
if (-not $infPath -or -not (Test-Path $infPath)) {
throw "No INF file found in extracted driver package for profile '$($Profile.ProfileName)'."
}
Write-LogHybrid "Using driver INF '$infPath' for profile '$($Profile.ProfileName)'." Info Printers -LogToEvent
return $infPath
}
function Get-SamyClientListFromServer {
<#
.SYNOPSIS
Queries the Node.js service for a list of clients/printers using a SAMYPW header.
.DESCRIPTION
Calls your Node.js endpoint with:
-Method Post
-Headers @{ SAMYPW = '<password>' }
-ContentType 'application/json'
and returns the JSON it sends back (normalized to an array).
.PARAMETER Uri
The HTTP/HTTPS endpoint (e.g. https://bananas.svstools.ca/getprinters).
.PARAMETER Password
Password/API key that will be sent as the SAMYPW header.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Uri,
[Parameter(Mandatory)]
[string]$Password
)
try {
Write-LogHybrid "Calling client list service at $Uri" Info Printers -LogToEvent
$headers = @{
SAMYPW = $Password
}
$resp = Invoke-RestMethod -Uri $Uri `
-Method Post `
-Headers $headers `
-ContentType 'application/json' `
-ErrorAction Stop
if (-not $resp) {
Write-LogHybrid "Client list service returned no data." Warning Printers -LogToEvent
return @()
}
# Normalize to an array so callers can rely on it
if ($resp -is [System.Collections.IEnumerable] -and -not ($resp -is [string])) {
return @($resp)
} else {
return ,$resp
}
}
catch {
Write-LogHybrid ("Get-SamyClientListFromServer failed for {0}: {1}" -f $Uri, $_.Exception.Message) Error Printers -LogToEvent
return @()
}
}
function Invoke-GetPrinters {
param($Context)
try {
if ($Context.Request.HttpMethod -ne 'POST') {
$Context.Response.StatusCode = 405
Send-Text $Context 'Use POST'
return
}
# Read JSON body: { "password": "..." }
$rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
if (-not $rawBody) {
$Context.Response.StatusCode = 400
Send-Text $Context 'Missing request body.'
return
}
try {
$body = $rawBody | ConvertFrom-Json
} catch {
$Context.Response.StatusCode = 400
Send-Text $Context 'Invalid JSON body.'
return
}
$password = $body.password
if (-not $password) {
$Context.Response.StatusCode = 400
Send-Text $Context 'Password is required.'
return
}
$uri = 'https://bananas.svstools.ca/getprinters'
Write-LogHybrid "Fetching printers from $uri" Info Printers -LogToEvent
# NOTE: We never log the actual password
$printers = Get-SamyClientListFromServer -Uri $uri -Password $password
# EXTRA SAFETY: never pass $null to Send-JSON
if ($null -eq $printers) {
Write-LogHybrid "Get-SamyClientListFromServer returned `$null; sending empty JSON array." Warning Printers -LogToEvent
$printers = @()
}
# Always update local printers.json with latest from bananas
# but don't wipe a good file when we got *nothing* back.
try {
Update-SamyPrinterConfig -PrinterProfiles $printers -SkipIfEmpty
}
catch {
Write-LogHybrid "Update-SamyPrinterConfig failed: $($_.Exception.Message)" Warning Printers -LogToEvent
}
# Return raw objects as JSON; JS will filter/group
Send-JSON $Context $printers
}
catch {
Write-LogHybrid "Invoke-GetPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent
$Context.Response.StatusCode = 500
Send-Text $Context "Internal server error fetching printers."
}
}
function Invoke-InstallPrinters {
param($Context)
try {
if ($Context.Request.HttpMethod -ne 'POST') {
$Context.Response.StatusCode = 405
Send-Text $Context 'Use POST'
return
}
$rawBody = (New-Object IO.StreamReader $Context.Request.InputStream).ReadToEnd()
if (-not $rawBody) {
$Context.Response.StatusCode = 400
Send-Text $Context 'Missing request body.'
return
}
try {
$body = $rawBody | ConvertFrom-Json
} catch {
$Context.Response.StatusCode = 400
Send-Text $Context 'Invalid JSON body.'
return
}
$printers = $body.printers
if (-not $printers -or $printers.Count -eq 0) {
$Context.Response.StatusCode = 400
Send-Text $Context 'No printers specified.'
return
}
Write-LogHybrid "Received request to install $($printers.Count) printers." Info Printers -LogToEvent
$successCount = 0
$failures = @()
foreach ($p in $printers) {
# Expecting fields from JSON:
# ClientCode = 'ABC'
# ProfileName = 'FrontDesk'
# SetAsDefault = $true/$false (optional)
$clientCode = $p.ClientCode
$profileName = $p.ProfileName
$setDefault = $false
if ($p.PSObject.Properties.Name -contains 'SetAsDefault' -and $p.SetAsDefault) {
$setDefault = $true
}
if (-not $clientCode -or -not $profileName) {
$msg = "Skipping printer entry because ClientCode or ProfileName is missing."
Write-LogHybrid $msg Warning Printers -LogToEvent
$failures += $msg
continue
}
$summary = "ClientCode=$clientCode ProfileName=$profileName DisplayName=$($p.DisplayName) Location=$($p.Location) SetAsDefault=$setDefault"
Write-LogHybrid "Installing printer ($summary)" Info Printers -LogToEvent
try {
# SAFE PHASE: we call with -WhatIf so no real change happens
Invoke-SamyPrinterInstall `
-ClientCode $clientCode `
-ProfileName $profileName `
-SetAsDefault:$setDefault `
#-WhatIf
$successCount++
}
catch {
$errMsg = "Failed to install printer ($summary): $($_.Exception.Message)"
Write-LogHybrid $errMsg Error Printers -LogToEvent
$failures += $errMsg
}
}
$result = @{
SuccessCount = $successCount
FailureCount = $failures.Count
Failures = $failures
Message = "Printer install (WHATIF) processed. Check SAMY logs for detail."
}
Send-JSON $Context $result
}
catch {
Write-LogHybrid "Invoke-InstallPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent
$Context.Response.StatusCode = 500
Send-Text $Context "Internal server error installing printers."
}
}
#region Printer core (local config + install)
function Get-SamyPrinterLocalConfigPath {
[CmdletBinding()]
param()
# Use a stable location on every machine
$configDir = Join-Path $env:ProgramData 'SVS\Samy\Printers'
if (-not (Test-Path $configDir)) {
try {
New-Item -Path $configDir -ItemType Directory -Force | Out-Null
Write-LogHybrid "Created printer config folder at '$configDir'." Info Printers -LogToEvent
}
catch {
Write-LogHybrid "Failed to create printer config folder '$configDir': $($_.Exception.Message)" Warning Printers -LogToEvent
}
}
return (Join-Path $configDir 'printers.json')
}
function Get-SamyPrinterConfigFromFile {
[CmdletBinding()]
param()
$path = Get-SamyPrinterLocalConfigPath
if (-not (Test-Path $path)) {
throw "Local printer config file not found at '$path'. Create or update printers.json first."
}
$json = Get-Content -Path $path -Raw -ErrorAction Stop
$profiles = $json | ConvertFrom-Json
if (-not $profiles) {
throw "Printer config file '$path' is empty or invalid JSON."
}
return $profiles
}
# Per-session cache
$Script:Samy_PrinterProfiles = $null
function Get-SamyPrinterProfiles {
<#
.SYNOPSIS
Returns all printer profiles, optionally filtered by ClientCode.
#>
[CmdletBinding()]
param(
[string]$ClientCode
)
if (-not $Script:Samy_PrinterProfiles) {
$Script:Samy_PrinterProfiles = Get-SamyPrinterConfigFromFile
}
$result = $Script:Samy_PrinterProfiles
if ($PSBoundParameters.ContainsKey('ClientCode') -and $ClientCode) {
$result = $result | Where-Object { $_.ClientCode -eq $ClientCode }
}
return $result
}
function Get-SamyPrinterProfile {
<#
.SYNOPSIS
Returns a single printer profile for a given ClientCode and ProfileName.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ClientCode,
[Parameter(Mandatory)][string]$ProfileName
)
$profiles = Get-SamyPrinterProfiles -ClientCode $ClientCode
$match = $profiles | Where-Object { $_.ProfileName -eq $ProfileName }
if (-not $match) {
throw "No printer profile found for ClientCode '$ClientCode' and ProfileName '$ProfileName'."
}
if ($match.Count -gt 1) {
throw "Multiple printer profiles found for ClientCode '$ClientCode' and ProfileName '$ProfileName'. De-duplicate in printers.json."
}
return $match
}
function Ensure-SamyPrinterDriver {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[pscustomobject]$Profile
)
$driverName = $Profile.DriverName
if (-not $driverName) {
throw "Profile '$($Profile.ProfileName)' has no DriverName defined in printer config."
}
# Already installed?
$existingDriver = Get-PrinterDriver -Name $driverName -ErrorAction SilentlyContinue
if ($existingDriver) {
Write-LogHybrid "Printer driver '$driverName' already installed." Info Printers -LogToEvent
return
}
Write-LogHybrid "Printer driver '$driverName' not found. Preparing to install." Info Printers -LogToEvent
# 1) Start with any static local path, if defined
$infPath = $null
if ($Profile.PSObject.Properties.Name -contains 'DriverInfPath' -and $Profile.DriverInfPath) {
if (Test-Path $Profile.DriverInfPath) {
$infPath = $Profile.DriverInfPath
} else {
Write-LogHybrid "Configured DriverInfPath '$($Profile.DriverInfPath)' does not exist, will try repo download." Warning Printers -LogToEvent
}
}
# 2) If no usable INF path, fall back to downloading from repo
if (-not $infPath) {
if ($Profile.PSObject.Properties.Name -contains 'DriverPackagePath' -or
$Profile.PSObject.Properties.Name -contains 'DriverPackageUrl') {
$infPath = Get-SamyDriverInfFromRepo -Profile $Profile
}
}
# 3) Still nothing? Hard fail with a clear message
if (-not $infPath -or -not (Test-Path $infPath)) {
throw "Driver '$driverName' is not installed and no valid DriverInfPath or DriverPackagePath/DriverPackageUrl is defined for profile '$($Profile.ProfileName)'."
}
Write-LogHybrid "Installing printer driver '$driverName' from '$infPath'." Info Printers -LogToEvent
# Install with pnputil
pnputil.exe /add-driver "`"$infPath`"" /install | Out-Null
# Verify
$existingDriver = Get-PrinterDriver -Name $driverName -ErrorAction SilentlyContinue
if (-not $existingDriver) {
throw "Failed to install printer driver '$driverName' from '$infPath'."
}
Write-LogHybrid "Printer driver '$driverName' installed successfully." Success Printers -LogToEvent
}
function Install-SamyTcpIpPrinter {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[pscustomobject]$Profile,
[switch]$SetAsDefault
)
$portName = $Profile.Address
$printerName = $Profile.DisplayName
if (-not $portName) {
throw "TCP/IP printer profile '$($Profile.ProfileName)' is missing Address in printer config."
}
if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) {
Write-Verbose "Creating TCP/IP port '$portName'."
Add-PrinterPort -Name $portName -PrinterHostAddress $Profile.Address
}
else {
Write-Verbose "TCP/IP port '$portName' already exists."
}
$existingPrinter = Get-Printer -Name $printerName -ErrorAction SilentlyContinue
if ($existingPrinter) {
Write-Verbose "Printer '$printerName' already exists. Skipping creation."
}
else {
Write-Verbose "Creating printer '$printerName' on port '$portName' using driver '$($Profile.DriverName)'."
Add-Printer -Name $printerName -PortName $portName -DriverName $Profile.DriverName
}
if ($SetAsDefault -or $Profile.IsDefault) {
Write-Verbose "Setting '$printerName' as default printer."
(New-Object -ComObject WScript.Network).SetDefaultPrinter($printerName)
}
}
function Install-SamySharedPrinter {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[pscustomobject]$Profile,
[switch]$SetAsDefault
)
if (-not $Profile.PrintServer -or -not $Profile.ShareName) {
throw "Shared printer profile '$($Profile.ProfileName)' is missing PrintServer or ShareName in printer config."
}
$connectionName = "\\$($Profile.PrintServer)\$($Profile.ShareName)"
$existing = Get-Printer -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -eq $Profile.DisplayName -or
$_.ShareName -eq $Profile.ShareName
}
if ($existing) {
Write-Verbose "Shared printer '$($Profile.DisplayName)' already connected as '$($existing.Name)'."
$printerName = $existing.Name
}
else {
Write-Verbose "Adding shared printer connection '$connectionName'."
Add-Printer -ConnectionName $connectionName
$printerName = (Get-Printer |
Where-Object { $_.Name -like "*$($Profile.ShareName)*" } |
Select-Object -First 1
).Name
}
if ($SetAsDefault -or $Profile.IsDefault) {
Write-Verbose "Setting '$printerName' as default printer."
(New-Object -ComObject WScript.Network).SetDefaultPrinter($printerName)
}
}
function Invoke-SamyPrinterInstall {
<#
.SYNOPSIS
Installs a printer based on a JSON-defined profile (supports -WhatIf).
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)]
[string]$ClientCode,
[Parameter(Mandatory)]
[string]$ProfileName,
[switch]$SetAsDefault
)
try {
$profile = Get-SamyPrinterProfile -ClientCode $ClientCode -ProfileName $ProfileName
$targetName = $profile.DisplayName
if ($PSCmdlet.ShouldProcess($targetName, "Install printer")) {
Write-LogHybrid "Installing printer profile '$($profile.ProfileName)' for client '$ClientCode' (WhatIf=$WhatIfPreference)." Info Printers -LogToEvent
Ensure-SamyPrinterDriver -Profile $profile
switch ($profile.Type) {
'TcpIp' {
Install-SamyTcpIpPrinter -Profile $profile -SetAsDefault:$SetAsDefault
}
'Shared' {
Install-SamySharedPrinter -Profile $profile -SetAsDefault:$SetAsDefault
}
default {
throw "Unsupported printer Type '$($profile.Type)' in profile '$($profile.ProfileName)'. Use 'TcpIp' or 'Shared'."
}
}
Write-LogHybrid "Installed printer '$($profile.DisplayName)' (Client=$ClientCode Profile=$ProfileName)." Info Printers -LogToEvent
}
}
catch {
Write-LogHybrid (
"Printer install failed for Client={0} Profile={1}: {2}" -f $ClientCode, $ProfileName, $_.Exception.Message
) Error Printers -LogToEvent
throw
}
}
function Update-SamyPrinterConfig {
<#
.SYNOPSIS
Writes the fetched printer profiles to the local printers.json file.
.DESCRIPTION
- Uses Get-SamyPrinterLocalConfigPath to determine where printers.json lives.
- Always overwrites printers.json when non-empty data is provided.
- If called with -SkipIfEmpty and the data is empty/null, it does *nothing*
so we don't wipe a good config on a bad day.
- Resets the in-memory cache so future Get-SamyPrinterProfiles calls reload from disk.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$PrinterProfiles,
[switch]$SkipIfEmpty
)
$path = Get-SamyPrinterLocalConfigPath
# Normalize to array
$profilesArray = @($PrinterProfiles)
if ($SkipIfEmpty -and ($null -eq $PrinterProfiles -or $profilesArray.Count -eq 0)) {
Write-LogHybrid "Update-SamyPrinterConfig: no printer profiles returned; keeping existing printers.json." Warning Printers -LogToEvent
return
}
if ($profilesArray.Count -eq 0) {
Write-LogHybrid "Update-SamyPrinterConfig: zero profiles; writing empty printers.json." Warning Printers -LogToEvent
}
try {
$profilesArray | ConvertTo-Json -Depth 5 | Set-Content -Path $path -Encoding UTF8
Write-LogHybrid "Saved $($profilesArray.Count) printer profiles to '$path'." Success Printers -LogToEvent
# Invalidate per-session cache so future reads use the new file
$Script:Samy_PrinterProfiles = $null
}
catch {
Write-LogHybrid "Failed to write printers.json to '$path': $($_.Exception.Message)" Error Printers -LogToEvent
}
}
#endregion Printer core (local config + install)
#endregion Printer handlers
#endregion Handler Stubs
#region Install-DattoRMM
<#
.SYNOPSIS
Installs/configures the Datto RMM agent, fetches site lists, and optionally saves the site list to disk.
.DESCRIPTION
Centralizes Datto RMM operations in one function:
- Fetch API credentials from a webhook (-UseWebhook)
- Acquire OAuth token
- Fetch site list (-FetchSites)
- Save site list to Desktop as JSON or CSV (-FetchSites + -SaveSitesList)
- Write site variables to registry (-PushSiteVars)
- Download & launch the RMM agent installer (-InstallRMM)
- Save a copy of the installer (-SaveCopy)
.PARAMETER UseWebhook
Fetches ApiUrl, ApiKey, and ApiSecretKey from the webhook when used with WebhookPassword.
.PARAMETER WebhookPassword
Password for authenticating to the credentials webhook.
.PARAMETER WebhookUrl
URL of the credentials webhook. Defaults to $Global:DattoWebhookUrl.
.PARAMETER ApiUrl
Direct Datto API endpoint URL (if not using webhook).
.PARAMETER ApiKey
Direct Datto API key (if not using webhook).
.PARAMETER ApiSecretKey
Direct Datto API secret (if not using webhook).
.PARAMETER FetchSites
Fetches the list of sites and skips all install steps.
.PARAMETER SaveSitesList
Saves the fetched site list to Desktop using OutputFile. Must be used with -FetchSites.
.PARAMETER OutputFile
Filename for saving the site list (.json or .csv). Defaults to 'datto_sites.csv'.
.PARAMETER PushSiteVars
Writes fetched site variables into HKLM:\Software\SVS\Deployment.
.PARAMETER InstallRMM
Downloads and runs the Datto RMM agent installer.
.PARAMETER SaveCopy
Saves a copy of the downloaded agent installer to C:\Temp.
.PARAMETER SiteUID
Unique identifier of the Datto site (required for install and registry push).
.PARAMETER SiteName
Friendly name of the Datto site (used for logging).
.EXAMPLE
# Fetch and save site list via webhook
Install-DattoRMM -UseWebhook -WebhookPassword 'Tndmeeisdwge!' -FetchSites -SaveSitesList -OutputFile 'sites.csv'
.EXAMPLE
# Headless install with site variables
Install-DattoRMM -ApiUrl 'https://api.example.com' -ApiKey 'KeyHere' -ApiSecretKey 'SecretHere' \
-SiteUID 'site-123' -SiteName 'Acme Corp' -PushSiteVars -InstallRMM
.EXAMPLE
# Download and save installer to C:\Temp without installing
Install-DattoRMM -ApiUrl 'https://api.example.com' -ApiKey 'KeyHere' -ApiSecretKey 'SecretHere' \
-SiteUID 'site-123' -SiteName 'Acme Corp' -SaveCopy
#>
function Install-DattoRMM {
[CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='Medium')]
param (
[switch]$UseWebhook,
[String]$WebhookPassword,
[string]$WebhookUrl = $Global:DattoWebhookUrl,
[string]$ApiUrl,
[string]$ApiKey,
[string]$ApiSecretKey,
[switch]$FetchSites,
[switch]$SaveSitesList,
[string]$OutputFile = 'datto_sites.csv',
[switch]$PushSiteVars,
[switch]$InstallRMM,
[switch]$SaveCopy,
[string]$SiteUID,
[string]$SiteName
)
# Validate mutually-dependent switches
if ($SaveSitesList -and -not $FetchSites) {
Write-LogHybrid "-SaveSitesList requires -FetchSites." Error DattoRMM -LogToEvent; return
}
# 1) Optionally fetch credentials from webhook
if ($UseWebhook) {
if (-not $WebhookPassword) {
Write-LogHybrid "Webhook password missing." Error DattoRMM -LogToEvent; return
}
try {
$resp = Invoke-RestMethod -Uri $WebhookUrl `
-Headers @{ SVSMSPKit = $WebhookPassword } `
-Method GET
$ApiUrl = $resp.ApiUrl
$ApiKey = $resp.ApiKey
$ApiSecretKey = $resp.ApiSecretKey
Write-LogHybrid "Webhook credentials fetched." Success DattoRMM -LogToEvent
} catch {
Write-LogHybrid "Failed to fetch webhook credentials: $($_.Exception.Message)" Error DattoRMM -LogToEvent; return
}
}
# 2) Validate API parameters
if (-not $ApiUrl -or -not $ApiKey -or -not $ApiSecretKey) {
Write-LogHybrid "Missing required API parameters." Error DattoRMM -LogToEvent; return
}
# 3) Acquire OAuth token
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
try {
$publicCred = New-Object System.Management.Automation.PSCredential(
'public-client', (ConvertTo-SecureString 'public' -AsPlainText -Force)
)
$tokenResp = Invoke-RestMethod -Uri "$ApiUrl/auth/oauth/token" `
-Credential $publicCred `
-Method Post `
-ContentType 'application/x-www-form-urlencoded' `
-Body "grant_type=password&username=$ApiKey&password=$ApiSecretKey"
$token = $tokenResp.access_token
Write-LogHybrid "OAuth token acquired." Success DattoRMM -LogToEvent
} catch {
Write-LogHybrid "OAuth token fetch failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent; return
}
$headers = @{ Authorization = "Bearer $token" }
# 4) Fetch site list only
if ($FetchSites) {
try {
$sitesResp = Invoke-RestMethod -Uri "$ApiUrl/api/v2/account/sites" -Method Get -Headers $headers
$siteList = $sitesResp.sites | Sort-Object name | ForEach-Object {
[PSCustomObject]@{ Name = $_.name; UID = $_.uid }
}
Write-LogHybrid "Fetched $($siteList.Count) sites." Success DattoRMM -LogToEvent
if ($SaveSitesList) {
$desktop = [Environment]::GetFolderPath('Desktop')
$path = Join-Path $desktop $OutputFile
$ext = [IO.Path]::GetExtension($OutputFile).ToLower()
if ($ext -eq '.json') {
$siteList | ConvertTo-Json -Depth 3 | Out-File -FilePath $path -Encoding UTF8
} else {
$siteList | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8
}
Write-LogHybrid "Wrote $($siteList.Count) sites to $path" Success DattoRMM -LogToEvent
}
return $siteList
} catch {
Write-LogHybrid "Failed to fetch sites: $($_.Exception.Message)" Error DattoRMM -LogToEvent; return @()
}
}
# 5) Push site variables to registry
if ($PushSiteVars) {
try {
$varsResp = Invoke-RestMethod -Uri "$ApiUrl/api/v2/site/$SiteUID/variables" -Method Get -Headers $headers
Write-LogHybrid "Fetched variables for '$SiteName'." Success DattoRMM -LogToEvent
} catch {
Write-LogHybrid "Variable fetch failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent
}
$regPath = "HKLM:\Software\SVS\Deployment"
foreach ($v in $varsResp.variables) {
try {
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
New-ItemProperty -Path $regPath -Name $v.name -Value $v.value -PropertyType String -Force | Out-Null
Write-LogHybrid "Wrote '$($v.name)' to registry." Success DattoRMM -LogToEvent
} catch {
Write-LogHybrid "Failed to write '$($v.name)': $($_.Exception.Message)" Error DattoRMM -LogToEvent
}
}
}
# 6) Download & install RMM agent
if ($InstallRMM) {
if ($PSCmdlet.ShouldProcess("Site '$SiteName'", "Install RMM agent")) {
try {
$dlUrl = "https://zinfandel.centrastage.net/csm/profile/downloadAgent/$SiteUID"
$tmp = "$env:TEMP\AgentInstall.exe"
Invoke-WebRequest -Uri $dlUrl -OutFile $tmp -UseBasicParsing
Write-LogHybrid "Downloaded agent to $tmp." Info DattoRMM -LogToEvent
Start-Process -FilePath $tmp -NoNewWindow
Write-LogHybrid "RMM agent installer launched." Success DattoRMM -LogToEvent
} catch {
Write-LogHybrid "Agent install failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent
}
}
}
# 7) Save a copy of installer to C:\Temp
if ($SaveCopy) {
try {
$dlUrl = "https://zinfandel.centrastage.net/csm/profile/downloadAgent/$SiteUID"
$path = "C:\Temp\AgentInstall.exe"
if (-not (Test-Path 'C:\Temp')) { New-Item -Path 'C:\Temp' -ItemType Directory | Out-Null }
Invoke-WebRequest -Uri $dlUrl -OutFile $path -UseBasicParsing
Write-LogHybrid "Saved installer copy to $path." Info DattoRMM -LogToEvent
} catch {
Write-LogHybrid "Save-copy failed: $($_.Exception.Message)" Error DattoRMM -LogToEvent
}
}
# 8) Warn if no action was taken
if (-not ($PushSiteVars -or $InstallRMM -or $SaveCopy)) {
Write-LogHybrid "No action specified. Use -FetchSites, -SaveSitesList, -PushSiteVars, -InstallRMM, or -SaveCopy." Warning DattoRMM -LogToEvent
}
}
#endregion Install-DattoRMM
#region Dispatch-Request
# Sends the HTML for a given page or invokes a task handler
function Dispatch-Request {
param($Context)
# figure out the path
$path = $Context.Request.Url.AbsolutePath.TrimStart('/')
# ---- Shutdown handler ----
if ($path -eq 'quit') {
Write-LogHybrid "Shutdown requested" "Info" "Server" -LogToEvent
Send-Text $Context "Server shutting down."
# stop the listener loop
$Global:Listener.Stop()
return
}
# ---- Tasks completed notification ----
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'tasksCompleted') {
Invoke-TasksCompleted $Context
return
}
# ---- Fetch Sites endpoint ----
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getpw') {
Invoke-FetchSites $Context
return
}
# ---- Rename Computer endpoint ----
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'renameComputer') {
Invoke-RenameComputer $Context
return
}
# ---- Printer endpoints ----
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'getprinters') {
Invoke-GetPrinters $Context
return
}
if ($Context.Request.HttpMethod -eq 'POST' -and $path -eq 'installprinters') {
Invoke-InstallPrinters $Context
return
}
# ---- Serve UI pages ----
if ($path -in @('', 'onboard', 'offboard', 'tweaks', 'SVSApps')) {
$page = if ($path -eq '') { 'onboard' } else { $path }
$html = Get-UIHtml -Page $page
Send-HTML $Context $html
return
}
# ---- Task invocation ----
$task = $Global:SamyTasks | Where-Object Name -EQ $path
if ($task) {
& $task.HandlerFn $Context
return
}
# ---- 404 ----
$Context.Response.StatusCode = 404
Send-Text $Context '404 - Not Found'
}
#endregion Dispatch-Request
#region EntryPoint: Define Invoke-ScriptAutomationMonkey
# ─────────────────────────────────────────────────────────────────────────
# 3) MAIN LOGIC (Toolkit vs DattoFetch vs DattoInstall vs UI)
# ─────────────────────────────────────────────────────────────────────────
switch ($PSCmdlet.ParameterSetName) {
'Toolkit' {
Write-LogHybrid "Toolkit-only mode" Info Startup -LogToEvent
Install-SVSMSP -InstallToolkit
return
}
'Cleanup' {
Write-LogHybrid "Running Toolkit cleanup mode" Info Startup -LogToEvent
Install-SVSMSP -Cleanup
return
}
# ───────────────────────────────────────────────────────────
# 2) If user only wants the site list, do that and exit
# ───────────────────────────────────────────────────────────
'DattoFetch' {
Write-LogHybrid "Fetching site list only…" Info DattoAuth -LogToEvent
$sites = Install-DattoRMM `
-UseWebhook `
-WebhookPassword $WebhookPassword `
-FetchSites `
-SaveSitesList:$SaveSitesList `
-OutputFile $OutputFile
Write-LogHybrid "Done." Success DattoAuth -LogToEvent
return
}
# ────────────────────────────────────────────
# 3) Invoke the existing Install-DattoRMM cmdlet
# ────────────────────────────────────────────
'DattoInstall' {
Write-LogHybrid "Headless DattoRMM deploy" Info DattoAuth -LogToEvent
if ($PSCmdlet.ShouldProcess("Datto site '$SiteName'", "Headless install")) {
Install-DattoRMM `
-UseWebhook `
-WebhookPassword $WebhookPassword `
-SiteUID $SiteUID `
-SiteName $SiteName `
-PushSiteVars:$PushSiteVars `
-InstallRMM:$InstallRMM `
-SaveCopy:$SaveCopy
}
return
}
'Offboard' {
Write-LogHybrid "Headless offboarding requested" Info OffBoard -LogToEvent
$offboardTasks = $Global:SamyTasks | Where-Object Page -EQ 'offboard'
if (-not $offboardTasks) {
Write-LogHybrid "No offboard tasks configured" Warning OffBoard -LogToEvent
return
}
if (-not $PSCmdlet.ShouldProcess("Full off-boarding flow", "Execute every offboard task")) {
return
}
foreach ($task in $offboardTasks) {
try {
Write-LogHybrid "Running offboard task: $($task.Label)" Info OffBoard -LogToEvent
if (-not (Get-Command $task.HandlerFn -ErrorAction SilentlyContinue)) {
Write-LogHybrid "Missing handler $($task.HandlerFn)" Error OffBoard -LogToEvent
continue
}
& $task.HandlerFn $null
} catch {
Write-LogHybrid "Offboard task $($task.Label) failed: $($_.Exception.Message)" Error OffBoard -LogToEvent
}
}
Write-LogHybrid "Headless offboarding completed" Success OffBoard -LogToEvent
return
}
'UI' {
$url = "http://localhost:$Port/"
Write-LogHybrid "Starting ScriptAutomationMonkey UI on $url" Info Startup
# Resolve Edge path explicitly (x86 first, then 64-bit, then PATH)
$edgeCandidates = @(
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe"
)
$edgePath = $edgeCandidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
if (-not $edgePath) {
$cmd = Get-Command -Name 'msedge.exe' -ErrorAction SilentlyContinue
if ($cmd) { $edgePath = $cmd.Path }
}
# Launch Edge (app mode) in a background job so Start-Server can block
Start-Job -Name 'OpenScriptAutomationMonkeyUI' -ScriptBlock {
param([string]$u, [string]$edge)
Start-Sleep -Milliseconds 400
try {
if ($edge -and (Test-Path $edge)) {
Start-Process -FilePath $edge -ArgumentList @('--new-window', "--app=$u")
} else {
Start-Process -FilePath $u # fallback to default browser
}
} catch { }
} -ArgumentList $url, $edgePath | Out-Null
# Now start the blocking listener loop
Start-Server
return
}
}
#endregion EntryPoint: Define Invoke-ScriptAutomationMonkey
}
if ($MyInvocation.InvocationName -eq '.') {
# dot-sourced, don't invoke
} elseif ($PSCommandPath) {
# script was saved and run directly
Invoke-ScriptAutomationMonkey @PSBoundParameters
} else {
# iwr | iex fallback
if ($args.Count -gt 0) {
# Convert -Param value -Switch into a hashtable for splatting
$namedArgs = @{}
for ($i = 0; $i -lt $args.Count; $i++) {
if ($args[$i] -is [string] -and $args[$i].StartsWith('-')) {
$key = $args[$i].TrimStart('-')
$next = $args[$i + 1]
if ($next -and ($next -notlike '-*')) {
$namedArgs[$key] = $next
$i++ # Skip next one, it's the value
} else {
$namedArgs[$key] = $true
}
}
}
Invoke-ScriptAutomationMonkey @namedArgs
} else {
Invoke-ScriptAutomationMonkey
}
}