commit fa307eaceb0d9848b15d6635c059a7309f8bca5e Author: syelle Date: Sun Dec 21 02:25:43 2025 -0500 Truncated history new root based on 0bd16c9 diff --git a/Drivers/HP/UPD/package.zip b/Drivers/HP/UPD/package.zip new file mode 100644 index 0000000..7370e43 Binary files /dev/null and b/Drivers/HP/UPD/package.zip differ diff --git a/Drivers/Sharp/MX-C428F/package.zip b/Drivers/Sharp/MX-C428F/package.zip new file mode 100644 index 0000000..813394d Binary files /dev/null and b/Drivers/Sharp/MX-C428F/package.zip differ diff --git a/Drivers/Toshiba/e-STUDIO2525AC/package.zip b/Drivers/Toshiba/e-STUDIO2525AC/package.zip new file mode 100644 index 0000000..b14d661 Binary files /dev/null and b/Drivers/Toshiba/e-STUDIO2525AC/package.zip differ diff --git a/Drivers/Xerox/Global/package.zip b/Drivers/Xerox/Global/package.zip new file mode 100644 index 0000000..82e0809 Binary files /dev/null and b/Drivers/Xerox/Global/package.zip differ diff --git a/Drivers/Zebra/GK420d/package.zip b/Drivers/Zebra/GK420d/package.zip new file mode 100644 index 0000000..d0e8900 Binary files /dev/null and b/Drivers/Zebra/GK420d/package.zip differ diff --git a/New-SamyPrinterProfileJson.ps1 b/New-SamyPrinterProfileJson.ps1 new file mode 100644 index 0000000..f03fe9a --- /dev/null +++ b/New-SamyPrinterProfileJson.ps1 @@ -0,0 +1,167 @@ +function New-SamyPrinterProfileJson { +<# +.SYNOPSIS + Generates a SAMY printer profile JSON template from existing printers + and optionally uploads it to a Git (Gitea) repository. + +.DESCRIPTION + Enumerates local printers via Get-Printer, maps them into SAMY printer + profile objects, and writes them to a JSON file. The JSON is intended + as a starting point / template for building printers.json used by SAMY. + + Each profile includes: + - ClientCode (from parameter) + - Location (from parameter) + - ProfileName (defaults to printer Name) + - DisplayName (printer Name) + - Type (TcpIp or Shared, best-effort guess) + - Address (for TCP/IP printers) + - PrintServer (for shared printers) + - ShareName (for shared printers) + - DriverName (printer DriverName) + - DriverInfPath, DriverPackagePath, DriverInfName (empty placeholders) + - IsDefault (true if this printer is default) + + Optionally, the generated JSON can be uploaded to a Git repo using + a personal access token (PAT) passed as a SecureString. + +.PARAMETER ClientCode + MSP/client code to stamp into each profile (for example "SVS"). + +.PARAMETER Location + Human-friendly location (for example "Embrun"). Used both as a field in + each profile and as part of the default JSON file name. + +.PARAMETER OutputPath + Folder where the JSON file will be saved. Default is: + C:\ProgramData\SVS\Samy\Printers + +.PARAMETER UploadToGit + When set, the function will attempt to upload the generated JSON file + to the specified Git (Gitea) repository and path. + +.PARAMETER GitApiBase + Base URL for the Git API, for example: + https://git.svstools.ca/api/v1 + +.PARAMETER GitRepo + Repository identifier in the form "Owner/Repo", for example: + SVS_Public_Repo/SAMY + +.PARAMETER GitBranch + Branch name to write to. Default is "beta". + +.PARAMETER GitPath + Path inside the repo where the JSON should be written, for example: + Printers/SVS/Embrun/printers.json + +.PARAMETER GitToken + Personal access token as a SecureString. Recommended source: + a secret environment variable (for example $env:GIT_PAT) converted via + ConvertTo-SecureString. + +.EXAMPLE + New-SamyPrinterProfileJson -ClientCode "SVS" -Location "Embrun" + + Generates a printers_SVS_Embrun.json in: + C:\ProgramData\SVS\Samy\Printers + +.EXAMPLE + $secureToken = ConvertTo-SecureString $env:GIT_PAT -AsPlainText -Force + + New-SamyPrinterProfileJson ` + -ClientCode "SVS" ` + -Location "Embrun" ` + -UploadToGit ` + -GitApiBase "https://git.svstools.ca/api/v1" ` + -GitRepo "SVS_Public_Repo/SAMY" ` + -GitBranch "beta" ` + -GitPath "Printers/SVS/Embrun/printers.json" ` + -GitToken $secureToken + + Generates the JSON locally and uploads it to the specified path + in the Git repository. +#> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$ClientCode, + + [Parameter(Mandatory = $true)] + [string]$Location, + + [string]$OutputPath = "C:\ProgramData\SVS\Samy\Printers", + + [switch]$UploadToGit, + + [string]$GitApiBase, + [string]$GitRepo, + [string]$GitBranch = "beta", + [string]$GitPath, + [SecureString]$GitToken + ) + + try { + Write-Log "Starting New-SamyPrinterProfileJson for ClientCode='$ClientCode' Location='$Location'." "Info" "PrinterJson" -LogToEvent + + # ------------------------------------------------------------------ + # 1) Ensure output folder exists and build a safe file name + # ------------------------------------------------------------------ + if (-not (Test-Path $OutputPath)) { + Write-Log "Creating output folder '$OutputPath'." "Info" "PrinterJson" -LogToEvent + New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null + } + + $safeLocation = $Location -replace '[^A-Za-z0-9_-]', '_' + $fileName = "printers_{0}_{1}.json" -f $ClientCode, $safeLocation + $filePath = Join-Path $OutputPath $fileName + + # ------------------------------------------------------------------ + # 2) Enumerate printers and build profile objects + # ------------------------------------------------------------------ + $printers = Get-Printer -ErrorAction SilentlyContinue + + if (-not $printers) { + Write-Log "No printers found on this system. JSON will be empty." "Warning" "PrinterJson" -LogToEvent + } else { + Write-Log ("Found {0} printer(s)." -f $printers.Count) "Info" "PrinterJson" -LogToEvent + } + + $profiles = @() + + foreach ($p in $printers) { + $profileName = $p.Name + $displayName = $p.Name + $driverName = $p.DriverName + $portName = $p.PortName + $isDefault = $p.Shared -eq $false -and $p.Default -eq $true + + # Try to resolve port details + $port = $null + if ($portName) { + $port = Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue + } + + $type = "TcpIp" + $address = $null + $printServer = $null + $shareName = $null + + if ($port -and $port.PrinterHostAddress) { + # Standard TCP/IP port + $type = "TcpIp" + $address = $port.PrinterHostAddress + } + elseif ($p.Shared -and $p.ShareName) { + # Best guess at a shared printer + $type = "Shared" + $shareName = $p.ShareName + $printServer = $env:COMPUTERNAME + } + + $profiles += [PSCustomObject]@{ + ClientCode = $ClientCode + Location = $Location + + ProfileName = $profileName + DisplayName = $displayName diff --git a/SAMY.png b/SAMY.png new file mode 100644 index 0000000..b4050fc Binary files /dev/null and b/SAMY.png differ diff --git a/SAMY2.png b/SAMY2.png new file mode 100644 index 0000000..608109d Binary files /dev/null and b/SAMY2.png differ diff --git a/SVS_Favicon.ico b/SVS_Favicon.ico new file mode 100644 index 0000000..ef789a0 Binary files /dev/null and b/SVS_Favicon.ico differ diff --git a/SVS_logo.svg b/SVS_logo.svg new file mode 100644 index 0000000..e718af3 --- /dev/null +++ b/SVS_logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/Datto.md b/docs/Datto.md new file mode 100644 index 0000000..735d149 --- /dev/null +++ b/docs/Datto.md @@ -0,0 +1,70 @@ +<# +.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 +#> \ No newline at end of file diff --git a/docs/SAMY.help.md b/docs/SAMY.help.md new file mode 100644 index 0000000..8e73138 --- /dev/null +++ b/docs/SAMY.help.md @@ -0,0 +1,120 @@ +<# +.SYNOPSIS + Script Automation Monkey (SAMY) is a unified MSP assistant that automates onboarding, headless offboarding, + Datto RMM deployments, printer provisioning, and toolkit management through a local UI, HTTP endpoints, + or direct PowerShell switches. + +.DESCRIPTION + SAMY can be used in three ways: + 1) Local UI (default) - launches a local web UI and exposes endpoints for tasks. + 2) Headless / RMM mode - run onboarding/offboarding/Datto flows using PowerShell switches. + 3) Toolkit-only / cleanup - install or remove the SVSMSP module and related artifacts. + + The Datto helper (Install-DattoRMM) centralizes: + - Credential retrieval (webhook or direct) + - OAuth token acquisition + - Site list retrieval and optional persistence + - Registry variable writes (HKLM:\Software\SVS\Deployment) + - Agent download/launch and optional installer archiving + + Printer endpoints provide: + - /getprinters (fetch profiles from server; optionally updates local config) + - /installprinters (installs requested printers; supports -WhatIf when enabled) + + Throughout, secrets are never written to logs or console, and all operations produce clear + success/failure messages via Write-LogHybrid. + +.CHANGES + - Windows 11 25H2: execution policy relaunch logic updated to improve reliability. + - UI template: HTML has been moved to samy.html (pulled from repo at runtime). + - UI hint message: now controlled via the {{SamyHintText}} placeholder in samy.html. + Recommended: set $Script:SamyHintText in the script or via a config file (future enhancement). + - Printers: Invoke-InstallPrinters supports enabling -WhatIf on Invoke-SVSPrinterInstall + (see Invoke-InstallPrinters; the line is commented by default). + - Assets: branch/base configuration is centralized in the SAMY asset config section + (SamyBranch/SamyRepoBase). Update those values once to switch UI/CSS/JS/HTML assets. + +.CONFIGURATION + SAMY asset config (branch + repo base): + $Script:SamyBranch = 'beta' # or 'main' + $Script:SamyRepoBase = 'https://git.svstools.ca/SVS_Public_Repo/SAMY/raw/branch' + + UI hint message: + - samy.html contains: {{SamyHintText}} + - PowerShell sets: $Script:SamyHintText = "Please use samy.svstools.ca" + +.PARAMETER SilentInstall + Toolkit-only mode. Installs/updates the SVSMSP toolkit module and exits (no UI). + +.PARAMETER Cleanup + Removes the SVSMSP module, unregisters repos, and clears related artifacts (including registry keys). + +.PARAMETER Offboard + Runs every offboarding task sequentially (same behavior as checking "Select All" in the Off-Boarding tab), + without launching the web UI. + +.PARAMETER UseWebhook + Fetches Datto API credentials from the webhook at WebhookUrl using WebhookPassword. + +.PARAMETER WebhookPassword + Password used to authenticate to the credentials webhook. Mandatory when -UseWebhook is set + for headless Datto modes. May be blank when using an allowlisted IP flow (server-side). + +.PARAMETER WebhookUrl + URL of the credentials webhook endpoint. Defaults to $Global:DattoWebhookUrl. + +.PARAMETER FetchSites + Fetches the list of Datto RMM sites and skips all install/variable-push actions. + +.PARAMETER SaveSitesList + Saves the fetched site list to the desktop as OutputFile. Must be used 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 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. + +.PARAMETER PushSiteVars + Fetches site-specific variables and writes them under HKLM:\Software\SVS\Deployment. + +.PARAMETER InstallRMM + Downloads and launches the Datto RMM agent installer for the specified site. + +.PARAMETER SaveCopy + Saves a copy of the downloaded Datto RMM installer into C:\Temp. + +.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)) -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 'samy.svstools.ca' -UseBasicParsing).Content)) -SilentInstall + +.EXAMPLE + & ([ScriptBlock]::Create((iwr 'samy.svstools.com' -UseBasicParsing).Content)) -Cleanup + +.EXAMPLE + & ([ScriptBlock]::Create((iwr 'samy.svstools.ca' -UseBasicParsing).Content)) -Offboard + + # Runs the off-boarding tasks in sequence without launching the UI. + +.EXAMPLE + # Printer provisioning (called from RMM / scripts) + $profiles = Get-SvsPrinterProfilesFromServer -Uri 'https://bananas.svstools.ca/getprinters' -Password $pw + Set-SvsPrinterLocalConfig -PrinterProfiles $profiles -SkipIfEmpty +#> diff --git a/docs/json.txt b/docs/json.txt new file mode 100644 index 0000000..0ff83aa --- /dev/null +++ b/docs/json.txt @@ -0,0 +1,6 @@ + # 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 \ No newline at end of file diff --git a/samy.css b/samy.css new file mode 100644 index 0000000..db9636c --- /dev/null +++ b/samy.css @@ -0,0 +1,383 @@ +:root { + /* Cool Palette */ + --background-color: rgba(18, 18, 18, 1); + --border-color: rgba(255, 255, 255, 0.15); + + /* Neutral Colors */ + --white-color: rgba(255, 255, 255, 1); + --gray-color: rgba(102, 102, 102, 1); + --dark-gray-color: rgba(51, 51, 51, 1); + --light-gray-color: rgba(187, 187, 187, 1); + + /* Sidebar Button Colors */ + --btn-sidebar-light-gray: rgba(68, 68, 68, 1); + --btn-sidebar-blue: rgba(30, 144, 255, 1); + --btn-hover: rgba(0, 86, 179, 1); + --btn-hover-scale: 1.05; + + /* Button Colors */ + --btn-success: rgba(40, 167, 69, 1); + --btn-success-disabled: rgba(108, 117, 125, 1); + --btn-danger: rgba(220, 53, 69, 1); + + /* Monkey + status panel settings */ + --monkey-size: 160px; /* size of SAMY */ + --monkey-bottom: 135px; /* how high from bottom of sidebar */ + --status-gap: 20px; /* space between status box and monkey */ + --status-height: 140px; /* max height of status box */ +} + +/* Make sizing easier to reason about */ +*, *::before, *::after { + box-sizing: border-box; +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background-color: var(--background-color); + color: var(--white-color); + height: 100%; + overflow: hidden; +} + +.logo-container { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + padding: 20px; +} + +.logo-container img { + max-width: 300px; + height: auto; +} + +.subtitle { + font-size: 1.2rem; + color: var(--gray-color); + margin-top: 0.5em; +} + +.container { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* Sidebar */ +.sidebar { + width: 200px; + background: var(--background-color); + padding: 10px; + position: relative; + padding-bottom: calc(var(--monkey-bottom) + var(--monkey-size) + var(--status-gap) + 10px); +} + +/* Status panel above monkey */ +#status-box { + position: absolute; + left: 10px; + bottom: calc(var(--monkey-bottom) + var(--monkey-size) + var(--status-gap)); + width: calc(100% - 20px); + max-height: var(--status-height); + overflow-y: auto; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + font-family: "Segoe UI", "Segoe UI Emoji", "Segoe UI Symbol", system-ui, sans-serif; + font-size: 12px; + line-height: 1.35; + z-index: 1; +} + +/* SAMY bottom-left */ +.sidebar::after { + content: ""; + position: absolute; + left: 10px; + bottom: var(--monkey-bottom); + width: var(--monkey-size); + height: var(--monkey-size); + background-image: url("SAMY.png"); + background-repeat: no-repeat; + background-size: contain; + opacity: 0.95; + pointer-events: none; +} + +.sidebar button { + display: block; + width: 100%; + margin-bottom: 10px; + padding: 10px; + color: var(--white-color); + background: var(--btn-sidebar-light-gray); + border: none; + border-radius: 5px; + cursor: pointer; + text-align: left; + transition: background-color 0.3s, transform 0.2s; +} + +.sidebar button.active { + background: var(--btn-sidebar-blue); +} + +.sidebar button:hover { + background: var(--btn-hover); + transform: scale(var(--btn-hover-scale)); +} + +.content { + position: relative; + flex: 1; + padding: 20px; + padding-bottom: 200px; + overflow-y: auto; + max-height: calc(100vh - 50px); +} + +/* Floating buttons (Exit / Run) */ +.fixed-buttons { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + gap: 10px; + z-index: 1000; +} + +.exit-button, +.run-button { + border: none; + border-radius: 5px; + padding: 10px 20px; + cursor: pointer; + color: var(--white-color); +} + +.exit-button { + background-color: var(--btn-danger); +} + +.run-button { + background-color: var(--btn-success); +} + +/* Standard buttons (shared look across the app) */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + + font-family: "Segoe UI", "Segoe UI Emoji", "Segoe UI Symbol", system-ui, sans-serif; + font-size: 14px; + font-weight: 600; + + padding: 10px 14px; + min-height: 36px; + + color: var(--white-color); + border: 1px solid transparent; + border-radius: 8px; + + cursor: pointer; + user-select: none; + + transition: transform 0.12s ease, background-color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; +} + +.btn:hover { transform: scale(1.02); } +.btn:active { transform: scale(0.99); } + +.btn:focus-visible { + outline: none; + border-color: var(--border-color); + box-shadow: 0 0 0 3px rgba(255, 127, 0, 0.18); +} + +/* Variants */ +.btn-primary { + background-color: var(--btn-sidebar-blue); +} +.btn-primary:hover { + background-color: var(--btn-hover); +} + +.btn-success { + background-color: var(--btn-success); +} +.btn-success:hover { + background-color: rgba(30, 140, 60, 1); +} + +.btn-danger { + background-color: var(--btn-danger); +} +.btn-danger:hover { + background-color: rgba(190, 40, 55, 1); +} + +.btn:disabled, +.btn[aria-disabled="true"] { + background-color: var(--btn-success-disabled); + opacity: 0.75; + cursor: not-allowed; + transform: none; +} + + +/* Tabs */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Columns & checkboxes */ +.columns-container { + display: flex; + gap: 20px; + flex-wrap: wrap; + align-items: flex-start; +} + +.column { + flex: 1; + max-width: 45%; + border: 2px solid var(--border-color); + border-radius: 8px; + padding: 10px; + background-color: var(--dark-gray-color); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +.checkbox-group label { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +/* Datto password + site dropdown (On-Boarding) */ +#PasswordContainer, +#dattoRmmContainer { + margin: 16px 0; + padding: 10px 12px; + border-radius: 8px; + background-color: var(--dark-gray-color); + border: 1px solid var(--border-color); + + /* Narrower so it fits under the stack column */ + width: calc(25% - 10px); + max-width: 480px; +} + +/* Printer panels on Devices tab */ +#printerPasswordContainer, +#printerClientContainer, +#printerListContainer { + margin: 16px 0; + padding: 10px 12px; + border-radius: 8px; + background-color: var(--dark-gray-color); + border: 1px solid var(--border-color); + + /* Full width in the Devices tab */ + width: 100%; + max-width: 600px; +} + + + +#PasswordContainer input, +#PasswordContainer button, +#dattoRmmContainer select, +#printerPasswordContainer input, +#printerPasswordContainer button, +#printerClientContainer select { + background-color: var(--dark-gray-color); + color: var(--white-color); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px; + font-size: 14px; +} + +/* Input + GO button inline rows */ +#PasswordContainer > div, +#printerPasswordContainer > div { + display: flex; + gap: 6px; +} + +#PasswordContainer input, +#printerPasswordContainer input { + flex: 1; + min-width: 0; +} + +/* Dropdown fills panel width */ +#dattoRmmContainer select, +#printerClientContainer select { + width: 100%; +} + +/* GO button styling */ +#PasswordContainer button, +.go-button { + background-color: var(--btn-sidebar-blue); + cursor: pointer; + transition: background-color 0.3s ease, transform 0.1s ease; +} + +#PasswordContainer button:hover, +.go-button:hover { + background-color: var(--btn-hover); + transform: scale(1.03); +} + +/* Tag line */ +.tagline { + font-size: 1.2rem; + color: var(--light-gray-color); + font-weight: bold; + text-align: center; +} + +/* Big red notice under tagline */ + .samy-hint { + margin-top: 0.25rem; + font-size: 3rem; + color: #ff4d4d; + font-weight: 900; + text-transform: uppercase; + text-align: center; + grid-column: 1 / -1; /* span both grid columns */ +} + + +/* Responsive */ +@media (max-width: 768px) { + .container { + flex-direction: column; + } + + .sidebar { + width: 100%; + } + + .column { + max-width: 100%; + } + + #PasswordContainer, + #dattoRmmContainer { + width: 100%; + max-width: 100%; + } +} diff --git a/samy.functions.ps1 b/samy.functions.ps1 new file mode 100644 index 0000000..6a47794 --- /dev/null +++ b/samy.functions.ps1 @@ -0,0 +1,81 @@ + 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 + } \ No newline at end of file diff --git a/samy.html b/samy.html new file mode 100644 index 0000000..ec51b4c --- /dev/null +++ b/samy.html @@ -0,0 +1,185 @@ + + + + + +Script Automation Monkey + + + + + +
+ +
+ SVS Logo + {{moduleVersion}} +
+ + +
+ Script Automation Monkey (Yeah!) +
+ + +
{{SamyHintText}}
+ +
+ +
+ +
+
+

On-Boarding

+

This new deployment method ensures everything is successfully deployed with greater ease!

+ +
+
+

SVSMSP Stack

+ + {{onboardLeftColumn}} +
+ +
+

Optional

+ + {{onboardRightColumn}} + + +
+
+ + + + + + + +
+ +
+

Off-Boarding

+
+
+

Remove Stack

+ + {{offboardCheckboxes}} +
+
+
+ +
+

Tweaks

+
+
+

Tweaks

+ {{tweaksCheckboxes}} +
+
+
+ +
+

SVS APPs

+
+
+

Applications

+ {{appsCheckboxes}} +
+
+
+ + +
+

Devices

+

Manage printers and other client devices.

+ + +
+ +
+ + +
+
+ + + + + + +
+
+
+ + + + + + + + +
+ + +
+ + + \ No newline at end of file diff --git a/samy.js b/samy.js new file mode 100644 index 0000000..96c7368 --- /dev/null +++ b/samy.js @@ -0,0 +1,691 @@ +// Use globals provided by the PowerShell-generated HTML bridge +const tasks = (window.SAMY_TASKS || []); +const defaultPage = (window.SAMY_DEFAULT_PAGE || "onboard"); + +let completedTasks = 0; +let totalTasks = 0; + +// Progress / title handling +function setTotalTaskCount(count) { + totalTasks = count; + completedTasks = 0; + updateTitle(); +} + +function logProgress(label, isSuccess) { + const statusBox = document.getElementById("status-box"); + completedTasks++; + updateTitle(); + + const msg = isSuccess + ? ` ${completedTasks}/${totalTasks} done: ${label}` + : ` ${completedTasks}/${totalTasks} failed: ${label}`; + + const div = document.createElement("div"); + div.style.color = isSuccess ? "lime" : "red"; + div.textContent = msg; + statusBox?.appendChild(div); + + if (completedTasks === totalTasks) { + const finalMsg = document.createElement("div"); + finalMsg.style.marginTop = "10px"; + finalMsg.innerHTML = ` All tasks completed (${completedTasks}/${totalTasks})`; + statusBox?.appendChild(finalMsg); + + document.title = ` ScriptMonkey - Complete (${completedTasks}/${totalTasks})`; + + const sound = new Audio( + "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=" + ); + sound.play().catch(() => {}); + flashTitle(document.title); + } +} + +function updateTitle() { + document.title = `ScriptMonkey - ${completedTasks}/${totalTasks} Done`; +} + +function flashTitle(finalTitle) { + let flashes = 0; + const interval = setInterval(() => { + document.title = document.title === "" ? finalTitle : ""; + flashes++; + if (flashes >= 10) { + clearInterval(interval); + document.title = finalTitle; + } + }, 800); +} + +// ======================================================================= +// Tab Navigation +// ======================================================================= +document.addEventListener("DOMContentLoaded", () => { + const tabButtons = document.querySelectorAll(".tab-button"); + const tabContents = document.querySelectorAll(".tab-content"); + + if (!tabButtons?.length || !tabContents?.length) { + console.error("ScriptMonkey: no tab buttons or tab contents found."); + return; + } + + tabButtons.forEach((btn) => { + btn.addEventListener("click", () => { + tabButtons.forEach((b) => b.classList.remove("active")); + tabContents.forEach((c) => c.classList.remove("active")); + + btn.classList.add("active"); + const targetId = btn.dataset.tab; + const target = document.getElementById(targetId); + if (target) target.classList.add("active"); + }); + }); + + // Default tab from PS (onboard/offboard/tweaks/SVSApps) + const defaultTabId = `${defaultPage}Tab`; + const defaultBtn = document.querySelector(`.tab-button[data-tab='${defaultTabId}']`); + const defaultTab = document.getElementById(defaultTabId); + if (defaultBtn) defaultBtn.classList.add("active"); + if (defaultTab) defaultTab.classList.add("active"); +}); + +// ======================================================================= +// Onboarding: Select-all left/right columns +// ======================================================================= +function toggleColumn(col) { + const master = document.getElementById( + `selectAll${col[0].toUpperCase() + col.slice(1)}Checkbox` + ); + const children = document.querySelectorAll( + `#onboardTab input[type=checkbox][data-column="${col}"]` + ); + + children.forEach((cb) => { + cb.checked = master.checked; + }); + + setTimeout(() => { + children.forEach((cb) => { + cb.dispatchEvent(new Event("change")); + }); + }, 0); +} + +function updateSelectAll(col) { + const master = document.getElementById( + `selectAll${col[0].toUpperCase() + col.slice(1)}Checkbox` + ); + const children = document.querySelectorAll( + `#onboardTab input[type=checkbox][data-column=${col}]` + ); + + master.checked = Array.from(children).every((cb) => cb.checked); +} + +document.addEventListener("DOMContentLoaded", () => { + ["left", "right"].forEach((col) => { + document + .querySelectorAll(`#onboardTab input[type=checkbox][data-column=${col}]`) + .forEach((cb) => + cb.addEventListener("change", () => updateSelectAll(col)) + ); + }); +}); + +// ======================================================================= +// Off-boarding Select All +// ======================================================================= +function toggleOffboardAll() { + const master = document.getElementById("offboardSelectAll"); + const children = document.querySelectorAll( + "#offboardTab input[type=checkbox]:not(#offboardSelectAll)" + ); + + children.forEach((cb) => { + cb.checked = master.checked; + }); +} + +function updateOffboardSelectAll() { + const master = document.getElementById("offboardSelectAll"); + if (!master) return; + + const children = document.querySelectorAll( + "#offboardTab input[type=checkbox]:not(#offboardSelectAll)" + ); + if (!children.length) { + master.checked = false; + return; + } + + master.checked = Array.from(children).every((cb) => cb.checked); +} + +document.addEventListener("DOMContentLoaded", () => { + const offChildren = document.querySelectorAll( + "#offboardTab input[type=checkbox]:not(#offboardSelectAll)" + ); + + if (!offChildren?.length) return; + + offChildren.forEach((cb) => + cb.addEventListener("change", updateOffboardSelectAll) + ); + updateOffboardSelectAll(); +}); + +// ======================================================================= +// DattoRMM options + Enter key handling +// ======================================================================= +function toggleDattoRMMOptions() { + const master = document.getElementById("installDattoRMM"); + const container = document.getElementById("installDattoRMMOptionsContainer"); + if (!container) return; + const checked = master?.checked; + container.style.display = checked ? "block" : "none"; + container + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = checked)); +} + +document.addEventListener("DOMContentLoaded", () => { + const master = document.getElementById("installDattoRMM"); + if (master) { + master.addEventListener("change", toggleDattoRMMOptions); + } + + const passwordField = document.getElementById("Password"); + const goButton = document.querySelector("button[onclick='fetchSites()']"); + + if (passwordField && goButton) { + passwordField.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + goButton.click(); + } + }); + } + + const siteDropdown = document.getElementById("dattoDropdown"); + const runButton = document.querySelector(".run-button"); + + if (siteDropdown && runButton) { + siteDropdown.addEventListener("keydown", (e) => { + if (e.key === "Enter" && siteDropdown.value) { + runButton.click(); + } + }); + } +}); + +// ======================================================================= +// Fetch Sites handler (calls /getpw) +// ======================================================================= +async function fetchSites() { + const pwdInput = document.getElementById("Password"); + const pwd = (pwdInput?.value ?? "").trim(); // allow blank, normalize whitespace + + + + const dropdown = document.getElementById("dattoDropdown"); + dropdown.innerHTML = ''; + + try { + const resp = await fetch("/getpw", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: pwd }), + }); + + if (!resp.ok) throw "HTTP " + resp.status; + + const sites = await resp.json(); + + if (!Array.isArray(sites) || sites.length === 0) { + dropdown.innerHTML = + ''; + alert("No Datto sites returned. Verify credentials/allowlist, or try again in a moment."); + return; + } + + dropdown.innerHTML = ""; + + sites.forEach((site) => { + const option = document.createElement("option"); + option.value = site.UID; + option.textContent = site.Name; + dropdown.appendChild(option); + }); + + document.getElementById("dattoRmmContainer").style.display = "block"; + } catch (e) { + console.error(e); + dropdown.innerHTML = + ''; + alert("Failed to fetch sites. Check password or confirm your public IP is allowlisted."); + + } +} + +// ======================================================================= +// Printer management (Devices tab) +// ======================================================================= +let allPrinters = []; + +// POST /getprinters with password from Devices tab +async function fetchPrinters() { + const pwdInput = document.getElementById("PrinterPassword"); + + const pwd = (pwdInput?.value ?? ""); // allow blank + + const clientContainer = document.getElementById("printerClientContainer"); + const listContainer = document.getElementById("printerListContainer"); + const dropdown = document.getElementById("printerClientDropdown"); + const checkboxContainer = document.getElementById("printerCheckboxContainer"); + + if (dropdown) { + dropdown.innerHTML = ''; + } + if (checkboxContainer) { + checkboxContainer.innerHTML = ""; + } + if (clientContainer) clientContainer.style.display = "none"; + if (listContainer) listContainer.style.display = "none"; + + try { + const resp = await fetch("/getprinters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: pwd }), + }); + + if (!resp.ok) throw new Error("HTTP " + resp.status); + + const data = await resp.json(); + allPrinters = Array.isArray(data) ? data : []; + + if (!allPrinters.length) { + alert("No printers returned. Verify credentials/allowlist, or try again in a moment."); + return; + } + + // Build unique sorted ClientCode list + const codes = [...new Set(allPrinters.map((p) => p.ClientCode))].sort(); + + dropdown.innerHTML = ""; + const defaultOpt = new Option("Select a client...", "", true, true); + defaultOpt.disabled = true; + dropdown.appendChild(defaultOpt); + + codes.forEach((code) => { + dropdown.appendChild(new Option(code, code)); + }); + + if (clientContainer) clientContainer.style.display = "block"; + } catch (e) { + console.error("fetchPrinters error:", e); + if (dropdown) { + dropdown.innerHTML = + ''; + } + alert("Failed to fetch printers. Check password or confirm your public IP is allowlisted."); + + } +} + +function renderPrintersForClient(clientCode) { + const container = document.getElementById("printerCheckboxContainer"); + const listContainer = document.getElementById("printerListContainer"); + if (!container) return; + + container.innerHTML = ""; + + const printers = allPrinters.filter((p) => p.ClientCode === clientCode); + + if (!printers.length) { + container.textContent = "No printers found for this client."; + if (listContainer) listContainer.style.display = "block"; + return; + } + + printers.forEach((p, idx) => { + const id = `printer_${clientCode}_${idx}`; + const label = document.createElement("label"); + label.style.display = "block"; + label.style.marginBottom = "4px"; + + //Install-Checkbox + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.id = id; + + // stash all fields we might need later + cb.dataset.clientCode = p.ClientCode; + cb.dataset.profileName = p.ProfileName; + cb.dataset.displayName = p.DisplayName; + cb.dataset.location = p.Location; + cb.dataset.address = p.Address; + cb.dataset.printServer = p.PrintServer; + cb.dataset.shareName = p.ShareName; + cb.dataset.driverName = p.DriverName; + cb.dataset.driverInfPath = p.DriverInfPath; + + const nameText = p.DisplayName || p.ProfileName || "Unnamed printer"; + const locText = p.Location || "Unknown location"; + + // Line 1: install checkbox + printer label + label.appendChild(cb); + label.appendChild(document.createTextNode(" ")); + label.appendChild( + document.createTextNode(`${nameText} (${locText})`) + ); + + // Line 2: radio for "Make default" + const defaultWrapper = document.createElement("div"); + defaultWrapper.style.marginLeft = "24px"; + defaultWrapper.style.fontSize = "0.85em"; + defaultWrapper.style.opacity = "0.9"; + + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "defaultPrinter"; + radio.value = id; // associate default choice with this checkbox/printer + + const radioLabel = document.createElement("span"); + radioLabel.textContent = " Make default"; + + defaultWrapper.appendChild(radio); + defaultWrapper.appendChild(radioLabel); + + label.appendChild(document.createElement("br")); + label.appendChild(defaultWrapper); + + container.appendChild(label); + }); + + if (listContainer) listContainer.style.display = "block"; +} + +async function installSelectedPrinters() { + const container = document.getElementById("printerCheckboxContainer"); + if (!container) return; + + const checked = container.querySelectorAll("input[type=checkbox]:checked"); + if (!checked.length) { + alert("Please select at least one printer."); + return; + } + + // See which radio is checked for "Make default" + const defaultRadio = container.querySelector( + 'input[type=radio][name="defaultPrinter"]:checked' + ); + const defaultId = defaultRadio ? defaultRadio.value : null; + + const selected = Array.from(checked).map((cb) => ({ + ClientCode: cb.dataset.clientCode, + ProfileName: cb.dataset.profileName, + DisplayName: cb.dataset.displayName, + Location: cb.dataset.location, + Address: cb.dataset.address, + PrintServer: cb.dataset.printServer, + ShareName: cb.dataset.shareName, + DriverName: cb.dataset.driverName, + DriverInfPath: cb.dataset.driverInfPath, + // Only the printer whose checkbox id matches the selected radio gets SetAsDefault=true + SetAsDefault: defaultId !== null && cb.id === defaultId, + })); + + try { + const resp = await fetch("/installprinters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ printers: selected }), + }); + + if (!resp.ok) throw new Error("HTTP " + resp.status); + + const result = await resp.json().catch(() => null); + + console.log("Printer install result:", result); + + } catch (e) { + console.error("installSelectedPrinters error:", e); + alert("Failed to trigger printer install."); + } +} + + +// ======================================================================= +// Run Selected (main trigger) +// ======================================================================= +async function triggerInstall() { + const runBtn = document.querySelector(".run-button"); + if (!runBtn) return; + + runBtn.disabled = true; + + const statusBox = document.getElementById("status-box"); + if (statusBox) statusBox.innerHTML = ""; + + try { + // Figure out which standard tasks are checked + const checkedTasks = tasks.filter((t) => { + if (["installDattoRMM", "installSVSMSPModule", "renameComputer"].includes(t.id)) return false; + const cb = document.getElementById(t.id); + return cb && cb.checked; + }); + + // Rename checkbox / textbox + const renameCB = document.getElementById("renameComputer"); + const newNameInput = document.getElementById("txtNewComputerName"); + + // Count how many "extra" tasks (rename) we're doing + let extraTasks = 0; + if (renameCB && renameCB.checked) { + extraTasks = 1; // treat rename as one task in the progress counter + } + + setTotalTaskCount(checkedTasks.length + extraTasks); + + // 1. DattoRMM first + const dattoCB = document.getElementById("installDattoRMM"); + if (dattoCB && dattoCB.checked) { + const sub = Array.from( + document.querySelectorAll(".sub-option-installDattoRMM:checked") + ).map((x) => x.value); + const dropdown = document.getElementById("dattoDropdown"); + const uid = dropdown?.value; + const name = dropdown?.selectedOptions?.[0]?.text || "Datto"; + + if (!uid) { + alert("Please select a Datto RMM site before running."); + logProgress("Install DattoRMM (no site selected)", false); + } else { + try { + await fetch("/installDattoRMM", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ checkedValues: sub, UID: uid, Name: name }), + }); + logProgress("Install DattoRMM", true); + } catch (e) { + logProgress("Install DattoRMM", false); + console.error(e); + } + } + } + + // 2. SVSMSP module second + const svsCB = document.getElementById("installSVSMSPModule"); + if (svsCB && svsCB.checked) { + try { + await fetch("/installSVSMSPModule", { method: "GET" }); + logProgress("Install SVSMSP Module", true); + } catch (e) { + logProgress("Install SVSMSP Module", false); + console.error(e); + } + } + + // 3. Remaining tasks + for (const t of tasks) { + if (["installDattoRMM", "installSVSMSPModule", "renameComputer"].includes(t.id)) continue; + + const cb = document.getElementById(t.id); + if (!cb || !cb.checked) continue; + + try { + await fetch(t.handler, { method: "GET" }); + logProgress(t.label || t.id, true); + } catch (e) { + logProgress(t.label || t.id, false); + console.error(`Error running ${t.id}:`, e); + } + } + + // 4. Rename computer (LAST) + if (renameCB && renameCB.checked && newNameInput) { + const newName = newNameInput.value.trim(); + + // Same basic rules you'll enforce server-side + const nameIsValid = + newName.length > 0 && + newName.length <= 15 && + /^[A-Za-z0-9-]+$/.test(newName); + + if (!nameIsValid) { + alert( + "Invalid computer name. Must be 1-15 characters and only letters, numbers, and hyphens." + ); + // still mark it as a failed task so progress reaches 100% + logProgress("Rename computer", false); + } else { + try { + await fetch("/renameComputer", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newName }), + }); + logProgress("Rename computer", true); + } catch (e) { + console.error("Error calling /renameComputer:", e); + logProgress("Rename computer", false); + } + } + } + } catch (e) { + console.error("triggerInstall fatal error:", e); + } finally { + runBtn.disabled = false; + if (totalTasks > 0) { + console.info( + `[Info] All tasks completed (${completedTasks}/${totalTasks})` + ); + } + + // Best-effort notification to the server + try { + await fetch("/tasksCompleted", { method: "POST" }); + } catch (err) { + console.warn("Could not notify server about completion:", err); + } + } +} + + +// ======================================================================= +// Shutdown handler (Exit button & window close) +// ======================================================================= +function endSession() { + fetch("/quit", { method: "GET" }).finally(() => window.close()); +} + +// Sub-options auto-toggle, tagline rotation, and beforeunload hook +document.addEventListener("DOMContentLoaded", () => { + // Sub-option containers + const tasksWithSubOptions = document.querySelectorAll( + '[id$="OptionsContainer"]' + ); + + tasksWithSubOptions.forEach((container) => { + const taskId = container.id.replace("OptionsContainer", ""); + const masterCheckbox = document.getElementById(taskId); + if (!masterCheckbox) return; + + function updateVisibility() { + const checked = masterCheckbox.checked; + container.style.display = checked ? "block" : "none"; + container + .querySelectorAll('input[type="checkbox"]') + .forEach((cb) => (cb.checked = checked)); + + if (taskId === "installDattoRMM") { + const pwdBox = document.getElementById("PasswordContainer"); + const rmmBox = document.getElementById("dattoRmmContainer"); + if (pwdBox) pwdBox.style.display = checked ? "block" : "none"; + if (rmmBox) rmmBox.style.display = checked ? "block" : "none"; + } + } + + masterCheckbox.addEventListener("change", updateVisibility); + updateVisibility(); + }); + + // NEW: Rename computer checkbox -> show/hide text box + const renameCheckbox = document.getElementById("renameComputer"); + const renameBlock = document.getElementById("renameComputerBlock"); + + if (renameCheckbox && renameBlock) { + function updateRenameVisibility() { + renameBlock.style.display = renameCheckbox.checked ? "block" : "none"; + } + + renameCheckbox.addEventListener("change", updateRenameVisibility); + updateRenameVisibility(); + } + + // Tagline rotation + const taglines = [ + "Fast deployments, no monkey business.", + "Bananas for better builds.", + "Deploy without flinging code.", + "Tame your stack. Unleash the monkey.", + "Monkey see, monkey deploy.", + "Deploy smarter -- with a monkey on your team.", + "Don't pass the monkey -- let it deploy.", + "No more monkeying around. Stack handled.", + "Own your stack. But let the monkey do the work.", + "Why throw code when the monkey's got it?", + "Deployments so easy, a monkey could do it. Ours does.", + "Monkey in the stack, not on your back.", + ]; + + const el = document.getElementById("tagline"); + if (el) { + let idx = Math.floor(Math.random() * taglines.length); + el.textContent = taglines[idx]; + + setInterval(() => { + idx = (idx + 1) % taglines.length; + el.textContent = taglines[idx]; + }, 10_000); + } +}); + +// printer dropdown +document.addEventListener("DOMContentLoaded", () => { + const clientDropdown = document.getElementById("printerClientDropdown"); + if (clientDropdown) { + clientDropdown.addEventListener("change", (e) => { + const code = e.target.value; + if (code) renderPrintersForClient(code); + }); + } +}); + + +// notify server on window close +window.addEventListener("beforeunload", () => { + fetch("/quit", { method: "GET", keepalive: true }); +}); \ No newline at end of file diff --git a/samy.ps1 b/samy.ps1 new file mode 100644 index 0000000..1f4e9ca --- /dev/null +++ b/samy.ps1 @@ -0,0 +1,2092 @@ +<# +.SYNOPSIS + Script Automation Monkey (SAMY) ... +.NOTES + Full documentation: https://git.svstools.ca/.../docs/SAMY.help.md +#> + +#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 + + # Build token list (NO manual quoting) + $argList = foreach ($a in $args) { [string]$a } + + + if ($PSCommandPath) { + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$PSCommandPath" @argList + } else { + $argString = ($argList | ForEach-Object { '"' + ($_ -replace '"','`"') + '"' }) -join ' ' + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& { iwr 'https://samybeta.svstools.ca' -UseBasicParsing | iex } $argString" + } + 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://bananas.svstools.ca/dattormm' + + # 1) Params / defaults / branch selection logic (change branch or base once and it updates everything) + $Script:SamyRepoBase = 'https://git.svstools.ca/SVS_Public_Repo/SAMY/raw/branch' + $Script:SamyBranch = 'beta' # 'main' or 'beta' + + if (Get-Command Set-SvsPrinterRepoConfig -ErrorAction SilentlyContinue) { + Set-SvsPrinterRepoConfig -RepoBase $Script:SamyRepoBase -Branch $Script:SamyBranch + } + + # 2) Build all remote URLs + $Script:SamyTopLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_logo.svg?raw=1" + $Script:SamyBgLogoUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SAMY.png?raw=1" + $Script:SamyFaviconUrl = "$Script:SamyRepoBase/$Script:SamyBranch/SVS_Favicon.ico?raw=1" + $Script:SamyCssUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.css?raw=1" + $Script:SamyJsUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.js?raw=1" + $Script:SamyHtmlUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.html?raw=1" + $Script:SamyTasksUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.tasks.json?raw=1" + $Script:SamyFunctionsUrl = "$Script:SamyRepoBase/$Script:SamyBranch/samy.functions.ps1?raw=1" + + # Misc + $Script:SamyHintText = "" + + # 3) Load remote functions (must be before calling them) + try { + $functionsContent = (Invoke-WebRequest -UseBasicParsing $Script:SamyFunctionsUrl -ErrorAction Stop).Content + } + catch { + throw "Failed to download samy.functions.ps1 from $Script:SamyFunctionsUrl. $($_.Exception.Message)" + } + . ([ScriptBlock]::Create($functionsContent)) + + # 3) Load remote functions (must be before calling them) + try { + Write-Host "[Info] Loading functions from: $Script:SamyFunctionsUrl" -ForegroundColor Cyan + $functionsContent = (Invoke-WebRequest -UseBasicParsing $Script:SamyFunctionsUrl -ErrorAction Stop).Content + + if ([string]::IsNullOrWhiteSpace($functionsContent)) { + throw "Downloaded content was empty." + } + + # quick sanity check: make sure the file looks like it contains your function + if ($functionsContent -notmatch '(?im)^\s*function\s+Initialize-NuGetProvider\b') { + Write-Host "[Warning] samy.functions.ps1 loaded, but Initialize-NuGetProvider not found in content." -ForegroundColor Yellow + } + + # Load functions into the current scope + . ([ScriptBlock]::Create($functionsContent)) + + # Verify the function is now available + if (Get-Command Initialize-NuGetProvider -ErrorAction SilentlyContinue) { + Write-Host "[Success] Initialize-NuGetProvider is loaded and available." -ForegroundColor Green + } + else { + throw "Dot-sourcing completed, but Initialize-NuGetProvider is still not available." + } + } + catch { + throw "Failed to load samy.functions.ps1 from $Script:SamyFunctionsUrl. $($_.Exception.Message)" + } + + + # 4) Now call functions that live in samy.functions.ps1 + # Initialize-NuGetProvider + + # 5) Continue with rest of samy.ps1 logic (UI, CSS/JS/HTML download, etc.) + # 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 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 Remote Assets + Task Loading + +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 is available here (we placed this block after it) + Write-LogHybrid "Get-RemoteText failed for ${Url}: $($_.Exception.Message)" Warning UI -LogToEvent + return "" + } +} +function Get-SamyTasks { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Url + ) + + try { + $json = Get-RemoteText -Url $Url + if ([string]::IsNullOrWhiteSpace($json)) { throw "Tasks JSON was empty." } + + $parsed = $json | ConvertFrom-Json -ErrorAction Stop + $tasks = @($parsed) + + if ($tasks.Count -eq 0) { throw "Tasks JSON parsed but contained no tasks." } + + foreach ($task in $tasks) { + + # --- Normalize Label to a safe scalar string (never join arrays) --- + $labelRaw = $task.Label + if ($labelRaw -is [System.Collections.IEnumerable] -and -not ($labelRaw -is [string])) { + $labelRaw = @($labelRaw)[0] + } + $label = [string]$labelRaw + $task.Label = $label + + # --- Read Tooltip if present --- + $tooltipRaw = $null + if ($task.PSObject.Properties.Name -contains 'Tooltip') { + $tooltipRaw = $task.Tooltip + } + + # --- Normalize Tooltip to a safe scalar string (never join arrays) --- + $tooltip = if ($tooltipRaw -is [string]) { + $tooltipRaw + } + elseif ($tooltipRaw -is [System.Collections.IEnumerable] -and -not ($tooltipRaw -is [string])) { + [string](@($tooltipRaw)[0]) + } + else { + [string]$tooltipRaw + } + + # --- Fallback to *this task's* label only --- + if ([string]::IsNullOrWhiteSpace($tooltip)) { + $tooltip = $label + } + + # --- Ensure Tooltip property exists and is updated --- + if ($task.PSObject.Properties.Name -contains 'Tooltip') { + $task.Tooltip = $tooltip + } + else { + $task | Add-Member -NotePropertyName Tooltip -NotePropertyValue $tooltip -Force + } + } + + return $tasks + } + catch { + Write-LogHybrid "Failed to load tasks from ${Url}: $($_.Exception.Message)" Error UI -LogToEvent + return $null + } +} + + + + + +#endregion Remote Assets + Task Loading + + + #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 + + +$Global:SamyTasks = Get-SamyTasks -Url $Script:SamyTasksUrl +if (-not $Global:SamyTasks) { + throw "SAMY cannot continue: failed to load tasks from $Script:SamyTasksUrl" +} + +# DEBUG: detect any Tooltip that is an array/list (will cause "mega-tooltips") +$Global:SamyTasks | ForEach-Object { + if ($_.PSObject.Properties.Name -contains 'Tooltip' -and + $_.Tooltip -is [System.Collections.IEnumerable] -and + -not ($_.Tooltip -is [string])) { + + Write-LogHybrid "BAD TOOLTIP TYPE: Id=$($_.Id) Type=$($_.Tooltip.GetType().FullName) ValueCount=$(@($_.Tooltip).Count)" Warning UI -LogToEvent + } +} + +$Global:SamyTasks | ForEach-Object { + $tip = if ($_.PSObject.Properties.Name -contains 'Tooltip') { [string]$_.Tooltip } else { "" } + $len = $tip.Length + + if ($len -gt 80) { + $preview = $tip.Substring(0, [Math]::Min(160, $len)) + Write-LogHybrid "LONG TOOLTIP: Id=$($_.Id) Len=$len Preview='$preview'" Warning UI -LogToEvent + } +} + + #endregion building the Menu + + #region Publish-Checkboxes + function Publish-Checkboxes { + param( + [Parameter(Mandatory)][string]$Page, + [string]$Column + ) + + function Escape-HtmlAttr { + param([string]$s) + if ([string]::IsNullOrEmpty($s)) { return '' } + + $s = $s -replace "(`r`n|`r|`n)", ' ' + $s = $s -replace '&','&' + $s = $s -replace '"','"' + $s = $s -replace "'",''' + $s = $s -replace '<','<' + $s = $s -replace '>','>' + return $s + } + + function Escape-HtmlText { + param([string]$s) + if ([string]::IsNullOrEmpty($s)) { return '' } + + $s = $s -replace '&','&' + $s = $s -replace '<','<' + $s = $s -replace '>','>' + return $s + } + + + # 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 + $rawTooltip = if ($_.PSObject.Properties.Name -contains 'Tooltip' -and $_.Tooltip) { + [string]$_.Tooltip + } else { + [string]$_.Label + } + + $tooltipText = Escape-HtmlAttr $rawTooltip + $tooltipAttr = if ([string]::IsNullOrWhiteSpace($tooltipText)) { '' } else { " title=`"$tooltipText`"" } + + + $labelText = Escape-HtmlText ([string]$_.Label) + + $taskIdAttr = Escape-HtmlAttr ([string]$taskId) + $nameAttr = Escape-HtmlAttr ([string]$_.Name) + $colAttr = Escape-HtmlAttr ([string]$Column) + + $html = " $labelText" + + if ($_.SubOptions) { + $subHtml = ( + $_.SubOptions | + ForEach-Object { + $subLabel = Escape-HtmlText ([string]$_.Label) + + $subTaskIdClass = Escape-HtmlAttr ([string]$taskId) + $subValueAttr = Escape-HtmlAttr ([string]$_.Value) + + "" + + } + ) -join "`n" + + $html += @" + +"@ + } + + $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 "
+ Module Version: $($mod.Version)
+ UI Branch: $branchDisplay +
" + } + + return "
SVSMSP_Module not found
" +} + + + #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-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 '' + $devices = Publish-Checkboxes -Page 'devices' -Column '' + + # + # 2) Build the JS tasks array once (this is the only dynamic JS piece) + # + $tasksJson = @( + $Global:SamyTasks | ForEach-Object { + [pscustomobject]@{ + id = [string]$_.Id + handler = "/$([string]$_.Name)" + label = [string]$_.Label + } + } + ) | ConvertTo-Json -Depth 4 + + # If you still want to keep the variable name "tasksJsAll", just store JSON in it: + $tasksJsAll = $tasksJson + + + # Human friendly branch label for UI + $branchDisplay = switch ($Script:SamyBranch.ToLower()) { + 'main' { 'Main / Stable' } + 'beta' { 'Beta' } + default { $Script:SamyBranch } + } + + # + # 3) Pull CSS/JS/HTML from Gitea and inline them + # + $cssContent = Get-RemoteText -Url $Script:SamyCssUrl + $jsContent = Get-RemoteText -Url $Script:SamyJsUrl + $htmlTemplate = Get-RemoteText -Url $Script:SamyHtmlUrl + + + if (-not $htmlTemplate) { + # Hard fail or fallback, your call. + # This keeps the UI from becoming blank if the repo is unreachable. + Write-LogHybrid "UI template download failed. Returning minimal error page." Error UI -LogToEvent + return " +

SAMY UI template unavailable

+

Could not download samy.html from repo.

+ " + } + + # Optional: inject background override into CSS (same as you had) + if ($cssContent) { + $cssContent += @" + +/* SAMY background override injected by script */ +.sidebar::after { + background-image: url('$Script:SamyBgLogoUrl') !important; +} +"@ +} + + # + # 4) Replace placeholders (unchanged vs your version) + # + $html = $htmlTemplate + + $html = $html.Replace('{{CssContent}}', $cssContent) + $html = $html.Replace('{{JsContent}}', $jsContent) + + $html = $html.Replace('{{SamyFaviconUrl}}', $Script:SamyFaviconUrl) + $html = $html.Replace('{{SamyTopLogoUrl}}', $Script:SamyTopLogoUrl) + $html = $html.Replace('{{SamyHintText}}', $Script:SamyHintText) + + $html = $html.Replace('{{moduleVersion}}', (Get-ModuleVersionHtml)) + $html = $html.Replace('{{onboardLeftColumn}}', $onboardLeft) + $html = $html.Replace('{{onboardRightColumn}}', $onboardRight) + $html = $html.Replace('{{offboardCheckboxes}}', $offboard) + $html = $html.Replace('{{devicesCheckboxes}}', $devices) + $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 + } + + # --- Prevent caching (Edge app-mode loves to be "helpful") --- + try { + $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + $Context.Response.Headers["Pragma"] = "no-cache" + $Context.Response.Headers["Expires"] = "0" + } catch { + # HttpListenerResponse headers can throw in rare cases; ignore + } + + $bytes = [Text.Encoding]::UTF8.GetBytes($Html) + + # Include charset + $Context.Response.ContentType = "text/html; charset=utf-8" + $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 + } + + $json = $null + + try { + # Normalize output so GetBytes never sees $null + if ($null -eq $Object) { + Write-LogHybrid "Send-JSON called with `$null object; returning empty JSON array." Warning Printers -LogToEvent + $json = '[]' + } + else { + try { + $json = $Object | ConvertTo-Json -Depth 8 -ErrorAction Stop + } + catch { + Write-LogHybrid "Send-JSON serialization failed: $($_.Exception.Message); returning empty JSON array." Error Printers -LogToEvent + $json = '[]' + } + } + + $json = [string]$json + + # ---- No-cache headers (prevents stale UI data) ---- + try { + $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + $Context.Response.Headers["Pragma"] = "no-cache" + $Context.Response.Headers["Expires"] = "0" + } catch { } + + $bytes = [Text.Encoding]::UTF8.GetBytes($json) + + $Context.Response.ContentType = "application/json; charset=utf-8" + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + catch { + Write-LogHybrid "Send-JSON fatal error: $($_.Exception.Message)" Error Printers -LogToEvent + + # Best-effort fallback response + try { + $fallback = '[]' + $bytes = [Text.Encoding]::UTF8.GetBytes($fallback) + + try { + $Context.Response.Headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + $Context.Response.Headers["Pragma"] = "no-cache" + $Context.Response.Headers["Expires"] = "0" + } catch { } + + $Context.Response.ContentType = "application/json; charset=utf-8" + $Context.Response.ContentLength64 = $bytes.Length + $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } + catch { + # swallow: nothing else we can do safely here + } + } + finally { + try { $Context.Response.OutputStream.Close() } catch { } + } +} + + #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 for next call (can be blank) + $Global:WebhookPassword = [string]$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 Invoke-GetPrinters { + param($Context) + + try { + if ($Context.Request.HttpMethod -ne 'POST') { + $Context.Response.StatusCode = 405 + Send-Text $Context 'Use POST' + return + } + + # If printer cmdlets missing, try installing toolkit automatically + if (-not (Get-Command Get-SvsPrinterProfilesFromServer -ErrorAction SilentlyContinue)) { + + Write-LogHybrid "SVSMSP cmdlets missing. Attempting Install-SVSMSP -InstallToolkit..." Warning Printers -LogToEvent + + try { + Install-SVSMSP -InstallToolkit + Import-Module SVSMSP -Force -ErrorAction SilentlyContinue + } + catch { + Write-LogHybrid "Auto-install of SVSMSP failed: $($_.Exception.Message)" Error Printers -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "SVSMSP auto-install failed. Run 'Install SVSMSP Module' manually." + return + } + + # Re-check after install + if (-not (Get-Command Get-SvsPrinterProfilesFromServer -ErrorAction SilentlyContinue)) { + Write-LogHybrid "SVSMSP installed but printer cmdlets still unavailable." Error Printers -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "SVSMSP installed but printer commands still not available. Restart SAMY." + 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 + } + + # Allow blank password (IP allowlist handled server-side) + $password = [string]$body.password # $null -> '' + if ($password -eq '') { + Write-LogHybrid "Printer password is blank; relying on allowlisted IP (server-side)." Info Printers -LogToEvent + } + + $uri = 'https://bananas.svstools.ca/getprinters' + Write-LogHybrid "Fetching printers from $uri" Info Printers -LogToEvent + + $printers = Get-SvsPrinterProfilesFromServer -Uri $uri -Password $password + if ($null -eq $printers) { $printers = @() } + + try { + Set-SvsPrinterLocalConfig -PrinterProfiles $printers -SkipIfEmpty + } + catch { + Write-LogHybrid "Set-SvsPrinterLocalConfig failed: $($_.Exception.Message)" Warning Printers -LogToEvent + } + + 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 + } + + # Read 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 + } + + $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) { + $clientCode = $p.ClientCode + $profileName = $p.ProfileName + $setDefault = [bool]($p.PSObject.Properties.Name -contains 'SetAsDefault' -and $p.SetAsDefault) + + if (-not $clientCode -or -not $profileName) { + $msg = "Skipping printer entry: ClientCode or ProfileName missing." + Write-LogHybrid $msg Warning Printers -LogToEvent + $failures += $msg + continue + } + + $summary = "ClientCode=$clientCode ProfileName=$profileName SetAsDefault=$setDefault" + Write-LogHybrid "Installing printer ($summary)" Info Printers -LogToEvent + + try { + Invoke-SVSPrinterInstall ` + -ClientCode $clientCode ` + -ProfileName $profileName ` + -SetAsDefault:$setDefault ` + #-WhatIf + + $successCount++ + Write-LogHybrid "Printer installed successfully ($summary)" Success Printers -LogToEvent + + } + catch { + $errMsg = "Failed to install printer ($summary): $($_.Exception.Message)" + Write-LogHybrid $errMsg Error Printers -LogToEvent + $failures += $errMsg + } + } + + Send-JSON $Context @{ + SuccessCount = $successCount + FailureCount = $failures.Count + Failures = $failures + Message = "Printer install processed. Check SAMY logs for detail." + } + } + catch { + Write-LogHybrid "Invoke-InstallPrinters error: $($_.Exception.Message)" Error Printers -LogToEvent + $Context.Response.StatusCode = 500 + Send-Text $Context "Internal server error installing printers." + } +} + + + +#endregion Printer handlers + + + #endregion Handler Stubs + + #region Install-DattoRMM + + +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) { + + # Allow blank for IP allowlist scenario. Only treat true $null as missing. + if ($null -eq $WebhookPassword) { + $WebhookPassword = '' + Write-LogHybrid "Webhook password not provided (null). Treating as blank for allowlisted IP flow." Warning DattoRMM -LogToEvent + } + + try { + $resp = Invoke-RestMethod -Uri $WebhookUrl ` + -Headers @{ SAMYPW = [string]$WebhookPassword } ` + -Method GET ` + -ErrorAction Stop + + $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', 'devices')) { + $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) { + $fn = $task.HandlerFn + $cmd = Get-Command $fn -ErrorAction SilentlyContinue + if (-not $cmd) { + $Context.Response.StatusCode = 500 + Send-Text $Context "Handler not found: $fn" + return + } + + # If the handler declares a Context parameter, pass it by name. + if ($cmd.Parameters.ContainsKey('Context')) { + & $fn -Context $Context + } + else { + & $fn + } + 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 + } +} diff --git a/samy.tasks.json b/samy.tasks.json new file mode 100644 index 0000000..271f58b --- /dev/null +++ b/samy.tasks.json @@ -0,0 +1,155 @@ +[ + { + "Id": "setSVSPowerplan", + "Name": "setSVSPowerplan", + "Label": "Set SVS Powerplan", + "HandlerFn": "Invoke-SetSVSPowerPlan", + "Page": "onboard", + "Column": "left", + "Tooltip": "Applies the SVS power configuration" + }, + { + "Id": "installSVSMSPModule", + "Name": "installSVSMSPModule", + "Label": "Install SVSMSP Module", + "HandlerFn": "Invoke-InstallSVSMSP", + "Page": "onboard", + "Column": "left", + "Tooltip": "Installs or updates the SVSMSP toolkit module" + }, + { + "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", + "Tooltip": "Fetches sites then installs/configures Datto RMM", + "SubOptions": [ + { "Value": "inputVar", "Label": "Copy Site Variables" }, + { "Value": "rmm", "Label": "Install RMM Agent" }, + { "Value": "exe", "Label": "Download Executable" } + ] + }, + + { + "Id": "enableBitLocker", + "Name": "EnableBitLocker", + "Label": "Enable BitLocker", + "HandlerFn": "Set-SVSBitLocker", + "Page": "onboard", + "Column": "right", + "Tooltip": "Enables BitLocker drive encryption" + }, + { + "Id": "setEdgeDefaultSearch", + "Name": "setedgedefaultsearch", + "Label": "Set Edge Default Search", + "HandlerFn": "Invoke-SetEdgeDefaultSearchEngine", + "Page": "onboard", + "Column": "right", + "Tooltip": "Will configure Edge to use Google as default search provider" + }, + { + "Id": "renameComputer", + "Name": "renameComputer", + "Label": "Rename Computer", + "HandlerFn": "Invoke-RenameComputer", + "Page": "onboard", + "Column": "right", + "Tooltip": "Renames the device (reboot required)" + }, + { + "Id": "disableAnimations", + "Name": "disableAnimations", + "Label": "Disable Animations", + "HandlerFn": "Disable-Animations", + "Page": "onboard", + "Column": "right", + "Tooltip": "Disables Windows UI animations for performance" + }, + { + "Id": "wingetChrome", + "Name": "wingetChrome", + "Label": "Google Chrome", + "HandlerFn": "Invoke-InstallChrome", + "Page": "onboard", + "Column": "right" + }, + { + "Id": "wingetAcrobat", + "Name": "wingetAcrobat", + "Label": "Adobe Acrobat Reader (64-bit)", + "HandlerFn": "Invoke-InstallAcrobat", + "Page": "onboard", + "Column": "right" + }, + + { + "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" + } +]